Commit
Theria
fix: meter an eased kiter's retreat so it can be caught
An eased bot's reaction was throttled but its positioning was not, so a kiter backed off perfectly every tick and was impossible to run down — easy was not actually easy against a skirmisher. The kiter's retreat step is now metered on its own cadence (KITE_RETREAT_PERIOD): HARD still steps back every tick, while the softer levels step back only on a beat, dropping the effective retreat speed so a chaser closes the gap. Closing and holding the band stay crisp at every level, and casts, heals, and transforms are untouched. Pure (tick, id) like the cast cadence, so a bot match still replays identically.
modified CHANGELOG.md
@@ -69,8 +69,9 @@ protocol version.
connect screen's new picker or with `--bot-difficulty`. Easy is the default, so a practice
match is winnable out of the box, while Hard is the previous full-strength bot. A lower
difficulty slows the bots' reaction — they open their pokes on a slower beat, so a player
can out-trade them — without dulling their judgement: a bot at any level still positions,
kites, shifts form, and heals exactly as sharply. Sim-side and menu only; the netcode
can out-trade them — and meters a kiter's retreat so it no longer backs away flawlessly
and can be run down; otherwise their judgement is undulled: a bot at any level still
positions, shifts form, and heals exactly as sharply. Sim-side and menu only; the netcode
protocol is unchanged.
- A practice match can now be set up entirely from the connect screen: a hero picker lists
every hero of both tribes, and the choice drives the same tribe-versus-tribe seating as the
modified src/bot/bot_controller.gd
@@ -18,8 +18,11 @@ extends RefCounted
## Bot skill levels. A higher level reacts faster: HARD opens a damaging cast the
## instant one is ready (the full-strength bot the unit tests pin), while NORMAL and
## EASY only open one on a slower beat, so the bot's poke uptime drops and a human can
## out-trade it. Survival and positioning (heal, transform, kite, advance) are never
## throttled — the handicap slows the bot's hands, it does not dull its judgement.
## out-trade it. Survival and most positioning (heal, transform, advance, the kiter's
## poke and its closing) are never throttled — the handicap slows the bot's hands, not
## its judgement. The one exception is the kiter's retreat: a kiter that backs off
## perfectly every tick is uncatchable, so on the softer levels that one step is metered
## on its own cadence (KITE_RETREAT_PERIOD), letting a chaser close the gap.
enum Difficulty { EASY, NORMAL, HARD }
## Stop advancing once within this many world units of the target.
@@ -43,6 +46,18 @@ const CAST_PERIOD := {
Difficulty.HARD: 1,
}
## Ticks between the beats on which a kiter of each difficulty takes a step back (at 60
## ticks/s). A kiter's retreat is the one bit of footwork the handicap dulls: HARD steps
## back every tick and is genuinely uncatchable (the test-pinned behaviour), while the
## softer levels step back only once per this many ticks, so the kiter's effective retreat
## speed drops to a fraction of its move speed and a chaser at full speed reels it in.
## Closing and holding the band are never metered — only the escape. Eyeball-tunable.
const KITE_RETREAT_PERIOD := {
Difficulty.EASY: 3,
Difficulty.NORMAL: 2,
Difficulty.HARD: 1,
}
## The difficulty names the `--bot-difficulty` flag and the connect menu pass, mapped
## to a level. The single place the spelling-to-level mapping lives.
const DIFFICULTY_NAMES := {
@@ -66,7 +81,7 @@ func decide(state: SimState, bot_id: int) -> InputCommand:
if target == null:
return command
if bot.is_hero and bot.stance == AbilityData.STANCE_KITE:
_kite_move(command, bot, target)
_kite_move(command, bot, target, state.tick)
else:
var offset := target.position - bot.position
if offset.length() > STOP_RANGE:
@@ -237,7 +252,9 @@ func _reaches(spec: AbilitySpec, dist: float) -> bool:
## within it so the poke lands. A kiter whose current form has no skillshot poke (it is
## briefly in the wrong form, about to shift back) just closes like a brawler until the
## stance step returns it to its ranged form. Movement only — the cast step still fires.
func _kite_move(command: InputCommand, bot: SimEntity, target: SimEntity) -> void:
## Closing and holding are crisp at every level; the back-off step is metered by
## `_may_step_back`, so an eased kiter cannot retreat perfectly tick after tick.
func _kite_move(command: InputCommand, bot: SimEntity, target: SimEntity, tick: int) -> void:
var to_enemy := target.position - bot.position
var dist := to_enemy.length()
if dist <= 0.0:
@@ -248,11 +265,23 @@ func _kite_move(command: InputCommand, bot: SimEntity, target: SimEntity) -> voi
command.move_dir = to_enemy / dist
return
if dist < band.x:
command.move_dir = -to_enemy / dist
if _may_step_back(bot, tick):
command.move_dir = -to_enemy / dist
elif dist > band.y:
command.move_dir = to_enemy / dist
## Whether `tick` is one of this kiter's retreat beats — the footwork handicap that lets a
## chaser catch an eased kiter. HARD's period of 1 makes every tick a beat, so it backs off
## without pause (the uncatchable, test-pinned retreat); the softer levels step back only
## once per `KITE_RETREAT_PERIOD[difficulty]` ticks, dropping the kiter's escape speed while
## its poke and its closing stay sharp. Phase-shifted by the bot's id so a squad's kiters do
## not all step on the same tick. A pure function of (tick, id), so a bot match still
## replays identically.
func _may_step_back(bot: SimEntity, tick: int) -> bool:
return (tick + bot.id) % KITE_RETREAT_PERIOD[difficulty] == 0
## The distance band a kiter holds — [range - radius, range + radius] of its current
## form's longest-range skillshot, the window in which that poke actually lands. Zero
## when the form holds no skillshot, which tells `_kite_move` to close like a brawler.
modified test/unit/test_bot_difficulty.gd
@@ -2,9 +2,12 @@ extends GutTest
## The bot's difficulty handicap — a cast-cadence reaction delay that softens a bot
## without dulling it. A higher level opens a damaging cast on more ticks: HARD every
## tick (full strength), the softer levels only on a slower beat, so the bot's poke
## uptime drops and a human can out-trade it. These pin that the handicap throttles only
## the damaging cast — never a heal (survival stays sharp) — and that the level-name
## mapping the flag and the menu share resolves as expected. Headless and deterministic.
## uptime drops and a human can out-trade it. A second handicap meters the kiter's
## retreat footwork: HARD backs off every tick (uncatchable), the softer levels only on
## a beat, so a chaser reels an eased kiter in. These pin that the cast handicap throttles
## only the damaging cast — never a heal (survival stays sharp) — that the retreat is
## metered while closing and holding stay crisp, and that the level-name mapping the flag
## and the menu share resolves as expected. Headless and deterministic.
const WILDKIN_SPIRIT_BOLT_SLOT := 0 # human SKILLSHOT, range 600 / radius 60
const WILDKIN_MEND_SLOT := 1 # human HEAL
@@ -70,6 +73,44 @@ func test_difficulty_handicap_never_throttles_a_heal() -> void:
)
func test_easy_difficulty_throttles_the_kite_retreat() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, "cheetah", Vector2.ZERO) # a kiter, id 1
sim.add_entity(1, Vector2(150.0, 0.0), 0.0, 600) # point-blank, inside the Spear's band
var bot := _bot()
bot.difficulty = BotController.Difficulty.EASY
var period: int = BotController.KITE_RETREAT_PERIOD[BotController.Difficulty.EASY]
# Off its retreat beat the eased kiter holds its ground — the stutter that lets a chaser
# close — though the enemy sits point-blank inside its poke band.
sim.state.tick = 0 # (0 + 1) % period != 0
assert_eq(
bot.decide(sim.state, id).move_dir,
Vector2.ZERO,
"off its beat the eased kiter does not step back"
)
# On a beat it backs off, the very retreat a full-strength kiter makes.
sim.state.tick = period - 1 # (period - 1 + 1) % period == 0
assert_lt(
bot.decide(sim.state, id).move_dir.x, 0.0, "on its beat the eased kiter retreats from the enemy"
)
func test_hard_difficulty_retreats_every_tick() -> 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) # point-blank, inside the Spear's band
var bot := _bot()
bot.difficulty = BotController.Difficulty.HARD # the default, asserted explicit here
sim.state.tick = 1 # an off-beat tick for any softer level
assert_lt(
bot.decide(sim.state, id).move_dir.x,
0.0,
"the full-strength kiter backs off every tick, no footwork handicap"
)
func test_difficulty_from_name_maps_levels_and_defaults_to_easy() -> void:
assert_eq(BotController.difficulty_from_name("hard"), BotController.Difficulty.HARD)
assert_eq(BotController.difficulty_from_name("normal"), BotController.Difficulty.NORMAL)