ajhahn.de
← Theria commits

Commit

Theria

feat: add a data-driven hero ability and shapeshift layer

ajhahnde · Jun 2026 · b6f9f21dfe2fcb036c8fd801cbc56508a20ec68a · parent: d86c3d4 · view on GitHub →

modified CHANGELOG.md
@@ -35,6 +35,15 @@ protocol version.
### Added
- A data-driven hero ability layer for Theria's shapeshifters. Every hero carries
two kits — a human form and an animal form — and transforms between them, each
form metering its own resource pool with separate cooldowns that keep running
across the swap. Abilities are defined as catalog data, not code: each is
skillshot-aimed, ground-targeted, unit-locked, or self-cast, and resolves inside
the authoritative simulation alongside the existing combat, so it stays
deterministic and replayable. This release includes one proving kit that
exercises the whole schema; the distinct heroes and the controls that cast them
are built against it next.
- An in-game connect screen: a windowed launch now opens a menu to start a
single-machine practice match, host a listen-server, or join one by address,
instead of requiring command-line flags. The flags still work and skip the menu
modified README.md
@@ -66,7 +66,7 @@ delay adapts to the connection's measured jitter rather than being fixed.
| Path | Contents |
| :------------- | :---------------------------------------------------- |
| `src/sim` | The authoritative simulation core and its data types. |
| `src/sim` | The authoritative simulation core, its data types, and the data-driven hero ability layer. |
| `src/bot` | Bot input derived from the world state. |
| `src/net` | Listen-server transport, the client/server wire protocol, remote-entity interpolation, and the playtest link-condition simulator. |
| `src/client` | The connect menu, local input sampling, and rendering. |
added src/sim/ability_data.gd
@@ -0,0 +1,135 @@
class_name AbilityData
extends RefCounted
## The ability catalog: every ability as a data row, and every hero kit as a map
## from form and slot to an ability id plus that form's resource pool. Pure static
## data — the single source of truth the simulation reads to equip a hero and the
## executor reads to act. New abilities and heroes are added here, by value, with
## no engine or render coupling, so the whole roster stays unit-testable.
##
## v0.1 ships one proving kit, "wildkin": a generic shapeshifter that exercises the
## whole schema — all four targeting modes, the three effects, a per-form resource,
## and the human/animal transform. The distinct Theria heroes are authored against
## this same catalog in a later slice.
## Ability rows keyed by catalog id. Each row is parsed on demand into a typed
## AbilitySpec by `spec`; a sparse row leans on the spec defaults. The dictionary's
## insertion order is stable, which keeps any iteration over the roster
## deterministic, like the rest of the simulation.
const ABILITIES := {
# --- wildkin, human form -------------------------------------------------
1:
{
"id": 1,
"name": "Spirit Bolt",
"form": AbilitySpec.FORM_HUMAN,
"slot": 0,
"target_kind": AbilitySpec.TARGET_SKILLSHOT,
"range": 600.0,
"radius": 60.0, # a tight bolt: clips enemies at its landing point
"cost": 20,
"cooldown_ticks": 30,
"effect": AbilitySpec.EFFECT_DAMAGE,
"power": 80,
},
2:
{
"id": 2,
"name": "Mend",
"form": AbilitySpec.FORM_HUMAN,
"slot": 1,
"target_kind": AbilitySpec.TARGET_SELF,
"cost": 30,
"cooldown_ticks": 120,
"effect": AbilitySpec.EFFECT_HEAL,
"power": 100,
},
3:
{
"id": 3,
"name": "Beast Form",
"form": AbilitySpec.FORM_HUMAN,
"slot": 3,
"target_kind": AbilitySpec.TARGET_SELF,
"cost": 0,
"cooldown_ticks": 60,
"effect": AbilitySpec.EFFECT_TRANSFORM,
},
# --- wildkin, animal form ------------------------------------------------
4:
{
"id": 4,
"name": "Pounce",
"form": AbilitySpec.FORM_ANIMAL,
"slot": 0,
"target_kind": AbilitySpec.TARGET_GROUND,
"range": 400.0,
"radius": 150.0, # an area slam: every enemy inside the circle is struck
"cost": 20,
"cooldown_ticks": 24,
"effect": AbilitySpec.EFFECT_DAMAGE,
"power": 60,
},
5:
{
"id": 5,
"name": "Rend",
"form": AbilitySpec.FORM_ANIMAL,
"slot": 2,
"target_kind": AbilitySpec.TARGET_UNIT,
"range": 200.0,
"cost": 30,
"cooldown_ticks": 48,
"effect": AbilitySpec.EFFECT_DAMAGE,
"power": 120,
},
6:
{
"id": 6,
"name": "Human Form",
"form": AbilitySpec.FORM_ANIMAL,
"slot": 3,
"target_kind": AbilitySpec.TARGET_SELF,
"cost": 0,
"cooldown_ticks": 60,
"effect": AbilitySpec.EFFECT_TRANSFORM,
},
}
## Hero kits keyed by kit id. A kit names, per form, the resource pool (`max` and
## the regen interval `regen_ticks` — one resource point restored every that many
## ticks, 0 for none) and the slot-to-ability-id bar. A hero equipped with a kit
## starts in human form with that form's resource full. Integer regen on a tick
## interval keeps resource growth deterministic, like the cooldown counters.
const KITS := {
"wildkin":
{
"resource":
{
# Human "Focus" and animal "Ferocity": same shape, distinct pools the
# transform swaps between, so each stance meters its own casts.
AbilitySpec.FORM_HUMAN: {"max": 100, "regen_ticks": 12},
AbilitySpec.FORM_ANIMAL: {"max": 100, "regen_ticks": 12},
},
"abilities":
{
AbilitySpec.FORM_HUMAN: {0: 1, 1: 2, 3: 3},
AbilitySpec.FORM_ANIMAL: {0: 4, 2: 5, 3: 6},
},
},
}
## The typed spec for a catalog id. Parses the row on demand — the catalog is small
## and the executor caches nothing, so a spec is always read fresh by value.
static func spec(id: int) -> AbilitySpec:
return AbilitySpec.from_dict(ABILITIES.get(id, {}))
## Whether an ability id exists in the catalog.
static func has_ability(id: int) -> bool:
return ABILITIES.has(id)
## A kit definition by id, or an empty dictionary if unknown.
static func kit(kit_id: String) -> Dictionary:
return KITS.get(kit_id, {})
added src/sim/ability_data.gd.uid
@@ -0,0 +1 @@
uid://dmvctjulfwfg3
added src/sim/ability_executor.gd
@@ -0,0 +1,109 @@
class_name AbilityExecutor
extends RefCounted
## Resolves and applies one ability cast against the world. Pure and engine-free,
## exactly like the simulation core it runs inside: given the world, a caster, the
## ability's spec, and the cast intent, it picks the targets and applies the effect
## deterministically — in insertion order, with integer damage — so the result is a
## function of state and input alone and replays identically.
##
## `can_cast` is the gate (form, resource, cooldown); `execute` performs the cast
## and books the cost. SimCore's ability step calls them in that order. Effects
## that reduce hp leave the kill to the core's death-resolution pass, so an ability
## and an auto-attack that both finish a unit this tick kill it once.
## Whether `caster` may cast `spec` this tick: the spec must belong to the caster's
## active form, the caster must hold enough resource, and the ability must be off
## cooldown. Reads only — the decision never mutates the world.
static func can_cast(caster: SimEntity, spec: AbilitySpec) -> bool:
if spec.form != caster.form:
return false
if caster.resource < spec.cost:
return false
if caster.ability_cooldowns.get(spec.id, 0) > 0:
return false
return true
## Performs the cast: applies the effect, puts the ability on cooldown, and spends
## its resource. Assumes `can_cast` already passed (SimCore gates on it), so the
## resource never goes negative. `command` carries the aim — the target point for an
## aimed ability, the target id for a unit-locked one.
static func execute(
state: SimState, caster: SimEntity, spec: AbilitySpec, command: InputCommand
) -> void:
match spec.effect:
AbilitySpec.EFFECT_TRANSFORM:
_transform(caster)
AbilitySpec.EFFECT_HEAL:
caster.hp = mini(caster.max_hp, caster.hp + spec.power)
AbilitySpec.EFFECT_DAMAGE:
for target in _targets(state, caster, spec, command):
target.hp -= spec.power
caster.ability_cooldowns[spec.id] = spec.cooldown_ticks
caster.resource -= spec.cost
## Swaps the caster to its other form and to that form's resource pool: max and
## regen switch, the current pool carries over clamped to the new max, and the regen
## counter restarts. Ability cooldowns are keyed by ability id, so they persist
## untouched across the swap.
static func _transform(caster: SimEntity) -> void:
var to_form := 1 - caster.form
caster.form = to_form
caster.resource_max = caster.form_resource_max[to_form]
caster.resource_regen_ticks = caster.form_resource_regen[to_form]
caster.resource = mini(caster.resource, caster.resource_max)
caster.resource_regen_counter = 0
## The enemies a damaging ability strikes, by its targeting mode. SELF deals no
## outward damage (returns nothing); UNIT returns its one locked enemy when valid
## and in range; an aimed ability returns every enemy inside the area at its landing
## point. Allies and non-combat entities (max_hp 0) are never struck.
static func _targets(
state: SimState, caster: SimEntity, spec: AbilitySpec, command: InputCommand
) -> Array[SimEntity]:
var hits: Array[SimEntity] = []
match spec.target_kind:
AbilitySpec.TARGET_UNIT:
var t: SimEntity = state.get_entity(command.target_id)
if (
t != null
and t.max_hp > 0
and t.team != caster.team
and caster.position.distance_to(t.position) <= spec.range
):
hits.append(t)
AbilitySpec.TARGET_SKILLSHOT, AbilitySpec.TARGET_GROUND:
hits = _enemies_in_area(state, caster, _landing_point(caster, spec, command), spec.radius)
return hits
## Where an aimed ability lands. A skillshot flies the full range along the aim
## direction (it travels through the cursor — dodgeable); a ground-target lands on
## the chosen point, pulled in to the maximum range. A zero-length aim lands on the
## caster.
static func _landing_point(caster: SimEntity, spec: AbilitySpec, command: InputCommand) -> Vector2:
var to_aim := command.target_point - caster.position
var dist := to_aim.length()
if dist <= 0.0:
return caster.position
var dir := to_aim / dist
if spec.target_kind == AbilitySpec.TARGET_SKILLSHOT:
return caster.position + dir * spec.range
return caster.position + dir * minf(spec.range, dist)
## Every living enemy of `caster` within `radius` of `center`, in deterministic
## insertion order.
static func _enemies_in_area(
state: SimState, caster: SimEntity, center: Vector2, radius: float
) -> Array[SimEntity]:
var hits: Array[SimEntity] = []
for id in state.entities:
var e: SimEntity = state.entities[id]
if e.team == caster.team or e.max_hp <= 0:
continue
if center.distance_to(e.position) <= radius:
hits.append(e)
return hits
added src/sim/ability_executor.gd.uid
@@ -0,0 +1 @@
uid://bx34dthpjxova
added src/sim/ability_spec.gd
@@ -0,0 +1,84 @@
class_name AbilitySpec
extends RefCounted
## The immutable definition of one ability — its targeting, cost, cooldown, and
## effect. Pure tuning data, parsed from the ability catalog; it owns no state and
## never mutates the world (the executor reads a spec to act). Keeping abilities as
## data, not code, lets the whole roster live in one catalog and be unit-tested by
## value.
##
## Theria heroes are shapeshifters: every hero carries a human kit and an animal
## kit, and a spec belongs to exactly one `form`. A spec is only castable while its
## form is the caster's active form, which is what makes the two stances play as
## two distinct kits sharing one hero.
## Active form a spec belongs to. A hero swaps between the two with a TRANSFORM
## ability; cooldowns and the per-form resource carry across the swap.
const FORM_HUMAN := 0
const FORM_ANIMAL := 1
## How an ability picks what it hits.
## SELF — the caster (heals, transforms, self-buffs); no aim.
## SKILLSHOT — aimed: a point defines a direction, the shot lands at the clamped
## range along it and strikes there. Dodgeable.
## GROUND — a point on the field (clamped to range): an area lands there.
## UNIT — a locked target entity within range; the effect cannot miss.
const TARGET_SELF := 0
const TARGET_SKILLSHOT := 1
const TARGET_GROUND := 2
const TARGET_UNIT := 3
## What an ability does where it lands.
## DAMAGE — subtracts `power` hp from each enemy struck.
## HEAL — restores `power` hp to the caster (clamped to max_hp).
## TRANSFORM — swaps the caster to its other form (and that form's resource).
const EFFECT_DAMAGE := 0
const EFFECT_HEAL := 1
const EFFECT_TRANSFORM := 2
## Catalog id (unique across the roster) and display name.
var id: int = 0
var name: String = ""
## The form this spec is cast from, and the slot it occupies in that form's kit
## (0..3, the Q/W/E/R bar). One id per (form, slot) within a kit.
var form: int = FORM_HUMAN
var slot: int = 0
var target_kind: int = TARGET_SELF
## Maximum cast distance (world units) for an aimed/targeted ability; ignored for
## SELF. `radius` is the area struck around the landing point: every enemy inside
## the circle is hit. A SKILLSHOT or GROUND ability gives it a small-to-large value
## (a tight bolt up to a wide slam); SELF and UNIT abilities ignore it (UNIT strikes
## its one locked target).
var range: float = 0.0
var radius: float = 0.0
## Resource spent to cast (see SimEntity's per-form resource) and the cooldown, in
## ticks, the ability goes onto once cast.
var cost: int = 0
var cooldown_ticks: int = 0
var effect: int = EFFECT_DAMAGE
## Magnitude of the effect: hp for DAMAGE/HEAL. Unused by TRANSFORM, which always
## swaps to the caster's other form.
var power: int = 0
## Builds a spec from one catalog row. Every field defaults, so a sparse row only
## states what it changes — keeping the catalog terse and the parse total.
static func from_dict(d: Dictionary) -> AbilitySpec:
var spec := AbilitySpec.new()
spec.id = d.get("id", 0)
spec.name = d.get("name", "")
spec.form = d.get("form", FORM_HUMAN)
spec.slot = d.get("slot", 0)
spec.target_kind = d.get("target_kind", TARGET_SELF)
spec.range = d.get("range", 0.0)
spec.radius = d.get("radius", 0.0)
spec.cost = d.get("cost", 0)
spec.cooldown_ticks = d.get("cooldown_ticks", 0)
spec.effect = d.get("effect", EFFECT_DAMAGE)
spec.power = d.get("power", 0)
return spec
added src/sim/ability_spec.gd.uid
@@ -0,0 +1 @@
uid://bji5gytl1na8t
modified src/sim/input_command.gd
@@ -9,3 +9,13 @@ extends RefCounted
## Desired movement direction. Components are expected in [-1, 1]; the
## simulation clamps the magnitude to 1 so diagonal input is not faster.
var move_dir: Vector2 = Vector2.ZERO
## Ability cast intent for this tick. `ability_slot` is the bar slot to cast (0..3
## of the active form's kit), or -1 for no cast. `target_point` aims a skillshot or
## ground ability (a world position); `target_id` locks a unit-targeted one (an
## entity id). The fields the chosen ability does not use are ignored. Only
## `move_dir` is carried over the wire today; ability intent is consumed locally
## (v0.1 is local-only), so adding it here does not reshape the netcode protocol.
var ability_slot: int = -1
var target_point: Vector2 = Vector2.ZERO
var target_id: int = 0
modified src/sim/sim_core.gd
@@ -112,6 +112,35 @@ func add_hero(team: int, position: Vector2, move_speed: float) -> int:
return _register(entity)
## Turns an already-spawned hero into an ability caster by equipping a kit from the
## catalog. The hero starts in human form with that form's resource pool full; the
## animal pool waits for the first transform. Kept separate from `add_hero` so a
## bare walking-skeleton hero — and the netcode that spawns one — is unchanged until
## a kit is equipped. A no-op for an unknown hero id or kit.
func equip_kit(hero_id: int, kit_id: String) -> void:
var hero := state.get_entity(hero_id)
if hero == null:
return
var kit_def := AbilityData.kit(kit_id)
if kit_def.is_empty():
return
var res: Dictionary = kit_def["resource"]
hero.is_hero = true
hero.form = AbilitySpec.FORM_HUMAN
hero.kit = (kit_def["abilities"] as Dictionary).duplicate(true)
hero.form_resource_max = PackedInt32Array(
[res[AbilitySpec.FORM_HUMAN]["max"], res[AbilitySpec.FORM_ANIMAL]["max"]]
)
hero.form_resource_regen = PackedInt32Array(
[res[AbilitySpec.FORM_HUMAN]["regen_ticks"], res[AbilitySpec.FORM_ANIMAL]["regen_ticks"]]
)
hero.resource_max = hero.form_resource_max[AbilitySpec.FORM_HUMAN]
hero.resource_regen_ticks = hero.form_resource_regen[AbilitySpec.FORM_HUMAN]
hero.resource = hero.resource_max
hero.resource_regen_counter = 0
hero.ability_cooldowns = {}
## Creates a lane creep at `position` and returns its id. The creep marches
## `lane` toward the enemy nexus and fights with the shared combat primitive.
func add_creep(team: int, lane: int, position: Vector2) -> int:
@@ -138,6 +167,7 @@ func step(inputs: Dictionary) -> void:
_step_spawning()
_step_movement(inputs)
_step_creeps()
_step_abilities(inputs)
_step_combat()
_resolve_deaths()
state.tick += 1
@@ -224,6 +254,62 @@ func _step_creeps() -> void:
creep.position = MapData.clamp_to_bounds(creep.position)
## Advances the ability layer one tick. First every hero's passive upkeep —
## resource regen and cooldown decay — which runs regardless of input so pools refill
## and cooldowns elapse while idle. Then any casts requested this tick: a cast is
## gated through AbilityExecutor.can_cast (form, resource, cooldown) and, on success,
## applied and its cost booked. Runs before `_step_combat` so an ability and an
## auto-attack that both finish a unit this tick are reconciled in one death pass.
## Pure and insertion-ordered like the rest of the step.
func _step_abilities(inputs: Dictionary) -> void:
for id in state.entities:
var hero: SimEntity = state.entities[id]
if not hero.is_hero:
continue
_regen_resource(hero)
_tick_cooldowns(hero)
for id in inputs:
var command: InputCommand = inputs[id]
if command == null or command.ability_slot < 0:
continue
var hero: SimEntity = state.get_entity(id)
if hero != null and hero.is_hero:
_try_cast(hero, command)
## Restores one resource point once `resource_regen_ticks` ticks have elapsed,
## capped at the form's max. Integer regen on a tick interval keeps the pool
## deterministic; a form with no regen (or a full pool) is left alone.
func _regen_resource(hero: SimEntity) -> void:
if hero.resource_regen_ticks <= 0 or hero.resource >= hero.resource_max:
return
hero.resource_regen_counter += 1
if hero.resource_regen_counter >= hero.resource_regen_ticks:
hero.resource_regen_counter = 0
hero.resource = mini(hero.resource + 1, hero.resource_max)
## Ticks every live ability cooldown down by one. Keyed by ability id, so a cooldown
## set in one form keeps elapsing while the hero is in the other.
func _tick_cooldowns(hero: SimEntity) -> void:
for ability_id in hero.ability_cooldowns:
var remaining: int = hero.ability_cooldowns[ability_id]
if remaining > 0:
hero.ability_cooldowns[ability_id] = remaining - 1
## Resolves the requested slot to an ability of the hero's active form and casts it
## if it is castable. An empty slot, an off-form slot, or a failed gate is a no-op.
func _try_cast(hero: SimEntity, command: InputCommand) -> void:
var slots: Dictionary = hero.kit.get(hero.form, {})
var ability_id: int = slots.get(command.ability_slot, 0)
if ability_id == 0 or not AbilityData.has_ability(ability_id):
return
var spec := AbilityData.spec(ability_id)
if AbilityExecutor.can_cast(hero, spec):
AbilityExecutor.execute(state, hero, spec, command)
## Every attacker ticks its cooldown down; when it hits 0 and an enemy is in
## range, it strikes the nearest one and the cooldown resets. Damage is applied
## to the shared entity in deterministic insertion order, so two attackers can
modified src/sim/sim_entity.gd
@@ -39,6 +39,37 @@ var is_creep: bool = false
var lane: int = 0
var waypoint_index: int = 0
## A hero is the player/bot unit that, on top of the shared auto-attack, casts
## abilities. The ability layer is inert until `is_hero` is set and a kit equipped
## (see SimCore.equip_kit); a hero with no kit just auto-attacks like before.
var is_hero: bool = false
## The active shapeshifter form (AbilitySpec.FORM_HUMAN / FORM_ANIMAL). Only the
## abilities of the active form are castable; a TRANSFORM ability flips it.
var form: int = 0
## The current form's resource pool: `resource` spent to cast (gated against
## `resource_max`), refilled by one point every `resource_regen_ticks` ticks
## (0 = no regen), counted by `resource_regen_counter`. The two forms keep separate
## pools — `form_resource_max[form]` / `form_resource_regen[form]` hold each form's
## tuning, and a transform swaps the active values to the destination form's.
var resource: int = 0
var resource_max: int = 0
var resource_regen_ticks: int = 0
var resource_regen_counter: int = 0
var form_resource_max: PackedInt32Array = PackedInt32Array([0, 0])
var form_resource_regen: PackedInt32Array = PackedInt32Array([0, 0])
## Remaining cooldown in ticks per ability id (absent/0 = ready). Keyed by ability
## id rather than slot, so a cooldown set in one form is still ticking when the hero
## transforms back to it.
var ability_cooldowns: Dictionary = {}
## The hero's bar, by form: `kit[form][slot]` is the ability id in that slot, or
## absent for an empty slot. Set once when the kit is equipped; the catalog holds
## the immutable specs the ids resolve to.
var kit: Dictionary = {}
func _init(
p_id: int = 0,
@@ -68,4 +99,14 @@ func clone() -> SimEntity:
copy.is_creep = is_creep
copy.lane = lane
copy.waypoint_index = waypoint_index
copy.is_hero = is_hero
copy.form = form
copy.resource = resource
copy.resource_max = resource_max
copy.resource_regen_ticks = resource_regen_ticks
copy.resource_regen_counter = resource_regen_counter
copy.form_resource_max = form_resource_max.duplicate()
copy.form_resource_regen = form_resource_regen.duplicate()
copy.ability_cooldowns = ability_cooldowns.duplicate()
copy.kit = kit.duplicate(true)
return copy
added test/unit/test_ability.gd
@@ -0,0 +1,259 @@
extends GutTest
## Deterministic checks on the hero ability layer — targeting, the per-form
## resource, cooldowns, and the shapeshift transform. These run headless against the
## exact step function the live client (later) drives, with creep waves off so only
## the ability under test changes the world.
func _hero(sim: SimCore, team: int, pos: Vector2) -> int:
var id := sim.add_hero(team, pos, 320.0)
sim.equip_kit(id, "wildkin")
return id
## Equips a wildkin hero and transforms it to its animal form with a real Beast Form
## cast, so the animal-kit tests start from the form the transform actually produces.
func _animal_hero(sim: SimCore, team: int, pos: Vector2) -> int:
var id := _hero(sim, team, pos)
var beast := InputCommand.new()
beast.ability_slot = 3 # human slot 3 = Beast Form
sim.step({id: beast})
return id
# --- Equip + form -----------------------------------------------------------
func test_equip_kit_makes_a_human_caster_with_a_full_pool() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, 0, Vector2.ZERO)
var h := sim.state.get_entity(id)
assert_true(h.is_hero, "an equipped hero is an ability caster")
assert_eq(h.form, AbilitySpec.FORM_HUMAN, "a freshly equipped hero starts human")
assert_eq(h.resource, 100, "and with its human pool full")
assert_eq(h.resource_max, 100)
assert_eq(h.kit[AbilitySpec.FORM_HUMAN][0], 1, "Spirit Bolt sits in the human Q slot")
# --- Targeting: skillshot, ground, unit -------------------------------------
func test_skillshot_strikes_an_enemy_at_its_landing_point() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, 0, Vector2.ZERO)
# Range 600 along +x; the bolt flies the full range and clips an enemy there.
var enemy := sim.add_entity(1, Vector2(600.0, 0.0), 0.0, 600)
var cast := InputCommand.new()
cast.ability_slot = 0
cast.target_point = Vector2(100.0, 0.0) # any point along +x: a skillshot flies through it
sim.step({id: cast})
assert_eq(sim.state.get_entity(enemy).hp, 520, "Spirit Bolt deals its power at the landing point")
assert_eq(sim.state.get_entity(id).resource, 80, "and the cast spends its resource")
func test_skillshot_misses_when_aimed_away() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, 0, Vector2.ZERO)
var enemy := sim.add_entity(1, Vector2(600.0, 0.0), 0.0, 600)
var cast := InputCommand.new()
cast.ability_slot = 0
cast.target_point = Vector2(0.0, 100.0) # aimed up +y: lands at (0,600), nowhere near the enemy
sim.step({id: cast})
assert_eq(sim.state.get_entity(enemy).hp, 600, "a bolt aimed away does not hit")
func test_ground_area_strikes_every_enemy_in_its_radius() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _animal_hero(sim, 0, Vector2.ZERO)
# Pounce: GROUND, range 400, radius 150. Land it at (400,0).
var inside := sim.add_entity(1, Vector2(400.0, 100.0), 0.0, 600) # 100 from centre -> hit
var outside := sim.add_entity(1, Vector2(400.0, 300.0), 0.0, 600) # 300 from centre -> spared
var cast := InputCommand.new()
cast.ability_slot = 0 # animal Q = Pounce
cast.target_point = Vector2(400.0, 0.0)
sim.step({id: cast})
assert_eq(sim.state.get_entity(inside).hp, 540, "an enemy inside the area is struck")
assert_eq(sim.state.get_entity(outside).hp, 600, "an enemy outside the radius is spared")
func test_unit_ability_strikes_its_locked_target() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _animal_hero(sim, 0, Vector2.ZERO)
sim.state.get_entity(id).attack_damage = 0 # isolate Rend from the auto-attack (same range band)
var enemy := sim.add_entity(1, Vector2(150.0, 0.0), 0.0, 600) # inside Rend's 200 range
var cast := InputCommand.new()
cast.ability_slot = 2 # animal E = Rend
cast.target_id = enemy
sim.step({id: cast})
assert_eq(sim.state.get_entity(enemy).hp, 480, "Rend deals its power to the locked target")
func test_unit_ability_whiffs_on_an_out_of_range_target() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _animal_hero(sim, 0, Vector2.ZERO)
sim.state.get_entity(id).attack_damage = 0
var enemy := sim.add_entity(1, Vector2(300.0, 0.0), 0.0, 600) # beyond Rend's 200 range
var cast := InputCommand.new()
cast.ability_slot = 2
cast.target_id = enemy
sim.step({id: cast})
assert_eq(sim.state.get_entity(enemy).hp, 600, "an out-of-range unit cast lands no damage")
assert_eq(sim.state.get_entity(id).resource, 70, "but the whiffed cast still books its cost")
# --- Effects: heal, transform -----------------------------------------------
func test_self_heal_restores_hp_clamped_to_max() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, 0, Vector2.ZERO)
var h := sim.state.get_entity(id)
h.hp = 550 # Mend heals 100; from 550 the clamp caps the result at max_hp, not 650
var cast := InputCommand.new()
cast.ability_slot = 1 # human W = Mend
sim.step({id: cast})
assert_eq(sim.state.get_entity(id).hp, 600, "a self-heal never overfills past max_hp")
assert_eq(sim.state.get_entity(id).resource, 70, "Mend spends its cost")
func test_transform_swaps_form_and_keeps_cooldowns_running() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, 0, Vector2.ZERO)
# Put Spirit Bolt on cooldown, then transform.
var bolt := InputCommand.new()
bolt.ability_slot = 0
bolt.target_point = Vector2(100.0, 0.0)
sim.step({id: bolt})
var beast := InputCommand.new()
beast.ability_slot = 3 # human R = Beast Form
sim.step({id: beast})
var h := sim.state.get_entity(id)
assert_eq(h.form, AbilitySpec.FORM_ANIMAL, "Beast Form swaps the hero to its animal form")
assert_true(h.ability_cooldowns[1] > 0, "the human bolt cooldown keeps running across the swap")
assert_eq(h.resource_max, 100, "the animal pool is active after the swap")
# --- Gates: form, resource, cooldown ----------------------------------------
func test_can_cast_gates_on_form_resource_and_cooldown() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, 0, Vector2.ZERO)
var h := sim.state.get_entity(id)
var bolt := AbilityData.spec(1) # human Spirit Bolt, cost 20
var pounce := AbilityData.spec(4) # animal Pounce
assert_true(AbilityExecutor.can_cast(h, bolt), "a human, full, ready hero can cast in form")
assert_false(AbilityExecutor.can_cast(h, pounce), "an animal ability is not castable while human")
h.resource = 10
assert_false(AbilityExecutor.can_cast(h, bolt), "a cast is refused without enough resource")
h.resource = 100
h.ability_cooldowns[1] = 5
assert_false(AbilityExecutor.can_cast(h, bolt), "a cast is refused while on cooldown")
func test_cooldown_blocks_recast_until_it_elapses() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, 0, Vector2.ZERO)
var cast := InputCommand.new()
cast.ability_slot = 0
cast.target_point = Vector2(100.0, 0.0)
sim.step({id: cast}) # cast 1: Spirit Bolt -> cooldown 30
var h := sim.state.get_entity(id)
assert_eq(h.ability_cooldowns[1], 30, "the ability enters its full cooldown when cast")
sim.step({id: cast}) # a recast one tick later is refused
assert_eq(h.ability_cooldowns[1], 29, "the cooldown ticks down and the recast is dropped")
for _i in 29:
sim.step({})
assert_eq(h.ability_cooldowns[1], 0, "the cooldown reaches zero")
sim.step({id: cast}) # now the recast lands
assert_eq(h.ability_cooldowns[1], 30, "off cooldown, the recast lands and re-enters cooldown")
func test_resource_regenerates_on_its_interval() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, 0, Vector2.ZERO)
var cast := InputCommand.new()
cast.ability_slot = 0
cast.target_point = Vector2(100.0, 0.0)
sim.step({id: cast}) # spend 20 -> 80
var h := sim.state.get_entity(id)
assert_eq(h.resource, 80, "the cast leaves the pool down by its cost")
for _i in 12: # regen is one point every 12 ticks
sim.step({})
assert_eq(h.resource, 81, "one point regenerates after the interval")
for _i in 12:
sim.step({})
assert_eq(h.resource, 82, "and another after the next interval")
# --- Inert without a kit + determinism --------------------------------------
func test_an_unequipped_hero_ignores_ability_intent() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := sim.add_hero(0, Vector2.ZERO, 320.0) # no equip_kit
var enemy := sim.add_entity(1, Vector2(600.0, 0.0), 0.0, 600)
var cast := InputCommand.new()
cast.ability_slot = 0
cast.target_point = Vector2(100.0, 0.0)
sim.step({id: cast}) # must not cast or crash
assert_false(sim.state.get_entity(id).is_hero, "a bare hero is not an ability caster")
assert_eq(sim.state.get_entity(enemy).hp, 600, "and its ability intent does nothing")
func test_an_ability_run_replays_identically() -> void:
var a := _run_abilities()
var b := _run_abilities()
assert_eq(a, b, "the ability layer must be a pure function of state + input")
## A scripted ability sequence over a fixed window: a hero casts a bolt, transforms,
## pounces, and idles, against two enemies. Returns a deterministic digest of the
## survivors so two runs can be compared field-for-field.
func _run_abilities() -> Array:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, 0, Vector2.ZERO)
sim.add_entity(1, Vector2(600.0, 0.0), 0.0, 600)
sim.add_entity(1, Vector2(400.0, 80.0), 0.0, 600)
var script: Array[InputCommand] = []
for i in 90:
var cmd := InputCommand.new()
if i == 0:
cmd.ability_slot = 0 # Spirit Bolt
cmd.target_point = Vector2(100.0, 0.0)
elif i == 5:
cmd.ability_slot = 3 # Beast Form
elif i == 10:
cmd.ability_slot = 0 # Pounce (animal)
cmd.target_point = Vector2(400.0, 0.0)
else:
cmd.ability_slot = -1
script.append(cmd)
for cmd in script:
sim.step({id: cmd})
return _digest(sim.state)
## A stable, comparable digest of the world: every surviving entity's id, hp, and
## rounded position, ordered by id.
func _digest(state: SimState) -> Array:
var ids := state.entities.keys()
ids.sort()
var rows: Array = []
for id in ids:
var e: SimEntity = state.entities[id]
rows.append([id, e.hp, e.position.round()])
return rows
added test/unit/test_ability.gd.uid
@@ -0,0 +1 @@
uid://ctcu48txi5ein