diff --git a/data/bot/clan_wars_arenas.tables.toml b/data/bot/clan_wars_arenas.tables.toml new file mode 100644 index 0000000000..64130712c4 --- /dev/null +++ b/data/bot/clan_wars_arenas.tables.toml @@ -0,0 +1,26 @@ +[clan_wars_arenas] +row_id = "string" +spawn_area = "string" +tiers = "list" + +[.clan_wars_ffa_safe] +spawn_area = "clan_wars_teleport" +tiers = [ + "clan_wars_ffa_safe_zerker", + "clan_wars_ffa_safe_dharoker", + "clan_wars_ffa_safe_ags_main", + "clan_wars_ffa_safe_obby_pure", + "clan_wars_ffa_safe_msb_pure", + "clan_wars_ffa_safe_karils_tank", + "clan_wars_ffa_safe_ancient_tank", + "clan_wars_ffa_safe_ancient_hybrid", +] + +[.clan_wars_ffa_dangerous] +spawn_area = "clan_wars_teleport" +tiers = [ + "clan_wars_ffa_dangerous_melee", + "clan_wars_ffa_dangerous_ranged", + "clan_wars_ffa_dangerous_magic", + "clan_wars_ffa_dangerous_hybrid", +] diff --git a/data/bot/clan_wars_tiers.tables.toml b/data/bot/clan_wars_tiers.tables.toml new file mode 100644 index 0000000000..140a6278bd --- /dev/null +++ b/data/bot/clan_wars_tiers.tables.toml @@ -0,0 +1,65 @@ +[clan_wars_tiers] +row_id = "string" +combat_style = "string" +skills = "list" +levels = "list" + +[.clan_wars_ffa_safe_zerker] +combat_style = "slash" +skills = ["attack", "strength", "defence", "constitution", "prayer"] +levels = [60, 80, 45, 75, 95] + +[.clan_wars_ffa_safe_dharoker] +combat_style = "slash" +skills = ["attack", "strength", "defence", "constitution", "prayer"] +levels = [70, 70, 70, 70, 95] + +[.clan_wars_ffa_safe_ags_main] +combat_style = "slash" +skills = ["attack", "strength", "defence", "constitution", "prayer"] +levels = [75, 85, 75, 85, 95] + +[.clan_wars_ffa_safe_obby_pure] +combat_style = "crush" +skills = ["attack", "strength", "defence", "constitution"] +levels = [1, 80, 1, 70] + +[.clan_wars_ffa_safe_msb_pure] +combat_style = "rapid" +skills = ["attack", "strength", "defence", "constitution", "ranged", "prayer"] +levels = [1, 1, 1, 70, 70, 95] + +[.clan_wars_ffa_safe_karils_tank] +combat_style = "rapid" +skills = ["attack", "strength", "defence", "constitution", "ranged", "prayer"] +levels = [1, 1, 70, 75, 75, 95] + +[.clan_wars_ffa_safe_ancient_tank] +combat_style = "accurate" +skills = ["magic", "defence", "constitution", "prayer"] +levels = [94, 70, 80, 95] + +[.clan_wars_ffa_safe_ancient_hybrid] +combat_style = "accurate" +skills = ["magic", "attack", "strength", "ranged", "defence", "constitution", "prayer"] +levels = [94, 75, 80, 75, 70, 85, 95] + +[.clan_wars_ffa_dangerous_melee] +combat_style = "slash" +skills = ["attack", "strength", "defence", "constitution", "prayer", "magic"] +levels = [60, 70, 40, 70, 95, 94] + +[.clan_wars_ffa_dangerous_ranged] +combat_style = "rapid" +skills = ["ranged", "defence", "constitution", "prayer"] +levels = [70, 40, 70, 95] + +[.clan_wars_ffa_dangerous_magic] +combat_style = "accurate" +skills = ["magic", "defence", "constitution", "prayer"] +levels = [94, 40, 70, 95] + +[.clan_wars_ffa_dangerous_hybrid] +combat_style = "slash" +skills = ["attack", "strength", "ranged", "magic", "defence", "constitution", "prayer"] +levels = [75, 80, 75, 94, 60, 80, 95] diff --git a/data/bot/minigame_combat.templates.toml b/data/bot/minigame_combat.templates.toml new file mode 100644 index 0000000000..dc88515200 --- /dev/null +++ b/data/bot/minigame_combat.templates.toml @@ -0,0 +1,729 @@ +# ============================================================================= +# Minigame Combat templates — how this file works +# ============================================================================= +# +# Each [section] below is a TEMPLATE. Templates are blueprints, not running +# activities. They get instantiated by entries in *.bots.toml (e.g. +# data/minigame/clan_wars/clan_wars.bots.toml) which set: +# +# [clan_wars_ffa_safe_zerker] +# template = "pvp_zerker" # name of a template below +# capacity = 3 # max concurrent bots in this activity +# timeout = 500 # ticks of no progress before fail +# fields = { style = "style2", area = "clan_wars_ffa_safe_arena", ... } +# +# Every "$name" string in a template is replaced with fields[name] at load +# time (see Fragment.resolve in game/.../bot/behaviour/Behaviour.kt). One +# template can drive many arenas just by varying `fields`. +# +# Top-level keys allowed in a template: +# requires array Bot-level prerequisites (skill levels, owned items). +# Filtered against `bot.player` when the activity is picked +# by BotManager.assignRandom; failing it makes the activity +# invisible to that bot. +# setup array Per-engagement state the bot must be in BEFORE `actions` +# can start. A failing setup row triggers a resolver +# sub-frame (e.g. "be in $area" → BotGoTo). Resolvers live +# in *.setups.toml and clan_wars.setups.toml. +# actions array Sequential body of the activity, advanced once per tick +# by BotManager.execute. The last entry is conventionally +# `restart` so the activity loops until `success` fires. +# reactive array Runs every tick alongside `actions` via +# BotManager.runReactive — REACTIVES RUN BEFORE THE MAIN +# ACTION each tick, and entries iterate top-to-bottom. +# Used for short-lived decisions (prayer toggles, drinks, +# spec attacks). Each reactive's `if` gates execution. +# produces array Tags ({ skill = "..." } etc.) the activity emits, used +# by BotManager.updateAvailable to recompute eligibility +# after the bot's state changes. +# loadouts map Hybrid templates only — named gear/inventory +# hybrid_starting_loadout str variants (see pvp_dangerous_hybrid). +# hybrid_swap_cooldown int Default 3 ticks between loadout swaps. +# hybrid_swap_per_tick int Default 1 equipment slot changed per tick. +# +# Lifecycle of one tick (BotManager.tick): +# 1. If no active frame, assignRandom() picks an activity (honouring +# bot.pinned for PvP bots). +# 2. runReactive() — every entry in `reactive` fires once. Reactives queue +# instructions but don't change the main frame's state. +# 3. execute() — advances the main frame: +# Pending → check `setup`; failures spawn a resolver sub-frame. +# Running → call current action's update(). +# Wait → countdown, then transition to the inner state. +# Success → step to the next action in `actions`. +# Failed → handle per Reason; pinned bots stay pinned. +# +# The pvp_zerker template below is fully annotated as a worked example. The +# rest of this file relies on the same vocabulary; refer back here when in +# doubt. Catalog of every condition / action type is in: +# game/src/main/kotlin/content/bot/behaviour/condition/Condition.kt (parse) +# game/src/main/kotlin/content/bot/behaviour/action/ActionParser.kt (parsers map) +# +# ============================================================================= + +# ----------------------------------------------------------------------------- +# pvp_zerker — annotated worked example +# ----------------------------------------------------------------------------- +[pvp_zerker] + +# `requires` rows are checked against bot.player when picking an activity. +# These are absolute base levels (BotSkillLevel uses levels.getMax, the +# XP-driven level — not the current decayed/boosted value). A drained skill +# therefore won't deactivate the activity mid-fight. +# { skill = { id, min, max } } → absolute level bounds +# { skill_percent = { id, max_percent = 30 } } → current vs max as percent +# { combat_level = { min, max } } / { equipment = ... } / { inventory = ... } +# etc. Full list: Condition.kt parse switch. +requires = [ + { skill = { id = "attack", min = 60 } }, + { skill = { id = "strength", min = 80 } }, + { skill = { id = "defence", min = 45, max = 45 } }, # zerker: defence pinned to 45 + { skill = { id = "constitution", min = 75 } }, + { skill = { id = "prayer", min = 95 } }, +] + +# `setup` rows are the engagement preconditions. Each row that doesn't pass +# triggers the matching resolver to run as a sub-frame. The activity blocks +# at Pending until every setup row checks true (or a resolver fails — which +# fails the activity). +# +# `equipment` and `inventory` rows here also drive applyTier in BotCommands.kt +# at spawn time: the bot is given exactly this kit when its tier is applied. +# +# Items support `min`, `max`, `equippable`, `usable`. Use `equippable = true` +# for items the bot will swap to mid-fight (granite_maul, dragon_dagger_p++) +# so the requirement pre-checks the bot can actually wear them. +setup = [ + { equipment = { + weapon = { id = "abyssal_whip" }, + chest = { id = "fighter_torso" }, + legs = { id = "rune_platelegs" }, + hat = { id = "helm_of_neitiznot" }, + feet = { id = "dragon_boots" }, + amulet = { id = "amulet_of_fury" }, + ring = { id = "berserker_ring" }, + cape = { id = "obsidian_cape" }, + } }, + { inventory = [ + { id = "shark", min = 16 }, # food: BotFightPlayer.eat() picks the first "Eat" option it finds + { id = "super_restore_4", min = 2 }, # prayer + safety-net restore + { id = "super_attack_4", min = 1 }, # boost — drunk by reactive below + { id = "super_strength_4", min = 1 }, + { id = "super_defence_4", min = 1 }, + { id = "dragon_dagger_p++", min = 1, equippable = true }, # spec weapon (BotSpecAttack swaps to it) + { id = "granite_maul", min = 1, equippable = true }, # alt spec weapon if the activity wants one + { id = "games_necklace_6", min = 1, equippable = true }, # retreat teleport (used by jewellery_teleport reactives in *.bots.toml) + ] }, + # `area` is the BotInArea condition. The matching resolver + # (enter_clan_wars_ffa_safe / _dangerous in clan_wars.setups.toml) walks + # the bot from the staging area through the portal into `$area`. + { area = { id = "$area" } }, +] + +# `actions` is the sequential body. Per tick BotManager.execute advances +# whichever action is current; on Success the next one starts. +actions = [ + # One-shot per restart: clicks the combat-style tab so subsequent attacks + # use the right style (`$style` is e.g. style2 = aggressive). The + # `interface` action targets "interface:component[:item]" ids. + { interface = { option = "Select", id = "combat_styles:$style" } }, + + # The long-running engagement loop — BotFightPlayer. Picks a target via + # spiral search within `radius`, attacks, eats when HP <= heal_percent + # of max, loots own kills (loot_strategy = survival prefers consumables). + # `success` here is what flips this action to Success early — the + # bot leaving `$area` (retreat or teleport-out) ends the fight cleanly + # so the next action (`restart`) can decide whether to loop. + { player = { + option = "Attack", + radius = 10, + delay = 1, + heal_percent = 40, + loot_strategy = "survival", + area = "$area", + success = { area = { id = "$area", present = false } }, + } }, + + # `restart` loops the body. Without this, the activity runs once and ends. + # Its own `success` is what ENDS the activity (vs continuing); same area- + # exit condition as `player.success` above means: while the bot is in + # the arena, restart the engagement; once it leaves, exit cleanly. + { restart = { success = { area = { id = "$area", present = false } } } }, +] + +# Reactives fire every tick in the order written. Earlier entries get to +# fire first — and once a reactive uses an interface that sets `drink_delay` +# (any potion drink) or otherwise blocks subsequent reactives, later entries +# no-op for that tick. +reactive = [ + # Overhead prayer: matches the most recent attacker's style. attacker_style + # reads the same source as the `Player.attackerStyle` debug. These three + # rows are mutually exclusive — only one matches per tick. + { pray = { id = "deflect_melee", if = { attacker_style = { equals = "melee" } } } }, + { pray = { id = "deflect_missiles", if = { attacker_style = { equals = "ranged" } } } }, + { pray = { id = "deflect_magic", if = { attacker_style = { equals = "magic" } } } }, + + # Damage-boosting curse — kept on while inside the arena, off elsewhere + # (so it doesn't drain prayer in the lobby). $area is replaced per arena. + { pray = { id = "berserker", if = { area = { id = "$area" } } } }, + { pray = { id = "turmoil", if = { area = { id = "$area" } } } }, + + # Spec attack: equips `weapon` (uses `fallback` between specs), checks + # min_energy + the `if` condition, then flips player.specialAttack = true + # so the next combat swing consumes spec energy. target_hp_percent.max=0.25 + # = "target HP at 25% or below" → finisher window. + { spec_attack = { weapon = "dragon_dagger_p++", fallback = "abyssal_whip", min_energy = 250, if = { target_hp_percent = { max = 0.25 } } } }, + + # ---- Boost-potion gating rule (PvP-wide) ------------------------------- + # Boosts are drunk ONLY at the start of a fight or right before a spec — + # never on plain decay during regular combat. + # + # `fight_starting` is a 5-tick clock set by BotFightPlayer.search() (and + # equivalents in BotCastSpell/BotFightNpc) every time a NEW InteractPlayer + # is queued. Five ticks gives `drink_delay` (~2 ticks per drink) room to + # sequence two-three drinks before the clock expires. + # + # super_strength is the spec-scaling stat for the dagger spec, so it + # ALSO fires inside the spec window via `any[fight_starting, target_hp...]`. + # super_attack/super_defence don't scale the spec, so they fight-start only. + { drink_potion = { item = "super_attack_*", skill = "attack", if = { clock = { id = "fight_starting" } } } }, + { drink_potion = { item = "super_strength_*", skill = "strength", if = { any = [{ clock = { id = "fight_starting" } }, { target_hp_percent = { max = 0.25 } }] } } }, + { drink_potion = { item = "super_defence_*", skill = "defence", if = { clock = { id = "fight_starting" } } } }, + + # Prayer restore: unconditional — BotDrinkPotion treats Skill.Prayer + # specially (drinks while current >= max), so any drained point fires it. + { drink_potion = { item = "super_restore_*", skill = "prayer" } }, + + # Safety-net restore: if a primary combat stat is dragged below 30% of + # max (e.g. after curse leeches or stacked saradomin brews on a brew + # template), drink super_restore even though prayer is already full. + # `skill_percent` reads current vs base/max as a percentage — + # see BotSkillPercent. Templates that carry brews additionally OR-in + # `variable brew_doses_since_restore.min=3` here; pvp_zerker has no brews. + { drink_potion = { item = "super_restore_*", skill = "strength", if = { skill_percent = { id = "strength", max_percent = 30 } } } }, +] + +# `produces` = events the activity emits when the bot's state changes. These +# names ("skill:attack" etc.) are matched against the keys/events sets each +# Condition declares; activities listed in `groups[event]` get re-evaluated +# in BotManager.updateAvailable. Tag every skill the bot trains/drinks here +# so the manager doesn't blacklist the activity after a successful fight. +produces = [ + { skill = "attack" }, + { skill = "strength" }, + { skill = "defence" }, + { skill = "constitution" }, + { skill = "prayer" }, +] +# ----------------------------------------------------------------------------- +# End of pvp_zerker worked example. The templates below use the same +# vocabulary — refer to the comments above when reading them. +# ----------------------------------------------------------------------------- + +[pvp_dharoker] +requires = [ + { skill = { id = "attack", min = 70 } }, + { skill = { id = "strength", min = 70 } }, + { skill = { id = "defence", min = 70 } }, + { skill = { id = "constitution", min = 70 } }, + { skill = { id = "prayer", min = 95 } }, +] +setup = [ + { equipment = { weapon = { id = "dharoks_greataxe" }, chest = { id = "dharoks_platebody" }, legs = { id = "dharoks_platelegs" }, hat = { id = "dharoks_helm" }, feet = { id = "dragon_boots" }, cape = { id = "obsidian_cape" } } }, + { inventory = [ + { id = "shark", min = 16 }, + { id = "super_restore_4", min = 2 }, + { id = "super_attack_4", min = 1 }, + { id = "super_strength_4", min = 1 }, + { id = "super_defence_4", min = 1 }, + { id = "dwarven_rock_cake", min = 1 }, + { id = "games_necklace_6", min = 1, equippable = true }, + ] }, + { area = { id = "$area" } }, +] +actions = [ + { interface = { option = "Select", id = "combat_styles:$style" } }, + { player = { option = "Attack", radius = 10, delay = 1, heal_percent = 25, loot_strategy = "survival", area = "$area", success = { area = { id = "$area", present = false } } } }, + { restart = { success = { area = { id = "$area", present = false } } } }, +] +reactive = [ + { pray = { id = "berserker", if = { area = { id = "$area" } } } }, + { pray = { id = "soul_split", if = { area = { id = "$area" } } } }, + { pray = { id = "turmoil", if = { area = { id = "$area" } } } }, + # Boost potions only at fight start; dharoker has no spec. + { drink_potion = { item = "super_attack_*", skill = "attack", if = { clock = { id = "fight_starting" } } } }, + { drink_potion = { item = "super_strength_*", skill = "strength", if = { clock = { id = "fight_starting" } } } }, + { drink_potion = { item = "super_defence_*", skill = "defence", if = { clock = { id = "fight_starting" } } } }, + { drink_potion = { item = "super_restore_*", skill = "prayer" } }, + { drink_potion = { item = "super_restore_*", skill = "strength", if = { skill_percent = { id = "strength", max_percent = 30 } } } }, +] +produces = [ + { skill = "attack" }, + { skill = "strength" }, + { skill = "defence" }, + { skill = "constitution" }, + { skill = "prayer" }, +] + +[pvp_ags_main] +requires = [ + { skill = { id = "attack", min = 75 } }, + { skill = { id = "strength", min = 85 } }, + { skill = { id = "defence", min = 75 } }, + { skill = { id = "constitution", min = 85 } }, + { skill = { id = "prayer", min = 95 } }, +] +setup = [ + { equipment = { weapon = { id = "armadyl_godsword" }, chest = { id = "torva_platebody,bandos_chestplate" }, legs = { id = "torva_platelegs,bandos_tassets" }, hat = { id = "torva_full_helm,helm_of_neitiznot" }, feet = { id = "bandos_boots" }, amulet = { id = "amulet_of_fury" }, ring = { id = "berserker_ring" }, cape = { id = "obsidian_cape" } } }, + { inventory = [ + { id = "shark", min = 16 }, + { id = "super_restore_4", min = 2 }, + { id = "saradomin_brew_4", min = 2 }, + { id = "overload_4", min = 1 }, + { id = "abyssal_whip", min = 1, equippable = true }, + { id = "games_necklace_6", min = 1, equippable = true }, + ] }, + { area = { id = "$area" } }, +] +actions = [ + { interface = { option = "Select", id = "combat_styles:$style" } }, + { player = { option = "Attack", radius = 10, delay = 1, heal_percent = 40, loot_strategy = "survival", area = "$area", success = { area = { id = "$area", present = false } } } }, + { restart = { success = { area = { id = "$area", present = false } } } }, +] +reactive = [ + { pray = { id = "deflect_melee", if = { attacker_style = { equals = "melee" } } } }, + { pray = { id = "deflect_missiles", if = { attacker_style = { equals = "ranged" } } } }, + { pray = { id = "deflect_magic", if = { attacker_style = { equals = "magic" } } } }, + { pray = { id = "berserker", if = { area = { id = "$area" } } } }, + { pray = { id = "turmoil", if = { area = { id = "$area" } } } }, + { spec_attack = { weapon = "armadyl_godsword", fallback = "armadyl_godsword", min_energy = 500, if = { target_hp_percent = { max = 0.25 } } } }, + # Overload at fight start or pre-spec; covers atk/str/def in one drink. + { drink_potion = { item = "overload_*", skill = "strength", if = { any = [{ clock = { id = "fight_starting" } }, { target_hp_percent = { max = 0.25 } }] } } }, + { drink_potion = { item = "super_restore_*", skill = "prayer" } }, + # Brew→food chain: food eaten this/last tick → drink brew next, stacking the overheal. + { drink_potion = { item = "saradomin_brew_*", skill = "constitution", if = { clock = { id = "just_ate_food" } } } }, + # Restore once 3 brews have stacked enough debuff OR the primary combat stat dips below 30% max. + { drink_potion = { item = "super_restore_*", skill = "strength", if = { any = [{ variable = { id = "brew_doses_since_restore", default = 0, min = 3 } }, { skill_percent = { id = "strength", max_percent = 30 } }] } } }, +] +produces = [ + { skill = "attack" }, + { skill = "strength" }, + { skill = "defence" }, + { skill = "constitution" }, + { skill = "prayer" }, +] + +[pvp_obby_pure] +requires = [ + { skill = { id = "strength", min = 80 } }, + { skill = { id = "defence", max = 1 } }, + { skill = { id = "constitution", min = 70 } }, +] +setup = [ + { equipment = { weapon = { id = "tzhaar_ket_om" }, chest = { id = "iron_platebody" }, legs = { id = "iron_platelegs" }, hat = { id = "bearhead" }, amulet = { id = "berserker_necklace" }, cape = { id = "obsidian_cape" } } }, + { inventory = [ + { id = "shark", min = 18 }, + { id = "super_strength_4", min = 2 }, + { id = "granite_maul", min = 1, equippable = true }, + { id = "games_necklace_6", min = 1, equippable = true }, + ] }, + { area = { id = "$area" } }, +] +actions = [ + { player = { option = "Attack", radius = 10, delay = 1, heal_percent = 40, loot_strategy = "survival", area = "$area", success = { area = { id = "$area", present = false } } } }, + { restart = { success = { area = { id = "$area", present = false } } } }, +] +reactive = [ + { spec_attack = { weapon = "granite_maul", fallback = "tzhaar_ket_om", min_energy = 500, if = { target_hp_percent = { max = 0.35 } } } }, + # Boost only at fight start or pre-spec; obby_pure has no super_restore so no safety-net. + { drink_potion = { item = "super_strength_*", skill = "strength", if = { any = [{ clock = { id = "fight_starting" } }, { target_hp_percent = { max = 0.35 } }] } } }, +] +produces = [ + { skill = "strength" }, + { skill = "constitution" }, +] + +[pvp_msb_pure] +requires = [ + { skill = { id = "ranged", min = 70 } }, + { skill = { id = "defence", max = 1 } }, + { skill = { id = "constitution", min = 70 } }, + { skill = { id = "prayer", min = 95 } }, +] +setup = [ + { equipment = { weapon = { id = "magic_shortbow" }, chest = { id = "black_dragonhide_body" }, legs = { id = "black_dragonhide_chaps" }, hat = { id = "archer_helm" }, feet = { id = "ranger_boots" }, hands = { id = "black_dragonhide_vambraces" }, ammo = { id = "rune_arrow", min = 10000 }, cape = { id = "avas_accumulator" } } }, + { inventory = [ + { id = "shark", min = 16 }, + { id = "super_restore_4", min = 1 }, + { id = "super_ranging_potion_4", min = 2 }, + { id = "rune_arrow", min = 10000 }, + { id = "games_necklace_6", min = 1, equippable = true }, + ] }, + { area = { id = "$area" } }, +] +actions = [ + { interface = { option = "Select", id = "combat_styles:$style" } }, + { player = { option = "Attack", radius = 12, delay = 1, heal_percent = 40, loot_strategy = "survival", area = "$area", success = { area = { id = "$area", present = false } } } }, + { restart = { success = { area = { id = "$area", present = false } } } }, +] +reactive = [ + { pray = { id = "deflect_melee", if = { attacker_style = { equals = "melee" } } } }, + { pray = { id = "deflect_missiles", if = { attacker_style = { equals = "ranged" } } } }, + { pray = { id = "deflect_magic", if = { attacker_style = { equals = "magic" } } } }, + { pray = { id = "berserker", if = { area = { id = "$area" } } } }, + { pray = { id = "leech_ranged", if = { area = { id = "$area" } } } }, + { spec_attack = { weapon = "magic_shortbow", fallback = "magic_shortbow", min_energy = 500, if = { target_hp_percent = { max = 0.25 } } } }, + # Ranged boost at fight start or pre-spec; no decay redose during regular combat. + { drink_potion = { item = "super_ranging_potion_*", skill = "ranged", if = { any = [{ clock = { id = "fight_starting" } }, { target_hp_percent = { max = 0.25 } }] } } }, + { drink_potion = { item = "super_restore_*", skill = "prayer" } }, + { drink_potion = { item = "super_restore_*", skill = "ranged", if = { skill_percent = { id = "ranged", max_percent = 30 } } } }, +] +produces = [ + { skill = "ranged" }, + { skill = "constitution" }, + { skill = "prayer" }, +] + +[pvp_karils_tank] +requires = [ + { skill = { id = "ranged", min = 75 } }, + { skill = { id = "defence", min = 70 } }, + { skill = { id = "constitution", min = 75 } }, + { skill = { id = "prayer", min = 95 } }, +] +setup = [ + { equipment = { weapon = { id = "rune_crossbow" }, chest = { id = "karils_top" }, legs = { id = "karils_skirt" }, hat = { id = "karils_coif" }, feet = { id = "ranger_boots" }, hands = { id = "black_dragonhide_vambraces" }, ammo = { id = "runite_bolts", min = 10000 }, amulet = { id = "amulet_of_fury" }, cape = { id = "avas_accumulator" } } }, + { inventory = [ + { id = "shark", min = 16 }, + { id = "super_restore_4", min = 2 }, + { id = "super_ranging_potion_4", min = 2 }, + { id = "runite_bolts", min = 10000 }, + { id = "games_necklace_6", min = 1, equippable = true }, + ] }, + { area = { id = "$area" } }, +] +actions = [ + { interface = { option = "Select", id = "combat_styles:$style" } }, + { player = { option = "Attack", radius = 12, delay = 1, heal_percent = 40, loot_strategy = "survival", area = "$area", success = { area = { id = "$area", present = false } } } }, + { restart = { success = { area = { id = "$area", present = false } } } }, +] +reactive = [ + { pray = { id = "deflect_melee", if = { attacker_style = { equals = "melee" } } } }, + { pray = { id = "deflect_missiles", if = { attacker_style = { equals = "ranged" } } } }, + { pray = { id = "deflect_magic", if = { attacker_style = { equals = "magic" } } } }, + { pray = { id = "berserker", if = { area = { id = "$area" } } } }, + { pray = { id = "leech_ranged", if = { area = { id = "$area" } } } }, + # Karil's tank has no spec, so the ranged boost only fires at fight start. + { drink_potion = { item = "super_ranging_potion_*", skill = "ranged", if = { clock = { id = "fight_starting" } } } }, + { drink_potion = { item = "super_restore_*", skill = "prayer" } }, + { drink_potion = { item = "super_restore_*", skill = "ranged", if = { skill_percent = { id = "ranged", max_percent = 30 } } } }, +] +produces = [ + { skill = "ranged" }, + { skill = "defence" }, + { skill = "constitution" }, + { skill = "prayer" }, +] + +[pvp_ancient_tank] +requires = [ + { skill = { id = "magic", min = 94 } }, + { skill = { id = "defence", min = 70 } }, + { skill = { id = "constitution", min = 80 } }, + { skill = { id = "prayer", min = 95 } }, +] +setup = [ + { equipment = { weapon = { id = "ancient_staff" }, shield = { id = "zamoraks_book_of_chaos" }, chest = { id = "ahrims_robe_top*" }, legs = { id = "ahrims_robe_skirt*" }, hat = { id = "ahrims_hood*" }, feet = { id = "infinity_boots" }, amulet = { id = "amulet_of_fury" }, cape = { id = "obsidian_cape" } } }, + { inventory = [ + { id = "shark", min = 14 }, + { id = "saradomin_brew_4", min = 2 }, + { id = "super_restore_4", min = 2 }, + { id = "super_magic_potion_4", min = 2 }, + { id = "blood_rune", min = 10000 }, + { id = "death_rune", min = 10000 }, + { id = "water_rune", min = 10000 }, + { id = "chaos_rune", min = 10000 }, + { id = "air_rune", min = 10000 }, + { id = "games_necklace_6", min = 1, equippable = true }, + ] }, + { area = { id = "$area" } }, +] +actions = [ + { cast_spell = { family = "ice", radius = 12, delay = 1, heal_percent = 40, kite = true, area = "$area", success = { area = { id = "$area", present = false } } } }, + { restart = { success = { area = { id = "$area", present = false } } } }, +] +reactive = [ + { pray = { id = "deflect_melee", if = { attacker_style = { equals = "melee" } } } }, + { pray = { id = "deflect_missiles", if = { attacker_style = { equals = "ranged" } } } }, + { pray = { id = "deflect_magic", if = { attacker_style = { equals = "magic" } } } }, + { pray = { id = "berserker", if = { area = { id = "$area" } } } }, + { pray = { id = "leech_magic", if = { area = { id = "$area" } } } }, + # Magic boost only at fight start (ancient_tank has no spec). + { drink_potion = { item = "super_magic_potion_*", skill = "magic", if = { clock = { id = "fight_starting" } } } }, + { drink_potion = { item = "super_restore_*", skill = "prayer" } }, + # Brew→food chain: food eaten this/last tick → drink brew next, stacking the overheal. + { drink_potion = { item = "saradomin_brew_*", skill = "constitution", if = { clock = { id = "just_ate_food" } } } }, + # Restore on 3 brew doses OR magic dipped below 30% max. + { drink_potion = { item = "super_restore_*", skill = "magic", if = { any = [{ variable = { id = "brew_doses_since_restore", default = 0, min = 3 } }, { skill_percent = { id = "magic", max_percent = 30 } }] } } }, +] +produces = [ + { skill = "magic" }, + { skill = "defence" }, + { skill = "constitution" }, + { skill = "prayer" }, +] + +[pvp_ancient_hybrid] +requires = [ + { skill = { id = "magic", min = 94 } }, + { skill = { id = "attack", min = 75 } }, + { skill = { id = "strength", min = 80 } }, + { skill = { id = "ranged", min = 75 } }, + { skill = { id = "defence", min = 70 } }, + { skill = { id = "constitution", min = 85 } }, + { skill = { id = "prayer", min = 95 } }, +] +setup = [ + { equipment = { weapon = { id = "ancient_staff" }, shield = { id = "zamoraks_book_of_chaos" }, chest = { id = "virtus_robe_top*,ahrims_robe_top*" }, legs = { id = "virtus_robe_legs*,ahrims_robe_skirt*" }, hat = { id = "virtus_mask*,ahrims_hood*" }, feet = { id = "infinity_boots" }, amulet = { id = "amulet_of_fury" }, ring = { id = "berserker_ring" }, cape = { id = "obsidian_cape" } } }, + { inventory = [ + { id = "shark", min = 14 }, + { id = "saradomin_brew_4", min = 2 }, + { id = "super_restore_4", min = 2 }, + { id = "blood_rune", min = 10000 }, + { id = "death_rune", min = 10000 }, + { id = "water_rune", min = 10000 }, + { id = "chaos_rune", min = 10000 }, + { id = "air_rune", min = 10000 }, + { id = "armadyl_godsword", min = 1, equippable = true }, + { id = "dark_bow", min = 1, equippable = true }, + { id = "dragon_arrow", min = 50 }, + { id = "games_necklace_6", min = 1, equippable = true }, + ] }, + { area = { id = "$area" } }, +] +actions = [ + { cast_spell = { family = "ice", radius = 12, delay = 1, heal_percent = 40, kite = true, area = "$area", success = { area = { id = "$area", present = false } } } }, + { restart = { success = { area = { id = "$area", present = false } } } }, +] +reactive = [ + { pray = { id = "deflect_melee", if = { attacker_style = { equals = "melee" } } } }, + { pray = { id = "deflect_missiles", if = { attacker_style = { equals = "ranged" } } } }, + { pray = { id = "deflect_magic", if = { attacker_style = { equals = "magic" } } } }, + { pray = { id = "berserker", if = { area = { id = "$area" } } } }, + { pray = { id = "leech_strength", if = { area = { id = "$area" } } } }, + { pray = { id = "soul_split", if = { area = { id = "$area" } } } }, + { pray = { id = "turmoil", if = { area = { id = "$area" } } } }, + { spec_attack = { weapon = "armadyl_godsword", fallback = "ancient_staff", min_energy = 500, if = { target_frozen = { hp_max = 0.30 } } } }, + { spec_attack = { weapon = "dark_bow", fallback = "ancient_staff", min_energy = 550, if = { target_frozen = { hp_max = 0.30 } } } }, + { drink_potion = { item = "super_restore_*", skill = "prayer" } }, + # Brew→food chain: food eaten this/last tick → drink brew next, stacking the overheal. + { drink_potion = { item = "saradomin_brew_*", skill = "constitution", if = { clock = { id = "just_ate_food" } } } }, + # Restore on 3 brew doses OR magic dipped below 30% max. + { drink_potion = { item = "super_restore_*", skill = "magic", if = { any = [{ variable = { id = "brew_doses_since_restore", default = 0, min = 3 } }, { skill_percent = { id = "magic", max_percent = 30 } }] } } }, +] +produces = [ + { skill = "magic" }, + { skill = "attack" }, + { skill = "strength" }, + { skill = "ranged" }, + { skill = "defence" }, + { skill = "constitution" }, + { skill = "prayer" }, +] + +# Dangerous-arena Clan Wars Templates + +[pvp_dangerous_melee] +requires = [ + { skill = { id = "attack", min = 60 } }, + { skill = { id = "strength", min = 70 } }, + { skill = { id = "defence", min = 40 } }, + { skill = { id = "constitution", min = 70 } }, + { skill = { id = "prayer", min = 95 } }, + { skill = { id = "magic", min = 94 } }, +] +setup = [ + { equipment = { weapon = { id = "abyssal_whip" }, chest = { id = "fighter_torso" }, legs = { id = "rune_platelegs" }, hat = { id = "helm_of_neitiznot" }, feet = { id = "dragon_boots" }, amulet = { id = "amulet_of_glory_4" }, cape = { id = "obsidian_cape" } } }, + { inventory = [ + { id = "shark", min = 13 }, + { id = "super_restore_4", min = 2 }, + { id = "super_attack_4", min = 1 }, + { id = "super_strength_4", min = 1 }, + { id = "super_defence_4", min = 1 }, + { id = "death_rune", min = 400 }, + { id = "astral_rune", min = 400 }, + { id = "earth_rune", min = 400 }, + { id = "dragon_dagger_p++", min = 1, equippable = true }, + { id = "games_necklace_6", min = 1, equippable = true }, + ] }, + { area = { id = "$area" } }, +] +actions = [ + { interface = { option = "Select", id = "combat_styles:$style" } }, + { player = { option = "Attack", radius = 10, delay = 1, heal_percent = 40, loot_strategy = "survival", area = "$area", success = { area = { id = "$area", present = false } } } }, + { restart = { success = { area = { id = "$area", present = false } } } }, +] +reactive = [ + { pray = { id = "deflect_melee", if = { attacker_style = { equals = "melee" } } } }, + { pray = { id = "deflect_missiles", if = { attacker_style = { equals = "ranged" } } } }, + { pray = { id = "deflect_magic", if = { attacker_style = { equals = "magic" } } } }, + { pray = { id = "berserker", if = { area = { id = "$area" } } } }, + { pray = { id = "turmoil", if = { area = { id = "$area" } } } }, + { spec_attack = { weapon = "dragon_dagger_p++", fallback = "abyssal_whip", min_energy = 250, if = { target_hp_percent = { max = 0.25 } } } }, + # Boost potions only at fight start (fight_starting clock) or pre-spec; no decay redose. + { drink_potion = { item = "super_attack_*", skill = "attack", if = { clock = { id = "fight_starting" } } } }, + { drink_potion = { item = "super_strength_*", skill = "strength", if = { any = [{ clock = { id = "fight_starting" } }, { target_hp_percent = { max = 0.25 } }] } } }, + { drink_potion = { item = "super_defence_*", skill = "defence", if = { clock = { id = "fight_starting" } } } }, + { drink_potion = { item = "super_restore_*", skill = "prayer" } }, + { drink_potion = { item = "super_restore_*", skill = "strength", if = { skill_percent = { id = "strength", max_percent = 30 } } } }, + { cast_vengeance = { } }, +] +produces = [ + { skill = "attack" }, + { skill = "strength" }, + { skill = "defence" }, + { skill = "constitution" }, + { skill = "prayer" }, + { skill = "magic" }, +] + +[pvp_dangerous_ranged] +requires = [ + { skill = { id = "ranged", min = 70 } }, + { skill = { id = "defence", min = 40 } }, + { skill = { id = "constitution", min = 70 } }, + { skill = { id = "prayer", min = 95 } }, +] +setup = [ + { equipment = { weapon = { id = "magic_shortbow" }, chest = { id = "black_dragonhide_body" }, legs = { id = "black_dragonhide_chaps" }, hat = { id = "archer_helm" }, feet = { id = "snakeskin_boots" }, hands = { id = "black_dragonhide_vambraces" }, ammo = { id = "rune_arrow", min = 10000 }, amulet = { id = "amulet_of_glory_4" }, cape = { id = "avas_accumulator" } } }, + { inventory = [ + { id = "shark", min = 16 }, + { id = "super_restore_4", min = 1 }, + { id = "super_ranging_potion_4", min = 2 }, + { id = "rune_arrow", min = 10000 }, + { id = "games_necklace_6", min = 1, equippable = true }, + ] }, + { area = { id = "$area" } }, +] +actions = [ + { interface = { option = "Select", id = "combat_styles:$style" } }, + { player = { option = "Attack", radius = 12, delay = 1, heal_percent = 40, loot_strategy = "survival", area = "$area", success = { area = { id = "$area", present = false } } } }, + { restart = { success = { area = { id = "$area", present = false } } } }, +] +reactive = [ + { pray = { id = "deflect_melee", if = { attacker_style = { equals = "melee" } } } }, + { pray = { id = "deflect_missiles", if = { attacker_style = { equals = "ranged" } } } }, + { pray = { id = "deflect_magic", if = { attacker_style = { equals = "magic" } } } }, + { pray = { id = "berserker", if = { area = { id = "$area" } } } }, + { pray = { id = "leech_ranged", if = { area = { id = "$area" } } } }, + { spec_attack = { weapon = "magic_shortbow", fallback = "magic_shortbow", min_energy = 500, if = { target_hp_percent = { max = 0.25 } } } }, + { drink_potion = { item = "super_ranging_potion_*", skill = "ranged", if = { any = [{ clock = { id = "fight_starting" } }, { target_hp_percent = { max = 0.25 } }] } } }, + { drink_potion = { item = "super_restore_*", skill = "prayer" } }, + { drink_potion = { item = "super_restore_*", skill = "ranged", if = { skill_percent = { id = "ranged", max_percent = 30 } } } }, +] +produces = [ + { skill = "ranged" }, + { skill = "defence" }, + { skill = "constitution" }, + { skill = "prayer" }, +] + +[pvp_dangerous_hybrid] +requires = [ + { skill = { id = "attack", min = 75 } }, + { skill = { id = "strength", min = 80 } }, + { skill = { id = "ranged", min = 75 } }, + { skill = { id = "magic", min = 94 } }, + { skill = { id = "defence", min = 60 } }, + { skill = { id = "constitution", min = 80 } }, + { skill = { id = "prayer", min = 95 } }, +] +hybrid_starting_loadout = "melee" +hybrid_swap_cooldown = 3 +hybrid_swap_per_tick = 4 +setup = [ + { equipment = { weapon = { id = "abyssal_whip" }, chest = { id = "fighter_torso" }, legs = { id = "rune_platelegs" }, hat = { id = "helm_of_neitiznot" }, feet = { id = "dragon_boots" }, amulet = { id = "amulet_of_glory_4" }, cape = { id = "obsidian_cape" } } }, + { inventory = [ + { id = "shark", min = 5 }, + { id = "super_restore_4", min = 2 }, + { id = "saradomin_brew_4", min = 2 }, + { id = "games_necklace_6", min = 1, equippable = true }, + ] }, + { area = { id = "$area" } }, +] +loadouts = { melee = { equipment = { weapon = { id = "abyssal_whip" }, chest = { id = "fighter_torso" }, legs = { id = "rune_platelegs" }, hat = { id = "helm_of_neitiznot" }, feet = { id = "dragon_boots" } } }, + magic = { equipment = { weapon = { id = "ancient_staff" }, chest = { id = "mystic_robe_top_blue" }, legs = { id = "mystic_robe_bottom_blue" }, hat = { id = "mystic_hat_blue" }, feet = { id = "mystic_boots_blue" } }, + inventory = [ { id = "blood_rune", min = 2000 }, { id = "death_rune", min = 2000 }, { id = "water_rune", min = 5000 }], + autocast = "ice_barrage" }, + } + +actions = [ + { player = { option = "Attack", radius = 10, delay = 1, heal_percent = 40, loot_strategy = "survival", area = "$area", success = { area = { id = "$area", present = false } } } }, + { restart = { success = { area = { id = "$area", present = false } } } }, +] +reactive = [ + { switch_loadout = { counter_attacker = true } }, + { pray = { id = "deflect_melee", if = { attacker_style = { equals = "melee" } } } }, + { pray = { id = "deflect_missiles", if = { attacker_style = { equals = "ranged" } } } }, + { pray = { id = "deflect_magic", if = { attacker_style = { equals = "magic" } } } }, + { pray = { id = "berserker", if = { area = { id = "$area" } } } }, + { pray = { id = "leech_strength", if = { area = { id = "$area" } } } }, + { drink_potion = { item = "super_restore_*", skill = "prayer" } }, + # Brew→food chain: food eaten this/last tick → drink brew next, stacking the overheal. + { drink_potion = { item = "saradomin_brew_*", skill = "constitution", if = { clock = { id = "just_ate_food" } } } }, + # Restore on 3 brew doses OR strength dipped below 30% max. + { drink_potion = { item = "super_restore_*", skill = "strength", if = { any = [{ variable = { id = "brew_doses_since_restore", default = 0, min = 3 } }, { skill_percent = { id = "strength", max_percent = 30 } }] } } }, +] +produces = [ + { skill = "attack" }, + { skill = "strength" }, + { skill = "ranged" }, + { skill = "magic" }, + { skill = "defence" }, + { skill = "constitution" }, + { skill = "prayer" }, +] + +[pvp_dangerous_magic] +requires = [ + { skill = { id = "magic", min = 94 } }, + { skill = { id = "defence", min = 40 } }, + { skill = { id = "constitution", min = 70 } }, + { skill = { id = "prayer", min = 95 } }, +] +setup = [ + { equipment = { weapon = { id = "ancient_staff" }, shield = { id = "zamoraks_book_of_chaos" }, chest = { id = "mystic_robe_top_blue" }, legs = { id = "mystic_robe_bottom_blue" }, hat = { id = "mystic_hat_blue" }, feet = { id = "mystic_boots_blue" }, hands = { id = "mystic_gloves_blue" }, amulet = { id = "amulet_of_glory_4" }, cape = { id = "saradomin_cape" } } }, + { inventory = [ + { id = "shark", min = 14 }, + { id = "super_restore_4", min = 2 }, + { id = "super_magic_potion_4", min = 2 }, + { id = "blood_rune", min = 2500 }, + { id = "death_rune", min = 2500 }, + { id = "water_rune", min = 10000 }, + { id = "chaos_rune", min = 2500 }, + { id = "air_rune", min = 10000 }, + { id = "games_necklace_6", min = 1, equippable = true }, + ] }, + { area = { id = "$area" } }, +] +actions = [ + { cast_spell = { family = "ice", radius = 12, delay = 1, heal_percent = 40, kite = true, area = "$area", success = { area = { id = "$area", present = false } } } }, + { restart = { success = { area = { id = "$area", present = false } } } }, +] +reactive = [ + { pray = { id = "deflect_melee", if = { attacker_style = { equals = "melee" } } } }, + { pray = { id = "deflect_missiles", if = { attacker_style = { equals = "ranged" } } } }, + { pray = { id = "deflect_magic", if = { attacker_style = { equals = "magic" } } } }, + { pray = { id = "berserker", if = { area = { id = "$area" } } } }, + { pray = { id = "leech_magic", if = { area = { id = "$area" } } } }, + # Magic boost only at fight start; pvp_dangerous_magic has no spec. + { drink_potion = { item = "super_magic_potion_*", skill = "magic", if = { clock = { id = "fight_starting" } } } }, + { drink_potion = { item = "super_restore_*", skill = "prayer" } }, + { drink_potion = { item = "super_restore_*", skill = "magic", if = { skill_percent = { id = "magic", max_percent = 30 } } } }, +] +produces = [ + { skill = "magic" }, + { skill = "defence" }, + { skill = "constitution" }, + { skill = "prayer" }, +] diff --git a/data/minigame/clan_wars/clan_wars.areas.toml b/data/minigame/clan_wars/clan_wars.areas.toml index 8caa4a15b9..60e5782121 100644 --- a/data/minigame/clan_wars/clan_wars.areas.toml +++ b/data/minigame/clan_wars/clan_wars.areas.toml @@ -24,3 +24,11 @@ y = [5512, 5631] x = [2948, 3071] y = [5571, 5631] tags = ["multi_combat"] + +[clan_wars_ffa_safe_lobby] +x = [2810, 2820] +y = [5508, 5511] + +[clan_wars_ffa_dangerous_lobby] +x = [3002, 3012] +y = [5508, 5511] diff --git a/data/minigame/clan_wars/clan_wars.bots.toml b/data/minigame/clan_wars/clan_wars.bots.toml new file mode 100644 index 0000000000..f179cacc98 --- /dev/null +++ b/data/minigame/clan_wars/clan_wars.bots.toml @@ -0,0 +1,87 @@ +[clan_wars_ffa_safe_zerker] +template = "pvp_zerker" +capacity = 3 +timeout = 500 +fields = { style = "style2", area = "clan_wars_ffa_safe_arena", safe_area = "clan_wars_ffa_safe_lobby" } + +[clan_wars_ffa_safe_dharoker] +template = "pvp_dharoker" +capacity = 3 +timeout = 500 +fields = { style = "style2", area = "clan_wars_ffa_safe_arena", safe_area = "clan_wars_ffa_safe_lobby" } + +[clan_wars_ffa_safe_ags_main] +template = "pvp_ags_main" +capacity = 3 +timeout = 500 +fields = { style = "style2", area = "clan_wars_ffa_safe_arena", safe_area = "clan_wars_ffa_safe_lobby" } + +[clan_wars_ffa_safe_obby_pure] +template = "pvp_obby_pure" +capacity = 3 +timeout = 500 +fields = { area = "clan_wars_ffa_safe_arena", safe_area = "clan_wars_ffa_safe_lobby" } + +[clan_wars_ffa_safe_msb_pure] +template = "pvp_msb_pure" +capacity = 3 +timeout = 500 +fields = { style = "style2", area = "clan_wars_ffa_safe_arena", safe_area = "clan_wars_ffa_safe_lobby" } + +[clan_wars_ffa_safe_karils_tank] +template = "pvp_karils_tank" +capacity = 3 +timeout = 500 +fields = { style = "style2", area = "clan_wars_ffa_safe_arena", safe_area = "clan_wars_ffa_safe_lobby" } + +[clan_wars_ffa_safe_ancient_tank] +template = "pvp_ancient_tank" +capacity = 3 +timeout = 500 +fields = { area = "clan_wars_ffa_safe_arena", safe_area = "clan_wars_ffa_safe_lobby" } + +[clan_wars_ffa_safe_ancient_hybrid] +template = "pvp_ancient_hybrid" +capacity = 3 +timeout = 500 +fields = { area = "clan_wars_ffa_safe_arena", safe_area = "clan_wars_ffa_safe_lobby" } + +[clan_wars_ffa_dangerous_melee] +template = "pvp_dangerous_melee" +capacity = 3 +timeout = 500 +fields = { style = "style2", area = "clan_wars_ffa_dangerous_arena", safe_area = "clan_wars_ffa_dangerous_lobby" } +reactive = [ + { interface = { option = "Wear", id = "inventory:inventory:games_necklace_6", if = { pvp_retreat_needed = {} }, success = { any = [{ area = { id = "clan_wars_teleport" } }, { equipment = { amulet = { id = "games_necklace_*" } } }] } } }, + { jewellery_teleport = { item = "games_necklace_6", area = "clan_wars_teleport", if = { pvp_retreat_needed = {} }, success = { area = { id = "clan_wars_teleport" } } } }, +] + +[clan_wars_ffa_dangerous_ranged] +template = "pvp_dangerous_ranged" +capacity = 3 +timeout = 500 +fields = { style = "style2", area = "clan_wars_ffa_dangerous_arena", safe_area = "clan_wars_ffa_dangerous_lobby" } +reactive = [ + { interface = { option = "Wear", id = "inventory:inventory:games_necklace_6", if = { pvp_retreat_needed = {} }, success = { any = [{ area = { id = "clan_wars_teleport" } }, { equipment = { amulet = { id = "games_necklace_*" } } }] } } }, + { jewellery_teleport = { item = "games_necklace_6", area = "clan_wars_teleport", if = { pvp_retreat_needed = {} }, success = { area = { id = "clan_wars_teleport" } } } }, +] + +[clan_wars_ffa_dangerous_magic] +template = "pvp_dangerous_magic" +capacity = 3 +timeout = 500 +fields = { area = "clan_wars_ffa_dangerous_arena", safe_area = "clan_wars_ffa_dangerous_lobby" } +reactive = [ + { interface = { option = "Wear", id = "inventory:inventory:games_necklace_6", if = { pvp_retreat_needed = {} }, success = { any = [{ area = { id = "clan_wars_teleport" } }, { equipment = { amulet = { id = "games_necklace_*" } } }] } } }, + { jewellery_teleport = { item = "games_necklace_6", area = "clan_wars_teleport", if = { pvp_retreat_needed = {} }, success = { area = { id = "clan_wars_teleport" } } } }, +] + +[clan_wars_ffa_dangerous_hybrid] +template = "pvp_dangerous_hybrid" +capacity = 3 +timeout = 500 +fields = { area = "clan_wars_ffa_dangerous_arena", safe_area = "clan_wars_ffa_dangerous_lobby" } +reactive = [ + { interface = { option = "Wear", id = "inventory:inventory:games_necklace_6", if = { pvp_retreat_needed = {} }, success = { any = [{ area = { id = "clan_wars_teleport" } }, { equipment = { amulet = { id = "games_necklace_*" } } }] } } }, + { jewellery_teleport = { item = "games_necklace_6", area = "clan_wars_teleport", if = { pvp_retreat_needed = {} }, success = { area = { id = "clan_wars_teleport" } } } }, +] diff --git a/data/minigame/clan_wars/clan_wars.setups.toml b/data/minigame/clan_wars/clan_wars.setups.toml new file mode 100644 index 0000000000..3079878146 --- /dev/null +++ b/data/minigame/clan_wars/clan_wars.setups.toml @@ -0,0 +1,68 @@ +[teleport_clan_wars_games_necklace] +weight = 100 +requires = [ + { equipment = { amulet = { id = "games_necklace_8" } } }, +] +actions = [ + { interface = { option = "Clan Wars", id = "worn_equipment:amulet_slot:games_necklace_8" } }, + { wait = { ticks = 5 } }, +] +produces = [ + { area = "clan_wars_teleport" }, +] + +[enter_clan_wars_ffa_safe] +weight = -10 +requires = [ + { combat_level = { min = 30 } }, + { area = { id = "clan_wars_teleport" } }, +] +actions = [ + { object = { option = "Enter", id = "clan_wars_portal_ffa_safe", radius = 15, success = { any = [{ interface_open = { id = "warning_clan_wars_ffa_safe" } }, { area = { id = "clan_wars_ffa_safe_lobby" } }, { area = { id = "clan_wars_ffa_safe_arena" } }] } } }, + { interface = { option = "Go in", id = "warning_clan_wars_ffa_safe:yes", if = { interface_open = { id = "warning_clan_wars_ffa_safe" } }, success = { any = [{ area = { id = "clan_wars_ffa_safe_lobby" } }, { area = { id = "clan_wars_ffa_safe_arena" } }] } } }, + { tile = { x = 2815, y = 5515, radius = 2 } }, +] +produces = [ + { area = "clan_wars_ffa_safe_arena" }, +] + +[enter_clan_wars_ffa_safe_from_lobby] +weight = -10 +requires = [ + { combat_level = { min = 30 } }, + { area = { id = "clan_wars_ffa_safe_lobby" } }, +] +actions = [ + { tile = { x = 2815, y = 5515, radius = 2 } }, +] +produces = [ + { area = "clan_wars_ffa_safe_arena" }, +] + +[enter_clan_wars_ffa_dangerous] +weight = -10 +requires = [ + { combat_level = { min = 30 } }, + { area = { id = "clan_wars_teleport" } }, +] +actions = [ + { object = { option = "Enter", id = "clan_wars_portal_ffa_dangerous", radius = 15, success = { any = [{ interface_open = { id = "warning_clan_wars_ffa_safe" } }, { area = { id = "clan_wars_ffa_dangerous_lobby" } }, { area = { id = "clan_wars_ffa_dangerous_arena" } }] } } }, + { interface = { option = "Go in", id = "warning_clan_wars_ffa_safe:yes", if = { interface_open = { id = "warning_clan_wars_ffa_safe" } }, success = { any = [{ area = { id = "clan_wars_ffa_dangerous_lobby" } }, { area = { id = "clan_wars_ffa_dangerous_arena" } }] } } }, + { tile = { x = 3007, y = 5515, radius = 2 } }, +] +produces = [ + { area = "clan_wars_ffa_dangerous_arena" }, +] + +[enter_clan_wars_ffa_dangerous_from_lobby] +weight = -10 +requires = [ + { combat_level = { min = 30 } }, + { area = { id = "clan_wars_ffa_dangerous_lobby" } }, +] +actions = [ + { tile = { x = 3007, y = 5515, radius = 2 } }, +] +produces = [ + { area = "clan_wars_ffa_dangerous_arena" }, +] diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/data/config/PrayerDefinition.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/data/config/PrayerDefinition.kt index 421d0a9778..504f8564b1 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/data/config/PrayerDefinition.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/data/config/PrayerDefinition.kt @@ -10,6 +10,7 @@ data class PrayerDefinition( val drains: Map = emptyMap(), val bonuses: Map = emptyMap(), val members: Boolean = false, + val isCurse: Boolean = false, override var stringId: String = "", override var params: Map? = null, ) : Parameterized { diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/data/definition/PrayerDefinitions.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/data/definition/PrayerDefinitions.kt index 7ab275afbc..d9aa633717 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/data/definition/PrayerDefinitions.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/data/definition/PrayerDefinitions.kt @@ -60,9 +60,10 @@ class PrayerDefinitions { else -> throw IllegalArgumentException("Unexpected key: '$key' ${exception()}") } val id = stringId.substring(0, stringId.lastIndexOf('_')) - val definition = PrayerDefinition(index, level, drain, groups, drains, bonuses, members, id) + val isCurse = stringId.endsWith("_curse") + val definition = PrayerDefinition(index, level, drain, groups, drains, bonuses, members, isCurse, id) definitions[id] = definition - if (stringId.endsWith("_curse")) { + if (isCurse) { curses[index] = definition } else { prayers[index] = definition diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/CharacterIndexMap.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/CharacterIndexMap.kt index 9c49ff68b9..c8f32cb1fd 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/CharacterIndexMap.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/CharacterIndexMap.kt @@ -11,7 +11,8 @@ class CharacterIndexMap(size: Int) { /** * Table mapping tiles to sets */ - private val table = Int2ObjectOpenHashMap>(size) + @PublishedApi + internal val table = Int2ObjectOpenHashMap>(size) /** * Which tile set the index is currently in @@ -51,8 +52,11 @@ class CharacterIndexMap(size: Int) { current.fill(INVALID) } - fun onEach(id: Int, action: (Int) -> Unit) { - table.get(id)?.onEach(action) + inline fun onEach(id: Int, action: (Int) -> Unit) { + val set = table.get(id) ?: return + for (index in set) { + action(index) + } } companion object { diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/combat/CombatMovement.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/combat/CombatMovement.kt index 7a922d6fd1..2364e9426a 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/combat/CombatMovement.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/combat/CombatMovement.kt @@ -47,6 +47,10 @@ class CombatMovement( character.mode = EmptyMode return } + if (target["dead", false]) { + character.mode = EmptyMode + return + } if (character is NPC) { val spawn: Tile = character["spawn_tile"] ?: return val definition = get().get(character.transformDef["combat_def", character.id]) diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/npc/NPCs.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/npc/NPCs.kt index 046a7686fc..b0b49d91cc 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/npc/NPCs.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/npc/NPCs.kt @@ -73,7 +73,10 @@ object NPCs : Runnable, } fun remove(npc: NPC?): Boolean { - if (npc == null || npc.index == -1) { + if (npc == null) { + return false + } + if (npc.index == -1) { logger.warn { "Unable to remove npc $npc." } return false } diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/player/Players.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/player/Players.kt index 67aa514e8a..81672bd838 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/player/Players.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/player/Players.kt @@ -9,9 +9,9 @@ import world.gregs.voidps.type.Zone object Players : Iterable, CharacterSearch { private val players = mutableListOf() - private val indexArray: Array = arrayOfNulls(MAX_PLAYERS) + @PublishedApi internal val indexArray: Array = arrayOfNulls(MAX_PLAYERS) private var indexer = 1 - private val map = CharacterIndexMap(MAX_PLAYERS) + @PublishedApi internal val map = CharacterIndexMap(MAX_PLAYERS) val size: Int get() = players.size @@ -71,6 +71,34 @@ object Players : Iterable, CharacterSearch { return list } + /** + * Non-allocating chebyshev-radius scan centred on [center]. Iterates only the zones whose + * bounds overlap `[center.x ± radius]` × `[center.y ± radius]` on [center]'s level, then + * filters each zone's players by exact tile bounds. + * + * Dramatically cheaper than calling [at] per-tile in a spiral: one zone lookup per overlapping + * zone (≤ 25 for radius 15) versus 961 tile lookups each scanning the full zone. + */ + inline fun forEachInRadius(center: Tile, radius: Int, action: (Player) -> Unit) { + val minZx = (center.x - radius) shr 3 + val maxZx = (center.x + radius) shr 3 + val minZy = (center.y - radius) shr 3 + val maxZy = (center.y + radius) shr 3 + val level = center.level + for (zx in minZx..maxZx) { + for (zy in minZy..maxZy) { + map.onEach(Zone.id(zx, zy, level)) { index -> + val player = indexArray[index] ?: return@onEach + val dx = player.tile.x - center.x + if (dx < -radius || dx > radius) return@onEach + val dy = player.tile.y - center.y + if (dy < -radius || dy > radius) return@onEach + action(player) + } + } + } + } + fun clear() { for (player in this) { Despawn.player(player) diff --git a/game/src/main/kotlin/content/area/wilderness/Wilderness.kt b/game/src/main/kotlin/content/area/wilderness/Wilderness.kt index 44ea54c986..e695aa6132 100644 --- a/game/src/main/kotlin/content/area/wilderness/Wilderness.kt +++ b/game/src/main/kotlin/content/area/wilderness/Wilderness.kt @@ -1,5 +1,6 @@ package content.area.wilderness +import world.gregs.voidps.engine.data.definition.Areas import world.gregs.voidps.engine.entity.character.Character import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.combatLevel @@ -35,6 +36,14 @@ val Character.inPvp: Boolean val Character.inWilderness: Boolean get() = get("in_wilderness", false) +/** + * True when wilderness-style PvP consequences apply: combat-start skull and full item drops on + * death. Wilderness itself, plus the Clan Wars FFA dangerous arena. Distinct from [inPvp], which + * only gates "can you attack here" and is also true in safer arenas (FFA safe, etc.). + */ +val Character.inFullPvp: Boolean + get() = inWilderness || tile in Areas["clan_wars_ffa_dangerous_arena"] + val Character.inMultiCombat: Boolean get() = contains("in_multi_combat") diff --git a/game/src/main/kotlin/content/bot/Bot.kt b/game/src/main/kotlin/content/bot/Bot.kt index b1bd35475b..17e3e26d3e 100644 --- a/game/src/main/kotlin/content/bot/Bot.kt +++ b/game/src/main/kotlin/content/bot/Bot.kt @@ -5,6 +5,7 @@ import content.bot.behaviour.BehaviourState import content.bot.behaviour.Reason import content.bot.behaviour.action.BotAction import content.bot.behaviour.activity.BotActivity +import content.bot.behaviour.perception.BotCombatContext import world.gregs.voidps.engine.entity.character.Character import world.gregs.voidps.engine.entity.character.player.Player import java.util.Stack @@ -15,6 +16,25 @@ data class Bot(val player: Player) : Character by player { val frames = Stack() val available = mutableSetOf() var evaluate = mutableSetOf() + var combatContext: BotCombatContext? = null + + /** + * Forces the manager to always (re)assign this activity id instead of picking from [available] or [previous]. + * + * Used by bots that are designed for a single role (e.g. PvP clan-war tiers) where the normal pick/reuse + * path would drift to another activity after a hard-fail, timeout, or death. Read by: + * - [content.bot.BotManager.assignRandom]: skips random selection, always assigns the pinned id. + * - [content.bot.BotManager.start]: if a non-area setup requirement fails, invokes [refresh] instead of + * spawning a resolver frame (bots shouldn't wander off to "fetch" missing kit). + * - [content.bot.BotManager.handleFail]: soft-fail on the pinned activity does not blacklist it. + */ + var pinned: String? = null + + /** + * Re-applies tier-specific state (skills, equipment, inventory) when the pinned activity's setup + * requirement fails. Called from [content.bot.BotManager.start] instead of running a resolver. + */ + var refresh: (() -> Unit)? = null fun noTask() = frames.isEmpty() diff --git a/game/src/main/kotlin/content/bot/BotCommands.kt b/game/src/main/kotlin/content/bot/BotCommands.kt index ddd1917f1b..329ddff49c 100644 --- a/game/src/main/kotlin/content/bot/BotCommands.kt +++ b/game/src/main/kotlin/content/bot/BotCommands.kt @@ -1,5 +1,19 @@ +@file:OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class) + package content.bot +import com.github.michaelbull.logging.InlineLogger +import content.bot.behaviour.condition.BotEquipmentSetup +import content.bot.behaviour.condition.BotInventorySetup +import content.bot.combat.ClanWarsBotContext +import content.bot.combat.CombatBotContext +import content.bot.combat.CombatBotContexts +import content.bot.combat.CombatTier +import content.entity.combat.dead +import content.entity.combat.killer +import content.entity.player.combat.special.MAX_SPECIAL_ATTACK +import content.entity.player.combat.special.specialAttack +import content.entity.player.combat.special.specialAttackEnergy import content.quest.questJournal import kotlinx.coroutines.* import world.gregs.voidps.engine.Contexts @@ -9,6 +23,8 @@ import world.gregs.voidps.engine.client.command.adminCommand import world.gregs.voidps.engine.client.command.intArg import world.gregs.voidps.engine.client.command.stringArg import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.variable.start +import world.gregs.voidps.engine.client.variable.stop import world.gregs.voidps.engine.data.AccountManager import world.gregs.voidps.engine.data.Settings import world.gregs.voidps.engine.data.definition.AccountDefinitions @@ -19,12 +35,19 @@ import world.gregs.voidps.engine.entity.MAX_PLAYERS import world.gregs.voidps.engine.entity.World import world.gregs.voidps.engine.entity.character.move.running import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Players import world.gregs.voidps.engine.entity.character.player.appearance import world.gregs.voidps.engine.entity.character.player.chat.ChatType import world.gregs.voidps.engine.entity.character.player.name import world.gregs.voidps.engine.entity.character.player.sex +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level +import world.gregs.voidps.engine.entity.item.Item import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.equipment import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.transact.operation.AddItem.add +import world.gregs.voidps.engine.inv.transact.operation.ClearItem.clear import world.gregs.voidps.engine.timer.* import world.gregs.voidps.network.client.DummyClient import world.gregs.voidps.network.login.protocol.visual.update.player.BodyColour @@ -42,12 +65,19 @@ class BotCommands( accountDefinitions: AccountDefinitions, ) : Script { + private val combatBotsLogger = InlineLogger("CombatBots") val bots = mutableListOf() val names = mutableListOf() + private val combatBotTiers = mutableMapOf() var counter = 0 init { + // Combat-bot contexts. New minigames register a CombatBotContext here so the generic + // dispatchers below (playerDeath, entered, maintain timer, ::combatbots) pick them up + // without touching this file. + CombatBotContexts.register(ClanWarsBotContext()) + worldTimerStart("bot_spawn") { TimeUnit.SECONDS.toTicks(Settings["bots.spawnSeconds", 60]) } worldTimerTick("bot_spawn") { @@ -58,9 +88,86 @@ class BotCommands( return@worldTimerTick Timer.CONTINUE } + // Per-context auto-spawn timers — every arena that any registered context lists in + // autospawnArenaKeys() gets its own maintain timer. Wired once at script init; the + // timer body re-resolves the context in case settings reload changes it. + for (context in CombatBotContexts.all()) { + for (arenaKey in context.autospawnArenaKeys()) { + val timerName = "combat_spawn_$arenaKey" + worldTimerStart(timerName) { context.autospawnIntervalTicks(arenaKey) } + worldTimerTick(timerName) { + maintainCombatArena(context, arenaKey) + Timer.CONTINUE + } + } + } + playerDespawn { if (isBot) { manager.remove(bot) + combatBotTiers.remove(accountName) + } + } + + playerDeath { + val tier = combatBotTiers[accountName] ?: return@playerDeath + val context = CombatBotContexts.find(tier) ?: return@playerDeath + if (get("debug", false)) { + combatBotsLogger.info { "playerDeath fired for '$accountName', tier=${tier.activityId}, context=${context.id}" } + } + // Drop policy is the context's call (dangerous-arena bots drop kit; safe-arena + // bots keep theirs to avoid duplicating the loadout on the floor after applyTier). + if (!context.shouldDropItems(this, tier)) { + it.dropItems = false + } + // Override default home respawn — drop the bot back inside the arena's spawn area. + val respawn = context.respawnTile(tier) + if (respawn != null) { + it.teleport = respawn + } + val capturedAccount = accountName + World.queue("respawn_tier_$capturedAccount", initialDelay = 10) { + val target = Players.find(capturedAccount) ?: return@queue + if (!target.isBot) return@queue + val freshBot = target.bot + applyTier(freshBot, tier) + manager.stop(freshBot) + freshBot.blocked.remove(tier.activityId) + freshBot.evaluate.clear() + freshBot.previous = null + } + } + + playerDeath { + // PvP bots have dropItems=false in the prior handler, so no loot to scan for. + if (isBot) return@playerDeath + val slayer = killer as? Player ?: return@playerDeath + if (!slayer.isBot) return@playerDeath + slayer.start("loot_pending", LOOT_PENDING_TICKS) + slayer["loot_drop_tile"] = tile.id + } + + // Wire one `entered` listener per area any context subscribes to. The handler + // dispatches by tier → context. Adding a new subscribed area in a new context works + // automatically as long as that context is registered before this init block runs. + for (areaId in CombatBotContexts.subscribedAreas()) { + entered(areaId) { + val tier = combatBotTiers[accountName] ?: return@entered + val context = CombatBotContexts.find(tier) ?: return@entered + // Death sequence also fires entered(...) while dead is still true; skip those + // — playerDeath already schedules a delayed applyTier and we don't want to + // double up. + if (dead) return@entered + context.onAreaEntered(this, tier, areaId) + if (!context.shouldRefreshOnAreaEntered(this, tier, areaId)) return@entered + // Restock kit before any portal-entry resolver walks the bot back; without + // this the bot re-enters the arena with empty inventory and gets stuck/killed. + val freshBot = bot + applyTier(freshBot, tier) + manager.stop(freshBot) + freshBot.blocked.remove(tier.activityId) + freshBot.evaluate.clear() + freshBot.previous = null } } @@ -76,6 +183,40 @@ class BotCommands( adminCommand("clear_bots", intArg("count", optional = true), desc = "Clear all or some amount of bots", handler = ::clear) adminCommand("bot", stringArg("task", optional = true, autofill = manager.activityNames), desc = "Toggle yourself on/off as a bot player", handler = ::toggle) adminCommand("bot_info", stringArg("name", optional = true, desc = "Filter by bot name", autofill = accountDefinitions.displayNames.keys), desc = "Print bot info", handler = ::info) + adminCommand("combatbots", stringArg("arena", autofill = { CombatBotContexts.all().flatMap { it.arenaKeys() }.toSet() }), intArg("count", optional = true), desc = "Spawn combat bots for a named arena", handler = ::combatBots) + adminCommand("bot_stress", intArg("ticks", optional = true), intArg("warmup", optional = true), desc = "Measure BotManager perf for N ticks (default 500); optional warmup ticks delay measurement.", handler = ::botStress) + } + + @Suppress("UNUSED_PARAMETER") + fun botStress(player: Player, args: List) { + val ticks = args.getOrNull(0)?.toIntOrNull() ?: 500 + val warmup = args.getOrNull(1)?.toIntOrNull() ?: 0 + if (ticks <= 0) { + player.message("bot_stress: ticks must be > 0.", ChatType.Console) + return + } + if (BotMetrics.measuring) { + player.message("bot_stress: a measurement is already running.", ChatType.Console) + return + } + val invokerName = player.accountName + val onComplete: (List) -> Unit = { lines -> + val target = Players.find(invokerName) + if (target != null) { + for (line in lines) { + target.message(line, ChatType.Console) + } + } + } + if (warmup > 0) { + player.message("bot_stress: warmup=$warmup ticks, then measure ticks=$ticks.", ChatType.Console) + World.queue("bot_stress_warmup", initialDelay = warmup) { + BotMetrics.start(ticks, label = "bots=${manager.bots.size}", onComplete = onComplete) + } + } else { + player.message("bot_stress: measuring ticks=$ticks (bots=${manager.bots.size}).", ChatType.Console) + BotMetrics.start(ticks, label = "bots=${manager.bots.size}", onComplete = onComplete) + } } private fun loadSettings() { @@ -86,8 +227,25 @@ class BotCommands( names.clear() names.addAll(File(Settings["bots.names"]).readLines()) } + CombatBotContexts.loadAll() + for (context in CombatBotContexts.all()) { + for (arenaKey in context.autospawnArenaKeys()) { + if (context.autospawnTarget(arenaKey) > 0) { + World.timers.start("combat_spawn_$arenaKey") + } + } + } + } + + private fun maintainCombatArena(context: CombatBotContext, arenaKey: String) { + val target = context.autospawnTarget(arenaKey) + if (target <= 0) return + val current = combatBotTiers.values.count { context.arenaContains(arenaKey, it) } + if (current >= target) return + spawnCombatBot(context, arenaKey) } + @Suppress("UNUSED_PARAMETER") fun spawn(player: Player, args: List) { val count = args[0].toIntOrNull() ?: 1 GlobalScope.launch { @@ -104,6 +262,7 @@ class BotCommands( } } + @Suppress("UNUSED_PARAMETER") fun clear(player: Player, args: List) { val count = args.getOrNull(0)?.toIntOrNull() ?: MAX_PLAYERS World.queue("bot_clear") { @@ -201,6 +360,136 @@ class BotCommands( return bot } + fun combatBots(player: Player, args: List) { + val arenaKey = args[0] + val context = CombatBotContexts.forArenaKey(arenaKey) + if (context == null) { + val keys = CombatBotContexts.all().flatMap { it.arenaKeys() }.joinToString() + player.message("Unknown arena '$arenaKey'. Options: $keys.", ChatType.Console) + return + } + val count = args.getOrNull(1)?.toIntOrNull() ?: 14 + GlobalScope.launch { + repeat(count) { index -> + if (index % Settings["network.maxLoginsPerTick", 25] == 0) { + suspendCancellableCoroutine { cont -> + World.queue("combatbot_$counter") { cont.resume(Unit) } + } + } + spawnCombatBot(context, arenaKey) + } + } + } + + private fun spawnCombatBot(context: CombatBotContext, arenaKey: String) { + GlobalScope.launch(Contexts.Game) { + counter++ + val name = pickBotName() + val spawn = context.arenaSpawn(arenaKey) ?: return@launch + val bot = Player(tile = spawn, accountName = name).initBot() + loader.connect(bot.player, DummyClient(), viewport = Settings["development.bots.live", false]) + setAppearance(bot.player) + delay(3) + val tiers = context.arenaTiers(arenaKey) + if (tiers.isEmpty()) return@launch + val tier = tiers.random(random) + combatBotTiers[bot.player.accountName] = tier + applyTier(bot, tier) + manager.add(bot) + bot.pinned = tier.activityId + // Intentionally leave bot.refresh = null. BotManager.start's refresh path was + // re-running applyTier every Pending tick whenever a setup item differed from + // the template (e.g. dose-decremented potions, mid-fight spec weapon swaps), + // which spun bots into a drink/eat/refresh loop. The surrounding `continue` + // in start() still keeps pinned PvP bots out of bank-chest resolvers without + // needing refresh; legitimate tier resets happen in the `playerDeath` handler. + bot.available.clear() + bot.available.add(tier.activityId) + bot.blocked.remove(tier.activityId) + manager.assign(bot, tier.activityId) + bot.player.running = true + } + } + + private fun applyTier(bot: Bot, tier: CombatTier) { + val target = bot.player + for ((skill, level) in tier.levels) { + val stored = if (skill == Skill.Constitution) level * 10 else level + target.experience.set(skill, Level.experience(skill, stored)) + target.levels.set(skill, stored) + } + // Belt-and-braces full restore: HP + prayer to max (in case the tier omits one), special + // attack energy to 100%, and clear any half-pressed spec toggle from the previous round. + target.levels.clear(Skill.Constitution) + target.levels.clear(Skill.Prayer) + target.specialAttackEnergy = MAX_SPECIAL_ATTACK + target.specialAttack = false + target["combat_style"] = tier.style + target["brew_doses_since_restore"] = 0 + target.stop("just_ate_food") + val activity = manager.activity(tier.activityId) ?: return + target.inventory.transaction { clear() } + target.equipment.transaction { clear() } + target.inventory.add("coins", 10000) + for (condition in activity.setup) { + when (condition) { + is BotEquipmentSetup -> target.equipment.transaction { + for ((slot, item) in condition.items) { + val usable = item.ids.filter { it != "empty" && !it.endsWith("_noted") && !it.endsWith("_broken") } + val id = usable.randomOrNull() ?: item.ids.filter { it != "empty" }.randomOrNull() ?: continue + set(slot.index, Item(id, item.min ?: 1)) + } + }.also { ok -> if (!ok) combatBotsLogger.warn { "equipment transaction failed for ${target.accountName}: ${target.equipment.transaction.error}" } } + is BotInventorySetup -> target.inventory.transaction { + for (item in condition.items) { + val id = item.ids.filter { it != "empty" }.randomOrNull() ?: continue + add(id, item.min ?: 1) + } + }.also { ok -> if (!ok) combatBotsLogger.warn { "inventory transaction failed for ${target.accountName}: ${target.inventory.transaction.error}" } } + else -> Unit + } + } + val starting = activity.hybridStartingLoadout + if (starting != null) { + target["current_loadout"] = starting + target["last_loadout_swap_tick"] = -10_000 + } + for ((name, loadout) in activity.loadouts) { + if (name == starting) continue + target.inventory.transaction { + for ((_, item) in loadout.equipment.items) { + val usable = item.ids.filter { it != "empty" && !it.endsWith("_noted") && !it.endsWith("_broken") } + val id = usable.randomOrNull() ?: item.ids.filter { it != "empty" }.randomOrNull() ?: continue + if (target.equipment.contains(id)) continue + if (target.inventory.contains(id)) continue + add(id, item.min ?: 1) + } + loadout.extraInventory?.items?.forEach { item -> + val id = item.ids.filter { it != "empty" }.randomOrNull() ?: return@forEach + add(id, item.min ?: 1) + } + }.also { ok -> if (!ok) combatBotsLogger.warn { "loadout '$name' didn't fit for ${target.accountName}: ${target.inventory.transaction.error}" } } + } + if (bot["debug", false]) { + combatBotsLogger.info { "applyTier ${tier.activityId} for ${target.accountName}: levels=${tier.levels.map { "${it.key}=cur${target.levels.get(it.key)}/max${target.levels.getMax(it.key)}" }}" } + combatBotsLogger.info { " inventory=${(0 until target.inventory.size).mapNotNull { target.inventory.getOrNull(it) }.filter { it.id.isNotEmpty() }.map { "${it.id}x${it.amount}" }}" } + combatBotsLogger.info { " equipment=${(0 until target.equipment.size).mapNotNull { target.equipment.getOrNull(it) }.filter { it.id.isNotEmpty() }.map { "${it.id}x${it.amount}" }}" } + for (condition in activity.setup) { + combatBotsLogger.info { " setup.check ${condition::class.simpleName} = ${condition.check(target)}" } + } + } + } + + private fun pickBotName(): String { + if (Settings["bots.numberedNames", false]) return "Bot $counter" + val prefix = Settings["bots.namePrefix", ""].trim('"') + val length = 12 - prefix.length + val short = names.filter { it.length < length } + val selected = short.randomOrNull(random) ?: names.removeAt(random.nextInt(names.size)) + names.remove(selected) + return "$prefix$selected" + } + fun setAppearance(player: Player): Player { val male = random.nextBoolean() player.body.male = male @@ -225,3 +514,5 @@ class BotCommands( return player } } + +private const val LOOT_PENDING_TICKS = 60 diff --git a/game/src/main/kotlin/content/bot/BotManager.kt b/game/src/main/kotlin/content/bot/BotManager.kt index ceebd3c2af..d3561ec44c 100644 --- a/game/src/main/kotlin/content/bot/BotManager.kt +++ b/game/src/main/kotlin/content/bot/BotManager.kt @@ -11,8 +11,10 @@ import content.bot.behaviour.Reason import content.bot.behaviour.action.BotWait import content.bot.behaviour.activity.ActivitySlots import content.bot.behaviour.activity.BotActivity +import content.bot.behaviour.condition.BotInArea import content.bot.behaviour.condition.Condition import content.bot.behaviour.loadBehaviours +import content.bot.behaviour.perception.BotCombatContextBuilder import content.bot.behaviour.setup.DynamicResolvers import content.bot.behaviour.setup.Resolver import world.gregs.voidps.engine.data.ConfigFiles @@ -43,6 +45,8 @@ class BotManager( val activityNames: Set get() = activities.keys + fun activity(id: String): BotActivity? = activities[id] + fun load(files: ConfigFiles): BotManager { loadBehaviours(files, activities, resolvers) reloadGroups() @@ -85,9 +89,17 @@ class BotManager( } override fun run() { + BotMetrics.beginRun() for (bot in bots) { - tick(bot) + if (BotMetrics.measuring) { + val start = System.nanoTime() + tick(bot) + BotMetrics.recordBotTick(System.nanoTime() - start) + } else { + tick(bot) + } } + BotMetrics.endRun(bots.size) } fun tick(bot: Bot) { @@ -96,12 +108,36 @@ class BotManager( assignRandom(bot) return } + updateCombatContext(bot) + runReactive(bot) execute(bot) } catch (exception: Exception) { logger.error(exception) { "Error in bot '${bot.player.accountName}' tick ${bot.frames.map { it.behaviour.id }}." } } } + private fun runReactive(bot: Bot) { + val rootFrame = bot.frames.firstOrNull() ?: return + val reactive = rootFrame.behaviour.reactive + if (reactive.isEmpty()) return + for (action in reactive) { + try { + action.update(bot, world, rootFrame) + } catch (exception: Exception) { + logger.error(exception) { "Reactive action failed for bot '${bot.player.accountName}' activity=${rootFrame.behaviour.id} action=$action." } + } + } + } + + private fun updateCombatContext(bot: Bot) { + val activity = bot.frames.firstOrNull()?.behaviour + if (activity != null && activity.reactive.isNotEmpty()) { + bot.combatContext = BotCombatContextBuilder.build(bot) + } else if (bot.combatContext != null) { + bot.combatContext = null + } + } + /** * Assign activity [id] to [bot] * Useful for debugging @@ -116,6 +152,17 @@ class BotManager( * Assign a random activity that is available to the [bot]. */ private fun assignRandom(bot: Bot) { + val pinned = bot.pinned + if (pinned != null) { + bot.evaluate.clear() + val pinnedActivity = activities[pinned] + if (pinnedActivity != null && hasRequirements(bot, pinnedActivity)) { + assign(bot, pinnedActivity) + } else { + assign(bot, activityFallback(bot)) + } + return + } if (bot.evaluate.isNotEmpty()) { updateAvailable(bot) } @@ -144,6 +191,9 @@ class BotManager( * When no available activity is found either idle or spawn requirements for a bot */ private fun activityFallback(bot: Bot): BotActivity { + if (bot.pinned != null) { + return idle + } if (!Settings["bots.spawnRequirements", false] || bot.player.networked) { return idle } @@ -249,10 +299,18 @@ class BotManager( return } val debug = bot.player["debug", false] + var refreshed = false for (requirement in behaviour.setup) { if (requirement.check(bot.player)) { continue } + if (bot.pinned == behaviour.id && requirement !is BotInArea) { + if (!refreshed) { + bot.refresh?.invoke() + refreshed = true + } + continue + } frame.blocked.removeAll(DynamicResolvers.ids()) val resolvers = buildList { DynamicResolvers.resolver(bot.player, requirement)?.also { add(it) } @@ -331,7 +389,15 @@ class BotManager( bot.frames.pop() } if (behaviour is BotActivity) { - bot.blocked.add(behaviour.id) + val pinnedSoft = bot.pinned == behaviour.id && state.reason !is Reason.Cancelled + if (!pinnedSoft) { + bot.blocked.add(behaviour.id) + } else { + // start() blocked the activity when it began (line 345); fail-paths never run the + // matching remove from nextAction. For a pinned bot we want it to be reassignable + // next tick, so undo the start-time block here. + bot.blocked.remove(behaviour.id) + } slots.release(behaviour) } } @@ -339,7 +405,7 @@ class BotManager( /** * Remove all behaviours and free up activity slots */ - private fun stop(bot: Bot) { + fun stop(bot: Bot) { for (frame in bot.frames) { if (frame.behaviour is BotActivity) { slots.release(frame.behaviour) diff --git a/game/src/main/kotlin/content/bot/BotMetrics.kt b/game/src/main/kotlin/content/bot/BotMetrics.kt new file mode 100644 index 0000000000..8e50b30f80 --- /dev/null +++ b/game/src/main/kotlin/content/bot/BotMetrics.kt @@ -0,0 +1,216 @@ +package content.bot + +import com.github.michaelbull.logging.InlineLogger +import java.lang.management.ManagementFactory + +/** + * Tier-0 perf instrumentation for [BotManager]. Disabled by default: the only overhead while idle + * is one volatile read + branch in [BotManager.run]. The `/bot_stress` admin command flips it on + * for a fixed number of ticks, then [finish] formats a multi-line report and fires [onComplete] + * once on the game thread. + * + * All mutating methods (begin/end/record/inc) are called from the game thread (`Contexts.Game`), + * so the internal arrays/longs do not need synchronization beyond the volatile flag that gates + * entry into the measuring path. + */ +object BotMetrics { + + private val logger = InlineLogger("BotMetrics") + private const val MAX_BOT_TICK_SAMPLES = 500_000 + + /** + * Wall-clock time per bot tick cannot distinguish "doing work" from "waiting on GC". The + * Oracle/OpenJDK extension [com.sun.management.ThreadMXBean.getCurrentThreadAllocatedBytes] + * reports bytes allocated on the current thread, which is a cleaner signal for hot-path + * churn. Wrapped in a try/catch so non-HotSpot JVMs still run (with alloc stats skipped). + */ + private val threadBean: com.sun.management.ThreadMXBean? = try { + (ManagementFactory.getThreadMXBean() as? com.sun.management.ThreadMXBean) + ?.takeIf { it.isThreadAllocatedMemorySupported } + ?.also { it.isThreadAllocatedMemoryEnabled = true } + } catch (_: Throwable) { + null + } + + @Volatile + var measuring: Boolean = false + private set + + private var label: String = "" + private var ticksRemaining: Int = 0 + private var ticksTotal: Int = 0 + private var runStartNanos: Long = 0L + private var runStartAllocBytes: Long = 0L + + private var runNanos: LongArray = LongArray(0) + private var runNanosCount: Int = 0 + + private var runAllocBytes: LongArray = LongArray(0) + private var runAllocBytesCount: Int = 0 + private var totalAllocBytes: Long = 0L + + private var botTickNanos: LongArray = LongArray(0) + private var botTickNanosCount: Int = 0 + private var botTickSamplesDropped: Int = 0 + + private var heapUsedStart: Long = 0L + private var heapUsedPeak: Long = 0L + private var heapUsedEnd: Long = 0L + + private var scans: Long = 0 + private var totalBotTicksCounted: Long = 0 + + private var onComplete: ((List) -> Unit)? = null + + fun start(ticks: Int, label: String = "", onComplete: (List) -> Unit) { + require(ticks > 0) { "ticks must be > 0" } + if (measuring) { + onComplete(listOf("BotMetrics: stress test already running, ignoring start().")) + return + } + this.label = label + ticksRemaining = ticks + ticksTotal = ticks + runNanos = LongArray(ticks) + runNanosCount = 0 + runAllocBytes = LongArray(ticks) + runAllocBytesCount = 0 + totalAllocBytes = 0L + botTickNanos = LongArray(MAX_BOT_TICK_SAMPLES) + botTickNanosCount = 0 + botTickSamplesDropped = 0 + heapUsedStart = usedHeapBytes() + heapUsedPeak = heapUsedStart + heapUsedEnd = heapUsedStart + scans = 0 + totalBotTicksCounted = 0 + this.onComplete = onComplete + // Set last so any begin/end on a parallel game-thread call sees a fully-initialized state. + measuring = true + logger.info { "BotMetrics: started label='$label' ticks=$ticks" } + } + + fun beginRun() { + if (!measuring) return + runStartNanos = System.nanoTime() + runStartAllocBytes = threadBean?.currentThreadAllocatedBytes ?: -1L + } + + fun endRun(botCount: Int) { + if (!measuring) return + val elapsed = System.nanoTime() - runStartNanos + if (runNanosCount < runNanos.size) { + runNanos[runNanosCount++] = elapsed + } + if (runStartAllocBytes >= 0) { + val allocEnd = threadBean?.currentThreadAllocatedBytes ?: -1L + if (allocEnd >= 0) { + val delta = allocEnd - runStartAllocBytes + if (delta >= 0) { + totalAllocBytes += delta + if (runAllocBytesCount < runAllocBytes.size) { + runAllocBytes[runAllocBytesCount++] = delta + } + } + } + } + val used = usedHeapBytes() + heapUsedEnd = used + if (used > heapUsedPeak) heapUsedPeak = used + ticksRemaining-- + if (ticksRemaining <= 0) { + finish(botCount) + } + } + + fun recordBotTick(nanos: Long) { + if (!measuring) return + totalBotTicksCounted++ + if (botTickNanosCount < botTickNanos.size) { + botTickNanos[botTickNanosCount++] = nanos + } else { + botTickSamplesDropped++ + } + } + + fun incScans() { + if (measuring) scans++ + } + + private fun usedHeapBytes(): Long { + val rt = Runtime.getRuntime() + return rt.totalMemory() - rt.freeMemory() + } + + private fun finish(currentBots: Int) { + val callback = onComplete + val report = buildReport(currentBots) + // Flip the flag off before invoking the callback so a re-entrant /bot_stress works. + measuring = false + onComplete = null + report.forEach { line -> logger.info { line } } + callback?.invoke(report) + } + + private fun buildReport(currentBots: Int): List { + val lines = mutableListOf() + lines += "=== BotMetrics report${if (label.isNotEmpty()) " (label=\"$label\")" else ""} ===" + lines += "bots : $currentBots" + lines += "ticks : $ticksTotal (recorded=$runNanosCount)" + lines += "managerRun ms : ${formatStats(runNanos, runNanosCount, divisorNs = 1_000_000.0, decimals = 2)}" + val perTickAvg = if (runNanosCount > 0) totalBotTicksCounted.toDouble() / runNanosCount else 0.0 + lines += "botTick us : ${formatStats(botTickNanos, botTickNanosCount, divisorNs = 1_000.0, decimals = 0)}" + + " samples=$totalBotTicksCounted perTick=${"%.1f".format(perTickAvg)}" + + if (botTickSamplesDropped > 0) " dropped=$botTickSamplesDropped" else "" + lines += "spiralScans : $scans perBotTick=${ratio(scans, totalBotTicksCounted)}" + lines += formatAllocLine() + lines += formatHeapLine() + lines += "=================================================" + return lines + } + + private fun formatAllocLine(): String { + if (threadBean == null) return "gameThreadAlloc: unsupported on this JVM" + if (runAllocBytesCount <= 0) return "gameThreadAlloc: no samples" + val sorted = runAllocBytes.copyOf(runAllocBytesCount) + sorted.sort() + val avg = totalAllocBytes.toDouble() / runAllocBytesCount + val p50 = sorted[(runAllocBytesCount * 50 / 100).coerceAtMost(runAllocBytesCount - 1)].toDouble() + val p95 = sorted[(runAllocBytesCount * 95 / 100).coerceAtMost(runAllocBytesCount - 1)].toDouble() + val p99 = sorted[(runAllocBytesCount * 99 / 100).coerceAtMost(runAllocBytesCount - 1)].toDouble() + val max = sorted[runAllocBytesCount - 1].toDouble() + val perBotTick = if (totalBotTicksCounted > 0) totalAllocBytes.toDouble() / totalBotTicksCounted else 0.0 + return "gameThreadAlloc KB/run: avg=${kb(avg)} p50=${kb(p50)} p95=${kb(p95)} p99=${kb(p99)} max=${kb(max)}" + + " total=${mb(totalAllocBytes.toDouble())}MB perBotTick=${formatBytes(perBotTick)}" + } + + private fun formatHeapLine(): String { + val rt = Runtime.getRuntime() + return "heap used MB : start=${mb(heapUsedStart.toDouble())} end=${mb(heapUsedEnd.toDouble())} peak=${mb(heapUsedPeak.toDouble())} max=${mb(rt.maxMemory().toDouble())}" + } + + private fun kb(bytes: Double): String = "%.1f".format(bytes / 1_024.0) + private fun mb(bytes: Double): String = "%.1f".format(bytes / (1_024.0 * 1_024.0)) + + private fun formatBytes(bytes: Double): String = when { + bytes >= 1_024 * 1_024 -> "%.2fMB".format(bytes / (1_024.0 * 1_024.0)) + bytes >= 1_024 -> "%.1fKB".format(bytes / 1_024.0) + else -> "%.0fB".format(bytes) + } + + private fun ratio(num: Long, denom: Long): String = if (denom <= 0) "n/a" else "%.2f".format(num.toDouble() / denom) + + private fun formatStats(values: LongArray, count: Int, divisorNs: Double, decimals: Int): String { + if (count <= 0) return "no samples" + val sorted = values.copyOf(count) + sorted.sort() + val sum = sorted.sumOf { it } + val avg = sum.toDouble() / count / divisorNs + val p50 = sorted[(count * 50 / 100).coerceAtMost(count - 1)] / divisorNs + val p95 = sorted[(count * 95 / 100).coerceAtMost(count - 1)] / divisorNs + val p99 = sorted[(count * 99 / 100).coerceAtMost(count - 1)] / divisorNs + val max = sorted[count - 1] / divisorNs + val fmt = "%.${decimals}f" + return "avg=${fmt.format(avg)} p50=${fmt.format(p50)} p95=${fmt.format(p95)} p99=${fmt.format(p99)} max=${fmt.format(max)}" + } +} diff --git a/game/src/main/kotlin/content/bot/behaviour/Behaviour.kt b/game/src/main/kotlin/content/bot/behaviour/Behaviour.kt index 1c4a1229fd..d2a96e4c15 100644 --- a/game/src/main/kotlin/content/bot/behaviour/Behaviour.kt +++ b/game/src/main/kotlin/content/bot/behaviour/Behaviour.kt @@ -3,6 +3,9 @@ package content.bot.behaviour import content.bot.behaviour.action.ActionParser import content.bot.behaviour.action.BotAction import content.bot.behaviour.activity.BotActivity +import content.bot.behaviour.activity.Loadout +import content.bot.behaviour.condition.BotEquipmentSetup +import content.bot.behaviour.condition.BotInventorySetup import content.bot.behaviour.condition.Condition import content.bot.behaviour.navigation.NavigationGraph import content.bot.behaviour.navigation.NavigationShortcut @@ -22,6 +25,7 @@ interface Behaviour { val requires: List val setup: List val actions: List + val reactive: List val produces: Set } @@ -45,10 +49,10 @@ fun loadGraph(files: ConfigFiles): NavigationGraph { private fun loadActivities(activities: MutableMap, templates: Map, paths: List) { timedLoad("bot activity") { val fragments = mutableListOf() - load(paths) { id, template, fields, capacity, timeout, requires, setup, actions, produces -> + load(paths) { id, template, fields, capacity, timeout, requires, setup, actions, reactive, produces -> if (template != null) { requireNotNull(fields) - fragments.add(Fragment(id, template, fields, capacity, timeout, requires, setup, actions, produces)) + fragments.add(Fragment(id, template, fields, capacity, timeout, requires, setup, actions, reactive, produces)) } else { val debug = "$id ${exception()}" activities[id] = BotActivity( @@ -58,6 +62,7 @@ private fun loadActivities(activities: MutableMap, template requires = Condition.parse(requires, debug), setup = Condition.parse(setup, debug), actions = ActionParser.parse(actions, debug), + reactive = ActionParser.parse(reactive, debug), produces = produces, ) } @@ -73,10 +78,10 @@ private fun loadActivities(activities: MutableMap, template private fun loadSetups(resolvers: MutableMap>, templates: Map, paths: List) { timedLoad("bot setup") { val fragments = mutableListOf() - load(paths) { id, template, fields, weight, timeout, requires, setup, actions, produces -> + load(paths) { id, template, fields, weight, timeout, requires, setup, actions, reactive, produces -> if (template != null) { requireNotNull(fields) - fragments.add(Fragment(id, template, fields, weight, timeout, requires, setup, actions, produces)) + fragments.add(Fragment(id, template, fields, weight, timeout, requires, setup, actions, reactive, produces)) } else { val debug = "$id ${exception()}" val resolver = Resolver( @@ -86,6 +91,7 @@ private fun loadSetups(resolvers: MutableMap>, tem requires = Condition.parse(requires, debug), setup = Condition.parse(setup, debug), actions = ActionParser.parse(actions, debug), + reactive = ActionParser.parse(reactive, debug), produces = produces, ) for (key in produces) { @@ -107,10 +113,10 @@ private fun loadSetups(resolvers: MutableMap>, tem private fun loadShortcuts(shortcuts: MutableList, templates: Map, paths: List) { timedLoad("bot shortcut") { val fragments = mutableListOf() - load(paths) { id, template, fields, weight, timeout, requires, setup, actions, produces -> + load(paths) { id, template, fields, weight, timeout, requires, setup, actions, reactive, produces -> if (template != null) { requireNotNull(fields) { "No fields found for $id ${exception()}" } - fragments.add(Fragment(id, template, fields, weight, timeout, requires, setup, actions, produces)) + fragments.add(Fragment(id, template, fields, weight, timeout, requires, setup, actions, reactive, produces)) } else { val debug = "$id ${exception()}" shortcuts.add( @@ -121,6 +127,7 @@ private fun loadShortcuts(shortcuts: MutableList, templates: requires = Condition.parse(requires, debug), setup = Condition.parse(setup, debug), actions = ActionParser.parse(actions, debug), + reactive = ActionParser.parse(reactive, debug), produces = produces, ), ) @@ -134,7 +141,7 @@ private fun loadShortcuts(shortcuts: MutableList, templates: } } -private fun load(paths: List, block: ConfigReader.(String, String?, Map?, Int, Int, List>>>, List>>>, List>>, Set) -> Unit) { +private fun load(paths: List, block: ConfigReader.(String, String?, Map?, Int, Int, List>>>, List>>>, List>>, List>>, Set) -> Unit) { for (path in paths) { Config.fileReader(path) { while (nextSection()) { @@ -146,6 +153,7 @@ private fun load(paths: List, block: ConfigReader.(String, String?, Map< val requires = mutableListOf>>>() val setup = mutableListOf>>>() val actions = mutableListOf>>() + val reactive = mutableListOf>>() val produces = mutableSetOf() while (nextPair()) { when (val key = key()) { @@ -153,6 +161,7 @@ private fun load(paths: List, block: ConfigReader.(String, String?, Map< "requires" -> requirements(requires) "setup" -> requirements(setup) "actions" -> actions(actions) + "reactive" -> actions(reactive) "produces" -> produces(produces) "weight", "capacity" -> value = int() "timeout" -> timeout = int() @@ -163,7 +172,7 @@ private fun load(paths: List, block: ConfigReader.(String, String?, Map< if (fields != null && template == null) { error("Found fields but no template for $id in ${exception()}") } - block.invoke(this, id, template, fields, value, timeout, requires, setup, actions, produces) + block.invoke(this, id, template, fields, value, timeout, requires, setup, actions, reactive, produces) } } } @@ -179,17 +188,27 @@ private fun loadTemplates(paths: List): Map { val requires = mutableListOf>>>() val setup = mutableListOf>>>() val actions = mutableListOf>>() + val reactive = mutableListOf>>() val produces = mutableSetOf() + var loadouts: Map = emptyMap() + var hybridStartingLoadout: String? = null + var hybridSwapCooldown: Int? = null + var hybridSwapPerTick: Int? = null while (nextPair()) { when (val key = key()) { "requires" -> requirements(requires) "setup" -> requirements(setup) "actions" -> actions(actions) + "reactive" -> actions(reactive) "produces" -> produces(produces) + "loadouts" -> loadouts = map() + "hybrid_starting_loadout" -> hybridStartingLoadout = string() + "hybrid_swap_cooldown" -> hybridSwapCooldown = int() + "hybrid_swap_per_tick" -> hybridSwapPerTick = int() else -> throw IllegalArgumentException("Unexpected key: '$key' ${exception()}") } } - templates[id] = Template(requires, setup, actions, produces) + templates[id] = Template(requires, setup, actions, reactive, produces, loadouts, hybridStartingLoadout, hybridSwapCooldown, hybridSwapPerTick) } } } @@ -244,6 +263,7 @@ private data class Fragment( val requires: List>>>, val setup: List>>>, val actions: List>>, + val reactive: List>>, val produces: Set, ) { fun activity(template: Template) = BotActivity( @@ -253,9 +273,33 @@ private data class Fragment( requires = resolveRequirements(template.requires, requires), setup = resolveRequirements(template.setup, setup), actions = resolveActions(template.actions, actions), + reactive = resolveActions(template.reactive, reactive), produces = resolve(template.produces) + produces, + loadouts = resolveLoadouts(template), + hybridStartingLoadout = template.hybridStartingLoadout, + hybridSwapCooldown = template.hybridSwapCooldown ?: 3, + hybridSwapPerTick = template.hybridSwapPerTick ?: 1, ) + @Suppress("UNCHECKED_CAST") + private fun resolveLoadouts(template: Template): Map { + if (template.loadouts.isEmpty()) return emptyMap() + val out = LinkedHashMap(template.loadouts.size) + val debug = "$id template ${this.template} loadouts" + for ((name, raw) in template.loadouts) { + val map = raw as? Map ?: error("Loadout '$name' must be a map in $debug, got $raw.") + val resolved = resolve(map, "loadouts.$name") + val eqRaw = resolved["equipment"] as? Map + ?: error("Loadout '$name' missing 'equipment' map in $debug.") + val equipment = Condition.parse(listOf("equipment" to listOf(eqRaw)), debug).single() as BotEquipmentSetup + val invRaw = resolved["inventory"] as? List> + val inventory = if (invRaw.isNullOrEmpty()) null else Condition.parse(listOf("inventory" to invRaw), debug).single() as BotInventorySetup + val autocast = resolved["autocast"] as? String + out[name] = Loadout(name, equipment, inventory, autocast) + } + return out + } + private fun resolve(set: Set) = set.map { value -> if (value.contains('$')) { val ref = value.reference() @@ -286,10 +330,7 @@ private data class Fragment( val combinedList = mutableListOf>>() combinedList.addAll(original) for ((type, map) in templated) { - val resolved = resolve(map, type) - if (resolved.isNotEmpty()) { - combinedList.add(type to resolved) - } + combinedList.add(type to resolve(map, type)) } if (combinedList.isEmpty()) { return emptyList() @@ -341,6 +382,7 @@ private data class Fragment( requires = resolveRequirements(template.requires, requires), setup = resolveRequirements(template.setup, setup), actions = resolveActions(template.actions, actions), + reactive = resolveActions(template.reactive, reactive), produces = resolve(template.produces) + produces, ) @@ -351,6 +393,7 @@ private data class Fragment( requires = resolveRequirements(template.requires, requires), setup = resolveRequirements(template.setup, setup), actions = resolveActions(template.actions, actions), + reactive = resolveActions(template.reactive, reactive), produces = resolve(template.produces) + produces, ) } @@ -359,5 +402,10 @@ private data class Template( val requires: List>>>, val setup: List>>>, val actions: List>>, + val reactive: List>>, val produces: Set, + val loadouts: Map = emptyMap(), + val hybridStartingLoadout: String? = null, + val hybridSwapCooldown: Int? = null, + val hybridSwapPerTick: Int? = null, ) diff --git a/game/src/main/kotlin/content/bot/behaviour/action/ActionParser.kt b/game/src/main/kotlin/content/bot/behaviour/action/ActionParser.kt index 53a55ed2ad..bf732452f7 100644 --- a/game/src/main/kotlin/content/bot/behaviour/action/ActionParser.kt +++ b/game/src/main/kotlin/content/bot/behaviour/action/ActionParser.kt @@ -1,6 +1,8 @@ package content.bot.behaviour.action import content.bot.behaviour.condition.Condition +import net.pearx.kasechange.toPascalCase +import world.gregs.voidps.engine.entity.character.player.skill.Skill sealed class ActionParser { open val required = emptySet() @@ -42,15 +44,143 @@ sealed class ActionParser { } } + object InteractPlayerParser : ActionParser() { + override val required = setOf("option") + override val optional = setOf("delay", "success", "radius", "heal_percent", "loot_over_value", "loot_strategy", "area") + + override fun parse(map: Map): BotAction { + val option = map["option"] as String + require(option == "Attack") { "Only 'Attack' option is supported for 'player' actions, got '$option'." } + val delay = map["delay"] as? Int ?: 0 + val success = requirement(map, "success").singleOrNull() + val radius = map["radius"] as? Int ?: 10 + val healPercent = map["heal_percent"] as? Int ?: 20 + val lootOverValue = map["loot_over_value"] as? Int ?: 0 + val lootStrategy = BotLootStrategy.of(map["loot_strategy"] as? String) + val area = map["area"] as? String + return BotFightPlayer(delay, success, radius, healPercent, lootOverValue, lootStrategy, area) + } + } + + object PrayParser : ActionParser() { + override val required = setOf("id") + override val optional = setOf("if") + + override fun parse(map: Map): BotAction { + val id = map["id"] as String + val condition = requirement(map, "if").singleOrNull() + return BotPray(id, condition) + } + } + + object SpecAttackParser : ActionParser() { + override val required = setOf("weapon", "fallback") + override val optional = setOf("min_energy", "if") + + override fun parse(map: Map): BotAction { + val weapon = map["weapon"] as String + val fallback = map["fallback"] as String + val minEnergy = map["min_energy"] as? Int ?: 250 + val condition = requirement(map, "if").singleOrNull() + return BotSpecAttack(weapon, fallback, minEnergy, condition) + } + } + + object DrinkPotionParser : ActionParser() { + override val required = setOf("item", "skill") + override val optional = setOf("if") + + override fun parse(map: Map): BotAction { + val item = map["item"] as String + val skillName = map["skill"] as String + val skill = Skill.of(skillName.toPascalCase()) + ?: error("Unknown skill '$skillName' in drink_potion action.") + val condition = requirement(map, "if").singleOrNull() + return BotDrinkPotion(item, skill, condition) + } + } + + object CastVengeanceParser : ActionParser() { + override fun parse(map: Map): BotAction = BotCastVengeance + } + + object CastSpellParser : ActionParser() { + override val optional = setOf("delay", "success", "radius", "heal_percent", "family", "kite", "area") + + override fun parse(map: Map): BotAction { + val delay = map["delay"] as? Int ?: 0 + val success = requirement(map, "success").singleOrNull() + val radius = map["radius"] as? Int ?: 10 + val healPercent = map["heal_percent"] as? Int ?: 40 + val family = map["family"] as? String ?: "ice" + val kite = map["kite"] as? Boolean ?: true + val area = map["area"] as? String + return BotCastSpell(delay, success, radius, healPercent, family, kite, area) + } + } + + object SwitchSetupParser : ActionParser() { + override val required = setOf("equipment") + override val optional = setOf("if") + + @Suppress("UNCHECKED_CAST") + override fun parse(map: Map): BotAction { + val raw = map["equipment"] as? Map + ?: error("switch_setup 'equipment' must be a map in $map.") + val setup = Condition.parse(listOf("equipment" to listOf(raw)), "SwitchSetupParser").single() + val equipment = (setup as content.bot.behaviour.condition.BotEquipmentSetup).items + val condition = requirement(map, "if").singleOrNull() + return BotSwitchSetup(equipment, condition) + } + } + + object SwitchLoadoutParser : ActionParser() { + override val optional = setOf("to", "counter_attacker", "if") + + override fun parse(map: Map): BotAction { + val to = map["to"] as? String + val counterAttacker = map["counter_attacker"] as? Boolean ?: false + require(to != null || counterAttacker) { "switch_loadout: must set 'to' or 'counter_attacker' in $map" } + val condition = requirement(map, "if").singleOrNull() + return BotSwitchLoadout(to, counterAttacker, condition) + } + } + + object RetreatParser : ActionParser() { + override val required = setOf("safe_area", "regroup_hp_percent") + override val optional = setOf("if") + + override fun parse(map: Map): BotAction { + val safeArea = map["safe_area"] as String + val regroup = map["regroup_hp_percent"] as Int + val condition = requirement(map, "if").singleOrNull() + return BotRetreat(safeArea, regroup, condition) + } + } + object InterfaceParser : ActionParser() { override val required = setOf("option", "id") - override val optional = setOf("success") + override val optional = setOf("success", "if") override fun parse(map: Map): BotAction { val option = map["option"] as String val id = map["id"] as String val success = requirement(map, "success").singleOrNull() - return BotInterfaceOption(option, id, success) + val condition = requirement(map, "if").singleOrNull() + return BotInterfaceOption(option, id, success, condition) + } + } + + object JewelleryTeleportParser : ActionParser() { + override val required = setOf("item", "area") + override val optional = setOf("if", "success") + + override fun parse(map: Map): BotAction { + val item = map["item"] as String + val area = map["area"] as String + val condition = requirement(map, "if").singleOrNull() + val success = requirement(map, "success").singleOrNull() + return BotJewelleryTeleport(item, area, condition, success) } } @@ -108,7 +238,7 @@ sealed class ActionParser { object InteractObjectParser : ActionParser() { override val required = setOf("option", "id", "success") - override val optional = setOf("delay", "radius", "x", "y") + override val optional = setOf("delay", "radius", "x", "y", "if") override fun parse(map: Map): BotAction { val option = map["option"] as String @@ -118,7 +248,8 @@ sealed class ActionParser { val radius = map["radius"] as? Int ?: 10 val x = map["x"] as? Int val y = map["y"] as? Int - return BotInteractObject(option, id, delay, success, radius, x, y) + val condition = requirement(map, "if").singleOrNull() + return BotInteractObject(option, id, delay, success, radius, x, y, condition) } } @@ -225,6 +356,7 @@ sealed class ActionParser { private val parsers = mapOf( "npc" to InteractNpcParser, + "player" to InteractPlayerParser, "object" to InteractObjectParser, "floor_item" to InteractFloorItemParser, "item_on_object" to ItemOnObjectParser, @@ -234,6 +366,15 @@ sealed class ActionParser { "wait" to WaitParser, "restart" to RestartParser, "interface" to InterfaceParser, + "jewellery_teleport" to JewelleryTeleportParser, + "pray" to PrayParser, + "spec_attack" to SpecAttackParser, + "drink_potion" to DrinkPotionParser, + "cast_vengeance" to CastVengeanceParser, + "cast_spell" to CastSpellParser, + "switch_setup" to SwitchSetupParser, + "switch_loadout" to SwitchLoadoutParser, + "retreat" to RetreatParser, "interface_close" to CloseInterfaceParser, "continue" to DialogueParser, "enter" to EnterParser, diff --git a/game/src/main/kotlin/content/bot/behaviour/action/BotArenaCenter.kt b/game/src/main/kotlin/content/bot/behaviour/action/BotArenaCenter.kt new file mode 100644 index 0000000000..701c9608e9 --- /dev/null +++ b/game/src/main/kotlin/content/bot/behaviour/action/BotArenaCenter.kt @@ -0,0 +1,33 @@ +package content.bot.behaviour.action + +import content.bot.Bot +import content.bot.behaviour.BotWorld +import world.gregs.voidps.engine.data.definition.Areas +import world.gregs.voidps.network.client.instruction.Walk +import world.gregs.voidps.type.Tile + +object BotArenaCenter { + private const val RECENTER_THRESHOLD = 12 + + private val centers = mapOf( + "clan_wars_ffa_safe_arena" to Tile(2815, 5515, 0), + "clan_wars_ffa_dangerous_arena" to Tile(3007, 5514, 0), + ) + + fun maybeRecenter(bot: Bot, world: BotWorld, area: String?): Boolean { + val center = centers[area] ?: return false + if (bot.tile.level != center.level) return false + if (bot.tile.distanceTo(center) <= RECENTER_THRESHOLD) return false + val dx = (center.x - bot.tile.x).coerceIn(-1, 1) + val dy = (center.y - bot.tile.y).coerceIn(-1, 1) + if (dx == 0 && dy == 0) return false + val nx = bot.tile.x + dx * 2 + val ny = bot.tile.y + dy * 2 + val dest = Tile(nx, ny, bot.tile.level) + if (area != null && dest !in Areas[area]) return false + world.execute(bot.player, Walk(nx, ny)) + bot.player.clear("bot_kite_anchor") + bot.player.clear("bot_kite_anchor_target") + return true + } +} diff --git a/game/src/main/kotlin/content/bot/behaviour/action/BotAutocast.kt b/game/src/main/kotlin/content/bot/behaviour/action/BotAutocast.kt new file mode 100644 index 0000000000..ccac1e6d52 --- /dev/null +++ b/game/src/main/kotlin/content/bot/behaviour/action/BotAutocast.kt @@ -0,0 +1,24 @@ +package content.bot.behaviour.action + +import content.skill.magic.spell.spellBook +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.data.definition.InterfaceDefinitions +import world.gregs.voidps.engine.entity.character.player.Player + +/** + * Set [player]'s autocast to [spell] (an ancient_spellbook component id, e.g. "ice_barrage"). + * No-op when [spell] is null. Idempotent — re-call with the same spell short-circuits. + * + * Shared between [BotCastSpell] (per-attack autocast selection) and [BotSwitchLoadout] (autocast + * binding when entering a magic loadout). + */ +internal fun ensureAutocast(player: Player, spell: String?) { + if (spell == null) return + if (player.spellBook != "ancient_spellbook") { + player.open("ancient_spellbook") + } + val castId: Int = InterfaceDefinitions.getComponent("ancient_spellbook", spell)?.getOrNull("cast_id") ?: return + if (player.get("autocast", 0) == castId) return + player.set("autocast_spell", spell) + player.set("autocast", castId) +} diff --git a/game/src/main/kotlin/content/bot/behaviour/action/BotCastSpell.kt b/game/src/main/kotlin/content/bot/behaviour/action/BotCastSpell.kt new file mode 100644 index 0000000000..4e64b6e8f9 --- /dev/null +++ b/game/src/main/kotlin/content/bot/behaviour/action/BotCastSpell.kt @@ -0,0 +1,190 @@ +package content.bot.behaviour.action + +import content.area.wilderness.inMultiCombat +import content.bot.Bot +import content.bot.behaviour.BehaviourFrame +import content.bot.behaviour.BehaviourState +import content.bot.behaviour.BotWorld +import content.bot.behaviour.Reason +import content.bot.behaviour.condition.Condition +import content.entity.combat.Target +import content.entity.combat.dead +import content.entity.combat.target +import content.entity.effect.frozen +import world.gregs.voidps.engine.client.variable.start +import world.gregs.voidps.engine.data.definition.Areas +import world.gregs.voidps.engine.entity.character.mode.EmptyMode +import world.gregs.voidps.engine.entity.character.mode.combat.CombatMovement +import world.gregs.voidps.engine.entity.character.mode.interact.PlayerOnFloorItemInteract +import world.gregs.voidps.engine.entity.character.mode.interact.PlayerOnPlayerInteract +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Players +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.map.Spiral +import world.gregs.voidps.network.client.instruction.InteractInterface +import world.gregs.voidps.network.client.instruction.InteractPlayer +import world.gregs.voidps.network.client.instruction.Walk +import world.gregs.voidps.type.Tile + +data class BotCastSpell( + val delay: Int = 0, + val success: Condition? = null, + val radius: Int = 10, + val healPercentage: Int = 40, + val family: String = "ice", + val kite: Boolean = true, + val area: String? = null, +) : BotAction { + override fun update(bot: Bot, world: BotWorld, frame: BehaviourFrame): BehaviourState? { + // Success first so a retreat-by-teleport (bot now outside `area`) can complete the + // activity even at low HP — otherwise eat() spins forever when food is exhausted. + if (success?.check(bot.player) == true) return BehaviourState.Success + if (healPercentage > 0 && bot.levels.get(Skill.Constitution) <= bot.levels.getMax(Skill.Constitution) * healPercentage / 100) { + return eat(bot, world) + } + val target = engagedTarget(bot) + if (target != null) return handleCombat(bot, world, target) + if (bot.mode is PlayerOnFloorItemInteract) return BehaviourState.Running + if (bot.mode is EmptyMode) return search(bot, world) + return null + } + + private fun engagedTarget(bot: Bot): Player? { + val mode = bot.mode + if (mode is PlayerOnPlayerInteract) return mode.target + if (mode is CombatMovement) return bot.player.target as? Player + return null + } + + private fun handleCombat(bot: Bot, world: BotWorld, currentTarget: Player): BehaviourState { + if (currentTarget.dead) return BehaviourState.Running + if (targetGone(bot, currentTarget)) { + // Target left (teleport-out etc.). Clear the stale interact so search() picks a new + // target when the activity loops back. + bot.player.mode = EmptyMode + return if (success == null) BehaviourState.Success else BehaviourState.Running + } + + anchorIfNeeded(bot, currentTarget) + ensureAutocast(bot.player, chooseSpell(bot, currentTarget)) + if (!BotArenaCenter.maybeRecenter(bot, world, area)) { + maybeKite(bot, world, currentTarget) + } + + return if (success == null) BehaviourState.Success else BehaviourState.Running + } + + /** + * Cheap, non-throwing "target obviously left" check. See [BotFightPlayer.targetGone]. + */ + private fun targetGone(bot: Bot, target: Player): Boolean { + if (target.tile.level != bot.player.tile.level) return true + return bot.player.tile.distanceTo(target.tile) > radius * 2 + } + + private fun maybeKite(bot: Bot, world: BotWorld, target: Player) { + if (!kite || !target.frozen) return + if (bot.tile.distanceTo(target.tile) > 2) return + val dx = (bot.tile.x - target.tile.x).coerceIn(-1, 1) + val dy = (bot.tile.y - target.tile.y).coerceIn(-1, 1) + if (dx == 0 && dy == 0) return + val kx = bot.tile.x + dx * 2 + val ky = bot.tile.y + dy * 2 + val dest = Tile(kx, ky, bot.tile.level) + val anchor = bot.player.get("bot_kite_anchor") + if (anchor != null && dest.distanceTo(anchor) > 5) return + if (area != null && dest !in Areas[area]) return + world.execute(bot.player, Walk(kx, ky)) + } + + private fun anchorIfNeeded(bot: Bot, target: Player) { + val current = bot.player.get("bot_kite_anchor_target") + if (current == target.index) return + bot.player["bot_kite_anchor"] = bot.tile + bot.player["bot_kite_anchor_target"] = target.index + } + + private fun eat(bot: Bot, world: BotWorld): BehaviourState { + val inventory = bot.player.inventory + for (index in inventory.indices) { + val item = inventory[index] + val option = item.def.options.indexOf("Eat") + if (option == -1) continue + val valid = world.execute(bot.player, InteractInterface(149, 0, item.def.id, index, option)) + if (!valid) return BehaviourState.Failed(Reason.Invalid("Invalid inventory interaction: ${item.def.id} $index $option")) + return BehaviourState.Wait(1, BehaviourState.Running) + } + return BehaviourState.Running + } + + private fun search(bot: Bot, world: BotWorld): BehaviourState { + val player = bot.player + val attackOption = player.options.indexOf("Attack") + if (attackOption == -1) return handleNoTarget() + val target = pickTarget(bot) ?: run { + if (BotArenaCenter.maybeRecenter(bot, world, area)) return BehaviourState.Running + return handleNoTarget() + } + ensureAutocast(player, chooseSpell(bot, target)) + anchorIfNeeded(bot, target) + val valid = world.execute(player, InteractPlayer(target.index, attackOption)) + if (!valid) return BehaviourState.Failed(Reason.Invalid("Invalid player interaction: ${target.index} $attackOption")) + // Open a brief window for fight-start reactives (e.g. boost potions) to fire once + // per new engagement; gated on this clock so they don't re-drink on every decay tick. + player.start("fight_starting", 5) + return BehaviourState.Running + } + + private fun pickTarget(bot: Bot): Player? { + for (tile in Spiral.spiral(bot.player.tile, radius)) { + val first = enemiesAt(bot, tile).firstOrNull() + if (first != null) return first + } + return null + } + + private fun enemiesAt(bot: Bot, tile: Tile): List { + val context = bot.combatContext + if (context != null) return context.enemiesByTile[tile.id] ?: emptyList() + val player = bot.player + return Players.at(tile).filter { it !== player && !it.dead && Target.attackable(player, it, message = false) } + } + + private fun handleNoTarget(): BehaviourState { + if (success == null) return BehaviourState.Failed(Reason.NoTarget) + if (delay > 0) return BehaviourState.Wait(delay, BehaviourState.Running) + return BehaviourState.Running + } + + private fun chooseSpell(bot: Bot, target: Player): String? { + val player = bot.player + val maxHp = bot.levels.getMax(Skill.Constitution) + val hpFraction = if (maxHp > 0) bot.levels.get(Skill.Constitution).toDouble() / maxHp else 1.0 + // Re-evaluate only when target identity, frozen state, or HP bucket changes; otherwise reuse last spell. + val hpBucket = (hpFraction * 4).toInt().coerceIn(0, 4) + val targetKey = (target.index shl 3) or (hpBucket shl 1) or (if (target.frozen) 1 else 0) + if (player.get("autocast_choice_key", Int.MIN_VALUE) == targetKey) { + return player.get("autocast_choice_spell") + } + val magic = bot.levels.get(Skill.Magic) + // Multi-target spells only matter in multi-combat zones; skip the spiral scan otherwise. + val multi = bot.player.inMultiCombat + val fam = when { + hpFraction < 0.50 && magic >= 68 -> "blood" + !target.frozen && magic >= 58 -> "ice" + else -> family + } + val tier = when { + multi && magic >= 94 -> "barrage" + multi && magic >= 70 -> "burst" + magic >= 84 -> "blitz" + magic >= 58 -> "rush" + else -> null + } ?: return null + val spell = "${fam}_$tier" + player["autocast_choice_key"] = targetKey + player["autocast_choice_spell"] = spell + return spell + } +} diff --git a/game/src/main/kotlin/content/bot/behaviour/action/BotCastVengeance.kt b/game/src/main/kotlin/content/bot/behaviour/action/BotCastVengeance.kt new file mode 100644 index 0000000000..1d75a76c29 --- /dev/null +++ b/game/src/main/kotlin/content/bot/behaviour/action/BotCastVengeance.kt @@ -0,0 +1,69 @@ +package content.bot.behaviour.action + +import com.github.michaelbull.logging.InlineLogger +import content.bot.Bot +import content.bot.behaviour.BehaviourFrame +import content.bot.behaviour.BehaviourState +import content.bot.behaviour.BotWorld +import content.bot.behaviour.Reason +import content.skill.magic.spell.hasSpellItems +import content.skill.magic.spell.spellBook +import world.gregs.voidps.engine.client.ui.menu +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.client.variable.remaining +import world.gregs.voidps.engine.data.definition.InterfaceDefinitions +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.timer.epochSeconds +import world.gregs.voidps.network.client.instruction.InteractInterface + +private val logger = InlineLogger("PvpBots") + +object BotCastVengeance : BotAction { + private const val MIN_MAGIC = 94 + private const val SPELLBOOK = "lunar_spellbook" + private const val SPELL = "vengeance" + private const val RECHECK_SECONDS = 5 + + override fun update(bot: Bot, world: BotWorld, frame: BehaviourFrame): BehaviourState { + val player = bot.player + if (player.levels.get(Skill.Magic) < MIN_MAGIC) return BehaviourState.Success + val now = epochSeconds() + val nextCheck = player.get("bot_vengeance_next_check", 0) + if (now < nextCheck) return BehaviourState.Success + player["bot_vengeance_next_check"] = now + RECHECK_SECONDS + if (player.contains("vengeance")) return BehaviourState.Success + if (player.remaining("vengeance_delay", now) > 0) return BehaviourState.Success + // Don't disrupt other modal flows by opening the spellbook over them — e.g. the + // clan-wars portal "Go in" warning, which the bot's resolver depends on staying open + // long enough to click. Skip vengeance until the menu is clear. + val openMenu = player.menu + if (openMenu != null && openMenu != SPELLBOOK) return BehaviourState.Success + if (player.spellBook != SPELLBOOK && !player.open(SPELLBOOK)) { + logger.debug { "Vengeance: '${player.accountName}' failed to open lunar_spellbook (current=${player.spellBook})" } + return BehaviourState.Success + } + if (!player.hasSpellItems(SPELL, message = false)) { + logger.debug { "Vengeance: '${player.accountName}' missing runes" } + return BehaviourState.Success + } + val interfaceDef = InterfaceDefinitions.getOrNull(SPELLBOOK) ?: return BehaviourState.Success + val componentId = InterfaceDefinitions.getComponentId(SPELLBOOK, SPELL) ?: return BehaviourState.Success + val componentDef = InterfaceDefinitions.getComponent(SPELLBOOK, SPELL) ?: return BehaviourState.Success + val options = componentDef.options ?: componentDef.getOrNull("options") ?: run { + logger.debug { "Vengeance: no options on $SPELLBOOK:$SPELL component" } + return BehaviourState.Success + } + val option = options.indexOf("Cast") + if (option == -1) { + logger.debug { "Vengeance: 'Cast' not in options=${options.toList()}" } + return BehaviourState.Success + } + val valid = world.execute(player, InteractInterface(interfaceDef.id, componentId, -1, -1, option)) + if (!valid) { + logger.debug { "Vengeance: '${player.accountName}' InteractInterface rejected (interface=${interfaceDef.id} comp=$componentId opt=$option)" } + return BehaviourState.Failed(Reason.Invalid("Invalid vengeance cast")) + } + logger.info { "Vengeance: '${player.accountName}' cast" } + return BehaviourState.Success + } +} diff --git a/game/src/main/kotlin/content/bot/behaviour/action/BotDrinkPotion.kt b/game/src/main/kotlin/content/bot/behaviour/action/BotDrinkPotion.kt new file mode 100644 index 0000000000..2294ff879b --- /dev/null +++ b/game/src/main/kotlin/content/bot/behaviour/action/BotDrinkPotion.kt @@ -0,0 +1,59 @@ +package content.bot.behaviour.action + +import content.bot.Bot +import content.bot.behaviour.BehaviourFrame +import content.bot.behaviour.BehaviourState +import content.bot.behaviour.BotWorld +import content.bot.behaviour.Reason +import content.bot.behaviour.condition.Condition +import world.gregs.voidps.engine.client.variable.hasClock +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.event.wildcardEquals +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.network.client.instruction.InteractInterface + +/** + * Drinks a potion dose to keep [skill] boosted. Re-doses whenever the boost has fully decayed + * (`current <= max`). No-ops once the inventory runs out, so the activity loop continues. + */ +data class BotDrinkPotion( + val item: String, + val skill: Skill, + val condition: Condition? = null, +) : BotAction { + override fun update(bot: Bot, world: BotWorld, frame: BehaviourFrame): BehaviourState { + val player = bot.player + if (player.hasClock("drink_delay")) return BehaviourState.Success + // Prayer caps at max (super_restore can't overshoot), so drinking at current == max + // wastes a dose. Boost potions overshoot, so for them the original `current > max` + // check is correct (drink while at-or-below-max means boost has decayed). + val current = player.levels.get(skill) + val maxLevel = player.levels.getMax(skill) + val full = if (skill == Skill.Prayer) current >= maxLevel else current > maxLevel + if (full) return BehaviourState.Success + if (condition != null && !condition.check(player)) return BehaviourState.Success + val inv = player.inventory + for (index in inv.indices) { + val slotItem = inv[index] + if (slotItem.isEmpty()) continue + if (!wildcardEquals(item, slotItem.id)) continue + val option = slotItem.def.options.indexOf("Drink") + if (option == -1) continue + val valid = world.execute(player, InteractInterface(149, 0, slotItem.def.id, index, option)) + if (!valid) { + return BehaviourState.Failed(Reason.Invalid("Invalid potion drink: ${slotItem.id} $index $option")) + } + // Track Saradomin-brew dose count vs the last restore so templates can fire a + // super_restore once brews have stacked enough combat-stat debuff to matter. + // Restore potions reset the counter (they undo the debuff). + val drunkId = slotItem.id + if (drunkId.startsWith("saradomin_brew")) { + player["brew_doses_since_restore"] = (player.get("brew_doses_since_restore") ?: 0) + 1 + } else if (drunkId.startsWith("super_restore") || drunkId.startsWith("restore_potion")) { + player["brew_doses_since_restore"] = 0 + } + return BehaviourState.Wait(1, BehaviourState.Running) + } + return BehaviourState.Success + } +} diff --git a/game/src/main/kotlin/content/bot/behaviour/action/BotFightNpc.kt b/game/src/main/kotlin/content/bot/behaviour/action/BotFightNpc.kt index 63c33683b0..ba21c5c398 100644 --- a/game/src/main/kotlin/content/bot/behaviour/action/BotFightNpc.kt +++ b/game/src/main/kotlin/content/bot/behaviour/action/BotFightNpc.kt @@ -9,6 +9,7 @@ import content.bot.behaviour.condition.Condition import content.entity.combat.attacker import content.entity.combat.dead import content.entity.combat.underAttack +import world.gregs.voidps.engine.client.variable.start import world.gregs.voidps.engine.entity.character.mode.EmptyMode import world.gregs.voidps.engine.entity.character.mode.interact.PlayerOnFloorItemInteract import world.gregs.voidps.engine.entity.character.mode.interact.PlayerOnNPCInteract @@ -33,7 +34,7 @@ data class BotFightNpc( val lootOverValue: Int = 0, ) : BotAction { override fun update(bot: Bot, world: BotWorld, frame: BehaviourFrame) = when { - healPercentage > 0 && bot.levels.get(Skill.Constitution) <= bot.levels.getMax(Skill.Constitution) / healPercentage -> eat(bot, world) + healPercentage > 0 && bot.levels.get(Skill.Constitution) <= bot.levels.getMax(Skill.Constitution) * healPercentage / 100 -> eat(bot, world) success?.check(bot.player) == true -> BehaviourState.Success bot.mode is PlayerOnNPCInteract -> if (success == null) BehaviourState.Success else BehaviourState.Running bot.mode is PlayerOnFloorItemInteract -> BehaviourState.Running @@ -53,6 +54,8 @@ data class BotFightNpc( if (!valid) { return BehaviourState.Failed(Reason.Invalid("Invalid inventory interaction: ${item.def.id} $index $option")) } + // Window for a follow-up brew reactive to chain on top of the food (overheal stack). + bot.player.start("just_ate_food", 2) return BehaviourState.Wait(1, BehaviourState.Running) } return BehaviourState.Running @@ -90,6 +93,7 @@ data class BotFightNpc( if (!valid) { return BehaviourState.Failed(Reason.Invalid("Invalid npc interaction: ${npc.index} ${index + 1}")) } + bot.player.start("fight_starting", 5) return BehaviourState.Running } } diff --git a/game/src/main/kotlin/content/bot/behaviour/action/BotFightPlayer.kt b/game/src/main/kotlin/content/bot/behaviour/action/BotFightPlayer.kt new file mode 100644 index 0000000000..62f01b15e1 --- /dev/null +++ b/game/src/main/kotlin/content/bot/behaviour/action/BotFightPlayer.kt @@ -0,0 +1,183 @@ +package content.bot.behaviour.action + +import content.bot.Bot +import content.bot.behaviour.BehaviourFrame +import content.bot.behaviour.BehaviourState +import content.bot.behaviour.BotWorld +import content.bot.behaviour.Reason +import content.bot.behaviour.condition.Condition +import content.entity.combat.Target +import content.entity.combat.dead +import world.gregs.voidps.engine.client.variable.hasClock +import world.gregs.voidps.engine.client.variable.start +import world.gregs.voidps.engine.client.variable.stop +import world.gregs.voidps.engine.entity.character.mode.EmptyMode +import world.gregs.voidps.engine.entity.character.mode.interact.PlayerOnFloorItemInteract +import world.gregs.voidps.engine.entity.character.mode.interact.PlayerOnPlayerInteract +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Players +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.item.floor.FloorItem +import world.gregs.voidps.engine.entity.item.floor.FloorItems +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.map.Spiral +import world.gregs.voidps.network.client.instruction.InteractFloorItem +import world.gregs.voidps.network.client.instruction.InteractInterface +import world.gregs.voidps.network.client.instruction.InteractPlayer +import world.gregs.voidps.type.Tile + +data class BotFightPlayer( + val delay: Int = 0, + val success: Condition? = null, + val radius: Int = 10, + val healPercentage: Int = 20, + val lootOverValue: Int = 0, + val lootStrategy: BotLootStrategy = BotLootStrategy.DEFAULT, + val area: String? = null, +) : BotAction { + override fun update(bot: Bot, world: BotWorld, frame: BehaviourFrame) = when { + // Success first so a retreat-by-teleport (bot now outside `area`) can complete the + // activity even at low HP — otherwise eat() spins forever when food is exhausted. + success?.check(bot.player) == true -> BehaviourState.Success + healPercentage > 0 && bot.levels.get(Skill.Constitution) <= bot.levels.getMax(Skill.Constitution) * healPercentage / 100 -> eat(bot, world) + bot.mode is PlayerOnPlayerInteract -> handleEngaged(bot, world) + bot.mode is PlayerOnFloorItemInteract -> BehaviourState.Running + bot.mode is EmptyMode -> search(bot, world) + else -> null + } + + private fun handleEngaged(bot: Bot, world: BotWorld): BehaviourState { + val mode = bot.mode as PlayerOnPlayerInteract + if (targetGone(bot, mode.target)) { + // Target has clearly left (different level or far outside our scan radius — typical + // sign of a teleport-out). Clear the stale interact so search() picks a new target + // when the activity loops back via restart. + bot.player.mode = EmptyMode + } + BotArenaCenter.maybeRecenter(bot, world, area) + return if (success == null) BehaviourState.Success else BehaviourState.Running + } + + /** + * Cheap, non-throwing "target obviously left" check. Avoids the heavier attackable/dead + * checks to stay compatible with relaxed mocks in tests where those properties aren't stubbed. + */ + private fun targetGone(bot: Bot, target: Player): Boolean { + if (target.tile.level != bot.player.tile.level) return true + return bot.player.tile.distanceTo(target.tile) > radius * 2 + } + + private fun eat(bot: Bot, world: BotWorld): BehaviourState { + val inventory = bot.player.inventory + for (index in inventory.indices) { + val item = inventory[index] + val option = item.def.options.indexOf("Eat") + if (option == -1) { + continue + } + val valid = world.execute(bot.player, InteractInterface(149, 0, item.def.id, index, option)) + if (!valid) { + return BehaviourState.Failed(Reason.Invalid("Invalid inventory interaction: ${item.def.id} $index $option")) + } + // Window for a follow-up brew reactive to chain on top of the food (overheal stack). + bot.player.start("just_ate_food", 2) + return BehaviourState.Wait(1, BehaviourState.Running) + } + return BehaviourState.Running + } + + private fun search(bot: Bot, world: BotWorld): BehaviourState { + val player = bot.player + val attackOption = player.options.indexOf("Attack") + if (player.hasClock("loot_pending")) { + val lootResult = takeLoot(bot, world) + if (lootResult != null) return lootResult + // Nothing eligible at the recorded drop tile; stop scanning until the next kill. + player.stop("loot_pending") + player.clear("loot_drop_tile") + } + if (attackOption == -1) { + return handleNoTarget() + } + val target = pickTarget(bot) + if (target != null) { + val valid = world.execute(bot.player, InteractPlayer(target.index, attackOption)) + if (!valid) { + return BehaviourState.Failed(Reason.Invalid("Invalid player interaction: ${target.index} $attackOption")) + } + // Open a brief window for fight-start reactives (e.g. boost potions) to fire once + // per new engagement; gated on this clock so they don't re-drink on every decay tick. + bot.player.start("fight_starting", 5) + return BehaviourState.Running + } + if (BotArenaCenter.maybeRecenter(bot, world, area)) return BehaviourState.Running + return handleNoTarget() + } + + private fun takeLoot(bot: Bot, world: BotWorld): BehaviourState? { + val player = bot.player + val packed = player.get("loot_drop_tile") ?: return null + val dropTile = Tile(packed) + var bestItem: FloorItem? = null + var bestScore = Long.MIN_VALUE + for (item in FloorItems.at(dropTile)) { + if (!isLootable(player, item)) continue + if (!lootStrategy.ranks()) { + return executeTake(bot, world, item) + } + val score = lootStrategy.score(item) + if (score > bestScore) { + bestScore = score + bestItem = item + } + } + return bestItem?.let { executeTake(bot, world, it) } + } + + private fun isLootable(player: Player, item: FloorItem): Boolean { + if (item.owner != player.accountName) return false + if (item.def.cost <= lootOverValue) return false + if (!lootStrategy.accepts(item)) return false + // Reserve one inventory slot (e.g. for weapon switches) unless the item can stack onto an existing pile. + val inv = player.inventory + val canStack = item.def.stackable == 1 && inv.indexOf(item.id) >= 0 + if (inv.spaces <= 1 && !canStack) return false + return true + } + + private fun executeTake(bot: Bot, world: BotWorld, item: FloorItem): BehaviourState { + val index = item.def.floorOptions.indexOf("Take") + val valid = world.execute(bot.player, InteractFloorItem(item.def.id, item.tile.x, item.tile.y, index)) + if (!valid) { + return BehaviourState.Failed(Reason.Invalid("Invalid floor item interaction: $item $index")) + } + return BehaviourState.Running + } + + private fun pickTarget(bot: Bot): Player? { + for (tile in Spiral.spiral(bot.player.tile, radius)) { + val first = enemiesAt(bot, tile).firstOrNull() + if (first != null) return first + } + return null + } + + private fun enemiesAt(bot: Bot, tile: Tile): List { + val context = bot.combatContext + if (context != null) { + return context.enemiesByTile[tile.id] ?: emptyList() + } + val player = bot.player + return Players.at(tile).filter { it !== player && !it.dead && Target.attackable(player, it, message = false) } + } + + private fun handleNoTarget(): BehaviourState { + if (success == null) { + return BehaviourState.Failed(Reason.NoTarget) + } + if (delay > 0) { + return BehaviourState.Wait(delay, BehaviourState.Running) + } + return BehaviourState.Running + } +} diff --git a/game/src/main/kotlin/content/bot/behaviour/action/BotInteractObject.kt b/game/src/main/kotlin/content/bot/behaviour/action/BotInteractObject.kt index 947ec55e98..a1488044ce 100644 --- a/game/src/main/kotlin/content/bot/behaviour/action/BotInteractObject.kt +++ b/game/src/main/kotlin/content/bot/behaviour/action/BotInteractObject.kt @@ -25,8 +25,12 @@ data class BotInteractObject( val radius: Int = 10, val x: Int? = null, val y: Int? = null, + val condition: Condition? = null, ) : BotAction { override fun start(bot: Bot, world: BotWorld, frame: BehaviourFrame): BehaviourState { + if (condition != null && !condition.check(bot.player)) { + return BehaviourState.Success + } if (success != null && success.check(bot.player)) { return BehaviourState.Success } @@ -34,6 +38,7 @@ data class BotInteractObject( } override fun update(bot: Bot, world: BotWorld, frame: BehaviourFrame) = when { + condition != null && !condition.check(bot.player) -> BehaviourState.Success success?.check(bot.player) == true -> BehaviourState.Success bot.mode is PlayerOnObjectInteract -> if (success == null) BehaviourState.Success else BehaviourState.Running bot.mode is EmptyMode -> search(bot, world) diff --git a/game/src/main/kotlin/content/bot/behaviour/action/BotInterfaceOption.kt b/game/src/main/kotlin/content/bot/behaviour/action/BotInterfaceOption.kt index a8a6b91163..8e492f7312 100644 --- a/game/src/main/kotlin/content/bot/behaviour/action/BotInterfaceOption.kt +++ b/game/src/main/kotlin/content/bot/behaviour/action/BotInterfaceOption.kt @@ -12,8 +12,11 @@ import world.gregs.voidps.engine.data.definition.ItemDefinitions import world.gregs.voidps.network.client.instruction.InteractInterface import kotlin.collections.indexOf -data class BotInterfaceOption(val option: String, val id: String, val success: Condition? = null) : BotAction { +data class BotInterfaceOption(val option: String, val id: String, val success: Condition? = null, val condition: Condition? = null) : BotAction { override fun update(bot: Bot, world: BotWorld, frame: BehaviourFrame): BehaviourState? { + if (condition != null && !condition.check(bot.player)) { + return BehaviourState.Success + } if (success != null && success.check(bot.player)) { return BehaviourState.Success } @@ -32,12 +35,23 @@ data class BotInterfaceOption(val option: String, val id: String, val success: C } val itemDef = if (item != null) ItemDefinitions.getOrNull(item) else null var index = options.indexOf(option) - if (index == -1) { - if (itemDef == null || !itemDef.options.contains(option)) { - return BehaviourState.Failed(Reason.Invalid("No interface option $option for $id:$component:$item options=${options.contentToString()}.")) - } + if (index == -1 && itemDef != null && itemDef.options.contains(option)) { index = itemDef.options.indexOf(option) } + if (index == -1 && itemDef != null) { + // Worn-equipment options ("Burthorpe", "Clan Wars", etc. on jewellery, etc.) live in + // item params under keys "worn_option_1".."worn_option_5"; the server reads the param + // by raw option index (see WornEquipment.getEquipmentOption). + for (n in 1..5) { + if (itemDef.getOrNull("worn_option_$n") == option) { + index = n + break + } + } + } + if (index == -1) { + return BehaviourState.Failed(Reason.Invalid("No interface option $option for $id:$component:$item options=${options.contentToString()}.")) + } var inv = InterfaceHandler.getInventory(bot.player, id, component, componentDef) if (inv != null && component == "sample") { inv = "${inv}_sample" diff --git a/game/src/main/kotlin/content/bot/behaviour/action/BotJewelleryTeleport.kt b/game/src/main/kotlin/content/bot/behaviour/action/BotJewelleryTeleport.kt new file mode 100644 index 0000000000..1fb87b1017 --- /dev/null +++ b/game/src/main/kotlin/content/bot/behaviour/action/BotJewelleryTeleport.kt @@ -0,0 +1,51 @@ +package content.bot.behaviour.action + +import content.bot.Bot +import content.bot.behaviour.BehaviourFrame +import content.bot.behaviour.BehaviourState +import content.bot.behaviour.BotWorld +import content.bot.behaviour.Reason +import content.bot.behaviour.condition.Condition +import content.skill.magic.jewellery.itemTeleport +import world.gregs.voidps.engine.data.definition.Areas +import world.gregs.voidps.engine.inv.discharge +import world.gregs.voidps.engine.inv.equipment + +/** + * Bot-only jewellery teleport. Discharges one use of the equipped item, then runs the standard + * jewellery teleport coroutine (sound/gfx/animation/tele/landing gfx). Forces past the combat + * queue gate so a retreating bot can break out of an attack chain mid-fight. + */ +data class BotJewelleryTeleport( + val item: String, + val area: String, + val condition: Condition? = null, + val success: Condition? = null, +) : BotAction { + override fun update(bot: Bot, world: BotWorld, frame: BehaviourFrame): BehaviourState { + if (condition != null && !condition.check(bot.player)) { + return BehaviourState.Success + } + if (success != null && success.check(bot.player)) { + return BehaviourState.Success + } + val player = bot.player + // Teleport coroutine takes ~4 ticks (cast anim + tele + landing). Reactive ticks every + // 1 tick — without this guard we'd stack multiple discharges + teleports per retreat. + if (player.queue.contains("teleport_jewellery")) { + return BehaviourState.Running + } + val slot = player.equipment.indexOf(item) + if (slot < 0) { + return BehaviourState.Failed(Reason.Invalid("$item not equipped for jewellery teleport.")) + } + val areaDef = Areas.getOrNull(area)?.area ?: return BehaviourState.Failed(Reason.Invalid("Unknown area '$area'.")) + player.equipment.discharge(player, slot) + itemTeleport(player, areaDef, "jewellery", force = true) + return when { + success == null -> BehaviourState.Wait(1, BehaviourState.Success) + success.check(player) -> BehaviourState.Success + else -> BehaviourState.Running + } + } +} diff --git a/game/src/main/kotlin/content/bot/behaviour/action/BotLootStrategy.kt b/game/src/main/kotlin/content/bot/behaviour/action/BotLootStrategy.kt new file mode 100644 index 0000000000..38e25830d9 --- /dev/null +++ b/game/src/main/kotlin/content/bot/behaviour/action/BotLootStrategy.kt @@ -0,0 +1,52 @@ +package content.bot.behaviour.action + +import world.gregs.voidps.engine.entity.item.floor.FloorItem + +/** + * Controls which floor items a bot is willing to pick up after a kill, + * and the order in which competing piles are evaluated. + */ +enum class BotLootStrategy { + /** Loot any owned item above the value threshold, in spiral order. */ + DEFAULT, + + /** Only loot consumables (food, potions, prayer restores). */ + SURVIVAL, + + /** Loot any owned item above the value threshold, picking the highest total value first. */ + WEALTH, + + ; + + /** Whether [item] is eligible to be looted under this strategy. */ + fun accepts(item: FloorItem): Boolean = when (this) { + SURVIVAL -> isConsumable(item) + DEFAULT, WEALTH -> true + } + + /** Whether candidates should be gathered and ranked instead of taking the first match. */ + fun ranks(): Boolean = this == WEALTH + + /** Total coin value to compare candidates by; higher is better. */ + fun score(item: FloorItem): Long = item.value + + companion object { + private val SURVIVAL_CATEGORIES = setOf("edible", "potion", "prayer_consumable") + + fun of(name: String?): BotLootStrategy = when (name?.lowercase()) { + null, "default" -> DEFAULT + "survival" -> SURVIVAL + "wealth" -> WEALTH + else -> error("Unknown loot strategy '$name'. Expected 'survival', 'wealth' or 'default'.") + } + + private fun isConsumable(item: FloorItem): Boolean { + val def = item.def + if (def.options.contains("Eat") || def.options.contains("Drink")) { + return true + } + val categories: Set = def.getOrNull("categories") ?: return false + return SURVIVAL_CATEGORIES.any { categories.contains(it) } + } + } +} diff --git a/game/src/main/kotlin/content/bot/behaviour/action/BotPray.kt b/game/src/main/kotlin/content/bot/behaviour/action/BotPray.kt new file mode 100644 index 0000000000..f4130584c8 --- /dev/null +++ b/game/src/main/kotlin/content/bot/behaviour/action/BotPray.kt @@ -0,0 +1,53 @@ +package content.bot.behaviour.action + +import content.bot.Bot +import content.bot.behaviour.BehaviourFrame +import content.bot.behaviour.BehaviourState +import content.bot.behaviour.BotWorld +import content.bot.behaviour.Reason +import content.bot.behaviour.condition.Condition +import content.skill.prayer.PrayerConfigs +import content.skill.prayer.getActivePrayerVarKey +import content.skill.prayer.isCurses +import world.gregs.voidps.engine.data.definition.PrayerDefinitions +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level.has +import world.gregs.voidps.engine.entity.character.player.skill.level.Level.hasMax +import world.gregs.voidps.engine.get + +data class BotPray(val id: String, val condition: Condition? = null) : BotAction { + override fun update(bot: Bot, world: BotWorld, frame: BehaviourFrame): BehaviourState { + val player = bot.player + val shouldBeOn = condition?.check(player) ?: true + if (!shouldBeOn) { + val key = player.getActivePrayerVarKey() + if (player.containsVarbit(key, id)) { + player.removeVarbit(key, id) + } + return BehaviourState.Success + } + val def = get().getOrNull(id) + ?: return BehaviourState.Failed(Reason.Invalid("Unknown prayer '$id'.")) + // Auto-switch the prayer book so curse ids land in ACTIVE_CURSES (and vice versa); + // BotPray bypasses the normal toggle-by-component path that would have flipped the book. + if (def.isCurse && !player.isCurses()) { + player[PrayerConfigs.PRAYERS] = "curses" + } else if (!def.isCurse && player.isCurses()) { + player[PrayerConfigs.PRAYERS] = "normal" + } + val key = player.getActivePrayerVarKey() + if (player.containsVarbit(key, id)) { + return BehaviourState.Success + } + if (!player.hasMax(Skill.Prayer, def.level)) { + return BehaviourState.Failed(Reason.Invalid("Insufficient prayer level for '$id': need ${def.level}.")) + } + if (!player.has(Skill.Prayer, 1)) { + // Out of prayer points is transient — drink_potion will top up. Don't surface as + // Failed; the reactive will retry next tick once the bot has sipped a super_restore. + return BehaviourState.Success + } + player.addVarbit(key, id) + return BehaviourState.Success + } +} diff --git a/game/src/main/kotlin/content/bot/behaviour/action/BotRetreat.kt b/game/src/main/kotlin/content/bot/behaviour/action/BotRetreat.kt new file mode 100644 index 0000000000..a408b049e2 --- /dev/null +++ b/game/src/main/kotlin/content/bot/behaviour/action/BotRetreat.kt @@ -0,0 +1,58 @@ +package content.bot.behaviour.action + +import content.bot.Bot +import content.bot.behaviour.BehaviourFrame +import content.bot.behaviour.BehaviourState +import content.bot.behaviour.BotWorld +import content.bot.behaviour.Reason +import content.bot.behaviour.condition.Condition +import content.entity.combat.dead +import world.gregs.voidps.engine.data.definition.Areas +import world.gregs.voidps.engine.entity.character.mode.EmptyMode +import world.gregs.voidps.engine.entity.character.player.skill.Skill + +data class BotRetreat( + val safeArea: String, + val regroupHpPercent: Int, + val condition: Condition? = null, +) : BotAction { + + override fun start(bot: Bot, world: BotWorld, frame: BehaviourFrame): BehaviourState { + if (condition != null && !condition.check(bot.player)) { + return BehaviourState.Success + } + val def = Areas.getOrNull(safeArea) + ?: return BehaviourState.Failed(Reason.Invalid("No areas found with id '$safeArea'.")) + if (bot.tile in def.area) { + return if (regrouped(bot)) BehaviourState.Success else BehaviourState.Running + } + if (bot.mode !== EmptyMode) { + bot.player.mode = EmptyMode + } + return BehaviourState.Running + } + + override fun update(bot: Bot, world: BotWorld, frame: BehaviourFrame): BehaviourState { + if (bot.player.dead) { + return BehaviourState.Failed(Reason.Cancelled) + } + val def = Areas.getOrNull(safeArea) + ?: return BehaviourState.Failed(Reason.Invalid("No areas found with id '$safeArea'.")) + if (bot.tile in def.area) { + return if (regrouped(bot)) BehaviourState.Success else BehaviourState.Running + } + if (bot.mode !== EmptyMode) { + return BehaviourState.Running + } + val list = mutableListOf() + val success = world.find(bot.player, list, safeArea) + return BotGoTo.queueRoute(success, list, world, bot, safeArea) + } + + private fun regrouped(bot: Bot): Boolean { + val max = bot.levels.getMax(Skill.Constitution) + if (max <= 0) return true + val hp = bot.levels.get(Skill.Constitution) + return hp * 100 >= max * regroupHpPercent + } +} diff --git a/game/src/main/kotlin/content/bot/behaviour/action/BotSpecAttack.kt b/game/src/main/kotlin/content/bot/behaviour/action/BotSpecAttack.kt new file mode 100644 index 0000000000..22b9503f3a --- /dev/null +++ b/game/src/main/kotlin/content/bot/behaviour/action/BotSpecAttack.kt @@ -0,0 +1,79 @@ +package content.bot.behaviour.action + +import content.bot.Bot +import content.bot.behaviour.BehaviourFrame +import content.bot.behaviour.BehaviourState +import content.bot.behaviour.BotWorld +import content.bot.behaviour.condition.Condition +import content.entity.player.combat.special.specialAttack +import content.entity.player.combat.special.specialAttackEnergy +import world.gregs.voidps.engine.data.definition.InterfaceDefinitions +import world.gregs.voidps.engine.entity.character.player.equip.equipped +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.network.client.instruction.InteractInterface +import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot + +data class BotSpecAttack( + val weapon: String, + val fallback: String, + val minEnergy: Int = 250, + val condition: Condition? = null, +) : BotAction { + override fun update(bot: Bot, world: BotWorld, frame: BehaviourFrame): BehaviourState { + val player = bot.player + val worn = player.equipped(EquipSlot.Weapon).id + val queued = player.specialAttack + val sameWeapon = weapon == fallback + + if (!sameWeapon && worn == weapon && !queued) { + equipFromInventory(bot, world, fallback) + return BehaviourState.Success + } + + if (queued) return BehaviourState.Success + if (condition != null && !condition.check(player)) return BehaviourState.Success + if (player.specialAttackEnergy < minEnergy) return BehaviourState.Success + + if (worn == weapon) { + player.specialAttack = true + } else if (worn == fallback) { + if (equipFromInventory(bot, world, weapon)) { + player.specialAttack = true + } + } + return BehaviourState.Success + } + + private fun equipFromInventory(bot: Bot, world: BotWorld, itemId: String): Boolean { + val player = bot.player + val inv = player.inventory + var invSlot = -1 + var invItemId = -1 + for (index in inv.indices) { + val item = inv[index] + if (item.id == itemId) { + invSlot = index + invItemId = item.def.id + break + } + } + if (invSlot == -1) return false + val componentDef = InterfaceDefinitions.getComponent("inventory", "inventory") ?: return false + val componentId = InterfaceDefinitions.getComponentId("inventory", "inventory") ?: return false + val options = componentDef.options ?: componentDef.getOrNull("options") ?: emptyArray() + var optionIndex = options.indexOf("Equip") + if (optionIndex == -1) optionIndex = options.indexOf("Wield") + if (optionIndex == -1) return false + val interfaceDef = InterfaceDefinitions.getOrNull("inventory") ?: return false + return world.execute( + player, + InteractInterface( + interfaceId = interfaceDef.id, + componentId = componentId, + itemId = invItemId, + itemSlot = invSlot, + option = optionIndex, + ), + ) + } +} diff --git a/game/src/main/kotlin/content/bot/behaviour/action/BotSwitchLoadout.kt b/game/src/main/kotlin/content/bot/behaviour/action/BotSwitchLoadout.kt new file mode 100644 index 0000000000..7594683518 --- /dev/null +++ b/game/src/main/kotlin/content/bot/behaviour/action/BotSwitchLoadout.kt @@ -0,0 +1,79 @@ +package content.bot.behaviour.action + +import content.bot.Bot +import content.bot.behaviour.BehaviourFrame +import content.bot.behaviour.BehaviourState +import content.bot.behaviour.BotWorld +import content.bot.behaviour.activity.BotActivity +import content.bot.behaviour.condition.Condition +import world.gregs.voidps.engine.GameLoop + +/** + * Reactive action for hybrid PvP bots: swap the entire worn kit to a named loadout declared on + * the bot's current [BotActivity]. Two safety rails: + * 1. Change-trigger — short-circuits when the bot is already on (or transitioning into) the target. + * 2. Cooldown — stamps `last_loadout_swap_tick` and gates new swaps for [BotActivity.hybridSwapCooldown] ticks. + * + * Target resolution: + * - Explicit: `to = "magic"` always picks the named loadout. + * - Auto-counter: `counter_attacker = true` reads `incomingAttackStyle` and picks the counter from + * the OSRS combat triangle (melee→magic, ranged→melee, magic→ranged). Loadout names must be + * "melee" / "ranged" / "magic" for this to resolve. If the counter loadout isn't declared on + * this activity (e.g. a melee/magic-only hybrid hit by magic looks up "ranged"), falls back + * to the "magic" loadout so the bot still moves to a defensive kit. + * + * Once the rails clear, delegates to [BotSwitchSetup] for the per-slot equip (up to + * [BotActivity.hybridSwapPerTick] slots committed per tick). When the target loadout declares an + * `autocast` spell, that autocast is bound on entry so subsequent attacks fire the spell. + */ +data class BotSwitchLoadout( + val to: String? = null, + val counterAttacker: Boolean = false, + val condition: Condition? = null, +) : BotAction { + override fun update(bot: Bot, world: BotWorld, frame: BehaviourFrame): BehaviourState { + val player = bot.player + if (condition != null && !condition.check(player)) return BehaviourState.Success + + val activity = frame.behaviour as? BotActivity ?: return BehaviourState.Success + + val resolved = if (counterAttacker) { + val style = bot.combatContext?.incomingAttackStyle ?: return BehaviourState.Success + val counter = COUNTERS[style] ?: return BehaviourState.Success + // If the counter loadout isn't declared on this activity, fall back to "magic" + // (better defensive coverage than staying in the wrong kit). Explicit `to` lookups + // are not subject to this fallback — they should fail loudly if misconfigured. + if (activity.loadouts.containsKey(counter)) counter else COUNTER_FALLBACK + } else { + to ?: return BehaviourState.Success + } + + val target = activity.loadouts[resolved] ?: return BehaviourState.Success + + val current = player["current_loadout", activity.hybridStartingLoadout ?: ""] + if (current == resolved) { + ensureAutocast(player, target.autocast) + return BotSwitchSetup(target.equipment.items, condition = null, maxPerTick = activity.hybridSwapPerTick).update(bot, world, frame) + } + + val last = player["last_loadout_swap_tick", -10_000] + if (GameLoop.tick - last <= activity.hybridSwapCooldown) return BehaviourState.Success + + player["current_loadout"] = resolved + player["last_loadout_swap_tick"] = GameLoop.tick + ensureAutocast(player, target.autocast) + + return BotSwitchSetup(target.equipment.items, condition = null, maxPerTick = activity.hybridSwapPerTick).update(bot, world, frame) + } + + companion object { + // Combat triangle: each style is countered by the one that beats it. + // melee > ranged > magic > melee (cycle). Bot picks counter to attacker's style. + private val COUNTERS = mapOf( + "melee" to "magic", + "ranged" to "melee", + "magic" to "ranged", + ) + private const val COUNTER_FALLBACK = "melee" + } +} diff --git a/game/src/main/kotlin/content/bot/behaviour/action/BotSwitchSetup.kt b/game/src/main/kotlin/content/bot/behaviour/action/BotSwitchSetup.kt new file mode 100644 index 0000000000..bf80beb88b --- /dev/null +++ b/game/src/main/kotlin/content/bot/behaviour/action/BotSwitchSetup.kt @@ -0,0 +1,101 @@ +package content.bot.behaviour.action + +import content.bot.Bot +import content.bot.behaviour.BehaviourFrame +import content.bot.behaviour.BehaviourState +import content.bot.behaviour.BotWorld +import content.bot.behaviour.Reason +import content.bot.behaviour.condition.BotItem +import content.bot.behaviour.condition.Condition +import world.gregs.voidps.engine.client.instruction.InterfaceHandler +import world.gregs.voidps.engine.data.definition.InterfaceDefinitions +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.equip.equipped +import world.gregs.voidps.engine.entity.item.slot +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.network.client.instruction.InteractInterface +import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot + +/** + * Equip from inventory the items declared in [equipment]. One slot dispatched per [update] call by + * default; hybrid loadout swaps pass [maxPerTick] > 1 to commit several slots within the same tick + * (engine processes each `InteractInterface` synchronously, so subsequent slot scans see the + * post-swap state). + * + * Returns Wait(1, Running) when at least one slot was dispatched, or Success when nothing left + * to do (all target slots already match, or no inventory candidate exists for the remaining ones). + */ +data class BotSwitchSetup( + val equipment: Map, + val condition: Condition? = null, + val maxPerTick: Int = 1, +) : BotAction { + override fun update(bot: Bot, world: BotWorld, frame: BehaviourFrame): BehaviourState { + val player = bot.player + if (condition != null && !condition.check(player)) { + return BehaviourState.Success + } + var swaps = 0 + while (swaps < maxPerTick) { + when (swapNextSlot(player, world)) { + SwapResult.SWAPPED -> swaps++ + SwapResult.NONE -> return if (swaps == 0) BehaviourState.Success else BehaviourState.Wait(1, BehaviourState.Running) + is SwapResult.Failed -> return BehaviourState.Failed(Reason.Invalid("Equip dispatch failed.")) + } + } + return BehaviourState.Wait(1, BehaviourState.Running) + } + + private fun swapNextSlot(player: Player, world: BotWorld): SwapResult { + for ((slot, entry) in equipment) { + val worn = player.equipped(slot) + if (entry.ids.contains(worn.id)) continue + val inv = player.inventory + var invSlot = -1 + var invItemId = -1 + for (index in inv.indices) { + val item = inv[index] + if (item.slot != slot) continue + if (!entry.ids.contains(item.id)) continue + invSlot = index + invItemId = item.def.id + break + } + if (invSlot == -1) continue + return dispatchEquip(player, world, slot, invSlot, invItemId) + } + return SwapResult.NONE + } + + private fun dispatchEquip(player: Player, world: BotWorld, slot: EquipSlot, invSlot: Int, invItemId: Int): SwapResult { + val componentDef = InterfaceDefinitions.getComponent("inventory", "inventory") ?: return SwapResult.Failed + val componentId = InterfaceDefinitions.getComponentId("inventory", "inventory") ?: return SwapResult.Failed + val options = componentDef.options ?: componentDef.getOrNull("options") ?: emptyArray() + var optionIndex = options.indexOf("Equip") + if (optionIndex == -1) optionIndex = options.indexOf("Wield") + if (optionIndex == -1) { + val def = InterfaceHandler.getInventory(player, "inventory", "inventory", componentDef) + val candidate = if (def != null) player.inventories.inventory(def)[invSlot].def.options.indexOf("Wield") else -1 + if (candidate == -1) return SwapResult.Failed + optionIndex = candidate + } + val interfaceDef = InterfaceDefinitions.getOrNull("inventory") ?: return SwapResult.Failed + val valid = world.execute( + player, + InteractInterface( + interfaceId = interfaceDef.id, + componentId = componentId, + itemId = invItemId, + itemSlot = invSlot, + option = optionIndex, + ), + ) + return if (valid) SwapResult.SWAPPED else SwapResult.Failed + } + + private sealed class SwapResult { + object SWAPPED : SwapResult() + object NONE : SwapResult() + object Failed : SwapResult() + } +} diff --git a/game/src/main/kotlin/content/bot/behaviour/activity/BotActivity.kt b/game/src/main/kotlin/content/bot/behaviour/activity/BotActivity.kt index adb0fc51f3..09e36e0d1e 100644 --- a/game/src/main/kotlin/content/bot/behaviour/activity/BotActivity.kt +++ b/game/src/main/kotlin/content/bot/behaviour/activity/BotActivity.kt @@ -17,5 +17,10 @@ data class BotActivity( override val requires: List = emptyList(), override val setup: List = emptyList(), override val actions: List = emptyList(), + override val reactive: List = emptyList(), override val produces: Set = emptySet(), + val loadouts: Map = emptyMap(), + val hybridStartingLoadout: String? = null, + val hybridSwapCooldown: Int = 3, + val hybridSwapPerTick: Int = 1, ) : Behaviour diff --git a/game/src/main/kotlin/content/bot/behaviour/activity/Loadout.kt b/game/src/main/kotlin/content/bot/behaviour/activity/Loadout.kt new file mode 100644 index 0000000000..8657b6a707 --- /dev/null +++ b/game/src/main/kotlin/content/bot/behaviour/activity/Loadout.kt @@ -0,0 +1,17 @@ +package content.bot.behaviour.activity + +import content.bot.behaviour.condition.BotEquipmentSetup +import content.bot.behaviour.condition.BotInventorySetup + +/** + * Named gear set used by hybrid PvP bots. + * - [equipment] is the worn kit when this loadout is active. + * - [extraInventory] is additional carry items the loadout depends on (e.g. runes for a magic + * loadout). Pre-stocked into the bot's inventory at spawn alongside the items from other loadouts. + */ +data class Loadout( + val name: String, + val equipment: BotEquipmentSetup, + val extraInventory: BotInventorySetup?, + val autocast: String? = null, +) diff --git a/game/src/main/kotlin/content/bot/behaviour/condition/BotAnyCondition.kt b/game/src/main/kotlin/content/bot/behaviour/condition/BotAnyCondition.kt new file mode 100644 index 0000000000..1a1989d05f --- /dev/null +++ b/game/src/main/kotlin/content/bot/behaviour/condition/BotAnyCondition.kt @@ -0,0 +1,9 @@ +package content.bot.behaviour.condition + +import world.gregs.voidps.engine.entity.character.player.Player + +data class BotAnyCondition(val children: List) : Condition(children.minOfOrNull { it.priority } ?: 0) { + override fun keys(): Set = children.flatMap { it.keys() }.toSet() + override fun events(): Set = children.flatMap { it.events() }.toSet() + override fun check(player: Player): Boolean = children.any { it.check(player) } +} diff --git a/game/src/main/kotlin/content/bot/behaviour/condition/BotAttackerStyle.kt b/game/src/main/kotlin/content/bot/behaviour/condition/BotAttackerStyle.kt new file mode 100644 index 0000000000..4e34759169 --- /dev/null +++ b/game/src/main/kotlin/content/bot/behaviour/condition/BotAttackerStyle.kt @@ -0,0 +1,15 @@ +package content.bot.behaviour.condition + +import content.bot.bot +import content.bot.isBot +import world.gregs.voidps.engine.entity.character.player.Player + +data class BotAttackerStyle(val equals: Set) : Condition(1) { + override fun keys() = emptySet() + override fun events() = emptySet() + override fun check(player: Player): Boolean { + if (!player.isBot) return false + val style = player.bot.combatContext?.incomingAttackStyle ?: return false + return style in equals + } +} diff --git a/game/src/main/kotlin/content/bot/behaviour/condition/BotInArea.kt b/game/src/main/kotlin/content/bot/behaviour/condition/BotInArea.kt index 4fcebc8aaa..c6a5aa4407 100644 --- a/game/src/main/kotlin/content/bot/behaviour/condition/BotInArea.kt +++ b/game/src/main/kotlin/content/bot/behaviour/condition/BotInArea.kt @@ -3,8 +3,8 @@ package content.bot.behaviour.condition import world.gregs.voidps.engine.data.definition.Areas import world.gregs.voidps.engine.entity.character.player.Player -data class BotInArea(val id: String) : Condition(1000) { - override fun keys() = setOf("area:$id") +data class BotInArea(val id: String, val present: Boolean = true) : Condition(1000) { + override fun keys() = if (present) setOf("area:$id") else setOf() override fun events() = setOf("area:$id") - override fun check(player: Player) = player.tile in Areas[id] + override fun check(player: Player) = (player.tile in Areas[id]) == present } diff --git a/game/src/main/kotlin/content/bot/behaviour/condition/BotPvpRetreatNeeded.kt b/game/src/main/kotlin/content/bot/behaviour/condition/BotPvpRetreatNeeded.kt new file mode 100644 index 0000000000..c51b0f5e98 --- /dev/null +++ b/game/src/main/kotlin/content/bot/behaviour/condition/BotPvpRetreatNeeded.kt @@ -0,0 +1,25 @@ +package content.bot.behaviour.condition + +import content.area.wilderness.inMultiCombat +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.inv.inventory + +/** + * True when a PvP bot in a single-combat zone has run out of food. + * + * Used to gate the dangerous-arena exit-portal action: the bot retreats only when standing in + * a single-combat tile and no edible item remains in inventory. In multi-combat areas the bot + * would die before reaching the portal, so the check returns false there. + */ +class BotPvpRetreatNeeded : Condition(1) { + override fun keys() = emptySet() + override fun events() = emptySet() + override fun check(player: Player): Boolean { + if (player.inMultiCombat) return false + val inv = player.inventory + for (i in inv.indices) { + if (inv[i].def.options.contains("Eat")) return false + } + return true + } +} diff --git a/game/src/main/kotlin/content/bot/behaviour/condition/BotSkillLevel.kt b/game/src/main/kotlin/content/bot/behaviour/condition/BotSkillLevel.kt index 76ddd3275d..1806430ec3 100644 --- a/game/src/main/kotlin/content/bot/behaviour/condition/BotSkillLevel.kt +++ b/game/src/main/kotlin/content/bot/behaviour/condition/BotSkillLevel.kt @@ -6,5 +6,9 @@ import world.gregs.voidps.engine.entity.character.player.skill.Skill data class BotSkillLevel(val skill: Skill, val min: Int? = null, val max: Int? = null) : Condition(1) { override fun keys() = setOf("skill:${skill.name.lowercase()}") override fun events() = setOf("skill:${skill.name.lowercase()}") - override fun check(player: Player) = inRange(player.levels.get(skill), min, max) + + // Base/max level (XP-driven), not current — otherwise a drained Prayer or HP drop knocks + // the bot out of its pinned activity's `requires` check and stalls it in idle until those + // stats naturally regenerate. + override fun check(player: Player) = inRange(player.levels.getMax(skill), min, max) } diff --git a/game/src/main/kotlin/content/bot/behaviour/condition/BotSkillPercent.kt b/game/src/main/kotlin/content/bot/behaviour/condition/BotSkillPercent.kt new file mode 100644 index 0000000000..18f1d45377 --- /dev/null +++ b/game/src/main/kotlin/content/bot/behaviour/condition/BotSkillPercent.kt @@ -0,0 +1,20 @@ +package content.bot.behaviour.condition + +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill + +/** + * Compares the bot's *current* level for [skill] against its base/max as a percentage. + * Used to gate reactive drinks on stat-debuff thresholds (e.g. drink super_restore once + * strength has been brewed below 30% of max), independently of absolute level numbers. + */ +data class BotSkillPercent(val skill: Skill, val minPercent: Int? = null, val maxPercent: Int? = null) : Condition(1) { + override fun keys() = setOf("skill:${skill.name.lowercase()}") + override fun events() = setOf("skill:${skill.name.lowercase()}") + override fun check(player: Player): Boolean { + val maxLevel = player.levels.getMax(skill) + if (maxLevel <= 0) return false + val percent = player.levels.get(skill) * 100 / maxLevel + return inRange(percent, minPercent, maxPercent) + } +} diff --git a/game/src/main/kotlin/content/bot/behaviour/condition/BotTargetFrozen.kt b/game/src/main/kotlin/content/bot/behaviour/condition/BotTargetFrozen.kt new file mode 100644 index 0000000000..cdfe3d7d40 --- /dev/null +++ b/game/src/main/kotlin/content/bot/behaviour/condition/BotTargetFrozen.kt @@ -0,0 +1,23 @@ +package content.bot.behaviour.condition + +import content.entity.effect.frozen +import world.gregs.voidps.engine.entity.character.mode.interact.PlayerOnPlayerInteract +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill + +data class BotTargetFrozen(val hpMin: Double? = null, val hpMax: Double? = null) : Condition(1) { + override fun keys() = emptySet() + override fun events() = emptySet() + + override fun check(player: Player): Boolean { + val target = (player.mode as? PlayerOnPlayerInteract)?.target ?: return false + if (!target.frozen) return false + if (hpMin == null && hpMax == null) return true + val maxHp = target.levels.getMax(Skill.Constitution) + if (maxHp <= 0) return false + val fraction = target.levels.get(Skill.Constitution).toDouble() / maxHp + if (hpMin != null && fraction < hpMin) return false + if (hpMax != null && fraction > hpMax) return false + return true + } +} diff --git a/game/src/main/kotlin/content/bot/behaviour/condition/BotTargetHpPercent.kt b/game/src/main/kotlin/content/bot/behaviour/condition/BotTargetHpPercent.kt new file mode 100644 index 0000000000..cbaa04fa8f --- /dev/null +++ b/game/src/main/kotlin/content/bot/behaviour/condition/BotTargetHpPercent.kt @@ -0,0 +1,20 @@ +package content.bot.behaviour.condition + +import world.gregs.voidps.engine.entity.character.mode.interact.PlayerOnPlayerInteract +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill + +data class BotTargetHpPercent(val min: Double? = null, val max: Double? = null) : Condition(1) { + override fun keys() = emptySet() + override fun events() = emptySet() + + override fun check(player: Player): Boolean { + val target = (player.mode as? PlayerOnPlayerInteract)?.target ?: return false + val maxHp = target.levels.getMax(Skill.Constitution) + if (maxHp <= 0) return false + val fraction = target.levels.get(Skill.Constitution).toDouble() / maxHp + if (min != null && fraction < min) return false + if (max != null && fraction > max) return false + return true + } +} diff --git a/game/src/main/kotlin/content/bot/behaviour/condition/Condition.kt b/game/src/main/kotlin/content/bot/behaviour/condition/Condition.kt index fa892ca2e5..074b8c3d9a 100644 --- a/game/src/main/kotlin/content/bot/behaviour/condition/Condition.kt +++ b/game/src/main/kotlin/content/bot/behaviour/condition/Condition.kt @@ -104,9 +104,58 @@ sealed class Condition(val priority: Int) { "interface_closed" -> parseInterfaceClosed(list) "mode" -> parseMode(list) "skill" -> parseSkills(list) + "skill_percent" -> parseSkillPercent(list) + "attacker_style" -> parseAttackerStyle(list) + "target_hp_percent" -> parseTargetHpPercent(list) + "target_frozen" -> parseTargetFrozen(list) + "pvp_retreat_needed" -> BotPvpRetreatNeeded() + "any" -> parseAny(list) else -> null } + @Suppress("UNCHECKED_CAST") + private fun parseAny(list: List>): Condition { + val children = mutableListOf() + for (map in list) { + val key = map.keys.singleOrNull() ?: error("any child must be single-key map: $map") + val value = map[key] + val subList: List> = when (value) { + is Map<*, *> -> listOf(value as Map) + is List<*> -> value as List> + else -> error("any child value must be map or list: $map") + } + val child = parse(key, subList) ?: error("No condition parser for '$key' in any.") + children.add(child) + } + return BotAnyCondition(children) + } + + private fun parseEnumSet(list: List>): Set? { + val map = list.single() + val raw = map["equals"] as? String ?: return null + return raw.split(",").map { it.trim() }.filter { it.isNotEmpty() }.toSet() + } + + private fun parseAttackerStyle(list: List>): Condition? { + val equals = parseEnumSet(list) ?: return null + return BotAttackerStyle(equals) + } + + private fun parseTargetHpPercent(list: List>): Condition? { + val map = list.single() + val min = (map["min"] as? Number)?.toDouble() + val max = (map["max"] as? Number)?.toDouble() + if (min == null && max == null) return null + return BotTargetHpPercent(min = min, max = max) + } + + private fun parseTargetFrozen(list: List>): Condition { + val map = list.singleOrNull() ?: emptyMap() + val hpMin = (map["hp_min"] as? Number)?.toDouble() + val hpMax = (map["hp_max"] as? Number)?.toDouble() + return BotTargetFrozen(hpMin = hpMin, hpMax = hpMax) + } + private fun parseInventory(list: List>): BotInventorySetup = BotInventorySetup(parseItems(list)) private fun parseItems(list: List>): MutableList { @@ -239,7 +288,8 @@ sealed class Condition(val priority: Int) { private fun parseArea(list: List>): Condition? { val map = list.single() if (map.containsKey("id")) { - return BotInArea(id = map["id"] as String) + val present = map["present"] as? Boolean ?: true + return BotInArea(id = map["id"] as String, present = present) } return null } @@ -288,11 +338,22 @@ sealed class Condition(val priority: Int) { return null } + private fun parseSkillPercent(list: List>): Condition? { + val map = list.single() + if (!map.containsKey("id")) return null + if (!map.containsKey("min_percent") && !map.containsKey("max_percent")) return null + return BotSkillPercent( + skill = Skill.of((map["id"] as String).toPascalCase()) ?: error("Unknown skill: '${map["id"]}'"), + minPercent = map["min_percent"] as? Int, + maxPercent = map["max_percent"] as? Int, + ) + } + fun grant(player: Player, condition: Condition) { when (condition) { is BotCombatLevel -> { val skills = setOf(Skill.Attack, Skill.Strength, Skill.Defence, Skill.Constitution, Skill.Ranged, Skill.Magic, Skill.Prayer) - for (i in 0 until 50) { + for (@Suppress("UNUSED_PARAMETER") i in 0 until 50) { val skill = skills.random(random) val level = (player.levels.getMax(skill) + 5).coerceAtMost(99) player.levels.set(skill, level) diff --git a/game/src/main/kotlin/content/bot/behaviour/navigation/NavigationShortcut.kt b/game/src/main/kotlin/content/bot/behaviour/navigation/NavigationShortcut.kt index 8368a5e641..dacaffa146 100644 --- a/game/src/main/kotlin/content/bot/behaviour/navigation/NavigationShortcut.kt +++ b/game/src/main/kotlin/content/bot/behaviour/navigation/NavigationShortcut.kt @@ -11,5 +11,6 @@ data class NavigationShortcut( override val requires: List = emptyList(), override val setup: List = emptyList(), override val actions: List = emptyList(), + override val reactive: List = emptyList(), override val produces: Set = emptySet(), ) : Behaviour diff --git a/game/src/main/kotlin/content/bot/behaviour/perception/BotCombatContext.kt b/game/src/main/kotlin/content/bot/behaviour/perception/BotCombatContext.kt new file mode 100644 index 0000000000..f95680ee0d --- /dev/null +++ b/game/src/main/kotlin/content/bot/behaviour/perception/BotCombatContext.kt @@ -0,0 +1,47 @@ +package content.bot.behaviour.perception + +import world.gregs.voidps.engine.entity.character.player.Player + +class BotCombatContext( + val incomingAttackStyle: String?, + enemiesByTile: Map>? = null, + private val spiralScanner: (() -> SpiralScan)? = null, +) { + /** + * Holds the spiral-scan output. Only built the first time [enemiesByTile] is read. Reactive + * actions that don't touch this field (e.g. BotAttackerStyle) pay nothing for the scan. + */ + private var scan: SpiralScan? = if (enemiesByTile != null) { + SpiralScan(enemiesByTile) + } else { + null + } + + val enemiesByTile: Map> get() = ensureScan().byTile + + fun copy( + incomingAttackStyle: String? = this.incomingAttackStyle, + enemiesByTile: Map> = this.enemiesByTile, + ): BotCombatContext = BotCombatContext( + incomingAttackStyle = incomingAttackStyle, + enemiesByTile = enemiesByTile, + ) + + private fun ensureScan(): SpiralScan { + val cached = scan + if (cached != null) return cached + val fresh = spiralScanner?.invoke() ?: SpiralScan.EMPTY + scan = fresh + return fresh + } + + data class SpiralScan(val byTile: Map>) { + companion object { + val EMPTY = SpiralScan(emptyMap()) + } + } + + companion object { + val EMPTY = BotCombatContext(incomingAttackStyle = null) + } +} diff --git a/game/src/main/kotlin/content/bot/behaviour/perception/BotCombatContextBuilder.kt b/game/src/main/kotlin/content/bot/behaviour/perception/BotCombatContextBuilder.kt new file mode 100644 index 0000000000..87d3c43ce3 --- /dev/null +++ b/game/src/main/kotlin/content/bot/behaviour/perception/BotCombatContextBuilder.kt @@ -0,0 +1,49 @@ +package content.bot.behaviour.perception + +import content.bot.Bot +import content.entity.combat.Target +import content.entity.combat.attacker +import content.entity.combat.dead +import content.entity.combat.underAttack +import content.skill.melee.weapon.Weapon +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Players + +object BotCombatContextBuilder { + const val DEFAULT_RADIUS = 15 + + /** + * Builds the cheap part of the context immediately and defers the spiral scan to first access + * of [BotCombatContext.enemiesByTile]. Reactive actions that only need + * [BotCombatContext.incomingAttackStyle] pay nothing for the scan. + */ + fun build(bot: Bot, radius: Int = DEFAULT_RADIUS): BotCombatContext { + val player = bot.player + val attacker = (player.attacker as? Player)?.takeIf { player.underAttack } + return BotCombatContext( + incomingAttackStyle = attacker?.let { categorize(it) }, + spiralScanner = { scan(player, radius) }, + ) + } + + private fun scan(player: Player, radius: Int): BotCombatContext.SpiralScan { + content.bot.BotMetrics.incScans() + val byTile = mutableMapOf>() + Players.forEachInRadius(player.tile, radius) { other -> + if (other === player || other.dead) { + return@forEachInRadius + } + if (Target.attackable(player, other, message = false)) { + byTile.getOrPut(other.tile.id) { mutableListOf() }.add(other) + } + } + return BotCombatContext.SpiralScan(byTile) + } + + private fun categorize(attacker: Player): String? = when (Weapon.type(attacker)) { + "melee" -> "melee" + "range" -> "ranged" + "magic" -> "magic" + else -> null + } +} diff --git a/game/src/main/kotlin/content/bot/behaviour/setup/DynamicResolvers.kt b/game/src/main/kotlin/content/bot/behaviour/setup/DynamicResolvers.kt index 6f88a91360..efd03e8d37 100644 --- a/game/src/main/kotlin/content/bot/behaviour/setup/DynamicResolvers.kt +++ b/game/src/main/kotlin/content/bot/behaviour/setup/DynamicResolvers.kt @@ -1,5 +1,6 @@ package content.bot.behaviour.setup +import content.area.wilderness.inPvp import content.bot.behaviour.action.BotAction import content.bot.behaviour.action.BotCloseInterface import content.bot.behaviour.action.BotGoTo @@ -36,8 +37,8 @@ object DynamicResolvers { fun resolver(player: Player, condition: Condition): Resolver? = when (condition) { is BotInArea -> Resolver("go_to_area", -1, actions = listOf(BotGoTo(condition.id))) - is BotEquipmentSetup -> resolveEquipment(player, condition.items) - is BotInventorySetup -> resolveInventory(player, condition.items) + is BotEquipmentSetup -> if (player.inPvp) null else resolveEquipment(player, condition.items) + is BotInventorySetup -> if (player.inPvp) null else resolveInventory(player, condition.items) else -> null } diff --git a/game/src/main/kotlin/content/bot/behaviour/setup/Resolver.kt b/game/src/main/kotlin/content/bot/behaviour/setup/Resolver.kt index b58ecf81be..57c47ad396 100644 --- a/game/src/main/kotlin/content/bot/behaviour/setup/Resolver.kt +++ b/game/src/main/kotlin/content/bot/behaviour/setup/Resolver.kt @@ -18,5 +18,6 @@ data class Resolver( override val requires: List = emptyList(), override val setup: List = emptyList(), override val actions: List = emptyList(), + override val reactive: List = emptyList(), override val produces: Set = emptySet(), ) : Behaviour diff --git a/game/src/main/kotlin/content/bot/combat/ClanWarsBotContext.kt b/game/src/main/kotlin/content/bot/combat/ClanWarsBotContext.kt new file mode 100644 index 0000000000..10f005e016 --- /dev/null +++ b/game/src/main/kotlin/content/bot/combat/ClanWarsBotContext.kt @@ -0,0 +1,109 @@ +package content.bot.combat + +import com.github.michaelbull.logging.InlineLogger +import world.gregs.voidps.engine.data.Settings +import world.gregs.voidps.engine.data.config.RowDefinition +import world.gregs.voidps.engine.data.definition.Areas +import world.gregs.voidps.engine.data.definition.Tables +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.timer.toTicks +import world.gregs.voidps.type.Tile +import java.util.concurrent.TimeUnit + +/** + * Combat-bot context for Clan Wars FFA arenas. Owns the `clan_wars_*` activity-id family, + * loads arena/tier data from the `clan_wars_arenas` and `clan_wars_tiers` cache tables, and + * defines the dangerous-arena drop rule + the `clan_wars_teleport` retreat refresh policy. + * + * Spawn / death wiring lives in `BotCommands`; this class only carries the policy that used + * to be inlined there. + */ +class ClanWarsBotContext : CombatBotContext { + + override val id: String = "clan_wars" + + override val subscribedAreas: Set = setOf("clan_wars_teleport") + + private val logger = InlineLogger("ClanWarsBots") + private val arenas = mutableMapOf() + + override fun handles(tier: CombatTier): Boolean = tier.activityId.startsWith("clan_wars_") + + override fun load() { + arenas.clear() + val arenaTable = Tables.getOrNull("clan_wars_arenas") ?: return + val tierTable = Tables.getOrNull("clan_wars_tiers") ?: return + val tiersById = tierTable.rows().associate { row -> row.rowId to row.toCombatTier() } + for (row in arenaTable.rows()) { + val spawnArea = row.string("spawn_area") + val tiers = row.stringList("tiers").mapNotNull { tiersById[it] } + if (tiers.isEmpty()) { + logger.warn { "No tiers resolved for arena '${row.rowId}'." } + continue + } + arenas[row.rowId] = ClanWarsArena(spawnArea, tiers) + } + } + + override fun arenaKeys(): Set = arenas.keys + + override fun arenaSpawn(arenaKey: String): Tile? { + val arena = arenas[arenaKey] ?: return null + return Areas[arena.spawnArea].random() + } + + override fun arenaTiers(arenaKey: String): List = arenas[arenaKey]?.tiers ?: emptyList() + + override fun autospawnArenaKeys(): List = AUTOSPAWN_ARENAS + + override fun autospawnIntervalTicks(arenaKey: String): Int = TimeUnit.SECONDS.toTicks(Settings["bots.combat.$arenaKey.spawnSeconds", 2]) + + override fun autospawnTarget(arenaKey: String): Int = Settings["bots.combat.$arenaKey.count", 0] + + override fun arenaContains(arenaKey: String, tier: CombatTier): Boolean = tier.activityId.startsWith("${arenaKey}_") + + override fun shouldDropItems(player: Player, tier: CombatTier): Boolean { + if (tier.activityId.startsWith("clan_wars_ffa_dangerous_")) return true + return player.tile in Areas["clan_wars_ffa_dangerous_arena"] + } + + override fun respawnTile(tier: CombatTier): Tile? { + val arena = arenas.values.firstOrNull { a -> a.tiers.any { t -> t.activityId == tier.activityId } } ?: return null + return Areas[arena.spawnArea].random() + } + + /** + * Logs the retreat — actual restocking (applyTier + state reset) runs in the generic + * dispatcher in BotCommands when [shouldRefreshOnAreaEntered] returns true. + */ + override fun onAreaEntered(player: Player, tier: CombatTier, areaId: String) { + if (areaId != "clan_wars_teleport") return + if (!tier.activityId.startsWith("clan_wars_ffa_dangerous_")) return + logger.info { "PvP bot retreat: '${player.accountName}' tier=${tier.activityId} teleported to clan_wars_teleport" } + } + + /** + * Dangerous-arena retreat: bot teleported out via jewellery → restock before it walks + * back through the portal so it doesn't re-enter the arena empty-inventory. + */ + override fun shouldRefreshOnAreaEntered(player: Player, tier: CombatTier, areaId: String): Boolean = areaId == "clan_wars_teleport" && tier.activityId.startsWith("clan_wars_ffa_dangerous_") + + private fun RowDefinition.toCombatTier(): CombatTier { + val skillNames = stringList("skills") + val values = intList("levels") + require(skillNames.size == values.size) { "clan_wars_tiers.$rowId: skills/levels size mismatch." } + val levels = LinkedHashMap(skillNames.size) + for ((index, name) in skillNames.withIndex()) { + val skillId = Skill.map[name] ?: error("clan_wars_tiers.$rowId: unknown skill '$name'.") + levels[Skill.all[skillId]] = values[index] + } + return CombatTier(activityId = rowId, levels = levels, style = string("combat_style")) + } + + private data class ClanWarsArena(val spawnArea: String, val tiers: List) + + companion object { + private val AUTOSPAWN_ARENAS = listOf("clan_wars_ffa_safe", "clan_wars_ffa_dangerous") + } +} diff --git a/game/src/main/kotlin/content/bot/combat/CombatBotContext.kt b/game/src/main/kotlin/content/bot/combat/CombatBotContext.kt new file mode 100644 index 0000000000..e143ec49ff --- /dev/null +++ b/game/src/main/kotlin/content/bot/combat/CombatBotContext.kt @@ -0,0 +1,77 @@ +package content.bot.combat + +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.type.Tile + +/** + * Per-minigame lifecycle policy for combat bots. One context owns the spawn/death/retreat rules + * for a family of arenas (clan wars, wilderness, pest control, etc.). The generic script + * machinery in [content.bot.BotCommands] dispatches each event to the context that [handles] + * the dying/retreating bot's tier. + * + * Adding a new context: + * 1. Implement this interface with a new ID and an `activityId` prefix in [handles]. + * 2. Register it with [CombatBotContexts.register] from `BotCommands.init`. + * 3. Add a corresponding `*.bots.toml` entry that creates activities under that prefix and a + * templates file (see `minigame_combat.templates.toml` for the PvP example). + */ +interface CombatBotContext { + + /** Stable identifier — used in logs and (eventually) settings paths. */ + val id: String + + /** + * Areas whose `entered` event should be dispatched to [onAreaEntered]. The script in + * BotCommands.kt registers one engine listener per name returned here at init time. + */ + val subscribedAreas: Set + get() = emptySet() + + /** Does this context own the given tier? Typically `tier.activityId.startsWith(prefix)`. */ + fun handles(tier: CombatTier): Boolean + + /** Refresh internal state from cache tables. Called on world spawn and settings reload. */ + fun load() + + /** Arena keys the context exposes for `::combatbots ` autocompletion + dispatch. */ + fun arenaKeys(): Set + + /** Random spawn tile for an arena, or null if the arena key is unknown. */ + fun arenaSpawn(arenaKey: String): Tile? + + /** Tier pool for an arena. Empty if the key is unknown. */ + fun arenaTiers(arenaKey: String): List + + /** Arena keys that should have a periodic top-up timer wired. */ + fun autospawnArenaKeys(): List = emptyList() + + /** Ticks between top-up checks for a given arena. */ + fun autospawnIntervalTicks(arenaKey: String): Int + + /** Target bot count for the arena (typically a Settings lookup); 0 means disabled. */ + fun autospawnTarget(arenaKey: String): Int + + /** Does the given tier belong to the given arena's pool? Used for current-count math. */ + fun arenaContains(arenaKey: String, tier: CombatTier): Boolean + + /** Drop policy on death: true → bot drops its kit (PK loot piñata), false → kit kept. */ + fun shouldDropItems(player: Player, tier: CombatTier): Boolean + + /** Override the default home respawn tile for this tier. Null falls through to engine default. */ + fun respawnTile(tier: CombatTier): Tile? + + /** + * Called when a bot owned by this context enters an area listed in [subscribedAreas]. + * This is a notification hook (logging, metrics) — the dispatcher inspects + * [shouldRefreshOnAreaEntered] separately to decide whether to re-apply the bot's tier. + */ + fun onAreaEntered(player: Player, tier: CombatTier, areaId: String) {} + + /** + * Should the bot have its tier (kit, levels, special, brew counter) re-applied after + * entering [areaId]? Used by the retreat-refresh path: a dangerous-arena bot teleporting + * out via games necklace returns true so the dispatcher restocks before the bot walks + * back through the portal. Defaults to false. + */ + fun shouldRefreshOnAreaEntered(player: Player, tier: CombatTier, areaId: String): Boolean = false +} diff --git a/game/src/main/kotlin/content/bot/combat/CombatBotContexts.kt b/game/src/main/kotlin/content/bot/combat/CombatBotContexts.kt new file mode 100644 index 0000000000..b17cbc5acf --- /dev/null +++ b/game/src/main/kotlin/content/bot/combat/CombatBotContexts.kt @@ -0,0 +1,36 @@ +package content.bot.combat + +/** + * Registry of [CombatBotContext] implementations. The script in `BotCommands` populates this + * once at init time; lifecycle dispatchers (playerDeath, entered, ...) look up the owning + * context by [find]. New minigames register here without touching `BotCommands`. + */ +object CombatBotContexts { + + private val all = mutableListOf() + + fun register(context: CombatBotContext) { + require(all.none { it.id == context.id }) { "Combat bot context '${context.id}' already registered." } + all.add(context) + } + + fun unregister(context: CombatBotContext): Boolean = all.remove(context) + + /** Test-only: clear every registration. */ + internal fun clear() = all.clear() + + fun all(): List = all.toList() + + /** First context whose [CombatBotContext.handles] matches the tier, or null. */ + fun find(tier: CombatTier): CombatBotContext? = all.firstOrNull { it.handles(tier) } + + /** Context that exposes the given arena key, or null if no context owns it. */ + fun forArenaKey(arenaKey: String): CombatBotContext? = all.firstOrNull { arenaKey in it.arenaKeys() } + + /** Union of every context's subscribed areas — used to wire `entered(...)` listeners once. */ + fun subscribedAreas(): Set = all.flatMapTo(mutableSetOf()) { it.subscribedAreas } + + fun loadAll() { + for (context in all) context.load() + } +} diff --git a/game/src/main/kotlin/content/bot/combat/CombatTier.kt b/game/src/main/kotlin/content/bot/combat/CombatTier.kt new file mode 100644 index 0000000000..f91aa09135 --- /dev/null +++ b/game/src/main/kotlin/content/bot/combat/CombatTier.kt @@ -0,0 +1,14 @@ +package content.bot.combat + +import world.gregs.voidps.engine.entity.character.player.skill.Skill + +/** + * Identity for a combat-bot kit: the activity the bot will be pinned to, the levels it should + * be set to on spawn/respawn, and the combat style to select. Loaded from per-context tables + * (e.g. clan_wars_tiers for [ClanWarsBotContext]); future contexts may define their own tables. + */ +data class CombatTier( + val activityId: String, + val levels: Map, + val style: String, +) diff --git a/game/src/main/kotlin/content/bot/combat/PestControlBotContext.kt b/game/src/main/kotlin/content/bot/combat/PestControlBotContext.kt new file mode 100644 index 0000000000..dc52ded914 --- /dev/null +++ b/game/src/main/kotlin/content/bot/combat/PestControlBotContext.kt @@ -0,0 +1,40 @@ +package content.bot.combat + +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.type.Tile + +/** + * Stub for pest control PvE minigame bots (NPC combat, defender role, wave timing). + * + * Not registered yet — the pest control implementation will land in a follow-up PR. That PR + * is responsible for: BotFightNpc-based combat, portal/defender role assignment, wave timing, + * and any minigame-specific drop / respawn semantics. + */ +class PestControlBotContext : CombatBotContext { + + override val id: String = "pest_control" + + override fun handles(tier: CombatTier): Boolean = tier.activityId.startsWith("pest_control_") + + override fun load() { + // TODO(pest_control): load wave / role tables. + } + + override fun arenaKeys(): Set = emptySet() + + override fun arenaSpawn(arenaKey: String): Tile? = null + + override fun arenaTiers(arenaKey: String): List = emptyList() + + override fun autospawnIntervalTicks(arenaKey: String): Int = 0 + + override fun autospawnTarget(arenaKey: String): Int = 0 + + override fun arenaContains(arenaKey: String, tier: CombatTier): Boolean = false + + /** Pest control: PvE minigame, no PK loot — bot keeps its kit. */ + override fun shouldDropItems(player: Player, tier: CombatTier): Boolean = false + + /** Pest control respawn defers to engine default until the wave/role logic ships. */ + override fun respawnTile(tier: CombatTier): Tile? = null +} diff --git a/game/src/main/kotlin/content/bot/combat/WildernessBotContext.kt b/game/src/main/kotlin/content/bot/combat/WildernessBotContext.kt new file mode 100644 index 0000000000..9707ac4926 --- /dev/null +++ b/game/src/main/kotlin/content/bot/combat/WildernessBotContext.kt @@ -0,0 +1,41 @@ +package content.bot.combat + +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.type.Tile + +/** + * Stub for wilderness roaming bots that target real players (PK fodder). + * + * Not registered yet — the wilderness implementation will land in a follow-up PR. + * That PR is responsible for: combat-level band matching, per-zone density caps, + * multi-combat awareness, real-player-only target selection, and the appropriate + * cache tables / templates. + */ +class WildernessBotContext : CombatBotContext { + + override val id: String = "wilderness" + + override fun handles(tier: CombatTier): Boolean = tier.activityId.startsWith("wilderness_") + + override fun load() { + // TODO(wilderness): load wilderness band tables. + } + + override fun arenaKeys(): Set = emptySet() + + override fun arenaSpawn(arenaKey: String): Tile? = null + + override fun arenaTiers(arenaKey: String): List = emptyList() + + override fun autospawnIntervalTicks(arenaKey: String): Int = 0 + + override fun autospawnTarget(arenaKey: String): Int = 0 + + override fun arenaContains(arenaKey: String, tier: CombatTier): Boolean = false + + /** Wilderness drops: standard player-death rules apply (full kit drop). */ + override fun shouldDropItems(player: Player, tier: CombatTier): Boolean = true + + /** Wilderness has no central respawn — fall through to engine default. */ + override fun respawnTile(tier: CombatTier): Tile? = null +} diff --git a/game/src/main/kotlin/content/entity/combat/Target.kt b/game/src/main/kotlin/content/entity/combat/Target.kt index cd411d97f7..36e96e9e81 100644 --- a/game/src/main/kotlin/content/entity/combat/Target.kt +++ b/game/src/main/kotlin/content/entity/combat/Target.kt @@ -31,7 +31,7 @@ import world.gregs.voidps.engine.entity.item.Item import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot object Target { - fun attackable(source: Character, target: Character): Boolean { + fun attackable(source: Character, target: Character, message: Boolean = true): Boolean { if (target is NPC) { if (target.id.startsWith("door_support") && NPCDefinitions.get(target.id).options[1] == "Destroy") { return true @@ -64,18 +64,20 @@ object Target { } if (source is Player && target is Player) { if (!source.inPvp && !source.inWilderness) { - source.message("You can only attack players in a player-vs-player area.") + if (message) source.message("You can only attack players in a player-vs-player area.") return false } if (!target.inPvp && !target.inWilderness) { - source.message("That player is not in the wilderness.") + if (message) source.message("That player is not in the wilderness.") return false } if (target.inWilderness) { val range = Wilderness.combatRange(source) if (target.combatLevel !in range) { - source.message("Your level difference is too great!") - source.message("You need to move deeper into the Wilderness.") + if (message) { + source.message("Your level difference is too great!") + source.message("You need to move deeper into the Wilderness.") + } return false } } @@ -85,16 +87,18 @@ object Target { } // If the target I'm trying to attack is already in combat and I am not the attacker if (target.inSingleCombat && target.underAttack && target.attacker != source) { - if (target is NPC) { - (source as? Player)?.message("Someone else is fighting that.") - } else { - (source as? Player)?.message("That player is already under attack.") + if (message) { + if (target is NPC) { + (source as? Player)?.message("Someone else is fighting that.") + } else { + (source as? Player)?.message("That player is already under attack.") + } } return false } // If I am already in combat and my attempted target is not my attacker if (source.inSingleCombat && source.underAttack && source.attacker != target) { - (source as? Player)?.message("You are already in combat.") + if (message) (source as? Player)?.message("You are already in combat.") return false } // PVP area, slayer requirements, in combat etc.. diff --git a/game/src/main/kotlin/content/entity/death/PlayerDeath.kt b/game/src/main/kotlin/content/entity/death/PlayerDeath.kt index 922bf23d97..da7c26bc6b 100644 --- a/game/src/main/kotlin/content/entity/death/PlayerDeath.kt +++ b/game/src/main/kotlin/content/entity/death/PlayerDeath.kt @@ -1,8 +1,9 @@ package content.entity.death import content.area.misthalin.lumbridge.church.Gravestone +import content.area.wilderness.inFullPvp import content.area.wilderness.inMultiCombat -import content.area.wilderness.inWilderness +import content.bot.isBot import content.entity.combat.* import content.entity.combat.Target import content.entity.combat.hit.directHit @@ -63,7 +64,6 @@ class PlayerDeath : Script { } val tile = tile.copy() set("death_tile", tile) - val wilderness = inWilderness retribution(player) wrath(player) message("Oh dear, you are dead!") @@ -79,7 +79,7 @@ class PlayerDeath : Script { dismissFamiliar() if (onDeath.dropItems) { val tile = instanceLogout() ?: tile - dropItems(player, killer, tile, wilderness) + dropItems(player, killer, tile) } levels.clear() runEnergy = MAX_RUN_ENERGY @@ -94,7 +94,7 @@ class PlayerDeath : Script { } } - fun dropItems(player: Player, killer: Character?, tile: Tile, inWilderness: Boolean) { + fun dropItems(player: Player, killer: Character?, tile: Tile) { if (player.isAdmin()) { return } @@ -108,16 +108,18 @@ class PlayerDeath : Script { } } + // inFullPvp covers wilderness + the Clan Wars FFA dangerous arena: no grave, drops go to the killer. + val pvpDrop = player.inFullPvp // Spawn grave val time = when { - inWilderness && killer is Player -> 0 + pvpDrop && killer is Player -> 0 tile in Areas["corporeal_beasts_lair"] -> TimeUnit.SECONDS.toTicks(210) else -> Gravestone.spawn(player, tile) } // Drop everything - drop(player, Item("bones"), tile, inWilderness, killer, time) - drop(player, player.inventory, tile, inWilderness, killer, time) - drop(player, player.equipment, tile, inWilderness, killer, time) + drop(player, Item("bones"), tile, pvpDrop, killer, time) + drop(player, player.inventory, tile, pvpDrop, killer, time) + drop(player, player.equipment, tile, pvpDrop, killer, time) // Clear everything player.inventory.clear() player.equipment.clear() @@ -148,10 +150,13 @@ class PlayerDeath : Script { ) { AuditLog.event(player, "lost", item) if (inWilderness && killer is Player) { + // PvP bot kills: drops stay private to the killer until despawn — never revealed to others. + // Real players keep the standard 180-tick private window before becoming public loot. + val reveal = if (player.isBot) FloorItems.NEVER else 180 if (item.tradeable) { - FloorItems.add(tile, item.id, item.amount, revealTicks = 180, disappearTicks = 240, owner = killer) + FloorItems.add(tile, item.id, item.amount, revealTicks = reveal, disappearTicks = 240, owner = killer) } else { - FloorItems.add(tile, "coins", item.amount * item.def.cost, revealTicks = 180, disappearTicks = 240, owner = killer) + FloorItems.add(tile, "coins", item.amount * item.def.cost, revealTicks = reveal, disappearTicks = 240, owner = killer) } } else { FloorItems.add(tile, item.id, item.amount, revealTicks = time, disappearTicks = time + 60, owner = player) diff --git a/game/src/main/kotlin/content/entity/player/command/PlayerCommands.kt b/game/src/main/kotlin/content/entity/player/command/PlayerCommands.kt index 6b2c238ee7..eec386ea89 100644 --- a/game/src/main/kotlin/content/entity/player/command/PlayerCommands.kt +++ b/game/src/main/kotlin/content/entity/player/command/PlayerCommands.kt @@ -149,9 +149,9 @@ class PlayerCommands( fun prayers(player: Player, args: List) { val target = Players.find(player, args.getOrNull(1)) ?: return - val name = args.getOrNull(0)?.removeSuffix("s") ?: "normal" - if (name == "regular" || name == "modern") { - player.message("Unknown prayer type '$name'. Did you mean 'normal'?", ChatType.Console) + val name = args.getOrNull(0) ?: "normal" + if (name !in setOf("normal", "curses")) { + player.message("Unknown prayer type '$name'. Expected 'normal' or 'curses'.", ChatType.Console) return } target[PRAYERS] = name diff --git a/game/src/main/kotlin/content/entity/player/kept/ItemsKeptOnDeath.kt b/game/src/main/kotlin/content/entity/player/kept/ItemsKeptOnDeath.kt index 36c4e299bd..2a95027005 100644 --- a/game/src/main/kotlin/content/entity/player/kept/ItemsKeptOnDeath.kt +++ b/game/src/main/kotlin/content/entity/player/kept/ItemsKeptOnDeath.kt @@ -1,8 +1,9 @@ package content.entity.player.kept -import content.area.wilderness.inWilderness +import content.area.wilderness.inFullPvp import content.entity.player.effect.skulled import content.skill.prayer.praying +import world.gregs.voidps.engine.data.definition.Areas import world.gregs.voidps.engine.data.definition.EnumDefinitions import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.item.Item @@ -19,7 +20,9 @@ object ItemsKeptOnDeath { .sortedByDescending { it.def.cost } fun kept(player: Player, items: List): List { - var save = if (player.skulled) 0 else 3 + // Dangerous-arena deaths use skull-equivalent drop rules without applying the visible skull. + val skullRules = player.skulled || player.tile in Areas["clan_wars_ffa_dangerous_arena"] + var save = if (skullRules) 0 else 3 if (player.praying("protect_item")) { save++ } @@ -42,7 +45,7 @@ object ItemsKeptOnDeath { continue } ItemKept.Always, ItemKept.Reclaim, ItemKept.Wilderness -> { - if (type == ItemKept.Wilderness && player.inWilderness) { + if (type == ItemKept.Wilderness && player.inFullPvp) { queue.pop() continue } diff --git a/game/src/main/kotlin/content/minigame/clan_wars/ClanWarsFreeForAll.kt b/game/src/main/kotlin/content/minigame/clan_wars/ClanWarsFreeForAll.kt index f8b7be9028..1e3d8fbe53 100644 --- a/game/src/main/kotlin/content/minigame/clan_wars/ClanWarsFreeForAll.kt +++ b/game/src/main/kotlin/content/minigame/clan_wars/ClanWarsFreeForAll.kt @@ -108,13 +108,13 @@ class ClanWarsFreeForAll : Script { playerDeath { if (tile !in Areas["clan_wars_ffa_safe_arena"]) return@playerDeath it.dropItems = false - it.teleport = outside + it.teleport = Areas["clan_wars_teleport"].random() } // On death in dangerous arena: drop items, respawn outside playerDeath { if (tile !in Areas["clan_wars_ffa_dangerous_arena"]) return@playerDeath - it.teleport = outside + it.teleport = Areas["clan_wars_teleport"].random() } } } diff --git a/game/src/main/kotlin/content/skill/prayer/Prayer.kt b/game/src/main/kotlin/content/skill/prayer/Prayer.kt index d2436fd500..2f0e064e14 100644 --- a/game/src/main/kotlin/content/skill/prayer/Prayer.kt +++ b/game/src/main/kotlin/content/skill/prayer/Prayer.kt @@ -45,7 +45,7 @@ object Prayer { if (!character.contains("turmoil")) { bonus += character.getLeech(skill) * 100.0 / character.levels.getMax(skill) / 100.0 } - bonus -= character.getBaseDrain(skill) + character.getDrain(skill) / 100.0 + bonus -= (character.getBaseDrain(skill) + character.getDrain(skill)) / 100.0 bonus } else -> 1.0 diff --git a/game/src/main/resources/game.properties b/game/src/main/resources/game.properties index e536082028..fce3db0b48 100644 --- a/game/src/main/resources/game.properties +++ b/game/src/main/resources/game.properties @@ -264,7 +264,7 @@ events.tearsOfGuthix.active=true #=================================== # The number of AI-controlled bots spawned on startup -bots.count=30 +bots.count=5 # Frequency between spawning bots on startup bots.spawnSeconds=60 @@ -296,6 +296,18 @@ bots.shortcuts=shortcuts.toml # File ending for bot navigation graph definitions bots.nav.definitions=nav-edges.toml +# Target population of combat bots inside the Clan Wars FFA Safe arena. 0 disables. +bots.combat.clan_wars_ffa_safe.count=0 + +# Seconds between combat bot spawns for the FFA Safe arena while ramping to count. +bots.combat.clan_wars_ffa_safe.spawnSeconds=10 + +# Target population of combat bots inside the Clan Wars FFA Dangerous arena. 0 disables. +bots.combat.clan_wars_ffa_dangerous.count=0 + +# Seconds between combat bot spawns for the FFA Dangerous arena while ramping to count. +bots.combat.clan_wars_ffa_dangerous.spawnSeconds=10 + #=================================== # Storage & File System #=================================== diff --git a/game/src/test/kotlin/content/bot/BotManagerTest.kt b/game/src/test/kotlin/content/bot/BotManagerTest.kt index 62ae2a5fba..3661401984 100644 --- a/game/src/test/kotlin/content/bot/BotManagerTest.kt +++ b/game/src/test/kotlin/content/bot/BotManagerTest.kt @@ -17,11 +17,18 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.PlayerLevels import world.gregs.voidps.type.setRandom class BotManagerTest { - fun testBot(vararg activities: BotActivity, name: String = "bot") = Bot(Player(accountName = name)).also { it.available.addAll(activities.map { a -> a.id }) } + fun testBot(vararg activities: BotActivity, name: String = "bot") = Bot(Player(accountName = name)).also { + // Real players link levels at login; mirror that here so BotSkillLevel.check (which reads + // levels.getMax) doesn't throw on the lateinit `level` property. + it.player.experience.player = it.player + it.player.levels.link(it.player, PlayerLevels(it.player.experience)) + it.available.addAll(activities.map { a -> a.id }) + } @BeforeEach fun setup() { diff --git a/game/src/test/kotlin/content/bot/BotUpdatesTest.kt b/game/src/test/kotlin/content/bot/BotUpdatesTest.kt index 64609b493f..55119411b6 100644 --- a/game/src/test/kotlin/content/bot/BotUpdatesTest.kt +++ b/game/src/test/kotlin/content/bot/BotUpdatesTest.kt @@ -25,6 +25,7 @@ import world.gregs.voidps.engine.entity.character.mode.move.Movement import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.entity.character.player.skill.exp.exp +import world.gregs.voidps.engine.entity.character.player.skill.level.Level import world.gregs.voidps.engine.entity.character.player.skill.level.PlayerLevels import world.gregs.voidps.engine.inv.add import world.gregs.voidps.engine.inv.inventory @@ -63,6 +64,10 @@ class BotUpdatesTest { manager.add(bot) assertTrue(bot.available.contains("kill_chickens")) + // BotSkillLevel.check reads max (XP-driven), so XP needs to grow past the cap, not just + // the boosted current level — otherwise drained Constitution/Prayer would knock pinned + // PvP bots out of their requires gate after every fight. + bot.player.experience.set(Skill.Attack, Level.experience(Skill.Attack, 10)) bot.levels.set(Skill.Attack, 10) manager.updateAvailable(bot) diff --git a/game/src/test/kotlin/content/bot/ClanWarsDangerousLoadoutCostTest.kt b/game/src/test/kotlin/content/bot/ClanWarsDangerousLoadoutCostTest.kt new file mode 100644 index 0000000000..2d8c88e8fe --- /dev/null +++ b/game/src/test/kotlin/content/bot/ClanWarsDangerousLoadoutCostTest.kt @@ -0,0 +1,117 @@ +package content.bot + +import WorldTest +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import world.gregs.config.Config +import world.gregs.voidps.engine.data.definition.ItemDefinitions +import world.gregs.voidps.engine.event.wildcardEquals + +class ClanWarsDangerousLoadoutCostTest : WorldTest() { + + @Test + fun `Dangerous Clan Wars FFA bots carry no item with cache cost above 1M`() { + val templateItems = mutableMapOf>() + Config.fileReader(TEMPLATES_PATH) { + while (nextSection()) { + val id = section() + val items = mutableSetOf() + while (nextPair()) { + when (key()) { + "setup" -> collectItems(value(), items) + "loadouts" -> collectLoadoutItems(value(), items) + else -> value() + } + } + templateItems[id] = items + } + } + + val violations = mutableListOf() + Config.fileReader(BOTS_PATH) { + while (nextSection()) { + val tierId = section() + val isDangerous = tierId.startsWith("clan_wars_ffa_dangerous_") + val tierItems = mutableSetOf() + var template: String? = null + while (nextPair()) { + when (key()) { + "template" -> template = string() + "setup" -> if (isDangerous) collectItems(value(), tierItems) else value() + else -> value() + } + } + if (!isDangerous) continue + val items = tierItems.toMutableSet() + if (template != null) { + items += templateItems[template] ?: error("Missing template '$template' for tier '$tierId'") + } + for (pattern in items) { + for ((id, cost) in resolve(pattern)) { + if (cost > MAX_COST) { + violations += "tier=$tierId item=$id cost=$cost (pattern=$pattern)" + } + } + } + } + } + + assertTrue(violations.isEmpty()) { + "Dangerous-arena bots must not carry items with def.cost > $MAX_COST. Violations:\n" + + violations.joinToString("\n") + } + } + + @Suppress("UNCHECKED_CAST") + private fun collectItems(setup: Any?, items: MutableSet) { + val list = setup as? List> ?: return + for (entry in list) { + (entry["equipment"] as? Map)?.let { equipment -> + for ((_, slot) in equipment) { + ((slot as? Map<*, *>)?.get("id") as? String)?.let(items::add) + } + } + (entry["inventory"] as? List<*>)?.let { inv -> + for (slot in inv) { + ((slot as? Map<*, *>)?.get("id") as? String)?.let(items::add) + } + } + } + } + + @Suppress("UNCHECKED_CAST") + private fun collectLoadoutItems(loadouts: Any?, items: MutableSet) { + val map = loadouts as? Map ?: return + for ((_, raw) in map) { + val entry = raw as? Map ?: continue + collectItems(listOf(entry), items) + } + } + + private fun resolve(pattern: String): List> { + val results = mutableListOf>() + for (token in pattern.split(',')) { + if (token.contains('*') || token.contains('#')) { + var matched = false + for (stringId in ItemDefinitions.ids.keys) { + if (wildcardEquals(token, stringId)) { + matched = true + results += stringId to ItemDefinitions.get(stringId).cost + } + } + check(matched) { "Wildcard '$token' matched no items" } + } else { + val def = ItemDefinitions.get(token) + check(def.id != -1) { "Unknown item id '$token'" } + results += token to def.cost + } + } + return results + } + + companion object { + private const val MAX_COST = 1_000_000 + private const val TEMPLATES_PATH = "../data/bot/minigame_combat.templates.toml" + private const val BOTS_PATH = "../data/minigame/clan_wars/clan_wars.bots.toml" + } +} diff --git a/game/src/test/kotlin/content/bot/FakeBehaviour.kt b/game/src/test/kotlin/content/bot/FakeBehaviour.kt index 1cd74eb7c1..30a1fb5a3b 100644 --- a/game/src/test/kotlin/content/bot/FakeBehaviour.kt +++ b/game/src/test/kotlin/content/bot/FakeBehaviour.kt @@ -10,5 +10,6 @@ class FakeBehaviour : Behaviour { override val requires: List = emptyList() override val setup: List = emptyList() override val actions: List = emptyList() + override val reactive: List = emptyList() override val produces: Set = emptySet() } diff --git a/game/src/test/kotlin/content/bot/behaviour/action/BotCastVengeanceTest.kt b/game/src/test/kotlin/content/bot/behaviour/action/BotCastVengeanceTest.kt new file mode 100644 index 0000000000..a512410149 --- /dev/null +++ b/game/src/test/kotlin/content/bot/behaviour/action/BotCastVengeanceTest.kt @@ -0,0 +1,78 @@ +package content.bot.behaviour.action + +import content.bot.Bot +import content.bot.FakeBehaviour +import content.bot.FakeWorld +import content.bot.behaviour.BehaviourFrame +import content.bot.behaviour.BehaviourState +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.client.variable.start +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level +import world.gregs.voidps.engine.entity.character.player.skill.level.PlayerLevels +import world.gregs.voidps.engine.timer.epochSeconds + +class BotCastVengeanceTest { + + private lateinit var bot: Bot + private lateinit var player: Player + + @BeforeEach + fun setup() { + player = Player() + bot = Bot(player) + player.experience.player = player + player.levels.link(player, PlayerLevels(player.experience)) + player.experience.set(Skill.Magic, Level.experience(Skill.Magic, 94)) + player.levels.set(Skill.Magic, 94) + } + + @Test + fun `Skips when magic below 94`() { + player.levels.set(Skill.Magic, 93) + var called = false + val world = FakeWorld(execute = { _, _ -> + called = true + true + }) + + val state = BotCastVengeance.update(bot, world, BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Success, state) + assertFalse(called) + } + + @Test + fun `Skips when vengeance already cast`() { + player.set("vengeance", true) + var called = false + val world = FakeWorld(execute = { _, _ -> + called = true + true + }) + + val state = BotCastVengeance.update(bot, world, BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Success, state) + assertFalse(called) + } + + @Test + fun `Skips while vengeance_delay cooldown active`() { + player.start("vengeance_delay", 30, epochSeconds()) + var called = false + val world = FakeWorld(execute = { _, _ -> + called = true + true + }) + + val state = BotCastVengeance.update(bot, world, BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Success, state) + assertFalse(called) + } +} diff --git a/game/src/test/kotlin/content/bot/behaviour/action/BotDrinkPotionTest.kt b/game/src/test/kotlin/content/bot/behaviour/action/BotDrinkPotionTest.kt new file mode 100644 index 0000000000..1d120331a7 --- /dev/null +++ b/game/src/test/kotlin/content/bot/behaviour/action/BotDrinkPotionTest.kt @@ -0,0 +1,196 @@ +package content.bot.behaviour.action + +import content.bot.Bot +import content.bot.FakeBehaviour +import content.bot.FakeWorld +import content.bot.behaviour.BehaviourFrame +import content.bot.behaviour.BehaviourState +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import world.gregs.voidps.cache.config.data.InventoryDefinition +import world.gregs.voidps.cache.definition.data.ItemDefinition +import world.gregs.voidps.engine.client.variable.start +import world.gregs.voidps.engine.data.definition.ItemDefinitions +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level +import world.gregs.voidps.engine.entity.character.player.skill.level.PlayerLevels +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.restrict.ValidItemRestriction +import world.gregs.voidps.engine.inv.stack.ItemDependentStack +import world.gregs.voidps.network.client.instruction.InteractInterface + +class BotDrinkPotionTest { + + private lateinit var bot: Bot + private lateinit var player: Player + + @BeforeEach + fun setup() { + player = Player() + bot = Bot(player) + player.experience.player = player + player.levels.link(player, PlayerLevels(player.experience)) + player.inventories.validItemRule = ValidItemRestriction() + player.inventories.player = player + player.inventories.normalStack = ItemDependentStack + player.inventories.inventory(InventoryDefinition(stringId = "inventory", length = 4)) + ItemDefinitions.set( + arrayOf( + ItemDefinition(id = 200, options = arrayOf("Drink")), + ItemDefinition(id = 201, options = arrayOf("Drink")), + ItemDefinition(id = 300, options = arrayOf("Wield")), + ItemDefinition(id = 400, options = arrayOf("Drink")), + ItemDefinition(id = 500, options = arrayOf("Drink")), + ), + mapOf( + "super_strength_4" to 0, + "super_strength_3" to 1, + "whip" to 2, + "saradomin_brew_4" to 3, + "super_restore_4" to 4, + ), + ) + player.experience.set(Skill.Strength, Level.experience(Skill.Strength, 80)) + player.levels.set(Skill.Strength, 80) + } + + @AfterEach + fun teardown() { + ItemDefinitions.clear() + } + + @Test + fun `Drinks dose when boost is fully decayed`() { + player.inventory.add("super_strength_4") + var instruction: InteractInterface? = null + val world = FakeWorld(execute = { _, ins -> + instruction = ins as? InteractInterface + true + }) + + val state = BotDrinkPotion(item = "super_strength_*", skill = Skill.Strength) + .update(bot, world, BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Wait(1, BehaviourState.Running), state) + assertEquals(200, instruction?.itemId) + } + + @Test + fun `Skips drinking while boost is still active`() { + player.inventory.add("super_strength_4") + player.levels.set(Skill.Strength, 95) + var called = false + val world = FakeWorld(execute = { _, _ -> + called = true + true + }) + + val state = BotDrinkPotion(item = "super_strength_*", skill = Skill.Strength) + .update(bot, world, BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Success, state) + assertFalse(called) + } + + @Test + fun `No-ops when no matching dose remains`() { + player.inventory.add("whip") + var called = false + val world = FakeWorld(execute = { _, _ -> + called = true + true + }) + + val state = BotDrinkPotion(item = "super_strength_*", skill = Skill.Strength) + .update(bot, world, BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Success, state) + assertFalse(called) + } + + @Test + fun `Skips drinking while drink_delay clock active`() { + player.inventory.add("super_strength_4") + player.start("drink_delay", 2) + var called = false + val world = FakeWorld(execute = { _, _ -> + called = true + true + }) + + val state = BotDrinkPotion(item = "super_strength_*", skill = Skill.Strength) + .update(bot, world, BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Success, state) + assertFalse(called) + } + + @Test + fun `Wildcard matches lower-dose variants for re-dosing`() { + player.inventory.add("super_strength_3") + var instruction: InteractInterface? = null + val world = FakeWorld(execute = { _, ins -> + instruction = ins as? InteractInterface + true + }) + + val state = BotDrinkPotion(item = "super_strength_*", skill = Skill.Strength) + .update(bot, world, BehaviourFrame(FakeBehaviour())) + + assertTrue(state is BehaviourState.Wait) + assertEquals(201, instruction?.itemId) + } + + @Test + fun `Drinking saradomin brew increments brew_doses_since_restore`() { + player.inventory.add("saradomin_brew_4") + val world = FakeWorld(execute = { _, _ -> true }) + + BotDrinkPotion(item = "saradomin_brew_*", skill = Skill.Constitution) + .update(bot, world, BehaviourFrame(FakeBehaviour())) + + assertEquals(1, player.get("brew_doses_since_restore")) + } + + @Test + fun `Multiple brew drinks accumulate the counter`() { + player["brew_doses_since_restore"] = 2 + player.inventory.add("saradomin_brew_4") + val world = FakeWorld(execute = { _, _ -> true }) + + BotDrinkPotion(item = "saradomin_brew_*", skill = Skill.Constitution) + .update(bot, world, BehaviourFrame(FakeBehaviour())) + + assertEquals(3, player.get("brew_doses_since_restore")) + } + + @Test + fun `Drinking super_restore zeroes the brew counter`() { + player["brew_doses_since_restore"] = 4 + player.inventory.add("super_restore_4") + val world = FakeWorld(execute = { _, _ -> true }) + + BotDrinkPotion(item = "super_restore_*", skill = Skill.Strength) + .update(bot, world, BehaviourFrame(FakeBehaviour())) + + assertEquals(0, player.get("brew_doses_since_restore")) + } + + @Test + fun `Drinking an unrelated potion leaves the brew counter unchanged`() { + player["brew_doses_since_restore"] = 2 + player.inventory.add("super_strength_4") + val world = FakeWorld(execute = { _, _ -> true }) + + BotDrinkPotion(item = "super_strength_*", skill = Skill.Strength) + .update(bot, world, BehaviourFrame(FakeBehaviour())) + + assertEquals(2, player.get("brew_doses_since_restore")) + } +} diff --git a/game/src/test/kotlin/content/bot/behaviour/action/BotFightPlayerTest.kt b/game/src/test/kotlin/content/bot/behaviour/action/BotFightPlayerTest.kt new file mode 100644 index 0000000000..7fb5d20cde --- /dev/null +++ b/game/src/test/kotlin/content/bot/behaviour/action/BotFightPlayerTest.kt @@ -0,0 +1,398 @@ +package content.bot.behaviour.action + +import content.bot.Bot +import content.bot.FakeBehaviour +import content.bot.FakeWorld +import content.bot.behaviour.BehaviourFrame +import content.bot.behaviour.BehaviourState +import content.bot.behaviour.Reason +import content.bot.behaviour.condition.BotHasClock +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import world.gregs.voidps.cache.config.data.InventoryDefinition +import world.gregs.voidps.cache.definition.data.ItemDefinition +import world.gregs.voidps.engine.client.variable.hasClock +import world.gregs.voidps.engine.client.variable.start +import world.gregs.voidps.engine.data.definition.ItemDefinitions +import world.gregs.voidps.engine.entity.character.mode.interact.PlayerOnPlayerInteract +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level +import world.gregs.voidps.engine.entity.character.player.skill.level.PlayerLevels +import world.gregs.voidps.engine.entity.item.floor.FloorItems +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.restrict.ValidItemRestriction +import world.gregs.voidps.engine.inv.stack.ItemDependentStack +import world.gregs.voidps.network.client.instruction.InteractFloorItem +import world.gregs.voidps.network.client.instruction.InteractInterface + +class BotFightPlayerTest { + + private lateinit var bot: Bot + private lateinit var player: Player + + @BeforeEach + fun setup() { + FloorItems.clear() + player = Player() + bot = Bot(player) + player.experience.player = player + player.levels.link(player, PlayerLevels(player.experience)) + } + + private fun initInventory(length: Int = 28) { + player.inventories.validItemRule = ValidItemRestriction() + player.inventories.player = player + player.inventories.normalStack = ItemDependentStack + player.inventories.inventory(InventoryDefinition(stringId = "inventory", length = length)) + } + + @Test + fun `Low hp triggers eat and returns wait`() { + initInventory(length = 2) + player.levels.set(Skill.Constitution, 5) + player.experience.set(Skill.Constitution, Level.experience(Skill.Constitution, 100)) + + ItemDefinitions.set( + arrayOf(ItemDefinition(id = 100, options = arrayOf("Eat"))), + mapOf("fish" to 0), + ) + player.inventory.add("fish") + + var called = false + val world = FakeWorld( + execute = { _, instruction -> + called = instruction is InteractInterface + true + }, + ) + + val action = BotFightPlayer() + + val state = action.update(bot, world, BehaviourFrame(FakeBehaviour())) + + assertTrue(called) + assertEquals(BehaviourState.Wait(1, BehaviourState.Running), state) + } + + @Test + fun `Eat fails if execution invalid`() { + initInventory(length = 2) + player.levels.set(Skill.Constitution, 5) + player.experience.set(Skill.Constitution, Level.experience(Skill.Constitution, 100)) + + ItemDefinitions.set( + arrayOf(ItemDefinition(id = 100, options = arrayOf("Eat"))), + mapOf("fish" to 0), + ) + player.inventory.add("fish") + + val world = FakeWorld(execute = { _, _ -> false }) + + val action = BotFightPlayer() + + val state = action.update(bot, world, BehaviourFrame(FakeBehaviour())) + + assertTrue(state is BehaviourState.Failed) + } + + @Test + fun `Success condition returns success`() { + player.start("true", 10) + val action = BotFightPlayer(success = BotHasClock("true")) + + val state = action.update(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Success, state) + } + + @Test + fun `PlayerOnPlayerInteract with no success returns success`() { + val mock = mockk(relaxed = true) + bot.mode = mock + + val action = BotFightPlayer() + + val state = action.update(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Success, state) + } + + @Test + fun `Loot valid floor item`() { + initInventory() + ItemDefinitions.set(arrayOf(ItemDefinition(cost = 1000, floorOptions = arrayOf("Take"), stackable = 1)), mapOf("coins" to 0)) + FloorItems.add(player.tile, "coins", 100, owner = player.accountName) + FloorItems.run() + player.start("loot_pending", 60) + player["loot_drop_tile"] = player.tile.id + + var called = false + val world = FakeWorld( + execute = { _, instruction -> + called = instruction is InteractFloorItem + true + }, + ) + + val action = BotFightPlayer(lootOverValue = 0) + + val state = action.update(bot, world, BehaviourFrame(FakeBehaviour())) + + assertTrue(called) + assertEquals(BehaviourState.Running, state) + } + + @Test + fun `Wealth strategy loots highest value item first`() { + initInventory() + ItemDefinitions.set( + arrayOf( + ItemDefinition(id = 200, cost = 100, floorOptions = arrayOf("Take"), stackable = 1), + ItemDefinition(id = 201, cost = 5_000, floorOptions = arrayOf("Take"), stackable = 1), + ), + mapOf("trinket" to 0, "rune" to 1), + ) + FloorItems.add(player.tile, "trinket", 1, owner = player.accountName) + FloorItems.add(player.tile, "rune", 1, owner = player.accountName) + FloorItems.run() + player.start("loot_pending", 60) + player["loot_drop_tile"] = player.tile.id + + var pickedId = -1 + val world = FakeWorld( + execute = { _, instruction -> + if (instruction is InteractFloorItem) { + pickedId = instruction.id + } + true + }, + ) + + val action = BotFightPlayer(lootStrategy = BotLootStrategy.WEALTH) + + action.update(bot, world, BehaviourFrame(FakeBehaviour())) + + assertEquals(201, pickedId) + } + + @Test + fun `Survival strategy skips non-consumable loot`() { + initInventory() + ItemDefinitions.set( + arrayOf(ItemDefinition(id = 300, cost = 1_000, floorOptions = arrayOf("Take"), stackable = 1)), + mapOf("rune_axe" to 0), + ) + FloorItems.add(player.tile, "rune_axe", 1, owner = player.accountName) + FloorItems.run() + player.start("loot_pending", 60) + player["loot_drop_tile"] = player.tile.id + + var called = false + val world = FakeWorld( + execute = { _, instruction -> + if (instruction is InteractFloorItem) called = true + true + }, + ) + + val action = BotFightPlayer(lootStrategy = BotLootStrategy.SURVIVAL) + + action.update(bot, world, BehaviourFrame(FakeBehaviour())) + + assertTrue(!called) + } + + @Test + fun `Survival strategy loots edible items`() { + initInventory() + ItemDefinitions.set( + arrayOf( + ItemDefinition(id = 400, cost = 10, floorOptions = arrayOf("Take"), options = arrayOf("Eat"), stackable = 0), + ItemDefinition(id = 401, cost = 9_999, floorOptions = arrayOf("Take"), stackable = 1), + ), + mapOf("shark" to 0, "diamond" to 1), + ) + FloorItems.add(player.tile, "shark", 1, owner = player.accountName) + FloorItems.add(player.tile, "diamond", 1, owner = player.accountName) + FloorItems.run() + player.start("loot_pending", 60) + player["loot_drop_tile"] = player.tile.id + + var pickedId = -1 + val world = FakeWorld( + execute = { _, instruction -> + if (instruction is InteractFloorItem) { + pickedId = instruction.id + } + true + }, + ) + + val action = BotFightPlayer(lootStrategy = BotLootStrategy.SURVIVAL) + + action.update(bot, world, BehaviourFrame(FakeBehaviour())) + + assertEquals(400, pickedId) + } + + @Test + fun `Survival strategy loots potions identified by categories`() { + initInventory() + ItemDefinitions.set( + arrayOf( + ItemDefinition( + id = 500, + cost = 50, + floorOptions = arrayOf("Take"), + options = arrayOf(null), + params = mapOf(5019 to setOf("potion")), + stackable = 0, + ), + ), + mapOf("super_restore_4" to 0), + ) + FloorItems.add(player.tile, "super_restore_4", 1, owner = player.accountName) + FloorItems.run() + player.start("loot_pending", 60) + player["loot_drop_tile"] = player.tile.id + + var called = false + val world = FakeWorld( + execute = { _, instruction -> + if (instruction is InteractFloorItem) called = true + true + }, + ) + + val action = BotFightPlayer(lootStrategy = BotLootStrategy.SURVIVAL) + + action.update(bot, world, BehaviourFrame(FakeBehaviour())) + + assertTrue(called) + } + + @Test + fun `Loot stops when inventory has only one free slot left`() { + initInventory(length = 4) + ItemDefinitions.set( + arrayOf( + ItemDefinition(id = 600, cost = 100, floorOptions = arrayOf("Take"), stackable = 1), + ItemDefinition(id = 601, cost = 200, floorOptions = arrayOf("Take"), stackable = 1), + ItemDefinition(id = 602, cost = 300, floorOptions = arrayOf("Take"), stackable = 1), + ItemDefinition(id = 603, cost = 1_000, floorOptions = arrayOf("Take"), stackable = 1), + ), + mapOf("a" to 0, "b" to 1, "c" to 2, "drop" to 3), + ) + player.inventory.add("a") + player.inventory.add("b") + player.inventory.add("c") + FloorItems.add(player.tile, "drop", 1, owner = player.accountName) + FloorItems.run() + player.start("loot_pending", 60) + player["loot_drop_tile"] = player.tile.id + + var called = false + val world = FakeWorld( + execute = { _, instruction -> + if (instruction is InteractFloorItem) called = true + true + }, + ) + + val action = BotFightPlayer() + + action.update(bot, world, BehaviourFrame(FakeBehaviour())) + + assertTrue(!called) + assertTrue(!player.hasClock("loot_pending")) + } + + @Test + fun `Full inventory still tops up an existing stackable pile`() { + initInventory(length = 4) + ItemDefinitions.set( + arrayOf( + ItemDefinition(id = 700, cost = 100, floorOptions = arrayOf("Take"), stackable = 1), + ItemDefinition(id = 701, cost = 100, floorOptions = arrayOf("Take"), stackable = 1), + ItemDefinition(id = 702, cost = 100, floorOptions = arrayOf("Take"), stackable = 1), + ), + mapOf("coins" to 0, "a" to 1, "b" to 2), + ) + player.inventory.add("coins", amount = 50) + player.inventory.add("a") + player.inventory.add("b") + FloorItems.add(player.tile, "coins", 100, owner = player.accountName) + FloorItems.run() + player.start("loot_pending", 60) + player["loot_drop_tile"] = player.tile.id + + var pickedId = -1 + val world = FakeWorld( + execute = { _, instruction -> + if (instruction is InteractFloorItem) { + pickedId = instruction.id + } + true + }, + ) + + val action = BotFightPlayer() + + action.update(bot, world, BehaviourFrame(FakeBehaviour())) + + assertEquals(700, pickedId) + } + + @Test + fun `Empty ground clears the loot_pending clock`() { + initInventory() + player.start("loot_pending", 60) + player["loot_drop_tile"] = player.tile.id + + val action = BotFightPlayer() + + action.update(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertTrue(!player.hasClock("loot_pending")) + } + + @Test + fun `No target without success returns failed`() { + val action = BotFightPlayer() + + val state = action.update(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Failed(Reason.NoTarget), state) + } + + @Test + fun `No target with success and delay returns wait`() { + val action = BotFightPlayer( + success = BotHasClock("false"), + delay = 5, + ) + + val state = action.update(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertEquals( + BehaviourState.Wait(5, BehaviourState.Running), + state, + ) + } + + @Test + fun `No target with success and no delay returns running`() { + val action = BotFightPlayer( + success = BotHasClock("false"), + ) + + val state = action.update(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Running, state) + } +} diff --git a/game/src/test/kotlin/content/bot/behaviour/action/BotInteractObjectTest.kt b/game/src/test/kotlin/content/bot/behaviour/action/BotInteractObjectTest.kt index 1658396d0f..c2942ebc59 100644 --- a/game/src/test/kotlin/content/bot/behaviour/action/BotInteractObjectTest.kt +++ b/game/src/test/kotlin/content/bot/behaviour/action/BotInteractObjectTest.kt @@ -93,4 +93,52 @@ class BotInteractObjectTest { assertEquals(BehaviourState.Failed(Reason.NoTarget), state) } + + @Test + fun `Skips action when if-condition is false`() { + ObjectDefinitions.set( + arrayOf(ObjectDefinition(id = 0, options = arrayOf("Open"), stringId = "door")), + mapOf("door" to 0), + ) + GameObjects.add("door", player.tile) + + var called = false + val world = FakeWorld(execute = { _, _ -> + called = true + true + }) + + // BotHasClock("never_set") returns false -> action should Success-skip without searching. + val action = BotInteractObject("Open", "door", condition = BotHasClock("never_set")) + + val state = action.update(bot, world, BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Success, state) + assertFalse(called) + } + + @Test + fun `Runs action when if-condition is true`() { + ObjectDefinitions.set( + arrayOf(ObjectDefinition(id = 0, options = arrayOf("Open"), stringId = "door")), + mapOf("door" to 0), + ) + player.mode = EmptyMode + player.tile = Tile(1234, 1234) + GameObjects.add("door", player.tile) + player.start("ready", 10) + + var called = false + val world = FakeWorld(execute = { _, instruction -> + called = instruction is InteractObject + true + }) + + val action = BotInteractObject("Open", "door", condition = BotHasClock("ready")) + + val state = action.update(bot, world, BehaviourFrame(FakeBehaviour())) + + assertTrue(called) + assertEquals(BehaviourState.Running, state) + } } diff --git a/game/src/test/kotlin/content/bot/behaviour/action/BotPrayTest.kt b/game/src/test/kotlin/content/bot/behaviour/action/BotPrayTest.kt new file mode 100644 index 0000000000..c11fecad62 --- /dev/null +++ b/game/src/test/kotlin/content/bot/behaviour/action/BotPrayTest.kt @@ -0,0 +1,228 @@ +package content.bot.behaviour.action + +import content.bot.Bot +import content.bot.FakeBehaviour +import content.bot.FakeWorld +import content.bot.behaviour.BehaviourFrame +import content.bot.behaviour.BehaviourState +import content.bot.behaviour.Reason +import content.bot.behaviour.condition.BotSkillLevel +import content.skill.prayer.PrayerConfigs +import content.skill.prayer.isCurses +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import world.gregs.voidps.engine.client.variable.BitwiseValues +import world.gregs.voidps.engine.data.config.PrayerDefinition +import world.gregs.voidps.engine.data.config.VariableDefinition +import world.gregs.voidps.engine.data.definition.PrayerDefinitions +import world.gregs.voidps.engine.data.definition.VariableDefinitions +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level +import world.gregs.voidps.engine.entity.character.player.skill.level.PlayerLevels + +class BotPrayTest { + + private lateinit var bot: Bot + private lateinit var player: Player + private lateinit var prayerDefinitions: PrayerDefinitions + + @BeforeEach + fun setup() { + player = Player() + bot = Bot(player) + player.experience.player = player + player.levels.link(player, PlayerLevels(player.experience)) + + VariableDefinitions.set( + mapOf( + PrayerConfigs.ACTIVE_PRAYERS to VariableDefinition.VarbitDefinition( + id = 0, + values = BitwiseValues(listOf("protect_from_melee", "piety")), + default = null, + persistent = false, + transmit = false, + ), + PrayerConfigs.ACTIVE_CURSES to VariableDefinition.VarbitDefinition( + id = 1, + values = BitwiseValues(listOf("turmoil", "soul_split", "berserker", "deflect_melee")), + default = null, + persistent = false, + transmit = false, + ), + PrayerConfigs.PRAYERS to VariableDefinition.CustomVariableDefinition( + values = world.gregs.voidps.engine.client.variable.StringValues, + default = "normal", + persistent = false, + ), + ), + ) + + prayerDefinitions = PrayerDefinitions().apply { + definitions = mapOf( + "protect_from_melee" to PrayerDefinition(index = 17, level = 43, stringId = "protect_from_melee"), + "piety" to PrayerDefinition(index = 25, level = 70, stringId = "piety"), + "turmoil" to PrayerDefinition(index = 19, level = 95, isCurse = true, stringId = "turmoil"), + "deflect_melee" to PrayerDefinition(index = 9, level = 71, isCurse = true, stringId = "deflect_melee"), + ) + } + startKoin { + modules( + module { + single { prayerDefinitions } + }, + ) + } + } + + @AfterEach + fun stop() { + stopKoin() + VariableDefinitions.clear() + } + + @Test + fun `Activates prayer and returns success`() { + player.experience.set(Skill.Prayer, Level.experience(Skill.Prayer, 70)) + player.levels.set(Skill.Prayer, 70) + + val state = BotPray("protect_from_melee").update(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Success, state) + assertTrue(player.containsVarbit(PrayerConfigs.ACTIVE_PRAYERS, "protect_from_melee")) + } + + @Test + fun `Already active prayer short-circuits to success`() { + player.experience.set(Skill.Prayer, Level.experience(Skill.Prayer, 70)) + player.levels.set(Skill.Prayer, 70) + player.addVarbit(PrayerConfigs.ACTIVE_PRAYERS, "protect_from_melee", refresh = false) + + val state = BotPray("protect_from_melee").update(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Success, state) + } + + @Test + fun `Insufficient level fails`() { + player.experience.set(Skill.Prayer, Level.experience(Skill.Prayer, 40)) + player.levels.set(Skill.Prayer, 40) + + val state = BotPray("protect_from_melee").update(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertTrue(state is BehaviourState.Failed) + assertTrue((state as BehaviourState.Failed).reason is Reason.Invalid) + } + + @Test + fun `No prayer points returns success silently to avoid spamming reactive failures`() { + player.experience.set(Skill.Prayer, Level.experience(Skill.Prayer, 70)) + player.levels.set(Skill.Prayer, 70) + player.levels.drain(Skill.Prayer, 70) + + val state = BotPray("protect_from_melee").update(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Success, state) + assertFalse(player.containsVarbit(PrayerConfigs.ACTIVE_PRAYERS, "protect_from_melee")) + } + + @Test + fun `Unknown prayer id fails`() { + player.experience.set(Skill.Prayer, Level.experience(Skill.Prayer, 99)) + player.levels.set(Skill.Prayer, 99) + + val state = BotPray("not_a_prayer").update(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertTrue(state is BehaviourState.Failed) + } + + @Test + fun `Gated condition true activates prayer`() { + player.experience.set(Skill.Prayer, Level.experience(Skill.Prayer, 70)) + player.levels.set(Skill.Prayer, 70) + + val state = BotPray("protect_from_melee", BotSkillLevel(Skill.Prayer, min = 1)).update(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Success, state) + assertTrue(player.containsVarbit(PrayerConfigs.ACTIVE_PRAYERS, "protect_from_melee")) + } + + @Test + fun `Gated condition false deactivates prayer when on`() { + player.experience.set(Skill.Prayer, Level.experience(Skill.Prayer, 70)) + player.levels.set(Skill.Prayer, 70) + player.addVarbit(PrayerConfigs.ACTIVE_PRAYERS, "protect_from_melee", refresh = false) + + val state = BotPray("protect_from_melee", BotSkillLevel(Skill.Prayer, min = 9999)).update(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Success, state) + assertFalse(player.containsVarbit(PrayerConfigs.ACTIVE_PRAYERS, "protect_from_melee")) + } + + @Test + fun `Gated condition false leaves prayer off`() { + player.experience.set(Skill.Prayer, Level.experience(Skill.Prayer, 70)) + player.levels.set(Skill.Prayer, 70) + + val state = BotPray("protect_from_melee", BotSkillLevel(Skill.Prayer, min = 9999)).update(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Success, state) + assertFalse(player.containsVarbit(PrayerConfigs.ACTIVE_PRAYERS, "protect_from_melee")) + } + + @Test + fun `Gated no prayer points returns success silently`() { + player.experience.set(Skill.Prayer, Level.experience(Skill.Prayer, 70)) + player.levels.set(Skill.Prayer, 70) + player.levels.drain(Skill.Prayer, 70) + + val state = BotPray("protect_from_melee", BotSkillLevel(Skill.Prayer, min = 1)).update(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Success, state) + assertFalse(player.containsVarbit(PrayerConfigs.ACTIVE_PRAYERS, "protect_from_melee")) + } + + @Test + fun `Curse activation switches prayer book and writes to active curses`() { + player.experience.set(Skill.Prayer, Level.experience(Skill.Prayer, 95)) + player.levels.set(Skill.Prayer, 95) + + val state = BotPray("turmoil").update(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Success, state) + assertEquals("curses", player.get(PrayerConfigs.PRAYERS, "")) + assertTrue(player.containsVarbit(PrayerConfigs.ACTIVE_CURSES, "turmoil")) + assertFalse(player.containsVarbit(PrayerConfigs.ACTIVE_PRAYERS, "turmoil")) + } + + @Test + fun `Normal prayer in curses mode flips back to normal book`() { + player.experience.set(Skill.Prayer, Level.experience(Skill.Prayer, 70)) + player.levels.set(Skill.Prayer, 70) + player[PrayerConfigs.PRAYERS] = "curses" + + val state = BotPray("piety").update(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Success, state) + assertFalse(player.isCurses()) + assertTrue(player.containsVarbit(PrayerConfigs.ACTIVE_PRAYERS, "piety")) + } + + @Test + fun `Insufficient curse level still switches book then fails`() { + player.experience.set(Skill.Prayer, Level.experience(Skill.Prayer, 80)) + player.levels.set(Skill.Prayer, 80) + + val state = BotPray("turmoil").update(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertTrue(state is BehaviourState.Failed) + assertEquals("curses", player.get(PrayerConfigs.PRAYERS, "")) + } +} diff --git a/game/src/test/kotlin/content/bot/behaviour/action/BotRetreatTest.kt b/game/src/test/kotlin/content/bot/behaviour/action/BotRetreatTest.kt new file mode 100644 index 0000000000..e614741947 --- /dev/null +++ b/game/src/test/kotlin/content/bot/behaviour/action/BotRetreatTest.kt @@ -0,0 +1,131 @@ +package content.bot.behaviour.action + +import content.bot.Bot +import content.bot.FakeBehaviour +import content.bot.FakeWorld +import content.bot.behaviour.BehaviourFrame +import content.bot.behaviour.BehaviourState +import content.bot.behaviour.Reason +import content.bot.behaviour.condition.BotHasClock +import content.entity.combat.dead +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.AreaDefinition +import world.gregs.voidps.engine.data.definition.Areas +import world.gregs.voidps.engine.entity.character.mode.EmptyMode +import world.gregs.voidps.engine.entity.character.mode.PauseMode +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level +import world.gregs.voidps.engine.entity.character.player.skill.level.PlayerLevels +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.area.Rectangle + +class BotRetreatTest { + + private lateinit var player: Player + private lateinit var bot: Bot + + @BeforeEach + fun setup() { + Areas.clear() + player = Player(tile = Tile(1234, 1234)) + bot = Bot(player) + player.experience.player = player + player.levels.link(player, PlayerLevels(player.experience)) + player.experience.set(Skill.Constitution, Level.experience(Skill.Constitution, 99)) + player.levels.set(Skill.Constitution, 99) + } + + @Test + fun `Gated condition false short-circuits success`() { + Areas.set(mapOf("lobby" to AreaDefinition("lobby", Rectangle(999, 999, 999, 999)))) + + val action = BotRetreat("lobby", regroupHpPercent = 70, condition = BotHasClock(id = "never_set")) + + val state = action.start(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Success, state) + } + + @Test + fun `Start fails if area missing`() { + val action = BotRetreat("missing", regroupHpPercent = 70) + + val state = action.start(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertEquals( + BehaviourState.Failed(Reason.Invalid("No areas found with id 'missing'.")), + state, + ) + } + + @Test + fun `In safe area and regrouped returns success`() { + Areas.set(mapOf("lobby" to AreaDefinition("lobby", player.tile.toCuboid(2)))) + + val action = BotRetreat("lobby", regroupHpPercent = 70) + + val state = action.start(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Success, state) + } + + @Test + fun `In safe area but below regroup threshold returns running`() { + Areas.set(mapOf("lobby" to AreaDefinition("lobby", player.tile.toCuboid(2)))) + player.levels.drain(Skill.Constitution, 70) + + val action = BotRetreat("lobby", regroupHpPercent = 70) + + val state = action.start(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Running, state) + } + + @Test + fun `Start breaks combat mode and returns running`() { + Areas.set(mapOf("lobby" to AreaDefinition("lobby", Rectangle(999, 999, 999, 999)))) + bot.mode = PauseMode + + val action = BotRetreat("lobby", regroupHpPercent = 70) + + val state = action.start(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Running, state) + assertTrue(bot.mode === EmptyMode) + } + + @Test + fun `Update returns failed when dead`() { + Areas.set(mapOf("lobby" to AreaDefinition("lobby", Rectangle(999, 999, 999, 999)))) + player.dead = true + + val action = BotRetreat("lobby", regroupHpPercent = 70) + + val state = action.update(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Failed(Reason.Cancelled), state) + } + + @Test + fun `Update queues route when outside area`() { + Areas.set(mapOf("lobby" to AreaDefinition("lobby", Rectangle(999, 999, 999, 999)))) + val world = FakeWorld( + find = { _, list, _ -> + list.add(1) + true + }, + actions = { _ -> listOf(BotCloseInterface) }, + ) + + val action = BotRetreat("lobby", regroupHpPercent = 70) + + val state = action.update(bot, world, BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Running, state) + assertTrue(bot.frames.isNotEmpty()) + } +} diff --git a/game/src/test/kotlin/content/bot/behaviour/action/BotSwitchSetupTest.kt b/game/src/test/kotlin/content/bot/behaviour/action/BotSwitchSetupTest.kt new file mode 100644 index 0000000000..b6cf350b11 --- /dev/null +++ b/game/src/test/kotlin/content/bot/behaviour/action/BotSwitchSetupTest.kt @@ -0,0 +1,92 @@ +package content.bot.behaviour.action + +import content.bot.Bot +import content.bot.FakeBehaviour +import content.bot.FakeWorld +import content.bot.behaviour.BehaviourFrame +import content.bot.behaviour.BehaviourState +import content.bot.behaviour.condition.BotHasClock +import content.bot.behaviour.condition.BotItem +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import set +import world.gregs.voidps.cache.config.data.InventoryDefinition +import world.gregs.voidps.cache.definition.Params +import world.gregs.voidps.cache.definition.data.ItemDefinition +import world.gregs.voidps.engine.data.definition.ItemDefinitions +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.inv.equipment +import world.gregs.voidps.engine.inv.restrict.ValidItemRestriction +import world.gregs.voidps.engine.inv.stack.ItemDependentStack +import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot + +class BotSwitchSetupTest { + + private lateinit var player: Player + private lateinit var bot: Bot + + @BeforeEach + fun setup() { + player = Player() + bot = Bot(player) + player.inventories.validItemRule = ValidItemRestriction() + player.inventories.player = player + player.inventories.normalStack = ItemDependentStack + player.inventories.inventory(InventoryDefinition(stringId = "worn_equipment", length = 14)) + player.inventories.inventory(InventoryDefinition(stringId = "inventory", length = 28)) + } + + @Test + fun `Gated condition false returns success without dispatch`() { + ItemDefinitions.set( + arrayOf(ItemDefinition(stringId = "rune_scimitar", params = mapOf(Params.SLOT to EquipSlot.Weapon))), + mapOf("rune_scimitar" to 0), + ) + val action = BotSwitchSetup( + equipment = mapOf(EquipSlot.Weapon to BotItem(setOf("rune_scimitar"), min = 1)), + condition = BotHasClock(id = "never_set"), + ) + + val state = action.update(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Success, state) + } + + @Test + fun `Already equipped target item returns success`() { + ItemDefinitions.set( + arrayOf(ItemDefinition(stringId = "rune_scimitar", params = mapOf(Params.SLOT to EquipSlot.Weapon))), + mapOf("rune_scimitar" to 0), + ) + player.equipment.set(EquipSlot.Weapon.index, "rune_scimitar") + + val action = BotSwitchSetup( + equipment = mapOf(EquipSlot.Weapon to BotItem(setOf("rune_scimitar"), min = 1)), + ) + + val state = action.update(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Success, state) + } + + @Test + fun `Mismatch with no inventory match returns success`() { + ItemDefinitions.set( + arrayOf( + ItemDefinition(stringId = "rune_scimitar", params = mapOf(Params.SLOT to EquipSlot.Weapon)), + ItemDefinition(stringId = "rune_crossbow", params = mapOf(Params.SLOT to EquipSlot.Weapon)), + ), + mapOf("rune_scimitar" to 0, "rune_crossbow" to 1), + ) + player.equipment.set(EquipSlot.Weapon.index, "rune_scimitar") + + val action = BotSwitchSetup( + equipment = mapOf(EquipSlot.Weapon to BotItem(setOf("rune_crossbow"), min = 1)), + ) + + val state = action.update(bot, FakeWorld(), BehaviourFrame(FakeBehaviour())) + + assertEquals(BehaviourState.Success, state) + } +} diff --git a/game/src/test/kotlin/content/bot/behaviour/condition/BotPerceptionConditionsTest.kt b/game/src/test/kotlin/content/bot/behaviour/condition/BotPerceptionConditionsTest.kt new file mode 100644 index 0000000000..02f53aa29c --- /dev/null +++ b/game/src/test/kotlin/content/bot/behaviour/condition/BotPerceptionConditionsTest.kt @@ -0,0 +1,51 @@ +package content.bot.behaviour.condition + +import content.bot.Bot +import content.bot.behaviour.perception.BotCombatContext +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.level.PlayerLevels + +class BotPerceptionConditionsTest { + + private lateinit var bot: Bot + private lateinit var player: Player + + @BeforeEach + fun setup() { + player = Player() + bot = Bot(player) + player.experience.player = player + player.levels.link(player, PlayerLevels(player.experience)) + player["bot"] = bot + } + + private fun setContext(incomingAttackStyle: String? = null) { + bot.combatContext = BotCombatContext( + incomingAttackStyle = incomingAttackStyle, + enemiesByTile = emptyMap(), + ) + } + + @Test + fun `AttackerStyle matches incoming style`() { + setContext(incomingAttackStyle = "melee") + assertTrue(BotAttackerStyle(setOf("melee", "ranged")).check(player)) + assertFalse(BotAttackerStyle(setOf("magic")).check(player)) + } + + @Test + fun `AttackerStyle returns false when no context`() { + bot.combatContext = null + assertFalse(BotAttackerStyle(setOf("melee")).check(player)) + } + + @Test + fun `AttackerStyle returns false when no incoming attacker`() { + setContext(incomingAttackStyle = null) + assertFalse(BotAttackerStyle(setOf("melee")).check(player)) + } +} diff --git a/game/src/test/kotlin/content/bot/behaviour/condition/BotPvpRetreatNeededTest.kt b/game/src/test/kotlin/content/bot/behaviour/condition/BotPvpRetreatNeededTest.kt new file mode 100644 index 0000000000..4e8e3802f8 --- /dev/null +++ b/game/src/test/kotlin/content/bot/behaviour/condition/BotPvpRetreatNeededTest.kt @@ -0,0 +1,79 @@ +package content.bot.behaviour.condition + +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import set +import world.gregs.voidps.cache.config.data.InventoryDefinition +import world.gregs.voidps.cache.definition.data.ItemDefinition +import world.gregs.voidps.engine.data.definition.ItemDefinitions +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.restrict.ValidItemRestriction +import world.gregs.voidps.engine.inv.stack.ItemDependentStack + +class BotPvpRetreatNeededTest { + + private lateinit var player: Player + private lateinit var condition: BotPvpRetreatNeeded + + @BeforeEach + fun setup() { + player = Player() + player.inventories.validItemRule = ValidItemRestriction() + player.inventories.player = player + player.inventories.normalStack = ItemDependentStack + player.inventories.inventory(InventoryDefinition(stringId = "inventory", length = 28)) + condition = BotPvpRetreatNeeded() + } + + @AfterEach + fun teardown() { + ItemDefinitions.clear() + } + + @Test + fun `Returns false when bot has eat-able item`() { + ItemDefinitions.set( + arrayOf(ItemDefinition(id = 100, options = arrayOf("Eat"))), + mapOf("shark" to 0), + ) + player.inventory.add("shark") + assertFalse(condition.check(player)) + } + + @Test + fun `Returns true when single-combat and inventory has no eat-able items`() { + ItemDefinitions.set( + arrayOf(ItemDefinition(id = 200, options = arrayOf("Wield"))), + mapOf("whip" to 0), + ) + player.inventory.add("whip") + assertTrue(condition.check(player)) + } + + @Test + fun `Returns true when inventory is completely empty`() { + assertTrue(condition.check(player)) + } + + @Test + fun `Returns false when in multi-combat zone regardless of food`() { + player["in_multi_combat"] = true + assertFalse(condition.check(player)) + } + + @Test + fun `Returns false in multi-combat even with no food`() { + player["in_multi_combat"] = true + ItemDefinitions.set( + arrayOf(ItemDefinition(id = 200, options = arrayOf("Wield"))), + mapOf("whip" to 0), + ) + player.inventory.add("whip") + assertFalse(condition.check(player)) + } +} diff --git a/game/src/test/kotlin/content/bot/behaviour/perception/BotCombatContextTest.kt b/game/src/test/kotlin/content/bot/behaviour/perception/BotCombatContextTest.kt new file mode 100644 index 0000000000..1b84acc667 --- /dev/null +++ b/game/src/test/kotlin/content/bot/behaviour/perception/BotCombatContextTest.kt @@ -0,0 +1,33 @@ +package content.bot.behaviour.perception + +import content.bot.Bot +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Players +import world.gregs.voidps.engine.entity.character.player.skill.level.PlayerLevels + +class BotCombatContextTest { + + private lateinit var bot: Bot + private lateinit var player: Player + + @BeforeEach + fun setup() { + Players.clear() + player = Player() + bot = Bot(player) + player.experience.player = player + player.levels.link(player, PlayerLevels(player.experience)) + } + + @Test + fun `No nearby players gives empty enemies-by-tile`() { + val context = BotCombatContextBuilder.build(bot, radius = 5) + + assertTrue(context.enemiesByTile.isEmpty()) + assertNull(context.incomingAttackStyle) + } +} diff --git a/game/src/test/kotlin/content/bot/combat/CombatBotContextsTest.kt b/game/src/test/kotlin/content/bot/combat/CombatBotContextsTest.kt new file mode 100644 index 0000000000..ed3dd118b4 --- /dev/null +++ b/game/src/test/kotlin/content/bot/combat/CombatBotContextsTest.kt @@ -0,0 +1,114 @@ +package content.bot.combat + +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.type.Tile + +class CombatBotContextsTest { + + @AfterEach + fun teardown() { + CombatBotContexts.clear() + } + + @Test + fun `find returns the context whose handles matches`() { + val cw = StubContext(id = "clan_wars", prefix = "clan_wars_") + val wild = StubContext(id = "wilderness", prefix = "wilderness_") + CombatBotContexts.register(cw) + CombatBotContexts.register(wild) + + val tier = tier("clan_wars_ffa_safe_zerker") + assertSame(cw, CombatBotContexts.find(tier)) + } + + @Test + fun `find returns null when no context handles the tier`() { + CombatBotContexts.register(StubContext(id = "clan_wars", prefix = "clan_wars_")) + assertNull(CombatBotContexts.find(tier("duel_arena_unstaked"))) + } + + @Test + fun `forArenaKey routes to the context that exposes that key`() { + val cw = StubContext(id = "clan_wars", prefix = "clan_wars_", arenaKeys = setOf("clan_wars_ffa_safe", "clan_wars_ffa_dangerous")) + val wild = StubContext(id = "wilderness", prefix = "wilderness_", arenaKeys = setOf("wilderness_low", "wilderness_high")) + CombatBotContexts.register(cw) + CombatBotContexts.register(wild) + + assertSame(cw, CombatBotContexts.forArenaKey("clan_wars_ffa_safe")) + assertSame(wild, CombatBotContexts.forArenaKey("wilderness_high")) + assertNull(CombatBotContexts.forArenaKey("pest_control_easy")) + } + + @Test + fun `register rejects duplicate ids`() { + CombatBotContexts.register(StubContext(id = "clan_wars", prefix = "a_")) + assertThrows(IllegalArgumentException::class.java) { + CombatBotContexts.register(StubContext(id = "clan_wars", prefix = "b_")) + } + } + + @Test + fun `subscribedAreas unions every contexts interest set`() { + CombatBotContexts.register(StubContext(id = "clan_wars", prefix = "clan_wars_", subscribed = setOf("clan_wars_teleport"))) + CombatBotContexts.register(StubContext(id = "wilderness", prefix = "wilderness_", subscribed = setOf("edgeville_lever", "ardougne_lever"))) + + val areas = CombatBotContexts.subscribedAreas() + assertEquals(setOf("clan_wars_teleport", "edgeville_lever", "ardougne_lever"), areas) + } + + @Test + fun `loadAll calls load on every registered context`() { + val first = StubContext(id = "first", prefix = "f_") + val second = StubContext(id = "second", prefix = "s_") + CombatBotContexts.register(first) + CombatBotContexts.register(second) + + CombatBotContexts.loadAll() + assertTrue(first.loaded) + assertTrue(second.loaded) + } + + @Test + fun `unregister removes a context so find no longer matches`() { + val cw = StubContext(id = "clan_wars", prefix = "clan_wars_") + CombatBotContexts.register(cw) + assertNotNull(CombatBotContexts.find(tier("clan_wars_ffa_safe_zerker"))) + + assertTrue(CombatBotContexts.unregister(cw)) + assertNull(CombatBotContexts.find(tier("clan_wars_ffa_safe_zerker"))) + } + + private fun tier(activityId: String) = CombatTier(activityId = activityId, levels = emptyMap(), style = "") + + private class StubContext( + override val id: String, + private val prefix: String, + private val arenaKeys: Set = emptySet(), + private val subscribed: Set = emptySet(), + ) : CombatBotContext { + var loaded = false + private set + + override val subscribedAreas: Set get() = subscribed + override fun handles(tier: CombatTier): Boolean = tier.activityId.startsWith(prefix) + override fun load() { + loaded = true + } + override fun arenaKeys(): Set = arenaKeys + override fun arenaSpawn(arenaKey: String): Tile? = null + override fun arenaTiers(arenaKey: String): List = emptyList() + override fun autospawnIntervalTicks(arenaKey: String): Int = 0 + override fun autospawnTarget(arenaKey: String): Int = 0 + override fun arenaContains(arenaKey: String, tier: CombatTier): Boolean = false + override fun shouldDropItems(player: Player, tier: CombatTier): Boolean = false + override fun respawnTile(tier: CombatTier): Tile? = null + } +}