File size: 7,176 Bytes
9fe1971
 
 
 
 
 
 
 
 
 
 
 
 
 
f9e5860
 
 
 
 
 
9fe1971
 
f9e5860
 
 
 
9fe1971
f9e5860
9fe1971
f9e5860
9fe1971
 
 
 
 
 
f9e5860
 
 
 
 
 
 
 
 
 
 
 
 
9fe1971
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f9e5860
9fe1971
f9e5860
 
 
 
 
 
 
 
 
 
9fe1971
f9e5860
 
 
 
 
 
9fe1971
f9e5860
9fe1971
 
 
 
 
 
 
f9e5860
 
9fe1971
f9e5860
 
9fe1971
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f9e5860
 
9fe1971
 
 
 
 
 
 
 
 
 
 
 
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
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
// Skill Forge validator — turns a model-authored skill into a SAFE, engine-resolvable
// CB skill object. The combat resolver (auto-battler src/engine/teamBattle.js) switches on
// effect `op`s and reads {category, target, cost, recharge, effects[]}; anything outside the
// whitelist here is dropped or clamped so a forged skill can never crash the resolver or be
// wildly unbalanced. Mirrors the op/condition vocabulary in teamBattle.js — keep in sync.

// Effect ops the resolver understands AND we consider safe to expose to generation.
// Each entry clamps/normalizes its payload to engine-ready numbers.
const NUM = (v, lo, hi, dflt) => {
  let n = typeof v === 'number' ? v : (v && Array.isArray(v.scale) ? (v.scale[1] ?? v.scale[0]) : Number(v))
  if (!Number.isFinite(n)) n = dflt
  return Math.max(lo, Math.min(hi, Math.round(n)))
}
const CONDITIONS = ['bleeding', 'poison', 'burning', 'deepWound', 'weakness', 'crippled', 'blind', 'dazed']

// Forged skills are a hero's signature: signature-strong, and most are AREA — they hit every foe
// within this radius of the caster (field units, ~7 tiles), routed through the engine's
// area_around_caster scope. Damage clamps are higher than canon, but capped so they can't trivially
// melt a Champion boss.
const AOE_RANGE = 180

const OPS = {
  damage: (e) => ({ op: 'damage', amount: NUM(e.amount, 12, 90, 45) }),
  bonus_damage: (e) => ({ op: 'damage', amount: NUM(e.amount, 12, 90, 40) }), // bonus_damage only works on the attack path; treat as damage for forged AoE
  heal: (e) => ({ op: 'heal', amount: NUM(e.amount, 1, 80, 30) }),
  life_steal: (e) => ({ op: 'life_steal', amount: NUM(e.amount, 6, 60, 24) }),
  apply_condition: (e) => CONDITIONS.includes(e.condition)
    ? { op: 'apply_condition', condition: e.condition, duration: NUM(e.duration, 1, 25, 8) }
    : null,
  knockdown: (e) => ({ op: 'knockdown', duration: NUM(e.duration, 1, 4, 2) }),
  interrupt: () => ({ op: 'interrupt' }),
  lose_all_adrenaline: () => ({ op: 'lose_all_adrenaline' }),
}
const CATEGORIES = ['melee_attack', 'ranged_attack', 'spell', 'shout', 'stance']
const TARGETS = ['foe', 'self', 'ally', 'area']

// Visual identity (cosmetic — drives the in-combat VFX; a bad value just falls back).
export const ELEMENTS = ['fire', 'ice', 'lightning', 'nature', 'poison', 'shadow', 'holy', 'arcane']
export const SHAPES = ['nova', 'ring', 'beam', 'pillar', 'projectile', 'shockwave', 'slash']
const ELEMENT_COLOR = { fire: '#ff6a2a', ice: '#6ad1ff', lightning: '#ffe14d', nature: '#5ad860', poison: '#9be24a', shadow: '#a56cff', holy: '#ffe9a8', arcane: '#c46cff' }
function validateVisual(v) {
  v = v && typeof v === 'object' ? v : {}
  const element = ELEMENTS.includes(v.element) ? v.element : 'arcane'
  const shape = SHAPES.includes(v.shape) ? v.shape : 'nova'
  const color = /^#[0-9a-fA-F]{6}$/.test(String(v.color || '')) ? v.color : ELEMENT_COLOR[element]
  const intensity = Math.max(1, Math.min(3, Math.round(Number(v.intensity)) || 2))
  return { element, shape, color, intensity }
}

