Skip to content

Commit

Permalink
Merge pull request #23 from dysonreturns/docs-t5-rework
Browse files Browse the repository at this point in the history
docs: rework tutorial 5 to introduce priority reduction instead of Un…
  • Loading branch information
dysonreturns committed Mar 13, 2024
2 parents 9fc8cab + c2b41c6 commit 6d4c865
Show file tree
Hide file tree
Showing 2 changed files with 128 additions and 63 deletions.
169 changes: 113 additions & 56 deletions docs/TUTORIAL_05.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ geysers_for_main = main_base.nearest(units: neutral.geysers, amount: 2)
It would be tempting to run this code universally, but other expansions on the map might not have exactly two geysers.
We might donate free SCV's to the enemy if we pick the wrong build location. Not ideal.

### Select Units inside a circle
#### Select Units inside a circle

Since we don't know how many geysers belong to this base, our first instinct could be drawing a circle around the main base, the selecting only the geysers inside of it.

![my eyes are cirlces](images/05_select_in_circle.jpg)

Fiddling with the auto-complete on neutral.geysers, we spot a Unit Group filter {Sc2::UnitGroup#select_in_circle}.
Fiddling with the auto-complete on `neutral.geysers`, we spot a Unit Group filter {Sc2::UnitGroup#select_in_circle UnitGroup#select_in_circle} which takes a `point` and a `radius`.

```ruby
geysers_for_main = neutral.geysers.select_in_circle(point: main_base.pos, radius: 8.5)
Expand All @@ -43,12 +43,11 @@ minerals_for_main = neutral.minerals.select_in_circle(point: main_base.pos, radi
gas_for_main = structures.gas.select_in_circle(point: main_base.pos, radius: 8.5)
```
This check's each geyser's distance to the main base's position and ensures it's less than 8.5 units away.
Perfectly valid, but does unnecessary calculation.
It needs to loop over every single mineral or geyser on the map and test the distance.
Perfectly valid, but this method does unnecessary calculation, since it needs to do distance checks for every mineral/geyser/gas on the map.

Let's save that computation time for battle.
`select_in_circle` is an excellent method and quite fast, but let's save whatever computation time we can for battle.

### Geo located resources
#### Geo located resources

More efficient and exact are the purpose-built methods in `geo`.

Expand All @@ -60,7 +59,7 @@ gas_for_main = geo.gas_for_base(main_base)
These are fast, because we do static map analysis once when the game starts and then use these values when doing lookups.
Tidy too. The best option.

## Gas structures
### Gas structures

Time to plant a gas structure on-top of the geyser. For Terran this structure is called a Refinery.

Expand Down Expand Up @@ -89,102 +88,160 @@ Note: Depending on your planned build order, you might want to delay the gas by
</div></div>


## Harvesting
### Harvesting

Right now, as refineries finish, only the constructing worker will harvest gas automatically.
On-top of the refinery we can see that the ideal number of harvesters are 3.
On-top of the refinery we can see that the ideal number of harvesters are 3. It takes 3 workers to "saturate" the gas optimally.

It's also worth considering that our gas harvesters could drop for various reasons, i.e. workers destroyed by the enemy.
It's also worth considering that gas harvesters could drop for various reasons, i.e. workers destroyed by the enemy.
Therefore, it's worth keeping a continuous eye on our refineries to ensure that they are saturated.

Every check we do, however, comes at a **performance cost**. Let's **limit how frequently we check** in on our refineries.
How about we check every 2 in-game seconds? At 16fps that's every 32 frames.

After our construction code, we add:
```ruby

# Gas saturation checks @ every 2s
if game_loop % 32 == 0
if game_loop % 32 == 0

# Get all gas structures for main base
gasses = geo.gas_for_base(main_base)

# Loop over completed gasses (don't worry about those under construction)
gasses.select(&:is_completed?).each do |gas|

gasses.each do |gas|
# Skip buildings which are still in progress
next unless gas.is_completed?

# Move on to the next gas if we are not missing harvesters
missing_harvesters = gas.missing_harvesters
next if missing_harvesters.zero?
next if gas.missing_harvesters.zero?

# From the 5 nearest workers, randomly select the amount needed and send them to gas
gas.nearest(units: units.workers, amount: 5)
.random(missing_harvesters)
.random(gas.missing_harvesters)
.each { |worker| worker.smart(target: gas) }
end
end
```

If we have harvesters missing (`missing_harvesters`), select randomly from workers nearby and send them in with the SMART action.
Works great!

Most of the code is familiar by now, but let's touch on two things quick:
Now, when we execute our bot, the green starts pouring in. Let's review.

**Unit progress**
#### Prioritize tasks
All code we execute comes at a performance cost.
It would be convenient to check everything on every frame, but we must always be mindful of priority.

Just like we checked orders progress, each Unit has a property called `build_progress`, which stores it's constructed state as a float between 0.0 and 1.0.
The helper method `unit.is_completed?` returns true if a Unit is completed (`build_progress == 1.0`).
This next condition limits how frequently refinery saturation is checked. Instead of every frame, let's do once every 2 in-game seconds.
`1 in-game second = 16 frames`
`2 in-game seconds = 32 frames`, so check that `game_loop` is divisible by `32`.

```ruby
gasses.select(&:is_completed?).each do |gas|
if game_loop % 32 == 0
#...
end
```

# Is the same as:
gasses.select{ |gas| gas.build_progress == 1.0 }.each do |gas|
#...
It works for us, because our `@step_count` is `2` (we step forward two frames at a time), so we run on all even numbers for `game_loop`.

The danger though, is that the steps we take might sometimes be variable.
In realtime mode (Bot vs Human), **steps counts change unpredictably**.

To have finer control and also to be less rigid, we should rather implement a time-difference method.

Here is an approach which remembers- and compares how many frames have passed since the last saturation check:
```ruby
# Save the game_loop on which we last checked (initially zero)
@last_saturation_check ||= 0

# If 32 frames have passed since our last check
if game_loop - @last_saturation_check >= 32

# Perform logic...

# Update last checked time
@last_saturation_check = game_loop
end
```

**Select and reject using Unit booleans**
That's two more lines of code, but our bot is more flexible.
`32` frames can be 31, 33, anything and our step count can be variable.
This code is safe for realtime mode.

Above we used a boolean method from Unit in a `select` using "Symbol to Proc" shorthand.

Like `is_completed?`, there are many more like:
#### Unit construction progress

- `is_flying?`
- `is_ground?`
- `is_burrowed?`
- `is_active?`
- `is_biological?`
- `is_mechanical?`
- `is_summoned?`
- `is_powered?`
- `is_armored?`, etc.
During our checks, we don't send workers to harvest until a refinery has finished building.

It's worth checking out {Api::Unit}, because as you've seen with progress checks, it can be super useful short-hand:
```ruby
gasses.each do |gas|
# Skip buildings which are still in progress
next unless gas.is_completed?

#...
end
```

Each Unit has a property called `build_progress`, which stores a value between `0.0` and `1.0`.
The helper method `unit.is_completed?` returns `true` if a Unit is completed, meaning `build_progress == 1.0`.

```ruby
# Land all flying buildings exactly where they are
structures.select(:is_flying?).each do |unit|
unit.action(ability_id: Api::AbilityId::LAND, target: unit.pos)
# This accomplishes the same as above:
gasses.each do |gas|
next if gas.build_progress < 1.0
#...
end

# Find all engineering bays which are not active (idle)
structures.select_type(Api::UnitTypeId::ENGINEERINGBAY).reject(:is_active?).each do |unit|
# queue an upgrade command...
# As does this variation. An upfront filter using UnitGroup#select and symbol-to-proc shorthand:
gasses.select(&:is_completed?).each do |gas|
#...
end
```

#### Harvester methods

Both base structures (`structures.hq` Command Centre/Nexus/Hatch) and gas structures (`structures.gas` Refinery/Extractor/Assimilator) have a set of harvester methods.
For saturation, we typically check `missing_harvesters`, which is `ideal_harvesters` minus `assigned_harvesters`.

Let's examine an imaginary gas with one miner assigned.
```ruby
gas_structure = structures.gas.random
puts gas_structure.ideal_harvesters #=> 3
puts gas_structure.assigned_harvesters #=> 1
puts gas_structure.missing_harvesters #=> 2
```

And now our code should make more sense:

```ruby
gasses.each do |gas|
#...

# Move on to the next gas if we are not missing harvesters
next if gas.missing_harvesters.zero?

# From the 5 nearest workers, randomly select the amount needed
gas.nearest(units: units.workers, amount: 5)
.random(gas.missing_harvesters)
#...

end
```
The number 5 here is arbitrarily chosen as anything higher than 3, to give us some flexibility in random selection.
The end result is this: In our case, we have 2 missing harvesters, so we would randomly select 2 out of the 5 nearest workers for harvesting.

Don't worry, we will practice this more later.
#### Harvest vespene

And finally, we loop over each selected worker and use the `SMART` ability on the target gas structure.
This will trigger the `HARVEST_GATHER` ability in the background, just as a right-click would, but the "smart" syntax has the benefit of being brief.

```ruby
gas.nearest(units: units.workers, amount: 5)
.random(gas.missing_harvesters)
.each { |worker| worker.smart(target: gas) }
```

---

Alright, we're cooking with gas. Download and run the full example and see it in action:
Now we're cooking with gas. Download and run the full example and see it in action:
[05_terran_gas.rb](https://github.com/dysonreturns/sc2ai/blob/main/docs/examples/05_terran_gas.rb)

Selecting units in zone, getting resources for a base, reading unit attributes, advanced group filters... we're getting pretty hardcore.
How about a simpler task, like - I don't know - TAKING OVER THE ENTIRE MAP!

How about some macroni?
So we have one base up and running, but we're clearly over-saturating on workers per mineral patch. We need more bases.
Are you thinking what I'm thinking? Time to take over the map!

---

Expand Down
22 changes: 15 additions & 7 deletions docs/examples/05_terran_gas.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def configure
def on_step
main_base = structures.hq.first
return if main_base.nil?

# 02 - Construct an SCV
if can_afford?(unit_type_id: Api::UnitTypeId::SCV)

Expand Down Expand Up @@ -80,19 +80,27 @@ def on_step
end

# Gas saturation checks - only every 2s in-game (32 frames)
if game_loop % 32 == 0
# Save the game_loop on which we last checked (initially zero)
@last_saturation_check ||= 0

# If 32 frames have passed since our last check
if game_loop - @last_saturation_check >= 32
# Loop over completed gasses
gasses.each do |gas|
# Only check this gas if it's construction is completed
next unless gas.is_completed?

# Loop over completed gasses (don't worry about those under construction)
gasses.select(&:is_completed?).each do |gas|
# Move on to the next gas if we are not missing harvesters
missing_harvesters = gas.missing_harvesters
next if missing_harvesters.zero?
next if gas.missing_harvesters.zero?

# From the 5 nearest workers, randomly select the amount needed and send them to gas
gas.nearest(units: units.workers, amount: 5)
.random(missing_harvesters)
.random(gas.missing_harvesters)
.each { |worker| worker.smart(target: gas) }
end

# Update check time
@last_saturation_check = game_loop
end
end
end
Expand Down

0 comments on commit 6d4c865

Please sign in to comment.