Spaces:
Running
Running
Learn & manage skills from the in-Game character sheet
Browse filesExtract 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>
- web/skillForge.js +96 -0
- web/skillForgePanel.js +14 -107
- 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 —
|
| 2 |
-
//
|
| 3 |
-
//
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
import {
|
| 7 |
-
import {
|
| 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 |
-
|
| 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 (
|
| 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 |
-
|
| 143 |
-
|
| 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 |
-
|
| 149 |
-
|
| 150 |
-
|
| 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 |
-
// ──
|
| 519 |
-
{
|
|
|
|
|
|
|
|
|
|
| 520 |
const cs = currentHero.customSkills || {}
|
| 521 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 522 |
if (learned.length) {
|
| 523 |
-
const
|
| 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
|
| 527 |
-
row.style.cssText = `display:flex;gap:10px;align-items:center;width:100%;
|
| 528 |
-
const ic = document.createElement('
|
| 529 |
getSkillIcon(sk.id).then((b) => { if (b) ic.style.backgroundImage = `url(${URL.createObjectURL(b)})` })
|
| 530 |
-
|
| 531 |
-
const
|
|
|
|
| 532 |
const ef = document.createElement('div'); ef.textContent = effectSummary(sk); ef.style.cssText = 'font-size:11px;color:#9aa4b2'
|
| 533 |
-
txt.append(nm, ef);
|
| 534 |
-
|
| 535 |
-
|
|
|
|
|
|
|
| 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'
|