// Skill Forge — the Sandbox surface that uses the Coding Model to author a combat // skill for a chosen hero. Pick a recruited persona, describe the skill you want, and // the coding model (Nemotron via NIM, or Mellum2 via ZeroGPU — see Settings → Coding // Model) writes a self-contained skill definition tailored to that hero. For now it // just generates and shows the skill; wiring it into the battle engine comes later. import { streamCoding, currentCodingModel, onCodingModelChange } from '/web/codingModel.js' import { listPersonas, getPersona, onRosterChange } from '/web/personaStore.js' import { stripThink, stripThinkFinal } from '/web/personaPrompts.js' function el(tag, props = {}, kids = []) { const n = document.createElement(tag) for (const [k, v] of Object.entries(props)) { if (k === 'class') n.className = v else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2), v) else if (v != null) n.setAttribute(k, v) } for (const kid of [].concat(kids)) if (kid != null) n.append(kid) return n } const SYSTEM = [ 'You are the Skill Forge for a fantasy auto-battler. You author ONE combat skill for a', 'specific hero, tailored to their class, story and personality. Respond with exactly:', 'a one-line skill name, a one-sentence flavour description, then a fenced ```json block', 'with fields {name, type:"active"|"passive", target:"self"|"ally"|"enemy"|"area",', 'cooldown:, effect:""}.', 'Keep it concise and balanced. Output only the skill — no preamble, no commentary.', ].join(' ') function personaBlock(p) { if (!p) return '' return [ `Hero: ${p.name || 'Unnamed'}${p.unitClass ? ` — ${p.unitClass}` : ''}`, p.about ? `About: ${p.about}` : '', p.personality ? `Personality: ${p.personality}` : '', p.specialty ? `Specialty: ${p.specialty}` : '', ].filter(Boolean).join('\n') } export function mountSkillForgePanel(host) { const sel = el('select', { class: 'persona-input skillforge-hero' }) const req = el('textarea', { class: 'persona-prompt-edit skillforge-req', rows: 4, placeholder: 'what skill should this hero learn? (e.g. “a defensive shout that shields nearby allies”)' }) const btn = el('button', { class: 'persona-go', type: 'button' }, '⚒ Forge skill') const status = el('div', { class: 'persona-status' }) const out = el('pre', { class: 'persona-think skillforge-out' }) const empty = el('div', { class: 'persona-roster-empty' }, 'No heroes yet — recruit one in the Personas tab, then come back to forge its skills.') // "Show thinking" reveals the reasoning models' trace (Nemotron, BLS) in the debug // panel below; off by default so forging stays fast/clean. (Mellum2 has no reasoning.) const thinkChk = el('input', { type: 'checkbox', class: 'skillforge-think' }) const thinkLabel = el('label', { class: 'persona-label skillforge-think-label' }, [thinkChk, ' show model thinking']) const dbgEl = el('pre', { class: 'persona-think' }) const copyBtn = el('button', { class: 'persona-copy', type: 'button', onclick: () => navigator.clipboard?.writeText(dbgEl.textContent || '') }, 'copy') const dbgWrap = el('details', { class: 'persona-think-wrap' }, [el('summary', {}, 'model thinking / raw'), copyBtn, dbgEl]) dbgWrap.style.display = thinkChk.checked ? '' : 'none' const controls = el('aside', { class: 'persona-controls skillforge' }, [ el('div', { class: 'persona-sec' }, [el('div', { class: 'persona-sec-title' }, 'Skill Forge'), el('span')]), el('label', { class: 'persona-label' }, 'Hero'), sel, empty, el('label', { class: 'persona-label' }, 'Skill request'), req, thinkLabel, el('div', { class: 'persona-prompt-actions' }, [btn]), status, el('label', { class: 'persona-label' }, 'Forged skill'), out, dbgWrap, ]) host.append(controls) thinkChk.addEventListener('change', () => { dbgWrap.style.display = thinkChk.checked ? '' : 'none' }) function refreshHeroes() { const people = listPersonas() const prev = sel.value sel.replaceChildren(...people.map((p) => el('option', { value: p.id }, p.name || 'Unnamed hero'))) if (people.some((p) => p.id === prev)) sel.value = prev const none = people.length === 0 empty.style.display = none ? '' : 'none' sel.style.display = none ? 'none' : '' btn.disabled = none } function refreshStatus() { if (!status.dataset.busy) status.textContent = `Coding model: ${currentCodingModel().label}` } let running = false async function forge() { if (running) return const p = getPersona(sel.value) if (!p) { status.textContent = 'Pick a hero first.'; return } const ask = req.value.trim() if (!ask) { status.textContent = 'Describe the skill you want.'; return } running = true; status.dataset.busy = '1'; btn.disabled = true out.textContent = ''; dbgEl.textContent = '' status.textContent = `Forging with ${currentCodingModel().label}…` const user = `${personaBlock(p)}\n\nSkill to create: ${ask}` const showThink = thinkChk.checked let raw = '' try { const { stats } = await streamCoding(SYSTEM, user, { maxTokens: 512, temperature: 0.6, think: showThink, // Reasoning streams inside ; show the raw trace in the debug panel and // the stripped answer in the output (same split the persona panel uses). onToken: (t) => { raw += t out.textContent = stripThink(raw) if (showThink) { dbgEl.textContent = raw; dbgWrap.open = true; dbgEl.scrollTop = dbgEl.scrollHeight } }, }) out.textContent = stripThinkFinal(raw) const tps = stats && stats.tokPerSec ? ` · ${stats.tokPerSec} tok/s` : '' status.textContent = `Done${tps}.` } catch (e) { status.textContent = 'Forge failed: ' + (e && e.message ? e.message : e) } finally { running = false; delete status.dataset.busy; btn.disabled = listPersonas().length === 0 } } btn.addEventListener('click', forge) onRosterChange(refreshHeroes) onCodingModelChange(refreshStatus) refreshHeroes(); refreshStatus() return { refresh: refreshHeroes } }