class_name BotController extends RefCounted ## Produces an InputCommand for a bot-controlled entity from the world state. ## ## v0.1 behaviour: position against the nearest enemy and — once the entity is a kitted ## hero — cast its abilities. Positioning follows the kit's stance (AbilityData.STANCE_*): ## a BRAWLER walks in, stops on contact, and shifts toward whichever form can land a hit ## (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); a KITER (the skirmishers) ## instead holds its ranged form and keeps the enemy inside its skillshot band — backing ## off a point-blank enemy and closing on a distant one — so it pokes hit-and-run rather ## than committing to melee. Either way it heals 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. ## 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 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. const STOP_RANGE := 60.0 ## Ticks a cached approach route is reused before recomputing. Routing every tick is far too ## expensive (a full A* per bot per frame), and a route a fraction of a second old still steers a ## bot fine toward a moving target. const ROUTE_REFRESH_TICKS := 15 ## 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. The ## same threshold tells the bot when to favour the human form for its heal. const HEAL_HP_FRACTION := 0.6 ## Ticks between the beats on which a bot of each difficulty may open a damaging cast ## (at 60 ticks/s). HARD's period of 1 makes every tick a beat — no handicap; the ## softer levels add a reaction delay of up to this many ticks. Eyeball-tunable. const CAST_PERIOD := { Difficulty.EASY: 45, Difficulty.NORMAL: 18, 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 := { "easy": Difficulty.EASY, "normal": Difficulty.NORMAL, "hard": Difficulty.HARD, } ## This bot's skill level. Defaults to HARD so a bare BotController is full-strength ## (the behaviour the unit tests pin); the practice match dials it down to its own ## setting (EASY by default, so practice is winnable). var difficulty: int = Difficulty.HARD ## Per-bot cached approach route: bot id -> {path, index, tick}. Carried across ticks; a pure ## function of the (deterministic) state sequence, so a bot match still replays identically. var _routes: Dictionary = {} func decide(state: SimState, bot_id: int) -> InputCommand: var command := InputCommand.new() var bot := state.get_entity(bot_id) if bot == null: return command var target := _nearest_enemy(state, bot) if target == null: return command if bot.is_hero and bot.stance == AbilityData.STANCE_KITE: _kite_move(command, bot, target, state.tick) else: if bot.position.distance_to(target.position) > STOP_RANGE: command.move_dir = _approach_dir(bot, target, state.tick) if bot.is_hero: _choose_cast(command, bot, target, state.tick) return command ## The direction to advance on a target: straight at it in the open (the common case, exactly the ## old `offset.normalized()`), or along a routed path around the obstacles when the straight line is ## blocked. The route is cached per bot and refreshed only every ROUTE_REFRESH_TICKS — an A* every ## frame per bot is what dropped the game to a slideshow, and a third-of-a-second-old route still ## steers fine toward a moving target. Deterministic (the nav grid is pure and the cache is a ## function of the replayable state), so a bot match still replays identically. func _approach_dir(bot: SimEntity, target: SimEntity, tick: int) -> Vector2: var to_target := target.position - bot.position if to_target.length() < 0.0001: return Vector2.ZERO var nav := NavGrid.shared() if nav.segment_clear(bot.position, target.position): _routes.erase(bot.id) # clear line — drop any detour and head straight in return to_target.normalized() var route: Dictionary = _routes.get(bot.id, {}) if route.is_empty() or tick - int(route["tick"]) >= ROUTE_REFRESH_TICKS: route = {"path": nav.find_path(bot.position, target.position), "index": 0, "tick": tick} _routes[bot.id] = route var path: PackedVector2Array = route["path"] var idx: int = route["index"] while idx < path.size() and bot.position.distance_to(path[idx]) <= STOP_RANGE: idx += 1 route["index"] = idx if idx < path.size(): var leg := path[idx] - bot.position if leg.length() > 0.0001: return leg.normalized() return to_target.normalized() ## Maps a difficulty name — the `--bot-difficulty` value and the menu's metadata — to a ## level, falling back to EASY (the winnable practice default) for an unknown name. static func difficulty_from_name(level_name: String) -> int: return DIFFICULTY_NAMES.get(level_name, Difficulty.EASY) ## Layers an ability cast onto the bot's command when one is worth casting this ## 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, tick: int) -> void: 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 if not _may_open_cast(bot, tick): return 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 ## Whether `tick` is one of this bot's cast beats — the reaction handicap that sets the ## skill levels apart. HARD's period of 1 makes every tick a beat, so the bot opens a ## damaging cast the instant one is ready (the full-strength behaviour the tests pin); ## the softer levels open one only once per `CAST_PERIOD[difficulty]` ticks, so the ## bot's poke uptime drops and a human can out-trade it. The beat is phase-shifted by ## the bot's id so a squad's bots do not all fire on the same tick. Gates only the ## damaging cast — `_choose_cast` returns before this for a heal or a transform — so a ## hurt bot still heals and a cornered one still shifts every tick: the handicap slows ## the hands without dulling survival or positioning. A pure function of (tick, id), so ## a bot match still replays identically. func _may_open_cast(bot: SimEntity, tick: int) -> bool: return (tick + bot.id) % CAST_PERIOD[difficulty] == 0 ## 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 # A kiter does not drop to a shorter-range form to engage: it holds the form whose # poke reaches farthest and creates distance with its feet instead. if bot.stance == AbilityData.STANCE_KITE: return _ranged_form(bot) 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 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 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 ## 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 ## Positions a kiter: it holds the enemy inside its skillshot band — backing off when ## the enemy is nearer than the band, closing when it is farther, and holding still ## 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. ## 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: return var band := _kite_band(bot) if band == Vector2.ZERO: if dist > STOP_RANGE: command.move_dir = _approach_dir(bot, target, tick) return if dist < band.x: if _may_step_back(bot, tick): command.move_dir = -to_enemy / dist # backing off — straight away, slid off a wall behind elif dist > band.y: command.move_dir = _approach_dir(bot, target, tick) ## 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. func _kite_band(bot: SimEntity) -> Vector2: var best_range := 0.0 var best_radius := 0.0 for spec in _form_specs(bot, bot.form): if spec.effect != AbilitySpec.EFFECT_DAMAGE: continue if spec.target_kind != AbilitySpec.TARGET_SKILLSHOT: continue if spec.range > best_range: best_range = spec.range best_radius = spec.radius if best_range <= 0.0: return Vector2.ZERO return Vector2(best_range - best_radius, best_range + best_radius) ## A kiter's preferred form: the one whose longest-reaching damaging ability reaches ## farthest, so the kiter always fights from its poke form. A tie, or a kit with no ## damaging ability at all, falls to the human form. func _ranged_form(bot: SimEntity) -> int: if _longest_damage_range(bot, AbilitySpec.FORM_ANIMAL) > _longest_damage_range( bot, AbilitySpec.FORM_HUMAN ): return AbilitySpec.FORM_ANIMAL return AbilitySpec.FORM_HUMAN ## The range of the farthest-reaching damaging ability on `form`'s bar — how far that ## stance can threaten — or 0 when the form holds no damaging ability. func _longest_damage_range(bot: SimEntity, form: int) -> float: var best := 0.0 for spec in _form_specs(bot, form): if spec.effect == AbilitySpec.EFFECT_DAMAGE and spec.range > best: best = spec.range return best func _nearest_enemy(state: SimState, bot: SimEntity) -> SimEntity: var nearest: SimEntity = null var nearest_dist := INF for id in state.entities: var other: SimEntity = state.entities[id] if other.team == bot.team: continue var dist := bot.position.distance_to(other.position) if dist < nearest_dist: nearest_dist = dist nearest = other return nearest