export const SAFE_OPS = Object.keys(OPS)
export const SAFE_CONDITIONS = CONDITIONS.slice()

// A fresh id that won't collide with the canon skills (ids ~0–700) or the hero's own forged
// skills. Forged ids live in the 9000+ band.
export function nextSkillId(persona) {
  const used = Object.keys((persona && persona.customSkills) || {}).map((k) => +k).filter((n) => n >= 9000)
  return used.length ? Math.max(...used) + 1 : 9000
}

// Validate + normalize a raw model object into an engine-ready skill. Returns
// { ok, skill, errors }. `skill` is always engine-safe when ok is true.
export function validateSkill(raw, { persona, profession } = {}) {
  const errors = []
  if (!raw || typeof raw !== 'object') return { ok: false, skill: null, errors: ['not an object'] }
  const name = String(raw.name || '').trim().slice(0, 40)
  if (!name) errors.push('missing name')
  const target = TARGETS.includes(raw.target) ? raw.target : 'foe'
  const isSupport = target === 'self' || target === 'ally'
  const rawEffects = Array.isArray(raw.effects) ? raw.effects : []
  let effects = rawEffects.map((e) => (e && OPS[e.op] ? OPS[e.op](e) : null)).filter(Boolean).slice(0, 3)
  // Offensive forged skills are POWERFUL AREA skills: ensure a damage effect and make every
  // effect hit all foes in range (area_around_caster). Support skills (heal/buff) stay self/ally.
  if (!isSupport) {
    if (!effects.some((e) => e.op === 'damage' || e.op === 'life_steal')) effects.unshift({ op: 'damage', amount: 45 })
    effects = effects.slice(0, 3).map((e) => ({ ...e, scope: 'area_around_caster', range: AOE_RANGE }))
  } else {
    if (!effects.length) effects = [{ op: 'heal', amount: 30, scope: 'self' }]
    effects = effects.map((e) => ({ ...e, scope: e.op === 'heal' ? 'self' : (e.scope || 'self') }))
  }
  if (!effects.length) errors.push('no valid effects')
  // Routed through the engine's effect path (not single-target strike): offensive → 'spell',
  // support → 'shout'. Both gate on adrenaline (built by basic attacks → unleash), so every class
  // can power them. ATTACK categories (melee_attack) would single-target, so we avoid them here.
  const category = isSupport ? 'shout' : 'spell'
  const cost = { adrenaline: NUM(raw.cost?.adrenaline ?? raw.cost, 3, 12, 7) }
  const recharge = NUM(raw.recharge ?? raw.cooldown, 0, 20, isSupport ? 6 : 2)
  const flavor = String(raw.flavor || raw.description || '').trim().slice(0, 200)
  const visual = validateVisual(raw.visual)
  if (errors.length) return { ok: false, skill: null, errors }
  const skill = {
    id: nextSkillId(persona),
    name,
    profession: profession || (persona && persona.unitClass) || 'Warrior',
    attribute: 'Forged',
    category, target,
    cost, cast: 0, recharge,
    requires: [],
    effects,
    // ── metadata (ignored by the resolver, used by the UI/VFX) ──
    custom: true, flavor, visual, aoe: !isSupport,
  }
  return { ok: true, skill, errors: [] }
}

// Human one-line effect summary for skill cards (mirrors the engine's label style).
export function effectSummary(skill) {
  if (!skill || !Array.isArray(skill.effects)) return ''
  const parts = skill.effects.map((e) => {
    switch (e.op) {
      case 'damage': case 'bonus_damage': return `+${e.amount} dmg`
      case 'heal': return `+${e.amount} heal`
      case 'life_steal': return `${e.amount} life steal`
      case 'apply_condition': return e.condition
      case 'knockdown': return 'knockdown'
      case 'interrupt': return 'interrupt'
      case 'lose_all_adrenaline': return 'spend adrenaline'
      default: return e.op
    }
  })
  const s = parts.join(' · ')
  return skill.aoe ? `AoE · ${s}` : s
}

// Resolve a persona's equipped skill ids into engine-ready objects (custom objects pass
// through; numeric ids are left for the engine to resolve against CB_SKILLS).
export function resolveEquipped(persona) {
  if (!persona) return []
  const custom = persona.customSkills || {}
  return (persona.equippedSkills || [])
    .map((id) => custom[id] || (typeof id === 'number' ? id : null))
    .filter((x) => x != null)
    .slice(0, 3)
}