From 410758b44a79d3b6f4138049ba04161c9c28b1b5 Mon Sep 17 00:00:00 2001 From: Dyson Returns <4380544+dysonreturns@users.noreply.github.com> Date: Tue, 23 Jan 2024 23:05:23 -0800 Subject: [PATCH] doc: adding Quick Reference and updating README as necssary. yard does not want to read proto classes such as Api::Unit, unless we define them in a parseable way via _meta_documentation.rb many protobuf classes defined and two dead artifact classes are removed. using "Class.extend" instead of "self.included" axiom, for documentation purposes various errors in method documentation corrected. Github uses README in the root, but it's not yard doc parsed, so the repo file simply points to the real pages now. feature: enable_feature_layer configuration added refactor: Bot#warp_points moved to #geo.warp_points. documenation clarifies it's purpose. --- .yardopts | 19 +- README.md | 1 + bin/generate_meta_data | 2 +- docs/QUICK_REFERENCE.md | 978 ++++++++++++++++++ docs/README.md | 18 +- docs/TUTORIAL_01.md | 4 + docs/images/bot_lifecycle.drawio | 150 +++ docs/images/bot_lifecycle.png | Bin 0 -> 97258 bytes lib/sc2ai.rb | 1 - lib/sc2ai/{ => api}/data.rb | 20 +- lib/sc2ai/api/tech_tree.rb | 5 +- lib/sc2ai/cli/cli.rb | 2 + lib/sc2ai/configuration.rb | 7 + lib/sc2ai/connection.rb | 3 + lib/sc2ai/connection/requests.rb | 7 +- lib/sc2ai/local_play/match.rb | 5 +- lib/sc2ai/overrides/array.rb | 1 + lib/sc2ai/overrides/kernel.rb | 2 + lib/sc2ai/player.rb | 6 +- lib/sc2ai/player/actions.rb | 2 +- lib/sc2ai/player/debug.rb | 2 +- lib/sc2ai/player/game_state.rb | 1 + lib/sc2ai/player/geometry.rb | 107 +- lib/sc2ai/player/previous_state.rb | 2 +- lib/sc2ai/player/units.rb | 93 +- lib/sc2ai/protocol/_meta_documentation.rb | 39 + lib/sc2ai/protocol/extensions/color.rb | 7 +- lib/sc2ai/protocol/extensions/point.rb | 9 +- lib/sc2ai/protocol/extensions/point_2_d.rb | 12 +- .../protocol/extensions/point_distance.rb | 11 - lib/sc2ai/protocol/extensions/position.rb | 23 +- lib/sc2ai/protocol/extensions/power_source.rb | 7 +- lib/sc2ai/protocol/extensions/unit.rb | 85 +- lib/sc2ai/protocol/extensions/unit_type.rb | 9 - lib/sc2ai/unit_group.rb | 15 +- lib/sc2ai/unit_group/filter_ext.rb | 7 +- 36 files changed, 1427 insertions(+), 235 deletions(-) create mode 100644 README.md create mode 100644 docs/QUICK_REFERENCE.md create mode 100644 docs/TUTORIAL_01.md create mode 100644 docs/images/bot_lifecycle.drawio create mode 100644 docs/images/bot_lifecycle.png rename lib/sc2ai/{ => api}/data.rb (82%) create mode 100644 lib/sc2ai/protocol/_meta_documentation.rb delete mode 100644 lib/sc2ai/protocol/extensions/point_distance.rb delete mode 100644 lib/sc2ai/protocol/extensions/unit_type.rb diff --git a/.yardopts b/.yardopts index 8d696a2..a3f73dc 100644 --- a/.yardopts +++ b/.yardopts @@ -1,13 +1,26 @@ --protected --no-private ---embed-mixin ClassMethods +--embed-mixin=ClassMethods +--embed-mixin=Api::*Extension +--embed-mixin=Api::*Extension::ClassMethods +--embed-mixin=Sc2::Location --exclude /server/templates/ --exclude /yard/rubygems/ ---exclude lib/sc2ai/protocol/ +--exclude lib/sc2ai/protocol/common_pb.rb +--exclude lib/sc2ai/protocol/data_pb.rb +--exclude lib/sc2ai/protocol/debug_pb.rb +--exclude lib/sc2ai/protocol/error_pb.rb +--exclude lib/sc2ai/protocol/query_pb.rb +--exclude lib/sc2ai/protocol/raw_pb.rb +--exclude lib/sc2ai/protocol/sc2api_pb.rb +--exclude lib/sc2ai/protocol/score_pb.rb +--exclude lib/sc2ai/protocol/spatial_pb.rb +--exclude lib/sc2ai/protocol/ui_pb.rb --hide-tag todo ---embed-mixin=Sc2::Client::ConfigurableOptions +--embed-mixin Sc2::Client::ConfigurableOptions --readme ./docs/README.md --asset ./docs/images:images +lib/**/*.rb - ./docs/*.md LICENSE.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..514716c --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +https://dysonreturns.github.io/sc2ai/ \ No newline at end of file diff --git a/bin/generate_meta_data b/bin/generate_meta_data index dabe47e..cadad70 100755 --- a/bin/generate_meta_data +++ b/bin/generate_meta_data @@ -56,7 +56,7 @@ module GenerateMetaData raise "Error: const value not found (#{klass}, #{value})" end - # Helper for generating AbilitId from stableid json + # Helper for generating AbilityId from stableid json def ability_constants(data) result = {} data.each do |row| diff --git a/docs/QUICK_REFERENCE.md b/docs/QUICK_REFERENCE.md new file mode 100644 index 0000000..412311f --- /dev/null +++ b/docs/QUICK_REFERENCE.md @@ -0,0 +1,978 @@ +# Quick Reference + +**Bookmark this page and come back to it - it's your cheatsheet.** + +If you are reading the tutorial for the first time, scroll over basic info below and spend a minute if anything catches your eye. + +You will feel overwhelmed if you read this all in one go, so lets rather start with small examples. +After a quick scroll-over, {file:docs/TUTORIAL_01.md let's do some tutorials}. + +Unless stated otherwise, in the tables below methods are from the context of an instance of a Bot. Meaning `on_step` you can call `common.minerals`. + +## Constants + +There are five types of data identifiers. + +- **Unit Types**: zergling, marine, zealot, gateway, warpgate, barracks, flying barracks, etc. +- **Abilities**: attack, stop, build, morph, lift, land, casting specials, etc. +- **Buffs** (incl. nerfs): cloaked, neural parasite, carrying minerals/gas, etc. +- **Effects**: scanner sweep, psi storm, corrosive bile, etc. +- **Upgrades**: tunneling claws, stimpack, ground weapons level 2, psi storm tech, etc. + +A few thousand constants are generated from the game's `stableids.json` for your auto-completion convenience. + +| class | examples | +|-------------------|-----------------------------------------------------------------------------------------------------------------| +| {Api::UnitTypeId} | Api::UnitTypeId::ZERGLING, Api::UnitTypeId::BARRACKS | +| {Api::AbilityId} | Api::AbilityId::ATTACK, Api::AbilityId::MORPHZERGLINGTOBANELING_BANELING, Api::AbilityId::BARRACKSTRAIN_MARINE | +| {Api::BuffId} | Api::BuffId::FUNGALGROWTH, Api::BuffId::CARRYMINERALFIELDMINERALS | +| {Api::EffectId} | Api::EffectId::SCANNERSWEEP, Api::EffectId::PSISTORMPERSISTENT, Api::EffectId::GUARDIANSHIELDPERSISTENT | +| {Api::UpgradeId} | Api::UpgradeId::PROTOSSGROUNDWEAPONSLEVEL2, Api::EffectId::PSISTORMTECH | + + + + +## Bot identity + +| method | desc | Fast/Med/Slow | +|------------|----------------------------------------------------------------------------------------------|---------------| +| name | bot name | F | +| race | race selected. this is updated if set Random | F | +| enemy | Sc2::Player::Enemy | F | +| enemy.race | enemy race selected. if Random, set after first seen unit. callback: on_random_race_detected | F | +| enemy.name | enemy name | F | +| enemy.type | :Participant, :Computer | F | + +## Common info + +| method | desc | Fast/Med/Slow | +|--------------------------|---------------------------------------------------------|---------------| +| game_loop | incrementing int. timekeeping. nr of frames since start | F | +| common.minerals | mineral count, ui top right | F | +| common.vespene | gas, ui top right | F | +| common.food_cap | supply available, ui top-right | F | +| common.food_used | supply used, ui top-right | F | +| common.food_army | army supply, ui hover supply icon | F | +| common.food_workers | worker supply, ui hover supply icon | F | +| common.idle_worker_count | idlers, ui bottom left icon | F | +| common.army_count | unit count (not supply value) | F | +| common.warp_gate_count | Protoss: nr of warp gates | F | +| common.larva_count | Zerg: nr of larva | F | +| common.player_id | int identifier | F | + + +## Units / Group selection + +**Terminology** + +The Api refers to units and structures collectively as `Api::Unit`. +We refer to units as "units" and structures as "structures" and collectively as capital Unit. + +**Unit** +`Api::Unit` is a protobuf Message object with methods added for ease. +Each unit or structure is of type {Api::Unit}. +It has attributes, such as `unit.health` and `unit.pos.x` / `unit.pos.y` to get it's position. +**Unique Id:** Each Unit has an integer unique identifier called #tag; you will use `unit.tag` to identify units. + +**UnitGroup** +{Sc2::UnitGroup} is a construct of our own. +Units are contained in a Hash/Array hybrid which can perform actions such as #attack, #build, #train etc. +The 10 in-game "Control Groups" aren't useful at this scale; UnitGroup is a programmatic alternative. + +`Sc2::UnitGroup#units` holds `Hash` + + + +### Basic Unit selection and collection + +**Primer** +`Bot#all_units` holds everything, allied/enemy, units or structures. +`Bot#units` holds allied group of units only +`Bot#structures` holds allied group of structures +`UnitGroup` offers filters such as `#workers` which returns a new group, containing only workers. + +All for now, the full reference table is below these basic examples. + +```ruby +class MyBot < Sc2::Player::Bot + def on_step + + # Random example worker from UnitGroup units.workers + specific_worker = units.workers.random + first_worker = units.workers.at(0) # = units.workers.first + + # Get a unit tag (unique id) + specific_worker.tag #=> Integer + + # Get all unit tags in a group + units.workers.tags #=> Array of all unit tags = unit_group.units.keys + + # Look up a specific unit in a UnitGroup by tag + units.workers[specific_worker.tag] #=> returns specific_worker + + # New empty unit group + unit_group = Sc2::UnitGroup.new() + # or create from existing group or an Array + unit_group = Sc2::UnitGroup.new(other_ug) + unit_group = Sc2::UnitGroup.new(arr_units) + + # Add a unit / Remove a unit + unit_group.add(unit) + unit_group.remove(unit) + + # Add, subtract as you see fit, i.e. if you have some units assigned as a forward army + home_army = units.army.subtract(forward_army) + home_army = units.army - forward_army + + # Hash sub-/supersets sets can be useful (<, <=, =>, >) + # i.e. subset: are we sure all our flying units are present in the forward army? + forward_army > units.army.select_attribute(:Flying) #=> bool: true if all flying units present + + end +end +``` + +**Filters** +Every filter returns a new UnitGroup (cached), so you can chain and re-use without additional performance cost. + +```ruby +# Filter with blocks +injured = units.army.filter { |unit| unit.health < 100.0 } + +# Filter on type(s) +units.army.select_type(Api::UnitTypeId::BANELING) +units.army.select_type([Api::UnitTypeId::BANELINGBURROWED, Api::UnitTypeId::BANELING]) + +# Filter on attribute(s) +units.select_attribute(Api::Attribute::Structure) # rather use #structures +units.army.select_attribute([Api::Attribute::Mechanical, Api::Attribute::Armored]) + +# Filters allow #not, which makes the immediate next filter be the inverse +# Select structures which are not creep tumors and not spine crawlers +structures.not.creep_tumors.not.select_type(Api::UnitTypeId::SPINECRAWLER) +``` +### Global data + +| method | type of Api::Unit's in UnitGroup | Fast/Med/Slow | +|---------------------|-------------------------------------------|---------------| +| all_units | full, unfiltered list of units+structures | F | +| effects | effects such as psi storm, lurker spikes | F | +| neutral.minerals | mineral patches | F | +| neutral.gas | gas geysers | F | +| neutral.watchtowers | Xel'Naga watchtowers | F | +| neutral.debris | destructible debris | F | + +### Your Units and Structures + +| method | type of Api::Unit's in UnitGroup | Fast/Med/Slow | +|------------------------------------------|------------------------------------------------------|---------------| +| structures | with attribute :Structure | F | +| structures.hq | Command Centres, PF, Nexus, Hatch, Lair, Hive | F | +| structures.townhalls | Command Centres, PF, Nexus, Hatch, Lair, Hive | F | +| structures.bases | Command Centres, PF, Nexus, Hatch, Lair, Hive | F | +| structures.creep_tumors | Zerg creep tumors (any) | F | +| structures.creep_tumors_burrowed | Zerg creep tumors burrowed underground | F | +| structures.pylons / structures.warpables | Protoss pylons | F | +| structures.warpgates | Protoss warp gates | F | +| structures.not.creep_tumors | all structures excluding creep tumors | F | +| units | not attribute :Structure | F | +| units.workers | SCV, Probe, Drone + Burrowed | F | +| units.larva | Zerg larva | F | +| units.queens | Zerg queen | F | +| units.overlords | Zerg overlords | F | +| units.army | units without: workers, queens, overlords, larva | F | +| units.warpables | Protoss warp prism | F | + +### Enemy units +Same as Bot's `unit` and `structures` template, but prefixed with `enemy.` + +| method | type of Api::Unit's in UnitGroup | Fast/Med/Slow | +|------------------------------------------------|--------------------------------------------------|---------------| +| enemy.all_units | enemy only, all units+structures | F | +| enemy.structures | with attribute :Structure | F | +| enemy.structures.hq | Command Centres, PF, Nexus, Hatch, Lair, Hive | F | +| enemy.structures.townhalls | Command Centres, PF, Nexus, Hatch, Lair, Hive | F | +| enemy.structures.bases | Command Centres, PF, Nexus, Hatch, Lair, Hive | F | +| enemy.structures.creep_tumors | Zerg creep tumors (any) | F | +| enemy.structures.creep_tumors_burrowed | Zerg creep tumors burrowed underground | F | +| enemy.structures.pylons / structures.warpables | Protoss pylons | F | +| enemy.structures.warpgates | Protoss warp gates | F | +| enemy.units | not attribute :Structure | F | +| enemy.units.workers | SCV, Probe, Drone + Burrowed | F | +| enemy.units.larva | Zerg larva | F | +| enemy.units.queens | Zerg queen | F | +| enemy.units.overlords | Zerg overlords | F | +| enemy.units.army | units without: workers, queens, overlords, larva | F | +| enemy.structures.not.creep_tumors | all structures excluding creep tumors | F | +| enemy.units.warpables | Protoss warp prism | F | + +### Unit Group filters + +Common methods you can use on `Sc2::UnitGroup` to filter properties. + +| method | desc | Fast/Med/Slow | +|-----------------------------------|------------------------------------------------|---------------| +| select_type | select on Api::UnitTypeId | F | +| reject_type | rejects on Api::UnitTypeId | F | +| select_attribute | select on Api::Attribute | F | +| reject_attribute | rejects on Api::Attribute | F | +| filter(&block) | true/false from block: ug.filter(&:is_flying?) | F | +| nearest_to(pos:, amount:) | nearest unit(s) to position | M | +| select_in_circle(point:, radius:) | only units in circle | M | +| not | inverses next filter | M | + + +### Creating your own filters +`units.workers` is just a helper method executing the filter `UnitGroup#select_type`, as is `units.army`. +You can add a filter to UnitGroup by re-opening the class and defining your own filter methods. + +```ruby +# in my_unit_group_extensions.rb +module Sc2 + class UnitGroup + + # The efficient way to use this is sub-filter from `units.army`, because it loops less. + # i.e. units.army.zealots, even though the method also exists for units.zealots and any other unit group + def zealots + select_type(Api::UnitTypeId::ZEALOT) + end + + # units.army.roaches + # enemy.units.army.roaches + def roaches + select_type([Api::UnitTypeId::ROACH, Api::UnitTypeId::ROACHBURROWED]) + end + + end +end + + +``` + +## Performing actions / giving orders + +We refer to an order or command as an "Action". + +`Api::Action`s can be performed from both the context of Bot, Unit or UnitGroup. +Sending raw actions from Bot requires that you pass the source Unit(s). The advantage of performing actions on Units/UnitGroups is one less param and better reading code. + +An action requires an `ability_id`, such as `Api::AbilityId::ATTACK`, `Api::AbilityId::SMART`, etc. +"SMART" is the equivalent of performing a right-click in-game. +Sometimes an action requires a `target` which is a position `Api::Point2D` or a target `Api::Unit`. + +Let's inspect Unit Group first and it's action helper methods. + +### UnitGroup actions + +| method | desc | Fast/Med/Slow | +|-----------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------|---------------| +| action(ability_id:, target: ) | makes a group perform a raw ability, i.e. Api::AbilityId::TERRANBUILD_BARRACKS | F | +| build(unit_type_id:, target: ...) | units.workers.build(unit_type_id: Api::UnitTypeId::BARRACKS, ...)
provide the unit type and target, the ability id is calculated for you. | F | +| smart(target: ...) | right-click on a point or a unit, i.e. attack a unit, go into a bunker, mine a mineral | F | +| attack(target: ...) | attacks a Unit or attack-moves to position. units.army.attack(...) | F | +| warp(unit_type_id:, target: ...) | Protoss: structures.warpgates.warp(unit_type_id: Api::UnitTypeId::STALKER, target: point_2d_target).
geo.warp_points is helpful. see example. | F | +| repair(target: ...) | terran: units.workers.repair(target: injured_unit_or_structure) | F | + +Here's an example using Protoss `warp` for five stalkers. We use the unit group `structures.warpgates` as the source for this action. + +```ruby + +def on_step + enemy_main = game_info.start_raw.start_locations.first + + if can_afford?(unit_type_id: Api::UnitTypeId::STALKER, quantity: 5) + + # Find the nearest energy source, typically a Pylon (Api::Unit) + energy_source = structures.warpables.nearest_to(pos: enemy_main) + + # geo.warp_points finds an array of 2d coordinates inside the power field + # it matches the size of the unit_type_id passed, so they don't overlap + points = geo.warp_points(source: energy_source, + unit_type_id: Api::UnitTypeId::STALKER) + + # Pick the 5 facing the enemy base's position + points = points.min_by(5) { |p| p.distance_to(enemy_main) } + + # For UnitGroup structures.warpgates, tell them all to warp in stalkers at provided point + points.each do |point| + structures.warpgates.warp(unit_type_id: Api::UnitTypeId::STALKER, target: point) + end + + end + +end + +``` + +### Action queue / scheduling (Shift+Click) + +All action methods have a `queue_command:` parameter (omitted here for brevity), which queues this action after the others. +This is akin to a Shift+Click in-game. +It always defaults to `false`, so when giving multiple instructions to one unit per frame, only the last action is executed. + +When `queue_command:` is set to `true`, the action you are sending will not override the previous command, but perform after it in sequence. +It's handy for set-and-forget actions like constructing a structure and then queuing the worker to go mine afterwards. + +### Unit actions + +The signatures match UnitGroup, with the exception of `warp` which is really meant for a group of warpgates. + +**attack_with** +One addition is `#attack_with`. +We generally contextualize from our own units, but stylistically you can perform inverse of UnitGroup#attack on an enemy unit. +For instance, you spot a ghost launching a nuke and you need to attack it quick with any 3 army units nearby. + +```ruby +hit_squad = enemy_ghost_unit.nearest(units: units.army, amount: 3) +enemy_ghost_unit.attack_with(units: hit_squad) +# synonymous with: +hit_squad.attack(target: enemy_ghost_unit) +``` + + +| method | desc | Fast/Med/Slow | +|-----------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------|---------------| +| unit.action(ability_id:, target: ) | makes a group perform a raw ability, i.e. Api::AbilityId::TERRANBUILD_BARRACKS | F | +| unit.build(unit_type_id:, target: ...) | units.workers.build(unit_type_id: Api::UnitTypeId::BARRACKS, ...)
provide the unit type and location, the ability id is calculated for you. | F | +| unit.smart(target: ...) | right-click on a point or a unit, i.e. attack a unit, go into a bunker, mine a mineral | F | +| unit.attack(target: ...) | attacks a Unit or attack-moves to position. units.army.attack(...) | F | +| unit.attack_with(units: ...) | attacks self with supplied units:. single_enemy.attack_with(units: units.army) | F | +| unit.repair(target: ...) | terran: units.workers.repair(target: injured_unit_or_structure) | F | + + +You are always welcome to perform requests, however raw, directly from Bot#api or use some of the built-in Bot Action helpers. +See the [Api Requests](#label-Api+Requests) section for a breakdown of raw commands and [Bot Actions](#label-Bot+Actions) for convenience methods built on-top of them. + +## Unit Data + +We want you to explore the [protobuf protocol definitions](https://github.com/Blizzard/s2client-proto/blob/c04df4adbe274858a4eb8417175ee32ad02fd609/s2clientprotocol/raw.proto#L99). It is the best reference for information which comes back from the Api. + +Below is an extract from the message `Unit` in a table. +For each of these properties you can use dot notation to access the property, i.e. `unit.health`. + +We also provide `unit.previous`, if you need to compare the current state vs the previous state, i.e. +```ruby +puts "taking shield damage!" if unit.shield < unit.previous.shield +# ... side-note: rather implement callback on_unit_damaged(unit, amount) +``` + +| attribute | desc | Fast/Med/Slow | +|---------------------------|-------------------------------------------------------------------------------------|---------------| +| unit.tag | unique id | F | +| unit.unit_type | correspondes to Api::UnitTypeId::NAME | F | +| unit.owner | player id | F | +| unit.pos | Point - 3d position with #x,#y,#z | F | +| unit.facing | facing direction in radians | F | +| unit.radius | radius of unit model | F | +| unit.build_progress | range [0.0, 1.0] | F | +| unit.cloak | See CloakState | F | +| unit.buff_ids | array of Api::BuffId::NAME | F | +| unit.detect_range | detection range | F | +| unit.radar_range | sensor tower range | F | +| unit.is_selected | unit selected in game | F | +| unit.is_on_screen | unit on screen in game | F | +| unit.is_blip | detected by sensor tower | F | +| unit.is_powered | protoss: has power | F | +| unit.is_active | building is training/researching (i.e. animated) | F | +| unit.attack_upgrade_level | int 0,1,2,3 | F | +| unit.armor_upgrade_level | int 0,1,2,3 | F | +| unit.shield_upgrade_level | int 0,1,2,3 | F | +| unit.shield_upgrade_level | int 0,1,2,3 | F | +| unit.health | float | F | +| unit.health_max | float | F | +| unit.shield | float | F | +| unit.shield_max | float | F | +| unit.energy | float | F | +| unit.energy_max | float | F | +| unit.mineral_contents | minerals | F | +| unit.vespene_contents | geyser / gas building | F | +| unit.is_flying | above ground level | F | +| unit.is_burrowed | under ground level | F | +| unit.is_hallucination | unit is your own or detected as a hallucination | F | +| unit.orders | your unit's orders (UnitOrder) | F | +| unit.add_on_tag | terran: the id of the add-on building | F | +| unit.passengers | array of PassengerUnit. has PassengerUnit#tag | F | +| unit.cargo_space_taken | bunkers, medivacs, warp prism, etc. | F | +| unit.cargo_space_max | bunkers, medivacs, warp prism, etc. | F | +| unit.assigned_harvesters | for use with base structure | F | +| unit.ideal_harvesters | for use with base/harvesting structure | F | +| unit.weapon_cooldown | | F | +| unit.engaged_target_tag | | F | +| unit.buff_duration_remain | how long a buff or unit is still around (eg mule, broodling, chronoboost) | F | +| unit.buff_duration_max | | F | +| unit.rally_targets | array of RallyTarget. always has #point (Point), could be rallied to unit via #tag. | F | + + +**But that's NOT all.** +See [Static Unit Data](#label-Static+Unit+data) below for more fixed meta information about Unit Type, such as weapon damage, range, etc. + +## Unit Events + +This library generally offers two ways to access events which occurred, `@event_*` attributes or by overriding ano `on_*` callback. +The callbacks sometimes provide more context than just a Unit list, i.e. `on_unit_damaged(unit, amount)` provides the amount of damage for you. + +All attributes below return a `Sc2::UnitGroup` containing `Api::Unit`'s involved in the event. +This table provides events and callbacks available, with an example below. + +| attribute | callback | desc | Fast/Med/Slow | +|----------------------------|---------------------------------------------------|:-----------------------------------------------------|---------------| +| event_units_created | on_unit_created(unit) | units only. probe or marine trained | F | +| event_structures_started | on_structure_started(unit) | structure in physical progress. not completed. | F | +| event_structures_completed | on_structure_completed(unit) | structure completes | F | +| event_units_type_changed | on_unit_type_changed(unit, previous_unit_type_id) | morphs. baneling morph, viking land, etc. | F | +| event_units_damaged | on_unit_damaged(unit, amount) | hp or shields took damage, but unit is not destroyed | F | +| event_units_destroyed | on_unit_destroyed(dead_unit) | you heard the news that you're dead | F | + +### Example handling of dead units +From your Bot, you can read the UnitGroup `@event_units_destroyed` `on_step` for all Units which got destroyed in the last frame. +Even though those Units are not present in this frame, their `Api::Unit` objects are available, should like like to know, i.e. where something died and of what type. +For example, if an SCV died, you can read last frame's `Api::Unit#orders`. If it wanted to build an expansion, you might want to retry or expand elsewhere. + +You can loop over units in `@event_units_destroyed` **or** receive a callback by overriding (implementing) `on_unit_destroyed`. +The callbacks occur after the game has ticked forward, but before `on_step`. +Typically the callbacks come in the form of **individual units**, where the `@event_*` properties contain the **whole list**. + + +```ruby +class MyBot < Sc2::Player::Bot + + # Define the callback in your bot + def on_unit_destroyed(dead_unit) + + # Custom logic + if dead_unit.unit_type == Api::UnitTypeId::ORBITALCOMMAND && dead_unit.alliance == :Self + + # Dead base. Make SCV's mine a nearby mineral at the nearest base + endangered = units.workers.select_in_circle(point: dead_unit.pos, radius:8) + new_base = structures.bases.nearest_to(pos: dead_unit.pos) + + # Go mine nearby using right-click aka "smart" action + endangered.smart(target: neutral.minerals.nearest_to(pos: new_base)) + end + end + + def on_step + + # Alternatively, read the events attribute + @event_units_destroyed.select_type(Api::UnitTypeId::ORBITALCOMMAND).each do |dead_unit| + next unless dead_unit.alliance == :Self + + # Dead base. Same logic as above... + end + + # Or even case by type + @event_units_destroyed.filter{ |u| u.alliance == :Self }.each do |dead_unit| + case dead_unit.unit_type + when *Sc2::UnitGroup::TYPE_BASES # Covers all bases. CC/PF/Orbital + + # Dead base. Same logic as above... + end + end + + end +end +``` + +The approach you take is stylistic. That covers Unit callbacks. + +## Lifecycle + +Typically handled by Match or the ladder runner, the bot will automatically connect to the host and create/join a server. +The fist game state observation is made and bot is readied. + + +From here the lifecycle is as follows: + +![bot_lifecycle.png](images/bot_lifecycle.png) + +As a bare minimum you merely need to implement `on_step`. Observation and stepping is handled internally. + + +## Callbacks + +Having examined the Lifecycle, here's a list of the callbacks you can implement. +You can override these in your bot for additional features. + +| method | desc | Fast/Med/Slow | +|------------------------------------|----------------------------------------------------------------------------------------------|---------------| +| on_start | override to perform work before first on_step gets called | - | +| on_step | the main game loop. called after stepping + new state observation. | - | +| on_parse_observation_unit(unit) | called before step for each Unit after observation. use this to decorate Unit before on_step | - | +| on_actions_performed(actions) | called before step with actions you performed since last Observation | - | +| on_action_errors(errors) | called before step if errors are present (errors = equivalent to red text) | _ | +| on_alerts(alerts) | called before step with alerts. nuke launch, nydus, etc. | _ | +| on_upgrades_completed(upgrade_ids) | called before step with completed upgrade ids. | _ | +| on_random_race_detected(race) | called before step when Random enemy race discovered first | _ | +| on_finish(result) | game over. result of :Victory, :Defeat or :Tie | - | + +## Observation of game state + +While there are many niceties, realtime data can also be accessed in it's raw form. +The bulk of which is in #observation, which we parse to fill #units and #structures, callbacks, etc. +Game info (#game_info) is slightly less important, holds less info and is only called if needed. + +`Bot#debug_json_observation` gives you a json dump of the `#observation` in `./data/debug_observation.json` for casual review. +This might be easier to nagivate than `#observation.inspect`, due to it's size. + +Below are some game state and miscellaneous utility methods from {Sc2::Player::Bot}. + +| method | desc | Fast/Med/Slow | +|-----------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------| +| game_loop | incrementing int. timekeeping. nr of frames since start | F | +| observation | observation data as per Api::Observation from protobuf | F | +| game_info | Api::GameInfo from protobuf. will make api request + cache if stale. | M | +| previous | all game state info for previous frame stored, i.e. previous.structures | F | +| chats_received | read chat messages | F | +| result | :Victory, :Defeat, :Tie. use #on_finish callback instead. | F | +| status | :in_game, :launched, etc. | F | +| spent_minerals | virtually tracked mineral spend this step | F | +| spent_supply | virtually tracked supply spend this step | F | +| spent_vespene | virtually tracked vespene spend this step | F | +| can_afford?(unit_type_id:, quantity: 1) | internal bookkeeping tracks every spent resource for build/train/morph.
checks affordability for a Unit type (mineral/gas/supply). | F | +| debug_json_observation | saves observation to `./data/debug_observation.json` | M | + +## Observation Layers + +Before we talk about making Api requests, we should mention that there are several ways to interact with the game. +By default we use the Raw layer which is data communication only. +The Api providers additional layers which can be turned on simultaneously, namely the Render Layer and the Feature Layer. + +### Raw Layer (always on) +The default - all data is communicated via protobuf in what you can liken to a huge JSON dump of everything you need to know. +If something has a visual queue, like "Cloaked" (hidden), the data tells us this. +Similarly if there is a radar circle on the map, the api tells us this too. +The minimap is provided in bit map format, which we parse into a Numo array for efficiency. + +### Render Layer (not used) +The Render layer provides a visual view of the game in low quality. You see what the human sees. +Due to the data transfer cost, this layer is not used in competitive AI. +It's good for hobbyists who want to see what the game sees in ML, but it's outshone by an even better ML layer, the Feature Layer. + +### Feature Layer (optional) + +Advanced, and not necessary for most botters. +The feature layer provides access to a virtual UI, such as the player sees on the HUD in-game. +You can perform physical clicks on buttons. The center panel ("multi-panel") is available for you to review. + +The Feature Layer is almost exclusively used to fill in one or two missing parts from the Raw Layer, like unloading individual units. +While Raw's `Api::AbilityId::UNLOADALL` works just fine for most cases, you can have fine-grained control by focussing and "clicking" specific buttons. + +Enabling the feature layer comes at a few millisecond **performance cost** as it doubles the network throughput of RequestObservation. + + +To enable this layer, either set the flag `enable_feature_layer` before your bot loads via `Sc2.config` ... +```ruby +Sc2.config do |config| + config.enable_feature_layer = true +end +``` +**OR** add enable_feature_layer to you yaml config, `./sc2ai.yml`. +```yaml +--- +version: "4.10" +enable_feature_layer: true +``` + +**Feature layer actions** + +When enabled you can perform requests prefixed with: + +- `Bot#action_spatial`\* (i.e. action_spatial_unit_selection_rect) +- `Bot#action_ui`\* (i.e. action_ui_cargo_panel_unload) + +See {Sc2::Player::Actions}. + +## Api Requests + +Requests can be called on your api connection. +**Any request will be Slow** as it takes a few milliseconds to talk to the client. +You can perform these on `Bot#api`, i.e. `api.available_maps` will return an array of maps available. + +For most methods, there are helper method alternatives at the Bot level. +See [Bot Actions](#label-Bot+Actions). + +If you are interested in sending raw commands, you should be familiar with the protobuf definitions and/or have the `.proto` files handy for reference: +https://github.com/dysonreturns/sc2ai/tree/main/data/sc2ai/protocol + + +| method | desc | Fast/Med/Slow | +|-------------------------------|------------------------------------------------------------------------------------------------------|---------------| +| ping | tests connection. returns client information | S | +| available_maps | returns directory of maps that can be played on | S | +| create_game | send to host to initialize game | S | +| join_game | joins a game | S | +| step(step_count = 1) | ticks game loop forward by nr of steps | S | +| restart_game | restarts. single player only | S | +| leave_game | disconnects from a multiplayer game | S | +| request_quick_save | saves game to an in-memory bookmark | S | +| request_quick_load | loads from quick save | S | +| quit | quits and closes client. does not work on ladder | S | +| data | data about different gameplay elements. may be different for different games. | S | +| game_info | static data about the current game and map | S | +| observation | snapshot of the current game state | S | +| query | additional methods for inspecting game state. synchronous and must wait on response | S | +| query_pathings | pathing specific query helper | S | +| query_abilities | queries one or more ability availability checks | S | +| query_abilities_for_unit_tags | helper making ability queries using unit tags | S | +| query_placements | query if locations are placeable | S | +| action | executes an array of [Api::Action] for a participant.
rather use Bot#action* for batching. | S | +| debug | display debug information and execute debug actions.
rather use Bot#debug* methods for batching. | S | +| save_replay | gets replay binary data | S | +| replay_info | query game information about a replay without watching | S | +| start_replay | for watching replays | S | +| observer_action | move camera / follow player actions supported. | S | +| observer_action_camera_move | observer only, camera move action helper | S | + + +## Bot Actions + +See {Sc2::Player::Actions}. + +| method | desc | Fast/Med/Slow | +|----------------------------------------------|-------------------------------------------------------------------------------------------------------------------------|---------------| +| queue_action | helper for queueing bare-bone Api::Action (any type) for this step | S | +| action | Best way to queue an action. most other methods pass through here. does `Api::ActionRawUnitCommand` | S | +| build | abstracts action, detects correct ability from a passed unit type
build(unit_type_id: Api::UnitTypeId::BARRACKS, ... | S | +| warp | Protoss: structures.warpgates.warp(...) to warp in units | S | +| action_raw_toggle_autocast | enable/disable auto-cast for units | S | +| action_raw_camera_move | moves camera | S | +| action_raw_unit_command | helper for doing queue_action with an Api::ActionRawUnitCommand | S | +| action_chat | send a chat message via channel :Broadcast or :Team. | S | +| action_spatial_unit_command | Feature Layer: unit command | S | +| action_spatial_camera_move | Feature Layer: move camera | S | +| action_spatial_unit_selection_point | Feature Layer: click on unit/minimap | S | +| action_spatial_unit_selection_rect | Feature Layer: box units | S | +| action_ui_control_group | Feature Layer: set/select control groups | S | +| action_ui_select_army | Feature Layer: select army hotkey | S | +| action_ui_select_warp_gates | Feature Layer: select warp gate hotkey | S | +| action_ui_select_larva | Feature Layer: select larva gate hotkey | S | +| action_ui_select_idle_worker | Feature Layer: select idle workers gate hotkey | S | +| action_ui_multi_panel | Feature Layer: control the center bottom panel | S | +| action_ui_cargo_panel_unload | Feature Layer: control cargo displays in multi panel | S | +| action_ui_production_panel_remove_from_queue | Feature Layer: remove unit from production in multi panel | S | +| action_ui_toggle_autocast | Feature Layer: enable/disable auto-cast for units | S | + +You will want for nothing more than this from the Api. + +## Geometry / Map + +### Point2D +Targeting on the API is often for a 2d location `Api::Point2D`. +Our `Api:Unit` positions are 3d, in the form of `Api::Point` (via `unit.pos`), but there are several position types. + +The library provides the uniform `Sc2::Position`, to ease targeting 2d locations. +Proto objects `Api::Point`, `Api::Point2D`,`Api::PointI`,`Api::Size2DI` are additionally type `Sc2::Position` which respond to #x and #y. +An array of [x,y] coordinates can be turned into a `Api::Point2D` with Array#to_p2d. + +Here are some frequent conversions you might do to get a target `Api::Point2D`. +```ruby +# Creating directly +Api::Point[x, y] #=> +Api::Point2D.new(x: some_x, y: some_y) #=> + +# Convert array coordinate pair [x, y] +[1.0, 2.0].to_p2d #=> + +# Unit.pos (Api::Point) conversion +some_unit.pos.to_2d #=> + +``` + +**target keyword** +Additionally, most abstracted targets which require either a 2d position (`Api::Point2D`) or a unit's tag (`Integer`). +The `target:` keyword param in methods lets you know it accepts either. +To preserve syntactic flow without the need to type-cast as often, when you see "target", you can pass any reasonable type. + + +### Maps + +#### Coordinate system +Games generally use XY coordinates with the **origin bottom-left**, so `[0.0, 0.0]` is at the origin, bottom left. + +Maps have a maximum 255 width/height [0 to 254, 0 to 254]. +The maps are clamped to the dimensions it was created at and dont have to be square, i.e. a "2000AtmoshperesAIE" is 144 x 132 (width by height). + +The library returns maps in the form `Numo` arrays for speed and processing. +If you intend on using raw map info (instead of the helper methods below), we encourage you to learn about [Numo](https://github.com/ruby-numo/numo-narray) which is Ruby's NumPy equivalent. + +The map data comes in the form two arrays, where the outermost holds rows and the inner holds columns. +If these were pure Ruby arrays, you'd call them like this `parsed_visiblity_grid[y][x]` ❌, +but the Numo lookup is one method with two params `parsed_visiblity_grid[y, x]` ✅. +The 10th column, second from the bottom is therefore `parsed_visiblity_grid[1, 9]`. + +Through the library the **only time where X/Y is swapped** is when working with **raw map data**. + + +#### Raw map data + +Has Y amount of rows corresponding to map height and X amount of columns corresponding with map width. + +| method | desc | Fast/Med/Slow | +|------------------------|--------------------------------------------------------|-------------------------| +| parsed_placement_grid | boolean: map tiles marked placeable. static. | F | +| parsed_terrain_height | float: z position (height) of tile. static. | F | +| parsed_pathing_grid | boolean: pathable tiles. updates to reflect buildings. | S, then cached 2 frames | +| parsed_visibility_grid | boolean: minimap visibility. int flag 0,1,2 | S, then cached 2 frames | +| parsed_creep | boolean: minimap Zerg's creep spread | F | +| parsed_power_grid | boolean: Protoss' powered tiles | F | + + +For a visual aid, you can print the entire parsed_pathing_grid to console as 0's and 1's. + +```ruby +# We reverse here (Numo::NArray#flipud) before we print, otherwise the map is vertically flipped. +geo.parsed_pathing_grid.flipud.each_over_axis(0) do |row| + puts row.to_a.join("") +end +``` + +Typically, you'd want to use the helper methods such as `map_visible?(x:, y:)` or `placeable?(x:, y:)`, see table below. + +### Map, Minimap & Geometric helpers + +`Bot#geo` gives an instance of `Sc2::Player::Geometry`. +From the context of your bot, you can access the following map/geo methods: + +| method | desc | Fast/Med/Slow | +|------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------|---------------| +| geo.map_width | map tile width. Range is 1-255 | F | +| geo.map_height | map tile height. Range is 1-255 | F | +| geo.map_tile_range_x | range 0..(map_width - 1). useful for x.clamp() to stay in map bounds | M | +| geo.map_tile_range_y | range 0..(map_height - 1). useful for y.clamp() to stay in map bounds | M | +| geo.powered?(x:, y:) | whether a x/y block is powered | M | +| geo.pathable?(x:, y:) | whether a x/y block is pathable as per minimap | M | +| geo.terrain_height(x:, y:) | float height (z position) of tile (-16.0 to 16.0) | M | +| geo.visibility(x:, y:) | visibility indicator: 0=Hidden,1= Snapshot,2=Visible | M | +| geo.map_visible?(x:, y:) | whether the point (tile) is currently in vision | M | +| geo.map_seen?(x:, y:) | whether point (tile) has been seen before or currently visible | M | +| geo.map_unseen?(x:, y:) | has never been seen/explored before (dark fog) | M | +| geo.creep?(x:, y:) | Zerg: whether a tile has creep on it, as per minimap | M | +| geo.expansions | Gets expos and surrounding minerals.
`[Hash]` Location => UnitGroup of resources (minerals+geysers) | M | +| geo.expansion_points | Returns a list of 2d points for expansion build locations
= geo.expansions.keys | M | +| geo.expansions_unoccupied | a slice of #expansions where a base hasn't been built yet | M | +| geo.build_coordinates(length:, on_creep: false, in_power: false) | buildable point grid for squares of size, i.e. 3 = 3x3 placements | M | +| geo.build_placement_near(length:, target:, random: 1) | gets a buildable location for a square of length, near target.
can randomly select between `random` number of possible points for robustness. | M | +| geo.points_nearest_linear(source:, target:, offset: 0.0, increment: 1.0, count: 1) | find points on a straight line. equally spaced from a source to a target. line units in a row. | M | +| geo.point_random_near(pos:, offset: 1.0) | a random point near a location with a positive/negative offset magnitude. scatter units. | M | +| geo.point_random_on_circle(pos:, radius: 1.0) | a random point on a circle's circumference. fan units around point. | M | +| geo.warp_points(source:, unit_type_id: ) | Protoss: warp locations at a power source, for the width of a unit type. | M | + +### Position (Point and Vector math) + +When you get into "micro", the micro-management of unit actions for performance or strategic gain, these basic geometric helpers will get you started. + +As mentioned above, `Api::Unit#pos` returns a 3d `Api::Point` which conforms to `Sc2::Position`. +When targeting or working with coordinates, you generally work with `Api::Point2D`, which also conforms to `Sc2::Position`. +You thereby interoperate between the various coordinate types using common ground and without too type conversion. + +Let's review what you can do with `unit.pos` using these `Sc2::Position` methods. + +| method | desc | Fast/Med/Slow | +|--------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|---------------| +| add(other) ⇒ Api::Point2D (also: #+) | a new point representing the sum of this point and the other point
pos.add(other_pos) or: pos + other_pos | F | +| subtract(other) ⇒ Api::Point2D (also: #-) | returns a new point representing the difference between this point and the other point/number. | F | +| divide(scalar) ⇒ Api::Point2D (also: #/) | a new point representing this point divided by the scalar. | F | +| multiply(scalar) ⇒ Api::Point2D (also: #*) | returns this point multiplied by the scalar | F | +| cross_product(other) ⇒ Float | the cross product of this vector and the other vector. | F | +| dot(other) ⇒ Float | the dot product of this vector and the other vector. | F | +| angle_to(other) ⇒ Float | the angle between this vector and the other vector, in radians. | F | +| away_from(other, distance) ⇒ Point2D | moves in direction away from the other point by distance. | F | +| distance_squared_to(other) ⇒ Float | the squared distance between this point and the other point. | F | +| distance_to(other) ⇒ Float | calculates the distance between self and other Sc2::Position | F | +| distance_to_circle(center, radius) ⇒ Float | distance from this point to the circle. returns 0 if point is inside circle | F | +| distance_to_coordinate(x:, y:) ⇒ Float | distance between this point and coordinate of x and y | F | +| lerp(other, scale) ⇒ Api::Point2D | linear interpolation between this point and another for scale Finds a point on a line between two points at % along the way | F | +| magnitude ⇒ Float | for vector returns the magnitude, synonymous with Math.hypot | F | +| normalize ⇒ Api::Point2D | a new point representing the normalized version of this vector (unit length) | F | +| offset(x, y) ⇒ Api::Point2D | creates a new point with x and y which is offset | F | +| offset!(x, y) ⇒ Sc2::Position | changes this point’s x and y by the supplied offset. | F | +| random_offset(offset) ⇒ Api::Point2D | randomly adjusts both x and y by a range of: -offset..offset. | F | +| random_offset!(offset) ⇒ Sc2::Position | changes this point’s x and y by the supplied offset. | F | +| towards(other, distance) ⇒ Point2D | moves in direction towards other point by distance. | F | + +This is the most likely area which will gain expansion over time, so review the class {Sc2::Position} for updates. + +## Static Data + +There is a large amount of structure game data available, which doesn't pertain to state but rather the built-in game attributes. +What if you wanted to know the type of damage a Weapon does, a unit's vision range or how much an upgrade costs? + +The `Bot#data` grants access to information via `Sc2::Data`. + +### Data +See {Sc2::Data} and the data protocol file at https://github.com/Blizzard/s2client-proto/blob/master/s2clientprotocol/data.proto. + +| method | desc | Fast/Med/Slow | +|-------------------------------|-------------------------------------------------------------|---------------| +| data.abilities \[ability_id\] | Hash AbilityId => AbilityData | F | +| data.upgrades \[upgrade_id\] | Hash UpgradeId => UpgradeData | F | +| data.units \[unit_type_id\] | Hash UnitTypeId => UnitTypeData | F | +| data.effects | just a list, use `Api::EffectId` directly | F | +| data.buffs | just a list, use `Api::BuffId` directly | F | + + +### Static Unit data + +Data as it pertains to a specific unit's data type can be accessed via the convenience method `Api::Unit#unit_data`. + +```ruby + +# Consider you select a marine +fighter = units.army.select_type(Api::UnitTypeId::MARINE).random + +# Print info about unit type Marine +pp fighter.unit_data + +# Or from the context of your Bot, get data about a specific unit +unit_data(fighter) + +# Or by using a UnitTypeId +unit_data(Api::UnitTypeId::MARINE) +``` + +**Real-time data vs static** + +The Data class brings data from the current game, once before `on_start`. It is **fixed** for the entire game. +`some_unit.unit_data` (which calls `data.units`) will not change, nor will `data.abilities[...]` + +Conversely, to know information about abilities in this very moment of the game, you can run a Query for that instead. +This will show, i.e. if a specific Ghost can Nuke or whether a Gateway can warp a Stalker, etc. +```ruby +query_abilities_for_unit_tags(some_unit.tag, ignore_resource_requirements: true) +``` + +All actual {Api::Unit} properties are **realtime, for this moment**, refreshed on each Observation. +See the [protobuf message Unit](https://github.com/Blizzard/s2client-proto/blob/c04df4adbe274858a4eb8417175ee32ad02fd609/s2clientprotocol/raw.proto#L99) for a list of such properties or run #inspect on a unit to observe it's attributes. + +### Static Ability data + +As above with unit_data, `Bot#ability_data(ability_id)` can provide information surrounding a specific ability. + +### Tech tree + +Courtesy of the brilliant work of the python crew, Dentosal and Burny <3. +If you wish to do meta programming based on the game's tech requirement structure, you can use the tech {Api::TechTree}. + +All calls are statically made to the class. + +| method | desc | Fast/Med/Slow | +|------------------------------------------------------------------|------------------------------------------------------------------------------------|---------------| +| Api::TechTree.creates_unit_types(unit_type_id:) | what unit types a specific unit type can produce. Barracks->Marine/Marauder/Reaper | F | +| Api::TechTree.unit_created_from(unit_type_id:) | returns the unit type which can create this unit. Marine->Barracks | F | +| Api::TechTree.unit_type_creation_abilities(source:, target: nil) | which units can be created at source + the ability to trigger it. | F | +| Api::TechTree.unit_type_creation_ability_id(source:, target:) | which ability id creates the target, given the source unit/building | F | +| Api::TechTree.upgrade_researched_from(upgrade_id:) | what the unit type an upgrade is researched from | F | + + +## Protocol + +The library talks to SC2 over Google Protobuf. +The .proto files ship with the gem but are available for review here: +Proto: https://github.com/Blizzard/s2client-proto/tree/master/s2clientprotocol
+Overview: https://github.com/Blizzard/s2client-proto/blob/master/docs/protocol.md + +## Namespaces + +The code forces you to know at all times whether you're working with the library or api objects. +`Sc2` namespace is for gem internals. +`Api` namespace is for protocol data and Message objects. + +Functionality might be added to Api object, such as `Api::Unit#attack` for programming ease. +```hero_marine.attack(target: ...)``` is a more desirable usage than working from the bot instead, i.e. +```ruby +class MyBot < Sc2::Player::Bot + def on_step + + marines = units.army.select_type(Api::UnitTypeId::MARINE) + hero_marine = marines.first + + # Example raw action command for unit from Bot + api.send_request_for(action: Api::RequestAction.new( + actions: [Api::Action.new( + action_raw: Api::ActionRaw.new( + unit_command: Api::ActionRawUnitCommand.new( + ability_id: Api::AbilityId::ATTACK, + source: hero_marine.tag, + target_unit_tag: #... + ) + ) + )] + )) + + # Simplified with helper method action_raw_unit_command + api.action_raw_unit_command(unit_tags: hero_marine.tag, + ability_id: Api::AbilityId::ATTACK, + target_unit_tag: #... + ) + + # But from the context of a Unit, using #attack extension, + # this code feels better than both. + hero_marine.attack target: #... + + end +end +``` + +Therefore, sometimes `Api` objects such as `Api::Unit` or `Api::Point2D` will do `Sc2`-type work and be more than just store information or be a target for an action request. +It will be to your own benefit to actually reference the class documentation. If you open up {Api::Unit}, this will be immediately apparent. + +### Verbose by design +References are verbose by default and mimics the proto definition, which is good for your immersion. +If you target something from the Api, you have to type "Api" and you _learn_ about the Api. Yay! + +However, this means you often triple-bang into constants, i.e. `Api::AbilityId::RALLY_BUILDING`. +Once you've mastered everything, feel free to **reduce verbosity** in any way you see fit. + +```ruby +# Option A: Define short names. Safe. +TID = Api::UnitTypeId # i.e. TID:MOTHERSHIP +AID = Api::AbilityId # Api::AbilityId::EFFECT_STIM becomes AID:EFFECT_STIM +# etc. EffectId, BuffId, UpgradeId + +# Optionally: Namespace your bot inside module Sc2 to drop all Sc2::* prefixes. Safe. +module Sc2 + class MyBot < Player::Bot + + # Option B: Mildly riskier than Option A, but safe for now + include Api # All of Api is yours, directly call UnitTypeId::MOTHERSHIP + + end +end +``` + +Protobuf enums can be referenced as Symbols. +```ruby +# enum Result { +# Victory = 1; +# Defeat = 2; +# Tie = 3; +# Undecided = 4; +# } +Api::Result::Victory # can be substituted as... +:Victory + +# enum AIBuild { +# RandomBuild = 1; +# Rush = 2; +# Timing = 3; +# Power = 4; +# Macro = 5; +# Air = 6; +# } +Api::AIBuild::Air # can be substituted as ... +:Air + +# This is not true for the Api::*Id::* fields, such as AbilityId, because they are generated and not proto definitions. +``` + +**Keyword arguments** +Also, where an id for a specific type is required, keyword params will tell you what it wants. Great! +`def ...(unit_type_id: ...)` `def ...(ability_id: ...)` and so on. + +Descriptive keyword arguments are here to stay, but should you grow weary of the lengthy signatures, you are welcome to define your own wrapping methods for your most frequently typed calls. + +New botters should learn easily, but you can create your own joy. ❤️ + +--- + +Here be {file:docs/TUTORIAL_01.md tutorials}. \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index ae59a85..5b2de71 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,8 +9,10 @@ This Ruby language is also now available, free to play, at https://www.ruby-lang Mac (Apple® silicon / Intel®), Microsoft© Windows, WSL and Linux® are all supported. -## First rule - only tell the _cool_ nerds -The things we write are called "bots". They beat crap out of eachother on https://aiarena.net. +You should read this file and then {file:docs/QUICK_REFERENCE.md proceed to the tutorials}. + +## I am Jack's digital fury +The things we write are called "bots". They beat crap out of each other on https://aiarena.net. There are [regular tournaments](https://www.youtube.com/@ProbotsAI) and a permanent live stream here https://www.twitch.tv/aiarenastream. That's right, you just stumbled upon the final boss of competitive coding. Welcome to Fight Club for Nerds, nerd. @@ -56,8 +58,8 @@ From Command Prompt: ### Get the gem #### Regarding Ruby versions -Ruby 3.2.2 runs amazingly and all the profilers and debuggers work. -If you don't need to profile or debug, 3.3.0 performs even better. +If you have any issues with profiling or debugging, Ruby 3.2 runs amazingly. +Ruby 3.3 performs even better. Enabling YJIT is essential as we repeat methods frequently. Pass the runtime arg `--yjit` or `export RUBY_YJIT_ENABLE=1`. #### On to the gem @@ -196,11 +198,11 @@ Sc2::Match.new( Congrats, you're botting! The replay is auto-saved as `data/replays/autosave-#{botname}.SC2Replay` for casual review. -If the code scares you, especially that nasty deep `game_info.start_raw.start_locations.first` part (yuck), fear not - it's an anomaly. -The syntax is quite friendly while also forcibly teaching you the API (by design). +If the code scares you, especially that `game_info.start_raw.start_locations.first` part, fear not. +The syntax is generally quite friendly while also forcibly teaching you the API. We have some extremely useful tutorials ahead once you're done skimming the next two sections. -## Competiting on the ladder +## Competing on the ladder The dear ladder Admin have had their holiday consumed by moving the aiarena infrastructure to AWS. Ruby support for the aiarena.net ladder will follow in 2024. @@ -248,7 +250,7 @@ Or even how is ANY OF THIS possible? Let's go through all of the above in byte sized chunks with the tutorials which follows. The README is over, but check out [Acknowledgements](#label-Acknowledgements) below which answers one of these questions. -Onwards, {file:docs/TUTORIAL1_INFODUMP.md to the tutorials!} +Onwards, {file:docs/QUICK_REFERENCE.md to the tutorials!} ## Development diff --git a/docs/TUTORIAL_01.md b/docs/TUTORIAL_01.md new file mode 100644 index 0000000..6a55bd2 --- /dev/null +++ b/docs/TUTORIAL_01.md @@ -0,0 +1,4 @@ +# TUTORIAL 1 + +Work in progress. Due soon. +-Dyson, Jan 23 \ No newline at end of file diff --git a/docs/images/bot_lifecycle.drawio b/docs/images/bot_lifecycle.drawio new file mode 100644 index 0000000..0bfb079 --- /dev/null +++ b/docs/images/bot_lifecycle.drawio @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/images/bot_lifecycle.png b/docs/images/bot_lifecycle.png new file mode 100644 index 0000000000000000000000000000000000000000..08bbd5789efe347ec890d39fc36f7ac3c10e15e3 GIT binary patch literal 97258 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 k;d+id3Ow1e] AbilitId => AbilityData + # @return [Hash] AbilityId => AbilityData attr_accessor :abilities # @!attribute units # @return [Hash] UnitId => UnitData attr_accessor :units # @!attribute upgrades - # @return [Hash] UpgradeId => UpgradeData + # @return [Hash] UnitTypeId => UnitTypeData attr_accessor :upgrades # @!attribute buffs # Not particularly useful data. Just use BuffId directly - # @return [Hash] BuffId => BuffData + # @return [Hash] BuffId => BuffData attr_accessor :buffs # @!attribute effects # Not particularly useful data. Just use EffectId directly - # @return [Hash] EffectId => EffectData + # @return [Hash] EffectId => EffectData attr_accessor :effects # @param data [Api::ResponseData] diff --git a/lib/sc2ai/api/tech_tree.rb b/lib/sc2ai/api/tech_tree.rb index 3816818..fa5da2c 100644 --- a/lib/sc2ai/api/tech_tree.rb +++ b/lib/sc2ai/api/tech_tree.rb @@ -1,8 +1,9 @@ require_relative "tech_tree_data" -# Provides helper functions which work with and rely on auto generated data in tech_tree_data.rb -# To lighten code generation, these methods live in a file of their own and may be modified. + module Api + # Provides helper functions which work with and rely on auto generated data in tech_tree_data.rb + # To lighten code generation, these methods live in a file of their own and may be modified. module TechTree class << self # Get units can be created at source + the ability to trigger it. Optionally target a specific unit from source diff --git a/lib/sc2ai/cli/cli.rb b/lib/sc2ai/cli/cli.rb index 76a2d89..6f5a710 100644 --- a/lib/sc2ai/cli/cli.rb +++ b/lib/sc2ai/cli/cli.rb @@ -1,11 +1,13 @@ require "thor" module Sc2 + # Command line utilities class Cli < Thor package_name "Cli" map "-setup410" => :setup410 desc "setup410", "downloads and install SC2 v4.10" + # downloads and install SC2 v4.10 def setup410 puts " " puts "This script sets up SC2 at version 4.10, which we use competitively." diff --git a/lib/sc2ai/configuration.rb b/lib/sc2ai/configuration.rb index 5425008..facdbc4 100644 --- a/lib/sc2ai/configuration.rb +++ b/lib/sc2ai/configuration.rb @@ -34,6 +34,7 @@ class Configuration temp_dir egl_path osmesa_path + enable_feature_layer ].freeze # @!attribute sc2_platform @@ -52,6 +53,11 @@ class Configuration # @return [Array] attr_accessor :ports + # @!attribute enable_feature_layer + # Enables the feature layer at 1x1 pixels + # @return [Boolean] enable_feature_layer + attr_accessor :enable_feature_layer + # Create a new Configuration and sets defaults and loads config from yaml # @return [Sc2::Configuration] def initialize @@ -76,6 +82,7 @@ def set_defaults @sc2_platform = Paths.platform @sc2_path = Paths.install_dir @ports = [] + @enable_feature_layer = false load_default_launch_options end diff --git a/lib/sc2ai/connection.rb b/lib/sc2ai/connection.rb index 70173b6..78eb5ab 100644 --- a/lib/sc2ai/connection.rb +++ b/lib/sc2ai/connection.rb @@ -83,6 +83,9 @@ def add_listener(listener, klass:) @listeners[klass.to_s].push(listener) end + # Removes a listener of specific callback type + # @param listener [Object] + # @param klass [Module,Module] def remove_listener(listener, klass:) @listeners[klass.to_s].delete(listener) end diff --git a/lib/sc2ai/connection/requests.rb b/lib/sc2ai/connection/requests.rb index 10a2be6..e942eb0 100644 --- a/lib/sc2ai/connection/requests.rb +++ b/lib/sc2ai/connection/requests.rb @@ -27,7 +27,6 @@ def create_game(map:, players:, realtime: false) def join_game(race:, name:, server_host:, port_config:, enable_feature_layer: false, interface_options: {}) interface_options ||= {} - # TODO: enable_feature_layer should be a bot Sc2::Config (yaml or block) default_crop_playable_area = true default_raw_affects_selection = false @@ -148,12 +147,12 @@ def leave_game send_request_for leave_game: Api::RequestLeaveGame.new end - # Saves game to an in-memory bookmark. (Does not appear to do anything.) + # Saves game to an in-memory bookmark. def request_quick_save send_request_for quick_save: Api::RequestQuickSave.new end - # Loads from an in-memory bookmark. (Does not appear to do anything.) + # Loads from an in-memory bookmark. def request_quick_load send_request_for quick_load: Api::RequestQuickLoad.new end @@ -308,7 +307,7 @@ def query_pathings(queries) (arr_queries.size > 1) ? response.pathing : response.pathing.first end - # Queries one or more pathing queries + # Queries one or more ability-available checks # @param queries [Array, Api::RequestQueryAvailableAbilities] one or more pathing queries # @param ignore_resource_requirements [Boolean] Ignores requirements like food, minerals and so on. # @return [Array, Api::ResponseQueryAvailableAbilities] one or more results depending on input size diff --git a/lib/sc2ai/local_play/match.rb b/lib/sc2ai/local_play/match.rb index 86c64ed..da12fae 100644 --- a/lib/sc2ai/local_play/match.rb +++ b/lib/sc2ai/local_play/match.rb @@ -6,6 +6,8 @@ module Sc2 # Runs a match using a map and player configuration class Match include Sc2::Connection::StatusListener + + # Callback when game status changes def on_status_change(status) Sc2.logger.debug { "Status from Match: #{status}" } @@ -70,7 +72,8 @@ def run run_task.async do player.join_game( server_host: ClientManager.get(player_index).host, - port_config: + port_config:, + enable_feature_layer: Sc2.config.enable_feature_layer ) player.add_listener(self, klass: Connection::StatusListener) diff --git a/lib/sc2ai/overrides/array.rb b/lib/sc2ai/overrides/array.rb index f9c7fc8..c7c3ed5 100644 --- a/lib/sc2ai/overrides/array.rb +++ b/lib/sc2ai/overrides/array.rb @@ -1,3 +1,4 @@ +# Array extensions class Array # Turns an Array of Api::Unit into a Sc2::UnitGroup # @return [Sc2::UnitGroup] array converted to a unit group diff --git a/lib/sc2ai/overrides/kernel.rb b/lib/sc2ai/overrides/kernel.rb index 0b4f74a..76a5296 100644 --- a/lib/sc2ai/overrides/kernel.rb +++ b/lib/sc2ai/overrides/kernel.rb @@ -3,6 +3,8 @@ # @private # Patched from: rails/activesupport/lib/active_support/core_ext/kernel/reporting.rb # https://github.com/rails/rails/blob/04972d9b9ef60796dc8f0917817b5392d61fcf09/activesupport/lib/active_support/core_ext/kernel/reporting.rb#L26 + +# Kernel extensions module Kernel module_function diff --git a/lib/sc2ai/player.rb b/lib/sc2ai/player.rb index effbfbb..f1861f9 100755 --- a/lib/sc2ai/player.rb +++ b/lib/sc2ai/player.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require_relative "api/data" require_relative "connection/connection_listener" require_relative "connection/status_listener" require_relative "player/game_state" @@ -133,11 +134,12 @@ def create_game(map:, players:, realtime: false) # @param server_host [String] ip address # @param port_config [Sc2::PortConfig] # @param interface_options [Hash] - def join_game(server_host:, port_config:, interface_options: {}) + def join_game(server_host:, port_config:, enable_feature_layer:, interface_options: {}) Sc2.logger.debug { "Player \"#{@name}\" joining game..." } - @api.join_game(name: @name, race: @race, server_host:, port_config:, interface_options:) # , enable_feature_layer: false) + @api.join_game(name: @name, race: @race, server_host:, port_config:, enable_feature_layer:, interface_options:) # , enable_feature_layer: false) end + # Multiplayer only. Disconnects from a multiplayer game, equivalent to surrender. Keeps client alive. def leave_game @api.leave_game end diff --git a/lib/sc2ai/player/actions.rb b/lib/sc2ai/player/actions.rb index cd1b766..c65f97a 100644 --- a/lib/sc2ai/player/actions.rb +++ b/lib/sc2ai/player/actions.rb @@ -7,7 +7,7 @@ module Actions # @return [Array] attr_accessor :action_queue - # Queues action for performing later + # Queues action for performing end of step # @param action [Api::Action] # @return [void] def queue_action(action) diff --git a/lib/sc2ai/player/debug.rb b/lib/sc2ai/player/debug.rb index 62a632d..7e2faa0 100644 --- a/lib/sc2ai/player/debug.rb +++ b/lib/sc2ai/player/debug.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -# WARNING! Debug methods will not be available on Ladder module Sc2 class Player + # WARNING! Debug methods will not be available on Ladder # This provides debug helper functions for RequestDebug module Debug # Holds debug commands which will be queued off each time we step forward diff --git a/lib/sc2ai/player/game_state.rb b/lib/sc2ai/player/game_state.rb index c2faeb6..f7a5729 100644 --- a/lib/sc2ai/player/game_state.rb +++ b/lib/sc2ai/player/game_state.rb @@ -9,6 +9,7 @@ module GameState attr_accessor :status include Connection::StatusListener + # Callback when game status changes def on_status_change(status) self.status = status end diff --git a/lib/sc2ai/player/geometry.rb b/lib/sc2ai/player/geometry.rb index 44c1f4b..ce19eb3 100755 --- a/lib/sc2ai/player/geometry.rb +++ b/lib/sc2ai/player/geometry.rb @@ -96,6 +96,7 @@ def expo_placement_grid @expo_placement_grid end + # Returns a grid where powered locations are marked true def parsed_power_grid # Cache for based on power unit tags cache_key = bot.power_sources.map(&:tag).sort.hash @@ -280,7 +281,7 @@ def parsed_terrain_height # @param y [Float, Integer] # @return [Integer] 0=Hidden,1= Snapshot,2=Visible def visibility(x:, y:) - parsed_visiblity_grid[y.to_i, x.to_i] + parsed_visibility_grid[y.to_i, x.to_i] end # Returns whether the point (tile) is currently in vision @@ -308,10 +309,10 @@ def map_unseen?(x:, y:) end # Returns a parsed map_state.visibility from bot.observation.raw_data. - # Each value in [row][column] holds a float value which is the z height + # Each value in [row][column] holds one of three integers (0,1,2) to flag a vision type # @see #visibility for reading from this value # @return [Numo::SFloat] Numo array - def parsed_visiblity_grid + def parsed_visibility_grid if @parsed_visibility_grid.nil? image_data = bot.observation.raw_data.map_state.visibility @parsed_visibility_grid = ::Numo::UInt8.from_binary(image_data.data, @@ -320,7 +321,7 @@ def parsed_visiblity_grid @parsed_visibility_grid end - # Returns whether a x/y block has creep on it, as per minimap + # Returns whether a tile has creep on it, as per minimap # One pixel covers one whole block. Corrects float inputs on your behalf. # @param x [Float, Integer] # @param y [Float, Integer] @@ -401,11 +402,11 @@ def divide_grid(input_grid, length) # Gets expos and surrounding minerals # The index is a build location for an expo and the value is a UnitGroup, which has minerals and geysers # @example - # random_expo = expansions.keys.sample #=> Point2d + # random_expo = expansions.keys.sample #=> Point2D # expo_resources = geo.expansions[random_expo] #=> UnitGroup # alive_minerals = expo_resources.minerals - neutral.minerals # geysers = expo_resources.geysers - # @return [Hash UniGroup of resources (minerals+geysers) + # @return [Hash] Location => UnitGroup of resources (minerals+geysers) def expansions return @expansions unless @expansions.nil? @@ -508,7 +509,7 @@ def expansion_points # # What minerals/geysers does it have? # puts expansions_unoccupied[expo_pos].minerals # or expansions[expo_pos]... => UnitGroup # puts expansions_unoccupied[expo_pos].geysers # or expansions[expo_pos]... => UnitGroup - # @return [Hash UniGroup of resources (minerals+geysers) + # @return [Hash UnitGroup of resources (minerals+geysers) def expansions_unoccupied taken_bases = bot.structures.hq.map { |hq| hq.pos.to_p2d } + bot.enemy.structures.hq.map { |hq| hq.pos.to_p2d } remaining_points = expansion_points - taken_bases @@ -602,6 +603,98 @@ def build_placement_near(length:, target:, random: 1) coordinates[nearest.sample].to_p2d end + + # Protoss ------ + + # Draws a grid within a unit (pylon/prisms) radius, then selects points which are placeable + # @param source [Api::Unit] either a pylon or a prism + # @param unit_type_id [Api::Unit] optionally, the unit you wish to place. Stalkers are widest, so use default nil for a mixed composition warp + # @return [Array] an array of 2d points where theoretically placeable + def warp_points(source:, unit_type_id: nil) + # power source needed + power_source = bot.power_sources.find { |ps| source.tag == ps.tag } + return [] if power_source.nil? + + # hardcoded unit radius, otherwise only obtainable by owning a unit already + unit_type_id = Api::UnitTypeId::STALKER if unit_type_id.nil? + target_radius = case unit_type_id + when Api::UnitTypeId::STALKER + 0.625 + when Api::UnitTypeId::HIGHTEMPLAR, Api::UnitTypeId::DARKTEMPLAR + 0.375 + else + 0.5 # Adept, zealot, sentry, etc. + end + unit_width = target_radius * 2 + + # power source's inner and outer radius + outer_radius = power_source.radius + # Can not spawn on-top of pylon + inner_radius = (source.unit_type == Api::UnitTypeId::PYLON) ? source.radius : 0 + + # Make a grid of circles packed in triangle formation, covering the power field + points = [] + y_increment = Math.sqrt(Math.hypot(unit_width, unit_width / 2.0)) + offset_row = false + # noinspection RubyMismatchedArgumentType # rbs fixed in future patch + ((source.pos.y - outer_radius + target_radius)..(source.pos.y + outer_radius - target_radius)).step(y_increment) do |y| + ((source.pos.x - outer_radius + target_radius)..(source.pos.x + outer_radius - target_radius)).step(unit_width) do |x| + x += target_radius if offset_row + points << Api::Point2D[x, y] + end + offset_row = !offset_row + end + + # Select only grid points inside the outer source and outside the inner source + points.select! do |grid_point| + gp_distance = source.pos.distance_to(grid_point) + gp_distance > inner_radius + target_radius && gp_distance + target_radius < outer_radius + end + + # Find X amount of near units within the radius and subtract their overlap in radius with points + # we arbitrarily decided that a pylon will no be surrounded by more than 50 units + # We add 2.75 above, which is the fattest ground unit (nexus @ 2.75 radius) + units_in_pylon_range = bot.all_units.nearest_to(pos: source.pos, amount: 50) + .select_in_circle(point: source.pos, radius: outer_radius + 2.75) + + # Reject warp points which overlap with units inside + points.reject! do |point| + # Find units which overlap with our warp points + units_in_pylon_range.find do |unit| + xd = (unit.pos.x - point.x).abs + yd = (unit.pos.y - point.y).abs + intersect_distance = target_radius + unit.radius + next false if xd > intersect_distance || yd > intersect_distance + + Math.hypot(xd, yd) < intersect_distance + end + end + + # Select only warp points which are on placeable tiles + points.reject! do |point| + left = (point.x - target_radius).floor.clamp(map_tile_range_x) + right = (point.x + target_radius).floor.clamp(map_tile_range_x) + top = (point.y + target_radius).floor.clamp(map_tile_range_y) + bottom = (point.y - target_radius).floor.clamp(map_tile_range_y) + + unplaceable = false + x = left + while x <= right + break if unplaceable + y = bottom + while y <= top + unplaceable = !placeable?(x: x, y: y) + break if unplaceable + y += 1 + end + x += 1 + end + unplaceable + end + + points + end + # Geometry helpers --- # Finds points in a straight line. diff --git a/lib/sc2ai/player/previous_state.rb b/lib/sc2ai/player/previous_state.rb index 0da27e0..f0a0923 100644 --- a/lib/sc2ai/player/previous_state.rb +++ b/lib/sc2ai/player/previous_state.rb @@ -28,7 +28,7 @@ def reset(bot) @spent_minerals = bot.spent_minerals @spent_vespene = bot.spent_vespene @spent_supply = bot.spent_supply - # Skipping unnecessary bloat: events_*, chats_received, ... + # Skipping unnecessary bloat: event_*, chats_received, ... after_reset(bot) end diff --git a/lib/sc2ai/player/units.rb b/lib/sc2ai/player/units.rb index 1318aaa..1828f62 100644 --- a/lib/sc2ai/player/units.rb +++ b/lib/sc2ai/player/units.rb @@ -73,7 +73,7 @@ module Units attr_accessor :event_structures_completed # Units and Structures which had their health/shields reduced since last frame - # Read this on_step. Alternative to callback on_unit_type_changed + # Read this on_step. Alternative to callback on_unit_damaged # @!attribute event_units_damaged # @return [Sc2::UnitGroup] group of Units and Structures effected attr_accessor :event_units_damaged @@ -132,97 +132,6 @@ def unit_group_from_tags(tags) ug end - # Protoss ------ - - # Draws a grid within a unit (pylon/prisms) radius, then selects points which are placeable - # @param source [Api::Unit] either a pylon or a prism - # @param unit_type_id [Api::Unit] optionally, the unit you wish to place. Stalkers are widest, so use default nil for a mixed composition warp - # @return [Array] an array of 2d points where theoretically placeable - def warp_points(source:, unit_type_id: nil) - # power source needed - power_source = @power_sources.find { |ps| source.tag == ps.tag } - return [] if power_source.nil? - - # hardcoded unit radius, otherwise only obtainable by owning a unit already - unit_type_id = Api::UnitTypeId::STALKER if unit_type_id.nil? - target_radius = case unit_type_id - when Api::UnitTypeId::STALKER - 0.625 - when Api::UnitTypeId::HIGHTEMPLAR, Api::UnitTypeId::DARKTEMPLAR - 0.375 - else - 0.5 # Adept, zealot, sentry, etc. - end - unit_width = target_radius * 2 - - # power source's inner and outer radius - outer_radius = power_source.radius - # Can not spawn on-top of pylon - inner_radius = (source.unit_type == Api::UnitTypeId::PYLON) ? source.radius : 0 - - # Make a grid of circles packed in triangle formation, covering the power field - points = [] - y_increment = Math.sqrt(Math.hypot(unit_width, unit_width / 2.0)) - offset_row = false - # noinspection RubyMismatchedArgumentType # rbs fixed in future patch - ((source.pos.y - outer_radius + target_radius)..(source.pos.y + outer_radius - target_radius)).step(y_increment) do |y| - ((source.pos.x - outer_radius + target_radius)..(source.pos.x + outer_radius - target_radius)).step(unit_width) do |x| - x += target_radius if offset_row - points << Api::Point2D[x, y] - end - offset_row = !offset_row - end - - # Select only grid points inside the outer source and outside the inner source - points.select! do |grid_point| - gp_distance = source.pos.distance_to(grid_point) - gp_distance > inner_radius + target_radius && gp_distance + target_radius < outer_radius - end - - # Find X amount of near units within the radius and subtract their overlap in radius with points - # we arbitrarily decided that a pylon will no be surrounded by more than 50 units - # We add 2.75 above, which is the fattest ground unit (nexus @ 2.75 radius) - units_in_pylon_range = all_units.nearest_to(pos: source.pos, amount: 50) - .select_in_circle(point: source.pos, radius: outer_radius + 2.75) - - # Reject warp points which overlap with units inside - points.reject! do |point| - # Find units which overlap with our warp points - units_in_pylon_range.find do |unit| - xd = (unit.pos.x - point.x).abs - yd = (unit.pos.y - point.y).abs - intersect_distance = target_radius + unit.radius - next false if xd > intersect_distance || yd > intersect_distance - - Math.hypot(xd, yd) < intersect_distance - end - end - - # Select only warp points which are on placeable tiles - points.reject! do |point| - left = (point.x - target_radius).floor.clamp(geo.map_tile_range_x) - right = (point.x + target_radius).floor.clamp(geo.map_tile_range_x) - top = (point.y + target_radius).floor.clamp(geo.map_tile_range_y) - bottom = (point.y - target_radius).floor.clamp(geo.map_tile_range_y) - - unplaceable = false - x = left - while x <= right - break if unplaceable - y = bottom - while y <= top - unplaceable = !geo.placeable?(x: x, y: y) - break if unplaceable - y += 1 - end - x += 1 - end - unplaceable - end - - points - end - # Geo/Map/Macro ------ # @private diff --git a/lib/sc2ai/protocol/_meta_documentation.rb b/lib/sc2ai/protocol/_meta_documentation.rb new file mode 100644 index 0000000..3f26cdd --- /dev/null +++ b/lib/sc2ai/protocol/_meta_documentation.rb @@ -0,0 +1,39 @@ +# This file defines meta documentation for Protobuf objects +# Review data/sc2ai/protocol +# Do not add functionality. + +# Protobuf classes +# @!parse +# module Api; +# # Protobuf virtual class. +# class Color < Google::Protobuf::AbstractMessage; end; +# # Protobuf virtual class. +# class Point < Google::Protobuf::AbstractMessage; end; +# # Protobuf virtual class. +# class Point2D < Google::Protobuf::AbstractMessage; end; +# # Protobuf virtual class. +# class PointI < Google::Protobuf::AbstractMessage; end; +# # Protobuf virtual class. +# class PowerSource < Google::Protobuf::AbstractMessage; end; +# # Protobuf virtual class. +# class Size2DI < Google::Protobuf::AbstractMessage; end; +# # Protobuf virtual class. +# class Point < Google::Protobuf::AbstractMessage; end; +# # Protobuf virtual class. +# class Unit < Google::Protobuf::AbstractMessage; end; +# end + +# Protobuf enums --- +# Protobuf classes +# @!parse +# module Api; +# # Protobuf virtual enum. +# class Race < Google::Protobuf::AbstractMessage; end; +# # Protobuf virtual enum. +# class PlayerType < Google::Protobuf::AbstractMessage; end; +# # Protobuf virtual enum. +# class Difficulty < Google::Protobuf::AbstractMessage; end; +# # Protobuf virtual enum. +# class AIBuild < Google::Protobuf::AbstractMessage; end; +# end +# @!parse diff --git a/lib/sc2ai/protocol/extensions/color.rb b/lib/sc2ai/protocol/extensions/color.rb index adbdc28..67b74d2 100644 --- a/lib/sc2ai/protocol/extensions/color.rb +++ b/lib/sc2ai/protocol/extensions/color.rb @@ -1,16 +1,12 @@ module Api # Adds additional functionality and fixes quirks with color specifically pertaining to debug commands module ColorExtension - def self.included(base) - super(base) - base.extend ClassMethods - end - # For lines: r & b are swapped. def initialize(r:, g:, b:) super(r: b, g: g, b: r) end + # Adds additional functionality to message class Api::Color module ClassMethods # Creates a new Api::Color object with random rgb values # @return [Api::Color] random color @@ -21,3 +17,4 @@ def random end end Api::Color.include Api::ColorExtension +Api::Color.extend Api::ColorExtension::ClassMethods diff --git a/lib/sc2ai/protocol/extensions/point.rb b/lib/sc2ai/protocol/extensions/point.rb index e387d8d..8a60703 100644 --- a/lib/sc2ai/protocol/extensions/point.rb +++ b/lib/sc2ai/protocol/extensions/point.rb @@ -1,15 +1,13 @@ module Api # Adds additional functionality to message object Api::Point module PointExtension - def self.included(base) - super(base) - base.extend ClassMethods - end - + # Creates a Point2D using x and y + # @return [Api::Point2D] def to_p2d Api::Point2D.new(x: x, y: y) end + # Adds additional functionality to message class Api::Point module ClassMethods # Shorthand for creating an instance for [x, y, z] # @example @@ -22,3 +20,4 @@ def [](x, y, z) end end Api::Point.include Api::PointExtension +Api::Point.include Api::PointExtension::ClassMethods diff --git a/lib/sc2ai/protocol/extensions/point_2_d.rb b/lib/sc2ai/protocol/extensions/point_2_d.rb index 21809d8..dc31199 100644 --- a/lib/sc2ai/protocol/extensions/point_2_d.rb +++ b/lib/sc2ai/protocol/extensions/point_2_d.rb @@ -1,23 +1,16 @@ -# This class was partially generated with the help of AI. - module Api # Adds additional functionality to message object Api::Point2D module Point2DExtension - def self.included(base) - super(base) - base.extend ClassMethods - end - + # @private def hash [x, y].hash end def eql?(other) - # This is faster, but intolerant. Consider changing to method below it self.class == other.class && hash == other.hash - # self.class == other.class && ((x - other.x).abs < TOLERANCE) && ((y - other.y).abs < TOLERANCE) end + # Adds additional functionality to message class Api::Point2D module ClassMethods # Shorthand for creating an instance for [x, y] # @example @@ -30,3 +23,4 @@ def [](x, y) end end Api::Point2D.include Api::Point2DExtension +Api::Point2D.extend Api::Point2DExtension::ClassMethods diff --git a/lib/sc2ai/protocol/extensions/point_distance.rb b/lib/sc2ai/protocol/extensions/point_distance.rb deleted file mode 100644 index d038fff..0000000 --- a/lib/sc2ai/protocol/extensions/point_distance.rb +++ /dev/null @@ -1,11 +0,0 @@ -# This class was partially generated with the help of AI. - -module Api - # Adds additional functionality to message object Api::Unit - module PointDistanceExtension - end -end -Api::Point.include Api::PointDistanceExtension -Api::Point2D.include Api::PointDistanceExtension -Api::PointI.include Api::PointDistanceExtension -Api::Size2DI.include Api::PointDistanceExtension diff --git a/lib/sc2ai/protocol/extensions/position.rb b/lib/sc2ai/protocol/extensions/position.rb index 894afc4..a949635 100644 --- a/lib/sc2ai/protocol/extensions/position.rb +++ b/lib/sc2ai/protocol/extensions/position.rb @@ -92,33 +92,34 @@ def offset!(x, y) # Vector operations --- # For vector returns the magnitude, synonymous with Math.hypot + # @return [Float] def magnitude Math.hypot(x, y) end # The dot product of this vector and the other vector. - # @param other [Point2d] The other vector to calculate the dot product with. + # @param other [Api::Point2D] The other vector to calculate the dot product with. # @return [Float] def dot(other) x * other.x + y * other.y end - # The z-component of the cross product of this vector and the other vector. - # @param other [Point2d] The other vector to calculate the cross product with. + # The cross product of this vector and the other vector. + # @param other [Api::Point2D] The other vector to calculate the cross product with. # @return [Float] def cross_product(other) x * other.y - y * other.x end # The angle between this vector and the other vector, in radians. - # @param other [Point2d] The other vector to calculate the angle to. + # @param other [Api::Point2D] The other vector to calculate the angle to. # @return [Float] def angle_to(other) Math.acos(dot(other) / (magnitude * other.magnitude)) end # A new point representing the normalized version of this vector (unit length). - # @return [Point2d] + # @return [Api::Point2D] def normalize divide(magnitude) end @@ -128,6 +129,7 @@ def normalize # Linear interpolation between this point and another for scale # Finds a point on a line between two points at % along the way. 0.0 returns self, 1.0 returns other, 0.5 is halfway. # @param scale [Float] a value between 0.0..1.0 + # @return [Api::Point2D] def lerp(other, scale) Api::Point2D[x + (other.x - x) * scale, y + (other.y - y) * scale] end @@ -136,6 +138,7 @@ def lerp(other, scale) # Calculates the distance between self and other # @param other [Sc2::Position] + # @return [Float] def distance_to(other) if other.nil? || other == self return 0.0 @@ -153,6 +156,8 @@ def distance_squared_to(other) (x - other.x) * (y - other.y) end + # Distance between this point and coordinate of x and y + # @return [Float] def distance_to_coordinate(x:, y:) Math.hypot(self.x - x, self.y - y) end @@ -173,18 +178,18 @@ def distance_to_circle(center, radius) # Movement --- # Moves in direction towards other point by distance - # @param other [Point2D] The target point to move to. + # @param other [Api::Point2D] The target point to move to. # @param distance [Float] The distance to move. - # @return [Point2D] + # @return [Api::Point2D] def towards(other, distance) direction = other.subtract(self).normalize add(direction.multiply(distance)) end # Moves in direction away from the other point by distance - # @param other [Point2D] The target point to move away from + # @param other [Api::Point2D] The target point to move away from # @param distance [Float] The distance to move. - # @return [Point2D] + # @return [Api::Point2D] def away_from(other, distance) towards(other, -distance) end diff --git a/lib/sc2ai/protocol/extensions/power_source.rb b/lib/sc2ai/protocol/extensions/power_source.rb index 435b9c4..5c3014d 100644 --- a/lib/sc2ai/protocol/extensions/power_source.rb +++ b/lib/sc2ai/protocol/extensions/power_source.rb @@ -1,13 +1,9 @@ module Api # Adds additional functionality to message object Api::PowerSource module PowerSourceExtension - def self.included(base) - super(base) - base.extend ClassMethods - end - include Sc2::Position + # Adds additional functionality to message class Api::PowerSource module ClassMethods # Shorthand for creating an instance for [x, y, z] # @example @@ -20,3 +16,4 @@ def [](x, y, z) end end Api::PowerSource.include Api::PowerSourceExtension +Api::PowerSource.extend Api::PowerSourceExtension::ClassMethods diff --git a/lib/sc2ai/protocol/extensions/unit.rb b/lib/sc2ai/protocol/extensions/unit.rb index 9f23bfa..d572081 100755 --- a/lib/sc2ai/protocol/extensions/unit.rb +++ b/lib/sc2ai/protocol/extensions/unit.rb @@ -1,15 +1,10 @@ -# Open up the unit class to enable yard doc to read it. -module Api - # A protobuf message containing unit info - # @see data.proto message Unit{} - class Unit # < Google::Protobuf::AbstractMessage - end -end +# frozen_string_literal: true module Api # Adds additional functionality to message object Api::Unit # Mostly adds convenience methods by adding direct access to the Sc2::Bot data and api module UnitExtension + # @private def hash tag || super end @@ -117,11 +112,17 @@ def is_summoned? # @!group Virtual properties - # @!attribute [r] radius - # @return [Boolean] unit width is radius * 2 + # Helpers for unit properties + def width = radius * 2 + # @!parse + # # @!attribute width + # # width = radius * 2 + # # @return [Float] + # attr_reader :width # Some overrides to allow question mark references to boolean properties + # @!attribute [r] is_flying? # @return [Boolean] Unit is currently flying. def is_flying? = is_flying @@ -168,21 +169,21 @@ def is_ground? = !is_flying? # @param target [Api::Unit, Integer, Api::Point2D] is a unit, unit tag or a Api::Point2D # @param queue_command [Boolean] shift+command def action(ability_id:, target: nil, queue_command: false) - @bot.action(units: self, ability_id: ability_id, target: target, queue_command: queue_command) + @bot.action(units: self, ability_id:, target:, queue_command:) end # Shorthand for performing action SMART (right-click) # @param target [Api::Unit, Integer, Api::Point2D] is a unit, unit tag or a Api::Point2D # @param queue_command [Boolean] shift+command def smart(target: nil, queue_command: false) - action(ability_id: Api::AbilityId::SMART, target: target, queue_command: queue_command) + action(ability_id: Api::AbilityId::SMART, target:, queue_command:) end # Shorthand for performing action ATTACK # @param target [Api::Unit, Integer, Api::Point2D] is a unit, unit tag or a Api::Point2D # @param queue_command [Boolean] shift+command def attack(target:, queue_command: false) - action(ability_id: Api::AbilityId::ATTACK, target: target, queue_command: queue_command) + action(ability_id: Api::AbilityId::ATTACK, target:, queue_command:) end # Inverse of #attack, where you target self using another unit (source_unit) @@ -190,9 +191,9 @@ def attack(target:, queue_command: false) # @param queue_command [Boolean] shift+command # @return [void] def attack_with(units:, queue_command: false) - if units.is_a?(Api::Unit) || units.is_a?(Sc2::UnitGroup) - units.attack(target: self, queue_command: queue_command) - end + return unless units.is_a?(Api::Unit) || units.is_a?(Sc2::UnitGroup) + + units.attack(target: self, queue_command:) end # Builds target unit type, i.e. issuing a build command to worker.build(...Api::UnitTypeId::BARRACKS) @@ -236,19 +237,19 @@ def debug_draw_placement(color = nil) draw: Api::DebugDraw.new( lines: [ Api::DebugLine.new( - color: color, - line: Api::Line.new(p0: p0, p1: p1) + color:, + line: Api::Line.new(p0:, p1:) ), Api::DebugLine.new( - color: color, + color:, line: Api::Line.new(p0: p2, p1: p3) ), Api::DebugLine.new( - color: color, - line: Api::Line.new(p0: p0, p1: p3) + color:, + line: Api::Line.new(p0:, p1: p3) ), Api::DebugLine.new( - color: color, + color:, line: Api::Line.new(p0: p1, p1: p2) ) ] @@ -264,7 +265,7 @@ def debug_fire_range(weapon_index = 0, color = nil) attack_range = unit_data.weapons[weapon_index].range raised_position = pos.dup raised_position.z += 0.01 - @bot.debug_draw_sphere(point: raised_position, radius: attack_range, color: color) + @bot.debug_draw_sphere(point: raised_position, radius: attack_range, color:) end # Geometric/Map/Micro functions --- @@ -272,9 +273,8 @@ def debug_fire_range(weapon_index = 0, color = nil) # Calculates the distance between self and other # @param other [Sc2::Position, Api::Unit, Api::PowerSource, Api::RadarRing, Api::Effect] def distance_to(other) - if other.nil? || other == self - return 0.0 - end + return 0.0 if other.nil? || other == self + other = other.pos unless other.is_a? Sc2::Position pos.distance_to(other) end @@ -285,9 +285,7 @@ def distance_to(other) # @param amount [Integer] # @return [Sc2::UnitGroup, Api::Unit, nil] return group or a Unit if amount is not passed def nearest(units:, amount: nil) - if !amount.nil? && amount.to_i <= 0 - amount = 1 - end + amount = 1 if !amount.nil? && amount.to_i <= 0 # Performs suboptimal if sending an array. Don't. if units.is_a? Array @@ -295,7 +293,7 @@ def nearest(units:, amount: nil) units.use_kdtree = false # we will not re-use it's distance cache end - units.nearest_to(pos: pos, amount:) + units.nearest_to(pos:, amount:) end # Detects whether a unit is within a given circle @@ -323,18 +321,6 @@ def is_repairing?(target: nil) ) end - # @private - # Reduces repitition in the is_*action*?(target:) methods - private def is_performing_ability_on_target?(abilities, target: nil) - # Exit if not actioning the ability - return false unless is_performing_ability?(abilities) - - # If a target was given and we're targeting it, us that value - return is_engaged_with?(target) unless target.nil? - - true - end - # Checks whether the unit has # @param ability_ids [Integer, Array] accepts one or an array of Api::AbilityId def is_performing_ability?(ability_ids) @@ -428,8 +414,9 @@ def weapon(index = 0) # @return [Integer] number of harvesters required to saturate this structure def missing_harvesters return 0 if ideal_harvesters.zero? + missing = ideal_harvesters - assigned_harvesters - (missing > 0) ? missing : 0 + missing.positive? ? missing : 0 end # The placement size, by looking up unit's creation ability, then game ability data @@ -483,6 +470,20 @@ def build_reactor def build_tech_lab build(unit_type_id: Api::UnitTypeId::TECHLAB) end + + private + + # @private + # Reduces repitition in the is_*action*?(target:) methods + def is_performing_ability_on_target?(abilities, target: nil) + # Exit if not actioning the ability + return false unless is_performing_ability?(abilities) + + # If a target was given and we're targeting it, us that value + return is_engaged_with?(target) unless target.nil? + + true + end end end Api::Unit.include Api::UnitExtension diff --git a/lib/sc2ai/protocol/extensions/unit_type.rb b/lib/sc2ai/protocol/extensions/unit_type.rb deleted file mode 100644 index 161ecc8..0000000 --- a/lib/sc2ai/protocol/extensions/unit_type.rb +++ /dev/null @@ -1,9 +0,0 @@ -module Api - # Adds additional functionality to message object Api::Unit - module UnitTypeExtension - def mood - "Crafty" - end - end -end -Api::UnitTypeData.prepend Api::UnitTypeExtension diff --git a/lib/sc2ai/unit_group.rb b/lib/sc2ai/unit_group.rb index 2af9c54..8bddae8 100644 --- a/lib/sc2ai/unit_group.rb +++ b/lib/sc2ai/unit_group.rb @@ -34,7 +34,6 @@ def initialize(units = nil) @_cache = {} end - # TODO: The docs in these can be more explicit than "Forwards to :@unit" # @!macro [attach] def_delegators # @!method $2 # Forwards to hash of #units. @@ -44,23 +43,35 @@ def initialize(units = nil) def_delegator :@units, :length # Returns the count of entries. def_delegator :@units, :size # Returns the count of entries. + # @!macro [attach] def_delegators + # @!method $2 + # Forwards to hash of #units. + # @see Hash#$2 def_delegator :@units, :< # Returns whether #units is a proper subset of a given object. def_delegator :@units, :<= # Returns whether #units is a subset of a given object. def_delegator :@units, :== # Returns whether a given object is equal to #units. def_delegator :@units, :> # Returns whether #units is a proper superset of a given object def_delegator :@units, :>= # Returns whether #units is a proper superset of a given object. + # @!macro [attach] def_delegators + # @!method $2 + # Forwards to hash of #units. + # @see Hash#$2 def_delegator :@units, :[] # Returns the value associated with the given key, if found def_delegator :@units, :keys # Returns an array containing all keys in #units. def_delegator :@units, :values # Returns an array containing all values in #units. def_delegator :@units, :values_at # Returns an array containing values for given tags in #units. + # @!macro [attach] def_delegators + # @!method $2 + # Forwards to hash of #units. + # @see Hash#$2 def_delegator :@units, :clear # Removes all entries from #units. - # def_delegator :@units, :compact! # Removes all +nil+-valued entries from #units. def_delegator :@units, :delete_if # Removes entries selected by a given block. def_delegator :@units, :select! # Keep only those entries selected by a given block def_delegator :@units, :filter! # Keep only those entries selected by a given block def_delegator :@units, :keep_if # Keep only those entries selected by a given block + # def_delegator :@units, :compact! # Removes all +nil+-valued entries from #units. # find_all, #filter, #select: Returns elements selected by the block. diff --git a/lib/sc2ai/unit_group/filter_ext.rb b/lib/sc2ai/unit_group/filter_ext.rb index 081538a..9cee008 100644 --- a/lib/sc2ai/unit_group/filter_ext.rb +++ b/lib/sc2ai/unit_group/filter_ext.rb @@ -4,7 +4,7 @@ require "kdtree" module Sc2 - # A set of filters defined + # Manage virtual control groups of units, similar to Hash or Array. class UnitGroup TYPE_WORKER = [Api::UnitTypeId::SCV, Api::UnitTypeId::MULE, Api::UnitTypeId::DRONE, Api::UnitTypeId::DRONEBURROWED, Api::UnitTypeId::PROBE].freeze TYPE_GAS_STRUCTURE = [ @@ -119,14 +119,12 @@ def initialize(unit_group) super end - # TODO: This needs yjit opt (splat args) # @private # Does the opposite of selector and returns those values for parent def select_type(*) @parent.reject_type(*) end - # TODO: This needs yjit opt (splat args) # Does the opposite of selector and returns those values for parent def reject_type(*) @parent.select_type(*) @@ -191,6 +189,8 @@ def army reject_type(non_army_unit_type_ids) end + # Selects units with attribute Structure + # @return [Sc2::UnitGroup] structures def structures select_attribute(Api::Attribute::Structure) end @@ -211,7 +211,6 @@ def non_army_unit_type_ids ] end - # TODO: Get feedback on whether bases should include flying or not # Selects command posts (CC, OC, PF, Nexus, Hatch, Hive, Lair) # Aliases are #hq and #townhalls # @return [Sc2::UnitGroup] unit group of workers