ajhahn.de
← Theria commits

Commit

Theria

feat: let practice bots shapeshift to keep a hit in reach

ajhahnde · Jun 2026 · 928f1ed9891d1881f9e9a9820e645bfbecc772fb · parent: 1cbcd44 · view on GitHub →

modified CHANGELOG.md
@@ -35,6 +35,12 @@ protocol version.
### Added
- Bots now shapeshift mid-fight instead of fighting from one form: a bot transforms
toward the form that can land a hit when its current one cannot — closing into its
harder-hitting animal kit as an enemy slips inside the human poke's range, and back
to the human form to poke at range — and a hurt bot in animal form shifts back to
the human form for its heal when one is ready. The transform's own cooldown keeps
the stance from flip-flopping. This unlocks the animal kits in a practice match.
- Bots now cast their hero kit, not just walk: a bot heals itself when its health
drops and otherwise fires the first damaging ability of its active form that can
actually reach its target, picking the aim the way a player would. The reach test
modified README.md
@@ -91,7 +91,9 @@ Solane squad on each team — you drive one hero (the lion by default, or pass
or joined match is still a one-hero-per-team duel on the lion until multi-hero play
reaches the wire. Move the hero with **WASD** or the **arrow keys**; the bots close
on the nearest enemy and cast their own kits — healing when hurt, otherwise firing
the reachable ability of their form. Cast its abilities with
the reachable ability of their form, and shifting form to keep a hit in reach
(closing into the animal kit when an enemy slips inside the human poke, back to the
human form to poke at range or heal). Cast its abilities with
**1–4**, aimed at the mouse cursor — the hero shifts between a human and an animal
form (shown by the ring around it, white or amber), each form a different set of
abilities drawing on its own resource (the bar under the health bar). Abilities are
modified src/bot/bot_controller.gd
@@ -3,11 +3,14 @@ extends RefCounted
## Produces an InputCommand for a bot-controlled entity from the world state.
##
## v0.1 behaviour: walk toward the nearest enemy, stop on contact, and — once the
## entity is a kitted hero — cast its abilities. The bot heals itself when hurt,
## otherwise fires the first damaging ability of its active form that can actually
## reach the target. Deterministic — a pure function of the state — so a bot match
## replays identically and feeds the same simulation core a human would, gating on
## the very `AbilityExecutor.can_cast` the player's casts pass through.
## entity is a kitted hero — cast its abilities. The bot shifts into the stance that
## suits the fight (closing into its animal kit when an enemy slips inside the human
## poke's reach, falling back to the human form to poke at range or to heal when
## hurt), heals itself when hurt, and otherwise fires the first damaging ability of
## its active form that can actually reach the target.
## Deterministic — a pure function of the state — so a bot match replays identically
## and feeds the same simulation core a human would, gating every cast (a transform
## included) on the very `AbilityExecutor.can_cast` the player's casts pass through.
## Stop advancing once within this many world units of the target.
const STOP_RANGE := 60.0
@@ -17,7 +20,8 @@ const STOP_RANGE := 60.0
const SLOT_COUNT := 4
## Heal once health falls below this fraction of the maximum — soon enough to
## matter in a trade, but not so eager the bot tops off a scratch every tick.
## matter in a trade, but not so eager the bot tops off a scratch every tick. The
## same threshold tells the bot when to favour the human form for its heal.
const HEAL_HP_FRACTION := 0.6
@@ -38,47 +42,118 @@ func decide(state: SimState, bot_id: int) -> InputCommand:
## Layers an ability cast onto the bot's command when one is worth casting this
## tick: a self-heal first when the bot is hurt and can afford one, otherwise the
## first damaging ability of its active form that can land on `target`. Reads the
## same state the player's input sampler does and gates on the same cast rules, so a
## bot's casts stay pure and replayable. The bot fights from its starting form;
## transforming into the animal kit is a later step.
## tick. Stance comes first: when the bot would fight better in its other form it
## transforms — gated like every cast, so a bot still on transform cooldown simply
## fights on where it is. Otherwise it self-heals when hurt and can afford one, else
## fires the first damaging ability of its active form that lands on `target`. Reads
## the same state the player's input sampler does and gates on the same cast rules,
## so a bot's casts stay pure and replayable.
func _choose_cast(command: InputCommand, bot: SimEntity, target: SimEntity) -> void:
var slots: Dictionary = bot.kit.get(bot.form, {})
if bot.max_hp > 0 and bot.hp < int(float(bot.max_hp) * HEAL_HP_FRACTION):
var heal_slot := _castable_slot(bot, slots, AbilitySpec.EFFECT_HEAL, target)
if _preferred_form(bot, target) != bot.form:
var transform_slot := _castable_slot(bot, bot.form, AbilitySpec.EFFECT_TRANSFORM, target)
if transform_slot >= 0:
command.ability_slot = transform_slot
return
if _is_hurt(bot):
var heal_slot := _castable_slot(bot, bot.form, AbilitySpec.EFFECT_HEAL, target)
if heal_slot >= 0:
command.ability_slot = heal_slot
return
var damage_slot := _castable_slot(bot, slots, AbilitySpec.EFFECT_DAMAGE, target)
var damage_slot := _castable_slot(bot, bot.form, AbilitySpec.EFFECT_DAMAGE, target)
if damage_slot >= 0:
command.ability_slot = damage_slot
command.target_point = target.position
command.target_id = target.id
## The lowest slot in `slots` whose ability has `effect`, passes the cast gate
## (form, resource, cooldown), and — for a damaging ability — can reach `target`.
## -1 when none qualifies. A heal is self-cast, so it needs no reach check.
func _castable_slot(bot: SimEntity, slots: Dictionary, effect: int, target: SimEntity) -> int:
## The form the bot would rather fight this target in. Survival comes first: a hurt
## bot in the animal form wants the human form's heal (the animal kits carry none),
## but only when that heal is off cooldown — cooldowns persist across a transform,
## so the bot reads it from either stance, and it never flips toward a heal still
## recharging. Otherwise it shifts on reach: to the other form when its current one
## cannot land a damaging ability but the other could — closing into the animal kit
## as an enemy slips inside the human skillshot's range, and back to the human poke
## when the enemy outruns the animal kit — and stays put when it can already hit.
## The transform's own cooldown bounds how often this flips, so the pulls (engage,
## disengage, retreat to heal) cannot thrash tick to tick.
func _preferred_form(bot: SimEntity, target: SimEntity) -> int:
var other := 1 - bot.form
if (
bot.form != AbilitySpec.FORM_HUMAN
and _is_hurt(bot)
and _form_has_ready_heal(bot, AbilitySpec.FORM_HUMAN)
):
return AbilitySpec.FORM_HUMAN
if (
not _form_reaches_with_damage(bot, bot.form, target)
and _form_reaches_with_damage(bot, other, target)
):
return other
return bot.form
## Whether the bot is hurt enough to want a heal — health under `HEAL_HP_FRACTION`
## of its maximum. A non-combat entity (max_hp 0) is never hurt.
func _is_hurt(bot: SimEntity) -> bool:
return bot.max_hp > 0 and bot.hp < int(float(bot.max_hp) * HEAL_HP_FRACTION)
## The lowest slot in the bot's `form` bar whose ability has `effect`, passes the
## cast gate (form, resource, cooldown), and — for a damaging ability — can reach
## `target`. -1 when none qualifies. A heal is self-cast and a transform self-aimed,
## so neither needs a reach check.
func _castable_slot(bot: SimEntity, form: int, effect: int, target: SimEntity) -> int:
var dist := bot.position.distance_to(target.position)
for slot in SLOT_COUNT:
if not slots.has(slot):
continue
var ability_id: int = slots[slot]
if not AbilityData.has_ability(ability_id):
continue
var spec := AbilityData.spec(ability_id)
for spec in _form_specs(bot, form):
if spec.effect != effect:
continue
if not AbilityExecutor.can_cast(bot, spec):
continue
if effect == AbilitySpec.EFFECT_DAMAGE and not _reaches(spec, dist):
continue
return slot
return spec.slot
return -1
## Whether `form`'s bar holds a heal that is off cooldown right now. Reads the
## cooldown (which survives a transform, keyed by ability id) but neither the
## resource nor the active form, so it answers "would flipping to this form give me
## a heal to cast" from either stance — the resource is left to the post-transform
## cast gate.
func _form_has_ready_heal(bot: SimEntity, form: int) -> bool:
for spec in _form_specs(bot, form):
if spec.effect == AbilitySpec.EFFECT_HEAL and bot.ability_cooldowns.get(spec.id, 0) == 0:
return true
return false
## Whether `form`'s bar holds a damaging ability whose geometry would land on a
## target `dist` away — the "is this stance's payoff in reach" test that drives a
## transform. Geometry only: it ignores resource and cooldown (which the form swap
## changes), leaving those to the cast gate once the bot is in that form.
func _form_reaches_with_damage(bot: SimEntity, form: int, target: SimEntity) -> bool:
var dist := bot.position.distance_to(target.position)
for spec in _form_specs(bot, form):
if spec.effect == AbilitySpec.EFFECT_DAMAGE and _reaches(spec, dist):
return true
return false
## The abilities on `form`'s bar, lowest slot first — the specs the form's slot ids
## resolve to, skipping empty slots and ids absent from the catalog. Slot order
## keeps every scan over a form deterministic, like the rest of the simulation.
func _form_specs(bot: SimEntity, form: int) -> Array[AbilitySpec]:
var specs: Array[AbilitySpec] = []
var slots: Dictionary = bot.kit.get(form, {})
for slot in SLOT_COUNT:
if not slots.has(slot):
continue
var ability_id: int = slots[slot]
if AbilityData.has_ability(ability_id):
specs.append(AbilityData.spec(ability_id))
return specs
## Whether a cast of `spec` aimed straight at an enemy `dist` away would actually
## strike it — mirroring the executor's landing geometry so the bot never spends a
## cast on empty air. A UNIT ability reaches any enemy within range; a GROUND area
modified test/unit/test_bot_controller.gd
@@ -1,13 +1,16 @@
extends GutTest
## Behaviour checks on the bot's ability casting. The bot walks toward the nearest
## enemy (the walking-skeleton behaviour) and, once it is a kitted hero, layers a
## cast onto that intent: a self-heal when hurt, otherwise the first damaging
## ability of its active form that can actually reach the target. These pin the
## selection order and the per-targeting-mode reach gate, plus one end-to-end check
## cast onto that intent: it shifts form to keep a hit (and its heal) in reach, then
## self-heals when hurt, otherwise fires the first damaging ability of its active
## form that can actually reach the target. These pin the selection order, the
## per-targeting-mode reach gate, the form-shift policy, plus one end-to-end check
## that a chosen cast lands in the sim. Headless and deterministic, creep waves off.
const WILDKIN_SPIRIT_BOLT_SLOT := 0 # human SKILLSHOT, range 600 / radius 60
const WILDKIN_MEND_SLOT := 1 # human HEAL
const TRANSFORM_SLOT := 3 # the R slot holds the form swap in every kit
const LION_HEAL_ID := 11 # Mane Guard, the Lion's human-form heal
func _bot() -> BotController:
@@ -20,6 +23,14 @@ func _hero(sim: SimCore, kit_id: String, pos: Vector2) -> int:
return id
## Steps the hero into its animal form by casting the form-swap slot, so a test can
## start from the animal kit. No enemy need be present — a transform is self-cast.
func _to_animal(sim: SimCore, hero_id: int) -> void:
var shift := InputCommand.new()
shift.ability_slot = TRANSFORM_SLOT
sim.step({hero_id: shift})
# --- The is-a-kitted-hero gate ----------------------------------------------
@@ -92,6 +103,72 @@ func test_a_hurt_bot_heals_before_it_attacks() -> void:
assert_eq(command.ability_slot, WILDKIN_MEND_SLOT, "survival comes first: it heals, not pokes")
# --- Stance: shifting form to keep a hit (and a heal) in reach ---------------
func test_bot_transforms_to_engage_when_only_the_other_form_reaches() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, "lion", Vector2.ZERO) # human slot 0 = Sunfire Lash, a skillshot
# Inside the skillshot's dead zone (its band is around the 450 range) but well
# within the animal kit's reach: the human poke would whiff, so the bot shifts.
sim.add_entity(1, Vector2(150.0, 0.0), 0.0, 600)
var command := _bot().decide(sim.state, id)
assert_eq(command.ability_slot, TRANSFORM_SLOT, "it transforms instead of whiffing the poke")
assert_eq(
sim.state.get_entity(id).form, AbilitySpec.FORM_HUMAN, "the shift is queued, not yet applied"
)
func test_bot_does_not_transform_while_its_current_form_can_hit() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, "lion", Vector2.ZERO)
# At the skillshot's exact range the human poke lands; the animal kit reaches
# too, but a form that can already hit does not give up its turn to shift.
sim.add_entity(1, Vector2(450.0, 0.0), 0.0, 600)
var command := _bot().decide(sim.state, id)
assert_eq(command.ability_slot, 0, "it pokes with the reachable human ability, no transform")
func test_bot_transforms_back_when_the_enemy_outruns_the_animal_kit() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, "cheetah", Vector2.ZERO)
_to_animal(sim, id) # the animal kit reaches only to 280 (Hamstring / Killing Blow)
# Far outside the animal kit but on the human Spear Throw's 750 range: shift back.
sim.add_entity(1, Vector2(750.0, 0.0), 0.0, 600)
var command := _bot().decide(sim.state, id)
assert_eq(command.ability_slot, TRANSFORM_SLOT, "it shifts back toward the human poke")
assert_eq(
sim.state.get_entity(id).form, AbilitySpec.FORM_ANIMAL, "still animal until the cast resolves"
)
func test_a_hurt_bot_in_animal_form_retreats_to_the_human_heal() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, "lion", Vector2.ZERO)
_to_animal(sim, id)
sim.state.get_entity(id).hp = 100 # under the 60% heal threshold of 600
sim.add_entity(1, Vector2(100.0, 0.0), 0.0, 600) # within the animal kit's reach
var command := _bot().decide(sim.state, id)
assert_eq(command.ability_slot, TRANSFORM_SLOT, "it abandons the brawl to reach its heal")
func test_a_hurt_bot_stays_in_animal_form_when_the_heal_is_on_cooldown() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, "lion", Vector2.ZERO)
_to_animal(sim, id)
var bot := sim.state.get_entity(id)
bot.hp = 100
bot.ability_cooldowns[LION_HEAL_ID] = 50 # Mane Guard not ready: no point flipping for it
sim.add_entity(1, Vector2(100.0, 0.0), 0.0, 600)
var command := _bot().decide(sim.state, id)
assert_eq(command.ability_slot, 0, "with no heal to reach it fights on with the animal kit")
# --- End to end: the chosen cast lands --------------------------------------