ajhahn.de
← Theria
GDScript 259 lines
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: 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:
	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


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


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, "snake", Vector2.ZERO)  # a brawler, so it commits to its animal kit
	# Shift to the animal kit, whose slot 0 (Fang Strike) 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 Fang Strike's 360
	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")


# --- 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)
	# A kiter holds its ranged form: caught in the animal kit, it shifts back toward the
	# longer human Spear Throw rather than fighting from melee.
	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 --------------------------------------


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")


# --- Kite stance: a skirmisher holds its poke range -------------------------


func test_equip_stamps_the_kit_stance() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var cheetah := _hero(sim, "cheetah", Vector2.ZERO)
	var lion := _hero(sim, "lion", Vector2(50.0, 0.0))
	assert_eq(sim.state.get_entity(cheetah).stance, AbilityData.STANCE_KITE, "the cheetah kites")
	assert_eq(sim.state.get_entity(lion).stance, AbilityData.STANCE_BRAWL, "the lion brawls")
	# Equip also stamps the kit id — the hero's identity the renderer tints by.
	assert_eq(sim.state.get_entity(cheetah).kit_id, "cheetah", "the cheetah carries its kit id")
	assert_eq(sim.state.get_entity(lion).kit_id, "lion", "the lion carries its kit id")


func test_a_kiter_backs_off_a_point_blank_enemy_instead_of_meleeing() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var id := _hero(sim, "cheetah", Vector2.ZERO)
	sim.add_entity(1, Vector2(150.0, 0.0), 0.0, 600)  # inside the Spear's dead zone, point-blank
	var command := _bot().decide(sim.state, id)
	assert_lt(command.move_dir.x, 0.0, "the kiter retreats from the enemy, not into it")
	assert_eq(command.ability_slot, -1, "and does not drop to a melee form to engage")


func test_a_kiter_holds_and_pokes_from_its_band() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var id := _hero(sim, "cheetah", Vector2.ZERO)
	sim.add_entity(1, Vector2(750.0, 0.0), 0.0, 600)  # at the Spear Throw's landing range
	var command := _bot().decide(sim.state, id)
	assert_eq(command.move_dir, Vector2.ZERO, "inside its poke band the kiter holds position")
	assert_eq(command.ability_slot, 0, "and fires its long skillshot")


func test_a_kiter_closes_on_an_enemy_beyond_its_poke() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var id := _hero(sim, "cheetah", Vector2.ZERO)
	sim.add_entity(1, Vector2(1000.0, 0.0), 0.0, 600)  # past the band: it must close to poke
	var command := _bot().decide(sim.state, id)
	assert_gt(command.move_dir.x, 0.0, "beyond its band the kiter advances to bring the poke in")


func test_a_brawler_closes_a_point_blank_enemy() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var id := _hero(sim, "snake", Vector2.ZERO)  # a brawler: the contrast to the kiter
	sim.add_entity(1, Vector2(150.0, 0.0), 0.0, 600)
	var command := _bot().decide(sim.state, id)
	assert_gt(command.move_dir.x, 0.0, "a brawler closes the gap rather than kiting away")


func test_a_brawler_routes_around_a_blocking_obstacle() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var center := MapData.nexus_for_team(0)
	var bot := _hero(sim, "snake", center + Vector2(700.0, 0.0))
	var enemy_pos := center - Vector2(700.0, 0.0)
	sim.add_entity(1, enemy_pos, 0.0, 600)  # an enemy on the far side of the obstacle
	var bot_pos := sim.state.get_entity(bot).position
	var nav := NavGrid.shared()
	assert_false(nav.segment_clear(bot_pos, enemy_pos), "the straight line runs through the obstacle")
	var command := _bot().decide(sim.state, bot)
	var straight := (enemy_pos - bot_pos).normalized()
	assert_gt(command.move_dir.length(), 0.0, "the bot advances on the enemy")
	assert_gt(command.move_dir.distance_to(straight), 0.01, "it steers around, not straight in")
	assert_true(
		nav.segment_clear(bot_pos, bot_pos + command.move_dir * 300.0),
		"the step it takes is onto clear ground",
	)