Commit
Theria
feat: let practice bots cast their hero kits
modified CHANGELOG.md
@@ -35,6 +35,11 @@ protocol version.
### Added
- 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
mirrors each ability's landing geometry, so a bot never wastes a cast on empty air.
This makes a practice squad fight with abilities instead of only auto-attacking.
- A practice match now fields the full **Solane** squad on each team — one hero per
kit (Lion, Cheetah, Hyena) — so all three are on the field at once. The player drives
one (the Lion by default, or `--hero cheetah`/`--hero hyena`) and bots drive the rest.
modified README.md
@@ -89,8 +89,9 @@ start a listen-server, or type an address and **Join** one. Practice fields the
Solane squad on each team — you drive one hero (the lion by default, or pass
`--hero cheetah`/`--hero hyena` on the command line), bots drive the rest. A hosted
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
walk toward the nearest enemy. Cast its abilities with
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
**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
@@ -2,13 +2,24 @@ class_name BotController
extends RefCounted
## Produces an InputCommand for a bot-controlled entity from the world state.
##
## v0.1 skeleton behaviour: walk toward the nearest enemy and stop on contact.
## Deterministic — a pure function of the state — so a bot match replays
## identically and feeds the same simulation core as a human would.
## 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.
## Stop advancing once within this many world units of the target.
const STOP_RANGE := 60.0
## The ability bar is four slots (0..3) per form; the bot scans them in order so its
## pick is deterministic by slot rather than by dictionary iteration order.
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.
const HEAL_HP_FRACTION := 0.6
func decide(state: SimState, bot_id: int) -> InputCommand:
var command := InputCommand.new()
@@ -21,9 +32,70 @@ func decide(state: SimState, bot_id: int) -> InputCommand:
var offset := target.position - bot.position
if offset.length() > STOP_RANGE:
command.move_dir = offset.normalized()
if bot.is_hero:
_choose_cast(command, bot, target)
return command
## 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.
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 heal_slot >= 0:
command.ability_slot = heal_slot
return
var damage_slot := _castable_slot(bot, slots, 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:
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)
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 -1
## 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
## lands on the target (pulled in to range) and hits if the target sits inside its
## radius; a SKILLSHOT flies the full range along the aim, so it strikes only an
## enemy in the band one radius around that range.
func _reaches(spec: AbilitySpec, dist: float) -> bool:
match spec.target_kind:
AbilitySpec.TARGET_UNIT:
return dist <= spec.range
AbilitySpec.TARGET_GROUND:
return dist <= spec.range + spec.radius
AbilitySpec.TARGET_SKILLSHOT:
return absf(dist - spec.range) <= spec.radius
return false
func _nearest_enemy(state: SimState, bot: SimEntity) -> SimEntity:
var nearest: SimEntity = null
var nearest_dist := INF
added test/unit/test_bot_controller.gd
@@ -0,0 +1,107 @@
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
## 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
func _bot() -> BotController:
return BotController.new()
func _hero(sim: SimCore, kit_id: String, pos: Vector2) -> int:
var id := sim.add_hero(0, pos, 300.0)
sim.equip_kit(id, kit_id)
return id
# --- The is-a-kitted-hero gate ----------------------------------------------
func test_a_bot_without_a_kit_only_moves() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var mover := sim.add_hero(0, Vector2.ZERO, 300.0) # never equipped -> not a caster
sim.add_entity(1, Vector2(400.0, 0.0), 0.0, 600)
var command := _bot().decide(sim.state, mover)
assert_eq(command.ability_slot, -1, "a kit-less hero never casts")
assert_ne(command.move_dir, Vector2.ZERO, "but it still advances on the enemy")
# --- Selection: which slot, by reach and effect -----------------------------
func test_bot_fires_a_skillshot_at_an_enemy_in_its_band() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, "wildkin", Vector2.ZERO)
sim.add_entity(1, Vector2(600.0, 0.0), 0.0, 600) # at the skillshot's exact range
var command := _bot().decide(sim.state, id)
assert_eq(command.ability_slot, WILDKIN_SPIRIT_BOLT_SLOT, "it casts the reachable skillshot")
assert_eq(command.target_point, Vector2(600.0, 0.0), "aimed straight at the enemy")
func test_bot_holds_fire_when_no_ability_can_reach() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, "wildkin", Vector2.ZERO)
# Far outside the skillshot's [range-radius, range+radius] band: a cast would
# strike empty air, so the bot must not spend it.
sim.add_entity(1, Vector2(1200.0, 0.0), 0.0, 600)
var command := _bot().decide(sim.state, id)
assert_eq(command.ability_slot, -1, "a full-health bot out of reach casts nothing")
assert_ne(command.move_dir, Vector2.ZERO, "it closes the distance instead")
func test_bot_picks_a_ground_ability_that_can_reach() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, "hyena", Vector2.ZERO) # human slot 0 = Bone-Hex, GROUND
sim.add_entity(1, Vector2(400.0, 0.0), 0.0, 600) # inside range + radius
var command := _bot().decide(sim.state, id)
assert_eq(command.ability_slot, 0, "the ground area is cast on a reachable enemy")
assert_eq(command.target_point, Vector2(400.0, 0.0), "dropped on the target")
func test_a_unit_ability_locks_the_target() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, "cheetah", Vector2.ZERO)
# Shift to the animal kit, whose slot 0 (Hamstring) is unit-targeted.
var beast := InputCommand.new()
beast.ability_slot = 3
sim.step({id: beast})
var enemy := sim.add_entity(1, Vector2(200.0, 0.0), 0.0, 600) # inside Hamstring's 280
var command := _bot().decide(sim.state, id)
assert_eq(command.ability_slot, 0, "the unit ability is selected")
assert_eq(command.target_id, enemy, "and locked onto the nearest enemy")
func test_a_hurt_bot_heals_before_it_attacks() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, "wildkin", Vector2.ZERO)
sim.state.get_entity(id).hp = 100 # well under the 60% heal threshold of 600
sim.add_entity(1, Vector2(600.0, 0.0), 0.0, 600) # a damage target is also in reach
var command := _bot().decide(sim.state, id)
assert_eq(command.ability_slot, WILDKIN_MEND_SLOT, "survival comes first: it heals, not pokes")
# --- End to end: the chosen cast lands --------------------------------------
func test_a_bot_cast_lands_in_the_sim() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, "wildkin", Vector2.ZERO)
sim.state.get_entity(id).move_speed = 0.0 # hold position so the cast geometry is exact
# At the skillshot's range (600), and beyond the 250 auto-attack range, so only the cast lands.
var enemy := sim.add_entity(1, Vector2(600.0, 0.0), 0.0, 600)
sim.step({id: _bot().decide(sim.state, id)})
assert_eq(sim.state.get_entity(enemy).hp, 520, "Spirit Bolt's 80 lands on the enemy")
assert_eq(sim.state.get_entity(id).resource, 80, "and its 20 cost is booked")