ajhahn.de
← Theria commits

Commit

Theria

feat: give practice bots a difficulty, easy by default

ajhahnde · Jun 2026 · 8f63bacd9aa3c68ef9cb1ec08269bed6d3bce11d · parent: 6785783 · view on GitHub →

modified CHANGELOG.md
@@ -35,6 +35,13 @@ protocol version.
### Added
- Practice bots now have a difficulty: **Easy**, **Normal**, or **Hard**, chosen from the
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
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
command line's `--hero`, so picking a side no longer needs a flag. The screen now renders as
modified src/bot/bot_controller.gd
@@ -15,6 +15,13 @@ extends RefCounted
## 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 positioning (heal, transform, kite, advance) are never
## throttled — the handicap slows the bot's hands, it does not dull its judgement.
enum Difficulty { EASY, NORMAL, HARD }
## Stop advancing once within this many world units of the target.
const STOP_RANGE := 60.0
@@ -27,6 +34,28 @@ const SLOT_COUNT := 4
## 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,
}
## 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
func decide(state: SimState, bot_id: int) -> InputCommand:
var command := InputCommand.new()
@@ -43,10 +72,16 @@ func decide(state: SimState, bot_id: int) -> InputCommand:
if offset.length() > STOP_RANGE:
command.move_dir = offset.normalized()
if bot.is_hero:
_choose_cast(command, bot, target)
_choose_cast(command, bot, target, state.tick)
return command
## 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
@@ -54,7 +89,7 @@ func decide(state: SimState, bot_id: int) -> InputCommand:
## 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:
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:
@@ -65,6 +100,8 @@ func _choose_cast(command: InputCommand, bot: SimEntity, target: SimEntity) -> v
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
@@ -72,6 +109,20 @@ func _choose_cast(command: InputCommand, bot: SimEntity, target: SimEntity) -> v
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,
modified src/client/connect_menu.gd
@@ -16,10 +16,11 @@ signal host_requested
## The player chose to join a server at `address` (already resolved to the default
## when the field was left blank).
signal join_requested(address: String)
## The player chose a single-machine practice match driving `hero` (a kit id). That
## hero's tribe fields the player's team and the opposing tribe the bots, so the pick
## also chooses the match-up — the same role `--hero` fills on the command line.
signal practice_requested(hero: String)
## The player chose a single-machine practice match driving `hero` (a kit id) against
## bots of `difficulty` (a level name). The hero's tribe fields the player's team and the
## opposing tribe the bots, so the pick also chooses the match-up — the same roles
## `--hero` and `--bot-difficulty` fill on the command line.
signal practice_requested(hero: String, difficulty: String)
## Menu styling. An opaque backdrop covers the whole viewport so the debug map and its
## jungle camps — drawn behind the menu in world space — do not bleed through the otherwise
@@ -36,6 +37,11 @@ const TITLE_FONT_SIZE := 72
const BUTTON_MIN_SIZE := Vector2(560, 76)
const ADDRESS_MIN_WIDTH := 380.0
## The bot difficulty choices, as `[label, level name]` pairs — the label shown in the
## picker, the level name carried as item metadata and emitted on Practice (the same
## names `--bot-difficulty` accepts). Self-contained so the menu stays pure presentation.
const DIFFICULTY_OPTIONS := [["Easy", "easy"], ["Normal", "normal"], ["Hard", "hard"]]
## The address used when the player leaves the field blank. The driver injects its
## own default so the menu and the `--join` flag resolve to one value.
var default_address := "127.0.0.1"
@@ -45,11 +51,19 @@ var default_address := "127.0.0.1"
## command line. Empty selects the first hero in the list.
var default_hero := ""
## The bot difficulty the picker starts on (a level name). The driver injects its own
## default — any `--bot-difficulty` parsed, else "easy" — so the menu reflects the command
## line. An unknown name leaves the picker on its first option.
var default_difficulty := "easy"
var _address_field: LineEdit
## Picks the hero the player drives in a practice match. Populated from
## `AbilityData.TRIBE` so the roster cannot drift from the simulation's; each item
## carries its kit id as metadata.
var _hero_picker: OptionButton
## Picks the bot skill level for a practice match; each item carries its level name as
## metadata, emitted on the Practice choice.
var _difficulty_picker: OptionButton
func _ready() -> void:
@@ -99,6 +113,14 @@ func _ready() -> void:
_populate_heroes()
box.add_child(_hero_picker)
var difficulty_label := Label.new()
difficulty_label.text = "Bot difficulty"
box.add_child(difficulty_label)
_difficulty_picker = OptionButton.new()
_populate_difficulties()
box.add_child(_difficulty_picker)
var practice_button := Button.new()
practice_button.text = "Practice (single machine)"
practice_button.custom_minimum_size = BUTTON_MIN_SIZE
@@ -161,8 +183,29 @@ func _selected_hero() -> String:
return _hero_picker.get_item_metadata(_hero_picker.selected)
## Fills the difficulty picker from `DIFFICULTY_OPTIONS` — one item per level, carrying
## its level name as metadata — and selects `default_difficulty` (or the first option
## when the injected name is unknown).
func _populate_difficulties() -> void:
for option in DIFFICULTY_OPTIONS:
_difficulty_picker.add_item(option[0])
_difficulty_picker.set_item_metadata(_difficulty_picker.item_count - 1, option[1])
for i in _difficulty_picker.item_count:
if _difficulty_picker.get_item_metadata(i) == default_difficulty:
_difficulty_picker.select(i)
return
## The level name of the selected difficulty, falling back to `default_difficulty` if
## nothing is selected (never the case while options are defined).
func _selected_difficulty() -> String:
if _difficulty_picker.selected < 0:
return default_difficulty
return _difficulty_picker.get_item_metadata(_difficulty_picker.selected)
func _on_practice_pressed() -> void:
practice_requested.emit(_selected_hero())
practice_requested.emit(_selected_hero(), _selected_difficulty())
func _on_host_pressed() -> void:
modified src/client/main.gd
@@ -138,6 +138,10 @@ var _bot_id: int = 0
## picks the match-up. Falls back to the first hero of the default tribe if unset or
## unrecognised. Ignored by HOST/CLIENT, which seat the duel.
var _player_hero: String = AbilityData.TRIBE[DEFAULT_TRIBE][0]
## The bot skill level, from `--bot-difficulty` or the menu, applied to `_bot` when the
## match begins. Defaults to "easy" so practice is winnable out of the box; "normal" and
## "hard" sharpen the bots' reaction. Held as a name and resolved to a level at apply.
var _bot_difficulty: String = "easy"
## LOCAL: every bot-driven hero this match — the player's two squadmates and the
## three opponents — each stepped from its own BotController decision.
var _bot_ids: Array[int] = []
@@ -209,6 +213,10 @@ func _configure_from_cmdline() -> void:
if i + 1 < args.size() and not args[i + 1].begins_with("--"):
_player_hero = args[i + 1]
i += 1
elif arg == "--bot-difficulty":
if i + 1 < args.size() and not args[i + 1].begins_with("--"):
_set_bot_difficulty(args[i + 1])
i += 1
elif arg == "--netsim":
if i + 1 < args.size() and not args[i + 1].begins_with("--"):
_netsim_params = _parse_netsim(args[i + 1])
@@ -235,10 +243,23 @@ func _parse_netsim(value: String) -> Array:
]
## Records the bot skill level from a `--bot-difficulty` value (or the menu), keeping it
## only when it names a known level so a typo degrades to the current default with a
## warning rather than starting an unintended difficulty.
func _set_bot_difficulty(level_name: String) -> void:
if BotController.DIFFICULTY_NAMES.has(level_name):
_bot_difficulty = level_name
else:
push_warning("unknown --bot-difficulty %s; keeping %s (want easy|normal|hard)" % [
level_name, _bot_difficulty
])
## Dispatches to the selected mode and marks the match live, so the per-tick driver
## and entity draw begin. The single entry point for both the command-line path and a
## menu choice.
func _enter_match() -> void:
_bot.difficulty = BotController.difficulty_from_name(_bot_difficulty)
match _mode:
Mode.HOST:
_start_host()
@@ -264,6 +285,7 @@ func _open_connect_menu() -> void:
var menu := ConnectMenu.new()
menu.default_address = DEFAULT_JOIN_ADDRESS
menu.default_hero = _player_hero
menu.default_difficulty = _bot_difficulty
menu.practice_requested.connect(_on_practice_requested)
menu.host_requested.connect(_on_host_requested)
menu.join_requested.connect(_on_join_requested)
@@ -272,12 +294,14 @@ func _open_connect_menu() -> void:
add_child(_menu_layer)
## The menu's Practice choice carries the hero the player picked; it overrides any
## `--hero` parsed from the command line, and (like `--hero`) its tribe fields the player's
## team and the opposing tribe the bots, so the pick also chooses the match-up.
func _on_practice_requested(hero: String) -> void:
## The menu's Practice choice carries the hero the player picked and the bot difficulty;
## both override any `--hero` / `--bot-difficulty` parsed from the command line. The hero's
## tribe fields the player's team and the opposing tribe the bots, so the pick also chooses
## the match-up.
func _on_practice_requested(hero: String, difficulty: String) -> void:
_mode = Mode.LOCAL
_player_hero = hero
_set_bot_difficulty(difficulty)
_close_menu_and_enter()
added test/unit/test_bot_difficulty.gd
@@ -0,0 +1,81 @@
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.
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
func test_easy_difficulty_throttles_the_damage_cast_to_a_beat() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, "wildkin", Vector2.ZERO) # the first entity, id 1
sim.add_entity(1, Vector2(600.0, 0.0), 0.0, 600) # in the skillshot band, ready to poke
var bot := _bot()
bot.difficulty = BotController.Difficulty.EASY
var period: int = BotController.CAST_PERIOD[BotController.Difficulty.EASY]
# Off the bot's beat ((tick + id) % period != 0) it holds its poke — the reaction
# handicap — though the target sits squarely in range.
sim.state.tick = 0 # (0 + 1) % period != 0
assert_eq(bot.decide(sim.state, id).ability_slot, -1, "off its beat the eased bot does not poke")
# On a beat it fires the very skillshot a full-strength bot would.
sim.state.tick = period - 1 # (period - 1 + 1) % period == 0
assert_eq(
bot.decide(sim.state, id).ability_slot, WILDKIN_SPIRIT_BOLT_SLOT, "on its beat it pokes"
)
func test_hard_difficulty_pokes_every_tick() -> 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)
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_eq(
bot.decide(sim.state, id).ability_slot,
WILDKIN_SPIRIT_BOLT_SLOT,
"the full-strength bot opens its cast every tick, no reaction handicap"
)
func test_difficulty_handicap_never_throttles_a_heal() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, "wildkin", Vector2.ZERO)
sim.state.get_entity(id).hp = 100 # under the 60% heal threshold
sim.add_entity(1, Vector2(600.0, 0.0), 0.0, 600)
var bot := _bot()
bot.difficulty = BotController.Difficulty.EASY
sim.state.tick = 0 # off the cast beat, where a poke would be withheld
assert_eq(
bot.decide(sim.state, id).ability_slot,
WILDKIN_MEND_SLOT,
"survival is never throttled: a hurt eased bot still heals off-beat"
)
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)
assert_eq(BotController.difficulty_from_name("easy"), BotController.Difficulty.EASY)
assert_eq(
BotController.difficulty_from_name("bogus"),
BotController.Difficulty.EASY,
"an unknown name falls back to the winnable easy default"
)
added test/unit/test_bot_difficulty.gd.uid
@@ -0,0 +1 @@
uid://bv8n6huijhf5y
modified test/unit/test_connect_menu.gd
@@ -24,12 +24,22 @@ func _select_hero(menu: ConnectMenu, hero: String) -> void:
fail_test("hero %s is not in the picker" % hero)
# Selects the difficulty item carrying `level` as metadata; fails if none matches.
func _select_difficulty(menu: ConnectMenu, level: String) -> void:
for i in menu._difficulty_picker.item_count:
if menu._difficulty_picker.get_item_metadata(i) == level:
menu._difficulty_picker.select(i)
return
fail_test("difficulty %s is not in the picker" % level)
func test_practice_choice_requests_a_local_match() -> void:
var menu := _menu()
watch_signals(menu)
menu._on_practice_pressed()
# With no injected default the picker rests on the first roster hero, the Solane lead.
assert_signal_emitted_with_parameters(menu, "practice_requested", ["lion"])
# With no injected defaults the pickers rest on the first roster hero (the Solane lead)
# and the first difficulty (Easy).
assert_signal_emitted_with_parameters(menu, "practice_requested", ["lion", "easy"])
func test_practice_carries_the_picked_hero() -> void:
@@ -37,7 +47,24 @@ func test_practice_carries_the_picked_hero() -> void:
_select_hero(menu, "chameleon")
watch_signals(menu)
menu._on_practice_pressed()
assert_signal_emitted_with_parameters(menu, "practice_requested", ["chameleon"])
assert_signal_emitted_with_parameters(menu, "practice_requested", ["chameleon", "easy"])
func test_practice_carries_the_picked_difficulty() -> void:
var menu := _menu()
_select_difficulty(menu, "hard")
watch_signals(menu)
menu._on_practice_pressed()
assert_signal_emitted_with_parameters(menu, "practice_requested", ["lion", "hard"])
func test_injected_default_preselects_its_difficulty() -> void:
# default_difficulty must be set before `_ready` populates, so build it outside `_menu()`.
var menu := ConnectMenu.new()
menu.default_difficulty = "normal"
add_child_autoqfree(menu)
var selected: String = menu._difficulty_picker.get_item_metadata(menu._difficulty_picker.selected)
assert_eq(selected, "normal", "the driver's default difficulty is pre-selected")
func test_picker_offers_every_tribe_hero() -> void: