Spaces:
Running
Running
Skill forge: powerful AoE skills + visual identity (element/shape/color/intensity)
Browse filesOffensive forged skills are now signature-strong AREA skills: routed through the effect
path (category 'spell', adrenaline-gated), every effect scoped area_around_caster (~7
tiles), higher damage clamps (capped). Each skill carries a validated 'visual' block the
coding model fills (element/shape/hex color/intensity). Support skills stay self/ally.
Verified: melee input -> AoE fire-nova spell; self heal stays single-target.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- web/comboBattler.js +5 -0
- web/engine.js +5 -0
- web/skillForge.js +14 -11
- web/skillSchema.js +49 -19
web/comboBattler.js
CHANGED
|
@@ -5778,6 +5778,11 @@ function playerActionReason(b, a, action) {
|
|
| 5778 |
if (s.cost?.adrenaline && a.adrenaline < s.cost.adrenaline) return "need-adrenaline";
|
| 5779 |
if (s.cost?.energy && a.energy < s.cost.energy) return "need-energy";
|
| 5780 |
if (b.t < (a.recharge[s.name] || 0)) return "recharging";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5781 |
if (isAttack(s)) return !foe || edgeGap(a, foe) > reachOf(a) ? "no-target" : null;
|
| 5782 |
if (!isSupport(s)) return !foe || dist2(a, foe) > SPELL_RANGE ? "no-target" : null;
|
| 5783 |
return null;
|
|
|
|
| 5778 |
if (s.cost?.adrenaline && a.adrenaline < s.cost.adrenaline) return "need-adrenaline";
|
| 5779 |
if (s.cost?.energy && a.energy < s.cost.energy) return "need-energy";
|
| 5780 |
if (b.t < (a.recharge[s.name] || 0)) return "recharging";
|
| 5781 |
+
const aoe = (s.effects || []).find((e) => e.scope === "area_around_caster");
|
| 5782 |
+
if (aoe) {
|
| 5783 |
+
const r = aoe.range || 180;
|
| 5784 |
+
return b.actors.some((x) => x.alive && x.team !== a.team && dist2(a, x) <= r) ? null : "no-target";
|
| 5785 |
+
}
|
| 5786 |
if (isAttack(s)) return !foe || edgeGap(a, foe) > reachOf(a) ? "no-target" : null;
|
| 5787 |
if (!isSupport(s)) return !foe || dist2(a, foe) > SPELL_RANGE ? "no-target" : null;
|
| 5788 |
return null;
|
web/engine.js
CHANGED
|
@@ -1501,6 +1501,11 @@ function playerActionReason(b, a, action) {
|
|
| 1501 |
if (s.cost?.adrenaline && a.adrenaline < s.cost.adrenaline) return "need-adrenaline";
|
| 1502 |
if (s.cost?.energy && a.energy < s.cost.energy) return "need-energy";
|
| 1503 |
if (b.t < (a.recharge[s.name] || 0)) return "recharging";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1504 |
if (isAttack(s)) return !foe || edgeGap(a, foe) > reachOf(a) ? "no-target" : null;
|
| 1505 |
if (!isSupport(s)) return !foe || dist(a, foe) > SPELL_RANGE ? "no-target" : null;
|
| 1506 |
return null;
|
|
|
|
| 1501 |
if (s.cost?.adrenaline && a.adrenaline < s.cost.adrenaline) return "need-adrenaline";
|
| 1502 |
if (s.cost?.energy && a.energy < s.cost.energy) return "need-energy";
|
| 1503 |
if (b.t < (a.recharge[s.name] || 0)) return "recharging";
|
| 1504 |
+
const aoe = (s.effects || []).find((e) => e.scope === "area_around_caster");
|
| 1505 |
+
if (aoe) {
|
| 1506 |
+
const r = aoe.range || 180;
|
| 1507 |
+
return b.actors.some((x) => x.alive && x.team !== a.team && dist(a, x) <= r) ? null : "no-target";
|
| 1508 |
+
}
|
| 1509 |
if (isAttack(s)) return !foe || edgeGap(a, foe) > reachOf(a) ? "no-target" : null;
|
| 1510 |
if (!isSupport(s)) return !foe || dist(a, foe) > SPELL_RANGE ? "no-target" : null;
|
| 1511 |
return null;
|
web/skillForge.js
CHANGED
|
@@ -5,24 +5,27 @@
|
|
| 5 |
// and tiny.js (character sheet).
|
| 6 |
import { streamCoding } from '/web/codingModel.js'
|
| 7 |
import { stripThinkFinal } from '/web/personaPrompts.js'
|
| 8 |
-
import { validateSkill, SAFE_OPS, SAFE_CONDITIONS } from '/web/skillSchema.js'
|
| 9 |
import { generatePortrait } from '/web/imagen.js'
|
| 10 |
import { getPersona, patchPersona, putSkillIcon } from '/web/personaStore.js'
|
| 11 |
import { appendEvent } from '/web/progression.js'
|
| 12 |
|
| 13 |
export const FORGE_SYSTEM = [
|
| 14 |
-
'You are the Skill Forge for a fantasy auto-battler. Author ONE
|
| 15 |
-
'hero, tailored to their class, story and personality. Respond with a SINGLE fenced
|
| 16 |
-
'block (no prose outside it) of the form:',
|
| 17 |
'{"name": string, "flavor": string (one vivid in-world sentence),',
|
| 18 |
-
'"
|
| 19 |
-
'"
|
| 20 |
-
'"effects": [ {"op": <op>, ...args} ] }.',
|
| 21 |
`Allowed effect ops: ${SAFE_OPS.join(', ')}.`,
|
| 22 |
-
'damage/bonus_damage/heal/life_steal take {"amount": int
|
| 23 |
-
`{"condition": one of [${SAFE_CONDITIONS.join(', ')}], "duration": int
|
| 24 |
-
'knockdown takes {"duration": int}. interrupt and lose_all_adrenaline take no args.',
|
| 25 |
-
'
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
].join(' ')
|
| 27 |
|
| 28 |
export function personaBlock(p) {
|
|
|
|
| 5 |
// and tiny.js (character sheet).
|
| 6 |
import { streamCoding } from '/web/codingModel.js'
|
| 7 |
import { stripThinkFinal } from '/web/personaPrompts.js'
|
| 8 |
+
import { validateSkill, SAFE_OPS, SAFE_CONDITIONS, ELEMENTS, SHAPES } from '/web/skillSchema.js'
|
| 9 |
import { generatePortrait } from '/web/imagen.js'
|
| 10 |
import { getPersona, patchPersona, putSkillIcon } from '/web/personaStore.js'
|
| 11 |
import { appendEvent } from '/web/progression.js'
|
| 12 |
|
| 13 |
export const FORGE_SYSTEM = [
|
| 14 |
+
'You are the Skill Forge for a fantasy auto-battler. Author ONE POWERFUL signature skill for a',
|
| 15 |
+
'specific hero, tailored to their class, story and personality. Respond with a SINGLE fenced',
|
| 16 |
+
'```json block (no prose outside it) of the form:',
|
| 17 |
'{"name": string, "flavor": string (one vivid in-world sentence),',
|
| 18 |
+
'"target": "foe"|"area"|"self"|"ally", "effects": [ {"op": <op>, ...args} ],',
|
| 19 |
+
'"visual": {"element": <element>, "shape": <shape>, "color": "#rrggbb", "intensity": 1-3} }.',
|
|
|
|
| 20 |
`Allowed effect ops: ${SAFE_OPS.join(', ')}.`,
|
| 21 |
+
'damage/bonus_damage/heal/life_steal take {"amount": int (be generous — these are signature skills)}.',
|
| 22 |
+
`apply_condition takes {"condition": one of [${SAFE_CONDITIONS.join(', ')}], "duration": int}.`,
|
| 23 |
+
'knockdown takes {"duration": int}. interrupt and lose_all_adrenaline take no args. Use 1-3 effects.',
|
| 24 |
+
'MOST skills should be offensive AREA attacks (target "foe"/"area") that strike EVERY enemy nearby —',
|
| 25 |
+
'make them hit hard. Only use target "self"/"ally" for heals/buffs.',
|
| 26 |
+
`For "visual", pick an element from [${ELEMENTS.join(', ')}], a shape from [${SHAPES.join(', ')}]`,
|
| 27 |
+
'(how it manifests), a hex color, and intensity 1-3 — all chosen to match the skill\'s theme so it',
|
| 28 |
+
'looks unique. Output ONLY the json block.',
|
| 29 |
].join(' ')
|
| 30 |
|
| 31 |
export function personaBlock(p) {
|
web/skillSchema.js
CHANGED
|
@@ -12,23 +12,41 @@ const NUM = (v, lo, hi, dflt) => {
|
|
| 12 |
return Math.max(lo, Math.min(hi, Math.round(n)))
|
| 13 |
}
|
| 14 |
const CONDITIONS = ['bleeding', 'poison', 'burning', 'deepWound', 'weakness', 'crippled', 'blind', 'dazed']
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
const OPS = {
|
| 18 |
-
damage: (e) => ({ op: 'damage', amount: NUM(e.amount,
|
| 19 |
-
bonus_damage: (e) => ({ op: '
|
| 20 |
-
heal: (e) => ({ op: 'heal', amount: NUM(e.amount, 1,
|
| 21 |
-
life_steal: (e) => ({ op: 'life_steal', amount: NUM(e.amount,
|
| 22 |
apply_condition: (e) => CONDITIONS.includes(e.condition)
|
| 23 |
-
? { op: 'apply_condition', condition: e.condition, duration: NUM(e.duration, 1, 25, 8)
|
| 24 |
: null,
|
| 25 |
-
knockdown: (e) => ({ op: 'knockdown', duration: NUM(e.duration, 1,
|
| 26 |
interrupt: () => ({ op: 'interrupt' }),
|
| 27 |
lose_all_adrenaline: () => ({ op: 'lose_all_adrenaline' }),
|
| 28 |
}
|
| 29 |
const CATEGORIES = ['melee_attack', 'ranged_attack', 'spell', 'shout', 'stance']
|
| 30 |
const TARGETS = ['foe', 'self', 'ally', 'area']
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
export const SAFE_OPS = Object.keys(OPS)
|
| 33 |
export const SAFE_CONDITIONS = CONDITIONS.slice()
|
| 34 |
|
|
@@ -46,17 +64,28 @@ export function validateSkill(raw, { persona, profession } = {}) {
|
|
| 46 |
if (!raw || typeof raw !== 'object') return { ok: false, skill: null, errors: ['not an object'] }
|
| 47 |
const name = String(raw.name || '').trim().slice(0, 40)
|
| 48 |
if (!name) errors.push('missing name')
|
| 49 |
-
const category = CATEGORIES.includes(raw.category) ? raw.category : 'melee_attack'
|
| 50 |
const target = TARGETS.includes(raw.target) ? raw.target : 'foe'
|
|
|
|
| 51 |
const rawEffects = Array.isArray(raw.effects) ? raw.effects : []
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
if (!effects.length) errors.push('no valid effects')
|
| 54 |
-
//
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
const
|
|
|
|
| 59 |
const flavor = String(raw.flavor || raw.description || '').trim().slice(0, 200)
|
|
|
|
| 60 |
if (errors.length) return { ok: false, skill: null, errors }
|
| 61 |
const skill = {
|
| 62 |
id: nextSkillId(persona),
|
|
@@ -64,11 +93,11 @@ export function validateSkill(raw, { persona, profession } = {}) {
|
|
| 64 |
profession: profession || (persona && persona.unitClass) || 'Warrior',
|
| 65 |
attribute: 'Forged',
|
| 66 |
category, target,
|
| 67 |
-
cost, cast:
|
| 68 |
-
requires:
|
| 69 |
effects,
|
| 70 |
-
// ── metadata (ignored by the resolver, used by the UI) ──
|
| 71 |
-
custom: true, flavor,
|
| 72 |
}
|
| 73 |
return { ok: true, skill, errors: [] }
|
| 74 |
}
|
|
@@ -88,7 +117,8 @@ export function effectSummary(skill) {
|
|
| 88 |
default: return e.op
|
| 89 |
}
|
| 90 |
})
|
| 91 |
-
|
|
|
|
| 92 |
}
|
| 93 |
|
| 94 |
// Resolve a persona's equipped skill ids into engine-ready objects (custom objects pass
|
|
|
|
| 12 |
return Math.max(lo, Math.min(hi, Math.round(n)))
|
| 13 |
}
|
| 14 |
const CONDITIONS = ['bleeding', 'poison', 'burning', 'deepWound', 'weakness', 'crippled', 'blind', 'dazed']
|
| 15 |
+
|
| 16 |
+
// Forged skills are a hero's signature: signature-strong, and most are AREA — they hit every foe
|
| 17 |
+
// within this radius of the caster (field units, ~7 tiles), routed through the engine's
|
| 18 |
+
// area_around_caster scope. Damage clamps are higher than canon, but capped so they can't trivially
|
| 19 |
+
// melt a Champion boss.
|
| 20 |
+
const AOE_RANGE = 180
|
| 21 |
|
| 22 |
const OPS = {
|
| 23 |
+
damage: (e) => ({ op: 'damage', amount: NUM(e.amount, 12, 90, 45) }),
|
| 24 |
+
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
|
| 25 |
+
heal: (e) => ({ op: 'heal', amount: NUM(e.amount, 1, 80, 30) }),
|
| 26 |
+
life_steal: (e) => ({ op: 'life_steal', amount: NUM(e.amount, 6, 60, 24) }),
|
| 27 |
apply_condition: (e) => CONDITIONS.includes(e.condition)
|
| 28 |
+
? { op: 'apply_condition', condition: e.condition, duration: NUM(e.duration, 1, 25, 8) }
|
| 29 |
: null,
|
| 30 |
+
knockdown: (e) => ({ op: 'knockdown', duration: NUM(e.duration, 1, 4, 2) }),
|
| 31 |
interrupt: () => ({ op: 'interrupt' }),
|
| 32 |
lose_all_adrenaline: () => ({ op: 'lose_all_adrenaline' }),
|
| 33 |
}
|
| 34 |
const CATEGORIES = ['melee_attack', 'ranged_attack', 'spell', 'shout', 'stance']
|
| 35 |
const TARGETS = ['foe', 'self', 'ally', 'area']
|
| 36 |
|
| 37 |
+
// Visual identity (cosmetic — drives the in-combat VFX; a bad value just falls back).
|
| 38 |
+
export const ELEMENTS = ['fire', 'ice', 'lightning', 'nature', 'poison', 'shadow', 'holy', 'arcane']
|
| 39 |
+
export const SHAPES = ['nova', 'ring', 'beam', 'pillar', 'projectile', 'shockwave', 'slash']
|
| 40 |
+
const ELEMENT_COLOR = { fire: '#ff6a2a', ice: '#6ad1ff', lightning: '#ffe14d', nature: '#5ad860', poison: '#9be24a', shadow: '#a56cff', holy: '#ffe9a8', arcane: '#c46cff' }
|
| 41 |
+
function validateVisual(v) {
|
| 42 |
+
v = v && typeof v === 'object' ? v : {}
|
| 43 |
+
const element = ELEMENTS.includes(v.element) ? v.element : 'arcane'
|
| 44 |
+
const shape = SHAPES.includes(v.shape) ? v.shape : 'nova'
|
| 45 |
+
const color = /^#[0-9a-fA-F]{6}$/.test(String(v.color || '')) ? v.color : ELEMENT_COLOR[element]
|
| 46 |
+
const intensity = Math.max(1, Math.min(3, Math.round(Number(v.intensity)) || 2))
|
| 47 |
+
return { element, shape, color, intensity }
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
export const SAFE_OPS = Object.keys(OPS)
|
| 51 |
export const SAFE_CONDITIONS = CONDITIONS.slice()
|
| 52 |
|
|
|
|
| 64 |
if (!raw || typeof raw !== 'object') return { ok: false, skill: null, errors: ['not an object'] }
|
| 65 |
const name = String(raw.name || '').trim().slice(0, 40)
|
| 66 |
if (!name) errors.push('missing name')
|
|
|
|
| 67 |
const target = TARGETS.includes(raw.target) ? raw.target : 'foe'
|
| 68 |
+
const isSupport = target === 'self' || target === 'ally'
|
| 69 |
const rawEffects = Array.isArray(raw.effects) ? raw.effects : []
|
| 70 |
+
let effects = rawEffects.map((e) => (e && OPS[e.op] ? OPS[e.op](e) : null)).filter(Boolean).slice(0, 3)
|
| 71 |
+
// Offensive forged skills are POWERFUL AREA skills: ensure a damage effect and make every
|
| 72 |
+
// effect hit all foes in range (area_around_caster). Support skills (heal/buff) stay self/ally.
|
| 73 |
+
if (!isSupport) {
|
| 74 |
+
if (!effects.some((e) => e.op === 'damage' || e.op === 'life_steal')) effects.unshift({ op: 'damage', amount: 45 })
|
| 75 |
+
effects = effects.slice(0, 3).map((e) => ({ ...e, scope: 'area_around_caster', range: AOE_RANGE }))
|
| 76 |
+
} else {
|
| 77 |
+
if (!effects.length) effects = [{ op: 'heal', amount: 30, scope: 'self' }]
|
| 78 |
+
effects = effects.map((e) => ({ ...e, scope: e.op === 'heal' ? 'self' : (e.scope || 'self') }))
|
| 79 |
+
}
|
| 80 |
if (!effects.length) errors.push('no valid effects')
|
| 81 |
+
// Routed through the engine's effect path (not single-target strike): offensive → 'spell',
|
| 82 |
+
// support → 'shout'. Both gate on adrenaline (built by basic attacks → unleash), so every class
|
| 83 |
+
// can power them. ATTACK categories (melee_attack) would single-target, so we avoid them here.
|
| 84 |
+
const category = isSupport ? 'shout' : 'spell'
|
| 85 |
+
const cost = { adrenaline: NUM(raw.cost?.adrenaline ?? raw.cost, 3, 12, 7) }
|
| 86 |
+
const recharge = NUM(raw.recharge ?? raw.cooldown, 0, 20, isSupport ? 6 : 2)
|
| 87 |
const flavor = String(raw.flavor || raw.description || '').trim().slice(0, 200)
|
| 88 |
+
const visual = validateVisual(raw.visual)
|
| 89 |
if (errors.length) return { ok: false, skill: null, errors }
|
| 90 |
const skill = {
|
| 91 |
id: nextSkillId(persona),
|
|
|
|
| 93 |
profession: profession || (persona && persona.unitClass) || 'Warrior',
|
| 94 |
attribute: 'Forged',
|
| 95 |
category, target,
|
| 96 |
+
cost, cast: 0, recharge,
|
| 97 |
+
requires: [],
|
| 98 |
effects,
|
| 99 |
+
// ── metadata (ignored by the resolver, used by the UI/VFX) ──
|
| 100 |
+
custom: true, flavor, visual, aoe: !isSupport,
|
| 101 |
}
|
| 102 |
return { ok: true, skill, errors: [] }
|
| 103 |
}
|
|
|
|
| 117 |
default: return e.op
|
| 118 |
}
|
| 119 |
})
|
| 120 |
+
const s = parts.join(' · ')
|
| 121 |
+
return skill.aoe ? `AoE · ${s}` : s
|
| 122 |
}
|
| 123 |
|
| 124 |
// Resolve a persona's equipped skill ids into engine-ready objects (custom objects pass
|