⚓ Seaworth Games

lib/spells · scripts/spells · config/scriptsSpells.json

Spell System

One CSpell definition, two unrelated runtimes: in battle, casting means running a small ordered list of Lua scripts pulled from the same registry that drives combat's damage math; on the adventure map, it means one hardcoded C++ class chosen by a switch on the spell's identity. Nothing is shared between them but the name.

The shape of a cast

CSpell is the config-time object. It doesn't cast anything — it hands off to whichever mechanics factory matches the context.

CSpell, loaded once from JSON by CSpellHandler, is the school, level, cost and effect list a modder wrote down. It never touches the battlefield directly. Two separate factories turn it into something runnable, and they don't agree on much beyond the name:

Battle context

ISpellMechanicsFactory::get(spell)->create(event)

keyed by an IBattleCast event (who's casting, at what level, in which battle) → produces a BattleSpellMechanics, the same class for every spell.

Adventure context

IAdventureSpellMechanics::createMechanics(spell)

keyed only by the spell itself → a hardcoded switch that returns one of seven distinct C++ classes, unique per spell. See below.

Casting in battle

A spell cast is just another branch in the same switch that handles an attack — same envelope, same pack, different case label.

The client sends the identical MakeAction → BattleAction pack this system's Battle System doc already traced for an attack. BattleActionProcessor::dispatchBattleAction() switches on ba.actionType: WALK_AND_ATTACK goes to doAttackAction(), but HERO_SPELL, MONSTER_SPELL, and WALK_AND_CAST go to their own handlers — wrapped in the exact same StartAction / EndAction pair, still one request across the network, still one broadcast back.

Before anything applies, legality runs through two small rule engines rather than ad-hoc if-chains: TargetCondition composes named checks (createAbsoluteLevel, createElemental, createResistance, …) that decide whether a given unit is a legal target at all, and every rejection — from either side — is written into a Problem as one or more MetaString entries rather than a bare bool, which is what lets the client show the player why a cast is blocked instead of just refusing it.

The effect scripts

Every battle spell effect — built-in or modded, damage or dispel — is loaded through the exact same script registry that supplied Battle's damage calculator.

effects::Effects::loadJson() reads a spell's effect list and, for every named entry, resolves its "type" through LIBRARY->scriptTypes() — the identical ScriptHandler this atlas already met in Battle, where ScriptKind::DAMAGE_CALCULATOR supplied the default damage formula. Spell effects are just the sibling kind, ScriptKind::SPELL_EFFECT, and there is no third option: nothing here has a C++ fallback either. lib/spells/effects/ holds no concrete damage, heal, or summon class — only the composition machinery (Effects, Effect, LocationEffect). The effects themselves are Lua, one file per named type under scripts/spells/, registered in config/scriptsSpells.json:

  1. damagethe default offensive-spell formula — chain length, kill-by-percentage variants
  2. heal / sacrificerestores health; sacrifice trades one stack's health for another's
  3. timedbuffs and debuffs — grants a Bonus for a duration; has its own sub-patches for Shield and Bind variants
  4. obstacle / moatplaces a battlefield hazard, optionally hidden or a one-shot trap
  5. summon / demonSummon / cloneadds a new stack to the battlefield, permanent or temporary
  6. teleport / dispel / removeObstacle / catapultthe rest of the catalog — movement, cleansing, siege

A spell composes a handful of these by name in its own JSON, each with its own parameters, an optional flag (this effect isn't required for the cast to be legal — a Fire Wall's obstacle placement doesn't need a target to damage), and an indirect flag (side effect, not part of what makes a target valid). Effects::prepare() walks that ordered list once per cast and hands each applicable one, in order, to Effect::apply(). It's the same shape as the damage-calculator patch chain from Battle — a small ordered list of named scripts — just applied to composing one spell instead of layering onto every attack.

Adventure spells, alone

Step off the battlefield and every part of this — the script registry, the composable effect list, the generic mechanics class — disappears.

CSpell one config, loaded once cast in battle cast on the map BattleSpellMechanics one class, every spell createMechanics(spell) switch on SpellID effects::Effects ordered list of named Lua scripts damage, heal, timed, obstacle… config/scriptsSpells.json → scripts/spells/*.lua getEffect(caster) one dedicated C++ class TownPortalEffect, DimensionDoorEffect, ViewWorldEffect, SummonBoatEffect… no C++ fallback — scripted, like Battle's damage calc no script layer, no shared effect list — only 7 classes exist
The two worlds share only CSpell and the name of the spell. Battle routes every effect — built-in and modded alike — through the same Lua script registry Battle's damage calculator uses. The adventure map has no such registry: IAdventureSpellMechanics::createMechanics() is a hand-written switch over SpellID returning one of seven hardcoded classes (TownPortalEffect, DimensionDoorEffect, ReinforcementsEffect, RemoveObjectEffect, SummonBoatEffect, ViewWorldEffect, or a generic AdventureSpellEffect fallback) — adding an adventure spell means writing a C++ class, not a config entry.

Who's casting

Five caster types, one base class: ProxyCaster decouples who gets credited and whose stats apply from what actually triggered the cast.

ObstacleCasterProxya trap or landmine casting on whoever steps on it — credited to the hero who planted it, but silent: no message, no mana spent by the owner
BonusCastera granted Bonus (an artifact, a creature ability) casting on its own trigger, attributed to the bonus rather than a hero
AbilityCastera unit's innate ability casting at a fixed effective spell level, with no hero behind it at all
ExternalCastera rebindable stand-in for casts triggered from outside the normal flow — scripts, the map editor's test tools

Every one of them wraps a real Caster and forwards most of the interface, overriding only the handful of methods (mana cost, school level, the text shown to players) where "who gets credited" actually needs to differ from "who provided the underlying stats."