// Shared Skill-Forge pipeline — used by BOTH the Skill Forge tab and the in-Game character // sheet so a hero can learn skills from either place. Streams a skill from the coding model, // validates/clamps it into an engine-runnable CB skill (skillSchema.js), paints a Klein icon, // and persists it onto the hero (customSkills + learnedSkills + event). See skillForgePanel.js // and tiny.js (character sheet). import { streamCoding, currentCodingModel } from '/web/codingModel.js' import { stripThinkFinal } from '/web/personaPrompts.js' import { validateSkill, SAFE_OPS, SAFE_CONDITIONS, ELEMENTS, SHAPES } from '/web/skillSchema.js' import { generatePortrait } from '/web/imagen.js' import { getPersona, patchPersona, putSkillIcon, delSkillIcon, getPortrait } from '/web/personaStore.js' import { appendEvent } from '/web/progression.js' export const FORGE_SYSTEM = [ 'You are the Skill Forge for a fantasy auto-battler. Author ONE POWERFUL signature skill for a', 'specific hero, tailored to their class, story and personality. Respond with a SINGLE fenced', '```json block (no prose outside it) of the form:', '{"name": string, "flavor": string (one vivid in-world sentence),', '"target": "foe"|"area"|"self"|"ally", "effects": [ {"op": , ...args} ],', '"visual": {"element": , "shape": , "color": "#rrggbb", "intensity": 1-3} }.', `Allowed effect ops: ${SAFE_OPS.join(', ')}.`, 'damage/bonus_damage/heal/life_steal take {"amount": int (be generous — these are signature skills)}.', `apply_condition takes {"condition": one of [${SAFE_CONDITIONS.join(', ')}], "duration": int}.`, 'knockdown takes {"duration": int}. interrupt and lose_all_adrenaline take no args. Use 1-3 effects.', 'MOST skills should be offensive AREA attacks (target "foe"/"area") that strike EVERY enemy nearby —', 'make them hit hard. Only use target "self"/"ally" for heals/buffs.', `For "visual", pick an element from [${ELEMENTS.join(', ')}], a shape from [${SHAPES.join(', ')}]`, '(how it manifests), a hex color, and intensity 1-3 — all chosen to match the skill\'s theme so it', 'looks unique. Output ONLY the json block.', ].join(' ') export 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 parseSkillJson(text) { const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i) const body = fence ? fence[1] : text const a = body.indexOf('{'), b = body.lastIndexOf('}') if (a < 0 || b <= a) return null try { return JSON.parse(body.slice(a, b + 1)) } catch { return null } } // Stream one skill from the coding model and validate it. `onToken` streams raw text for a // debug view; `extra` lets a retry feed validation errors back in. async function generateOnce(p, ask, { think = false, onToken, extra = '' } = {}) { const user = `${personaBlock(p)}\n\nSkill to create: ${ask}${extra}` let raw = '' await streamCoding(FORGE_SYSTEM, user, { maxTokens: 512, temperature: 0.6, think, onToken: (t) => { raw += t; onToken && onToken(raw) }, }) const clean = stripThinkFinal(raw) return { v: validateSkill(parseSkillJson(clean), { persona: p }), raw, clean } } const blobToDataURL = (blob) => new Promise((res) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = () => res(null); r.readAsDataURL(blob) }) // The skill's image: a scene of THE CHARACTER performing the skill. Klein gets the hero's portrait // as an identity reference (refImage) so the actual character appears. This single image is the // skill's icon (and the detail-sheet art) — no separate icon + art any more. export function skillImagePrompt(p, skill) { return `${p.name || 'A warrior'}, a ${p.unitClass || 'fighter'}, unleashing the skill "${skill.name}": ${skill.flavor || ''}. dynamic fantasy action scene, the character mid-cast, motion and magical impact, dramatic lighting, painterly` } // Generate the skill image in the BACKGROUND (the skill is already usable). Stores it as the icon // and clears iconPending → the UI swaps its spinner for the image (via onRosterChange). async function generateSkillImage(personaId, skill) { let refImage = null try { const portrait = await getPortrait(personaId); if (portrait) refImage = await blobToDataURL(portrait) } catch { /* no portrait → text-only */ } try { const blob = await generatePortrait(skillImagePrompt(getPersona(personaId) || {}, skill), { seed: skill.id, refImage }) if (blob) await putSkillIcon(skill.id, blob) } catch (e) { console.warn('[tinyarmy:skill] image generation failed (skill still usable):', (e && e.message) || e) } finally { const cur = getPersona(personaId) if (cur && cur.customSkills && cur.customSkills[skill.id]) { patchPersona(personaId, { customSkills: { ...cur.customSkills, [skill.id]: { ...cur.customSkills[skill.id], iconPending: false } } }) } } } // Forge a skill for `personaId`. The skill is defined by the coding model, persisted IMMEDIATELY // (so it's usable at once), and its image is painted in the BACKGROUND. Returns { ok, skill } as // soon as the skill is defined; the image arrives later (iconPending → spinner until then). export async function forgeSkillForHero(personaId, request, { onStatus, onToken, think = false } = {}) { const p = getPersona(personaId) if (!p) return { ok: false, error: 'no hero' } const ask = (request || '').trim() if (!ask) return { ok: false, error: 'describe the skill you want' } onStatus && onStatus(`forging via ${currentCodingModel().label}…`) // name the model (like image/voice) let { v } = await generateOnce(p, ask, { think, onToken }) if (!v.ok) { onStatus && onStatus(`refining via ${currentCodingModel().label} (retrying)…`) ;({ v } = await generateOnce(p, ask, { think, onToken, extra: `\n\nYour previous attempt was invalid: ${v.errors.join('; ')}. Return ONLY a corrected json block.` })) } if (!v.ok) return { ok: false, error: v.errors.join('; ') } const skill = { ...v.skill, iconPending: true } // image paints in the background try { await delSkillIcon(skill.id) } catch { /* ignore */ } // forged ids are reused — drop any old art so the spinner shows, not a stale icon const fresh = getPersona(p.id) || p patchPersona(p.id, { customSkills: { ...(fresh.customSkills || {}), [skill.id]: skill }, learnedSkills: [...(fresh.learnedSkills || []), skill.id], events: appendEvent(fresh.events, 'skill_learned', { skill: skill.name, id: skill.id, ts: Date.now() }), }) onStatus && onStatus('learned ✓ (painting its scene…)') generateSkillImage(p.id, skill) // fire-and-forget — don't block use of the skill return { ok: true, skill } }