polats Claude Opus 4.8 (1M context) commited on
Commit
f9e5860
·
1 Parent(s): 33602dc

Skill forge: powerful AoE skills + visual identity (element/shape/color/intensity)

Browse files

Offensive 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>

Files changed (4) hide show
  1. web/comboBattler.js +5 -0
  2. web/engine.js +5 -0
  3. web/skillForge.js +14 -11
  4. 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 combat skill for a specific',
15
- 'hero, tailored to their class, story and personality. Respond with a SINGLE fenced ```json',
16
- 'block (no prose outside it) of the form:',
17
  '{"name": string, "flavor": string (one vivid in-world sentence),',
18
- '"category": "melee_attack"|"ranged_attack"|"spell"|"shout"|"stance",',
19
- '"target": "foe"|"self"|"ally"|"area", "cost": int, "recharge": int seconds,',
20
- '"effects": [ {"op": <op>, ...args} ] }.',
21
  `Allowed effect ops: ${SAFE_OPS.join(', ')}.`,
22
- 'damage/bonus_damage/heal/life_steal take {"amount": int}. apply_condition takes',
23
- `{"condition": one of [${SAFE_CONDITIONS.join(', ')}], "duration": int seconds}.`,
24
- 'knockdown takes {"duration": int}. interrupt and lose_all_adrenaline take no args.',
25
- 'Use 1-3 effects. Keep it concise, flavourful and balanced. Output ONLY the json block.',
 
 
 
 
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
- const SCOPES = ['self', 'party', 'nearby', 'area', 'target_and_adjacent', 'adjacent_to_target']
 
 
 
 
 
16
 
17
  const OPS = {
18
- damage: (e) => ({ op: 'damage', amount: NUM(e.amount, 1, 60, 15) }),
19
- bonus_damage: (e) => ({ op: 'bonus_damage', amount: NUM(e.amount, 1, 40, 12) }),
20
- heal: (e) => ({ op: 'heal', amount: NUM(e.amount, 1, 60, 20) }),
21
- life_steal: (e) => ({ op: 'life_steal', amount: NUM(e.amount, 1, 40, 12) }),
22
  apply_condition: (e) => CONDITIONS.includes(e.condition)
23
- ? { op: 'apply_condition', condition: e.condition, duration: NUM(e.duration, 1, 25, 8), ...(SCOPES.includes(e.scope) ? { scope: e.scope } : {}) }
24
  : null,
25
- knockdown: (e) => ({ op: 'knockdown', duration: NUM(e.duration, 1, 5, 2) }),
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
- const effects = rawEffects.map((e) => (e && OPS[e.op] ? OPS[e.op](e) : null)).filter(Boolean).slice(0, 3)
 
 
 
 
 
 
 
 
 
53
  if (!effects.length) errors.push('no valid effects')
54
- // Cost: keep it cheap enough to actually fire. Casters pay energy, fighters adrenaline.
55
- const isCaster = category === 'spell'
56
- const cost = isCaster ? { energy: NUM(raw.cost?.energy ?? raw.cost, 1, 15, 5) }
57
- : { adrenaline: NUM(raw.cost?.adrenaline ?? raw.cost, 0, 10, 4) }
58
- const recharge = NUM(raw.recharge ?? raw.cooldown, 0, 30, isCaster ? 4 : 0)
 
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: isCaster ? NUM(raw.cast, 0, 3, 1) : 0, recharge,
68
- requires: category === 'melee_attack' || category === 'ranged_attack' ? ['on_hit'] : [],
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
- return parts.join(' · ')
 
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