// 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) }