File size: 6,952 Bytes
727efc9
 
 
 
 
cedb3f2
727efc9
f9e5860
727efc9
e46cfbf
727efc9
 
 
f9e5860
 
 
727efc9
f9e5860
 
727efc9
f9e5860
 
 
 
 
 
 
 
727efc9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cedb3f2
 
 
 
 
 
 
727efc9
 
cedb3f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
727efc9
 
 
 
 
 
cedb3f2
727efc9
 
cedb3f2
727efc9
 
 
 
cedb3f2
e46cfbf
727efc9
 
 
 
 
 
cedb3f2
 
 
727efc9
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
// 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": <op>, ...args} ],',
  '"visual": {"element": <element>, "shape": <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 }
}