diff --git a/lib/sc2ai/api/data.rb b/lib/sc2ai/api/data.rb index 5144d60..994d3d0 100644 --- a/lib/sc2ai/api/data.rb +++ b/lib/sc2ai/api/data.rb @@ -15,7 +15,7 @@ class Data # @return [Hash] AbilityId => AbilityData attr_accessor :abilities # @!attribute units - # @return [Hash] UnitId => UnitData + # @return [Hash] UnitId => UnitTypeData attr_accessor :units # @!attribute upgrades # @return [Hash] UnitTypeId => UnitTypeData @@ -38,6 +38,8 @@ def initialize(data) @upgrades = upgrades_from_proto(data.upgrades) @buffs = buffs_from_proto(data.buffs) @effects = effects_from_proto(data.effects) + + override_unit_data end private @@ -97,5 +99,100 @@ def buffs_from_proto(buffs) end result end + + # @private + # Overrides unit data from api to implement fixes or change context + # i.e. Api::UnitTypeId::ORBITALCOMMAND cost is cost-to-upgrade instead of CC + Orbital combined cost. + # Run once. Depends on all data already being set. + # @return [Api::UnitTypeData] + def override_unit_data + + units.each do |unit_type_id, unit_data| + # Call these once to define their values at start. + unit_data.mineral_cost_sum + unit_data.vespene_cost_sum + + # Other corrections + + case unit_type_id + when Api::UnitTypeId::AUTOTURRET # TERRAN + unit_data.mineral_cost = 0 + when Api::UnitTypeId::CYCLONE + unit_data.mineral_cost = 125 + unit_data.vespene_cost = 50 + unit_data.food_required = 2 + when Api::UnitTypeId::LIBERATOR + unit_data.vespene_cost = 125 + when Api::UnitTypeId::MULE + unit_data.mineral_cost = 0 + when Api::UnitTypeId::RAVEN + unit_data.vespene_cost = 150 + when Api::UnitTypeId::BANELING # ZERG + unit_data.mineral_cost = 25 + unit_data.vespene_cost = 25 + unit_data.food_required = 0 + when Api::UnitTypeId::BROODLORD + unit_data.mineral_cost = 150 + unit_data.vespene_cost = 150 + unit_data.food_required = 0 + when Api::UnitTypeId::LURKERMP + unit_data.mineral_cost = 50 + unit_data.vespene_cost = 100 + unit_data.food_required = 0 + when Api::UnitTypeId::NYDUSCANAL + unit_data.mineral_cost = 75 + unit_data.vespene_cost = 75 + when Api::UnitTypeId::OVERSEER + unit_data.mineral_cost = 50 + when Api::UnitTypeId::QUEENMP + unit_data.mineral_cost = 150 + unit_data.food_required = 2 + when Api::UnitTypeId::RAVAGER + unit_data.mineral_cost = 25 + unit_data.vespene_cost = 75 + unit_data.food_required = 1 + when Api::UnitTypeId::OVERLORDTRANSPORT + unit_data.mineral_cost = 25 + unit_data.vespene_cost = 25 + when Api::UnitTypeId::ZERGLING + unit_data.mineral_cost = 25 + unit_data.food_required = 1 + when Api::UnitTypeId::ARCHON # PROTOSS + unit_data.mineral_cost = 0 + unit_data.vespene_cost = 0 + unit_data.food_required = 0 + end + end + + # @units.each do |id, u| + # case id + # when Api::UnitTypeId::ORBITALCOMMAND, Api::UnitTypeId::PLANETARYFORTRESS + # u.mineral_cost -= @units[Api::UnitTypeId::COMMANDCENTER].mineral_cost + # when Api::UnitTypeId::LAIR + # u.mineral_cost -= @units[Api::UnitTypeId::HATCHERY].mineral_cost + # u.vespene_cost -= @units[Api::UnitTypeId::HATCHERY].vespene_cost + # when Api::UnitTypeId::LAIR + # Api::UnitTypeId::HATCHERY, Api::UnitTypeId::HIVE, Api::UnitTypeId::LAIR + # else + # # no operation + # end + # end + + # python - can afford for unit type fix. + # if isinstance(item_id, UnitTypeId): + # # Fix cost for reactor and techlab where the API returns 0 for both + # if item_id in {UnitTypeId.REACTOR, UnitTypeId.TECHLAB, UnitTypeId.ARCHON, UnitTypeId.BANELING}: + # if item_id == UnitTypeId.REACTOR: + # return Cost(50, 50) + # if item_id == UnitTypeId.TECHLAB: + # return Cost(50, 25) + # if item_id == UnitTypeId.BANELING: + # return Cost(25, 25) + # if item_id == UnitTypeId.ARCHON: + # return self.calculate_unit_value(UnitTypeId.ARCHON) + # unit_data = self.game_data.units[item_id.value] + # # Cost of morphs is automatically correctly calculated by 'calculate_ability_cost' + # return self.game_data.calculate_ability_cost(unit_data.creation_ability.exact_id) + end end end diff --git a/lib/sc2ai/protocol/extensions/unit_type_data.rb b/lib/sc2ai/protocol/extensions/unit_type_data.rb new file mode 100644 index 0000000..089e030 --- /dev/null +++ b/lib/sc2ai/protocol/extensions/unit_type_data.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Api + # Adds additional functionality to message object Api::UnitTypeData + module UnitTypeDataExtension + # Sum of mineral costs for each morph (550 for Orbital command = 400M CC + 150M Upgrade) + # The original cost of units are the sums + # We fix the values for can_afford?, but back up the originals here + # @return [Integer] sum of mineral costs + def mineral_cost_sum + @mineral_cost_sum ||= mineral_cost + end + + # Sum of vespene cost for each morph (250G Broodlord = 100G Corruptor + 150G Morph) + # @return [Integer] sum of gas costs + def vespene_cost_sum + @vespene_cost_sum ||= vespene_cost + end + end +end +Api::UnitTypeData.include Api::UnitTypeDataExtension diff --git a/sig/sc2ai.rbs b/sig/sc2ai.rbs index 9990b0b..eaf797a 100644 --- a/sig/sc2ai.rbs +++ b/sig/sc2ai.rbs @@ -2378,12 +2378,18 @@ module Sc2 # sord omit - no YARD return type given, using untyped def buffs_from_proto: (untyped buffs) -> untyped + # sord warn - Api::UnitTypeData wasn't able to be resolved to a constant in this project + # Overrides unit data from api to implement fixes or change context + # i.e. Api::UnitTypeId::ORBITALCOMMAND cost is cost-to-upgrade instead of CC + Orbital combined cost. + # Run once. Depends on all data already being set. + def override_unit_data: () -> Api::UnitTypeData + # sord warn - Api::AbilityData wasn't able to be resolved to a constant in this project # _@return_ — AbilityId => AbilityData attr_accessor abilities: (::Hash[Integer, Api::AbilityData] | untyped) # sord warn - Api::UnitTypeData wasn't able to be resolved to a constant in this project - # _@return_ — UnitId => UnitData + # _@return_ — UnitId => UnitTypeData attr_accessor units: (::Hash[Integer, Api::UnitTypeData] | untyped) # sord warn - Api::UnitTypeData wasn't able to be resolved to a constant in this project @@ -3985,6 +3991,9 @@ module Sc2 # _@param_ `player_index` def stop: (Integer player_index) -> void + # sord omit - no YARD return type given, using untyped + def stop_all: () -> untyped + def initialize: () -> void # Returns the value of attribute clients. @@ -9644,6 +9653,7 @@ module Api # Protobuf virtual class. class Unit < Google::Protobuf::AbstractMessage + include Api::UnitTypeDataExtension include Api::UnitExtension # sord omit - no YARD return type given, using untyped @@ -9949,6 +9959,18 @@ module Api # sord omit - no YARD type given for "target:", using untyped # Reduces repetition in the is_*action*?(target:) methods def is_performing_ability_on_target?: (untyped abilities, ?target: untyped) -> bool + + # Sum of mineral costs for each morph (550 for Orbital command = 400M CC + 150M Upgrade) + # The original cost of units are the sums + # We fix the values for can_afford?, but back up the originals here + # + # _@return_ — sum of mineral costs + def mineral_cost_sum: () -> Integer + + # Sum of vespene cost for each morph (250G Broodlord = 100G Corruptor + 150G Morph) + # + # _@return_ — sum of gas costs + def vespene_cost_sum: () -> Integer end # Protobuf virtual enum. @@ -10121,6 +10143,21 @@ module Api def []: (untyped x, untyped y, untyped z) -> Api::Point end end + + # Adds additional functionality to message object Api::UnitTypeData + module UnitTypeDataExtension + # Sum of mineral costs for each morph (550 for Orbital command = 400M CC + 150M Upgrade) + # The original cost of units are the sums + # We fix the values for can_afford?, but back up the originals here + # + # _@return_ — sum of mineral costs + def mineral_cost_sum: () -> Integer + + # Sum of vespene cost for each morph (250G Broodlord = 100G Corruptor + 150G Morph) + # + # _@return_ — sum of gas costs + def vespene_cost_sum: () -> Integer + end end # Array extensions diff --git a/spec/integration/unit_data_spec.rb b/spec/integration/unit_data_spec.rb index 540035a..f9997ec 100644 --- a/spec/integration/unit_data_spec.rb +++ b/spec/integration/unit_data_spec.rb @@ -1,27 +1,93 @@ -class ExampleBot < Sc2::Player::Bot - def on_start - puts "test bot started..." - end +RSpec.describe Api::UnitTypeData, type: :integration do + let(:live_terran_data) do + data = {} + + # patch: 5.0.12 + data[Api::UnitTypeId::AUTOTURRET] = Sc2::RSpec::Data::Cost.new(0, 0, 0) + data[Api::UnitTypeId::BANSHEE] = Sc2::RSpec::Data::Cost.new(150, 100, 3) + data[Api::UnitTypeId::BATTLECRUISER] = Sc2::RSpec::Data::Cost.new(400, 300, 6) + data[Api::UnitTypeId::CYCLONE] = Sc2::RSpec::Data::Cost.new(125, 50, 2) + data[Api::UnitTypeId::GHOST] = Sc2::RSpec::Data::Cost.new(150, 125, 2) + data[Api::UnitTypeId::HELLION] = Sc2::RSpec::Data::Cost.new(100, 0, 2) + data[Api::UnitTypeId::HELLIONTANK] = Sc2::RSpec::Data::Cost.new(100, 0, 2) + data[Api::UnitTypeId::LIBERATOR] = Sc2::RSpec::Data::Cost.new(150, 125, 3) + data[Api::UnitTypeId::MARAUDER] = Sc2::RSpec::Data::Cost.new(100, 25, 2) + data[Api::UnitTypeId::MARINE] = Sc2::RSpec::Data::Cost.new(50, 0, 1) + data[Api::UnitTypeId::MEDIVAC] = Sc2::RSpec::Data::Cost.new(100, 100, 2) + data[Api::UnitTypeId::MULE] = Sc2::RSpec::Data::Cost.new(0, 0, 0) + data[Api::UnitTypeId::RAVEN] = Sc2::RSpec::Data::Cost.new(100, 150, 2) + data[Api::UnitTypeId::REAPER] = Sc2::RSpec::Data::Cost.new(50, 50, 1) + data[Api::UnitTypeId::SCV] = Sc2::RSpec::Data::Cost.new(50, 0, 1) + data[Api::UnitTypeId::SIEGETANK] = Sc2::RSpec::Data::Cost.new(150, 125, 3) + data[Api::UnitTypeId::SIEGETANKSIEGED] = Sc2::RSpec::Data::Cost.new(150, 125, 3) + data[Api::UnitTypeId::THOR] = Sc2::RSpec::Data::Cost.new(300, 200, 6) + data[Api::UnitTypeId::THORAP] = Sc2::RSpec::Data::Cost.new(300, 200, 6) + data[Api::UnitTypeId::VIKINGFIGHTER] = Sc2::RSpec::Data::Cost.new(150, 75, 2) + data[Api::UnitTypeId::VIKINGASSAULT] = Sc2::RSpec::Data::Cost.new(150, 75, 2) + data[Api::UnitTypeId::WIDOWMINE] = Sc2::RSpec::Data::Cost.new(75, 25, 2) + data[Api::UnitTypeId::WIDOWMINEBURROWED] = Sc2::RSpec::Data::Cost.new(75, 25, 2) + data[Api::UnitTypeId::BANELING] = Sc2::RSpec::Data::Cost.new(25, 25, 0) + data[Api::UnitTypeId::BROODLORD] = Sc2::RSpec::Data::Cost.new(150, 150, 0) + data[Api::UnitTypeId::BROODLING] = Sc2::RSpec::Data::Cost.new(0, 0, 0) + data[Api::UnitTypeId::CHANGELING] = Sc2::RSpec::Data::Cost.new(0, 0, 0) + data[Api::UnitTypeId::CORRUPTOR] = Sc2::RSpec::Data::Cost.new(150, 100, 2) + data[Api::UnitTypeId::DRONE] = Sc2::RSpec::Data::Cost.new(50, 0, 1) + data[Api::UnitTypeId::HYDRALISK] = Sc2::RSpec::Data::Cost.new(100, 50, 2) + data[Api::UnitTypeId::INFESTOR] = Sc2::RSpec::Data::Cost.new(100, 150, 2) + data[Api::UnitTypeId::LARVA] = Sc2::RSpec::Data::Cost.new(0, 0, 0) + data[Api::UnitTypeId::LOCUSTMP] = Sc2::RSpec::Data::Cost.new(0, 0, 0) + data[Api::UnitTypeId::LOCUSTMPFLYING] = Sc2::RSpec::Data::Cost.new(0, 0, 0) + data[Api::UnitTypeId::LURKERMP] = Sc2::RSpec::Data::Cost.new(50, 100, 0) + data[Api::UnitTypeId::MUTALISK] = Sc2::RSpec::Data::Cost.new(100, 100, 2) + data[Api::UnitTypeId::NYDUSCANAL] = Sc2::RSpec::Data::Cost.new(75, 75, 0) + data[Api::UnitTypeId::OVERLORD] = Sc2::RSpec::Data::Cost.new(100, 0, 0) + data[Api::UnitTypeId::OVERSEER] = Sc2::RSpec::Data::Cost.new(50, 50, 0) + data[Api::UnitTypeId::QUEENMP] = Sc2::RSpec::Data::Cost.new(150, 0, 2) + data[Api::UnitTypeId::RAVAGER] = Sc2::RSpec::Data::Cost.new(25, 75, 1) + data[Api::UnitTypeId::ROACH] = Sc2::RSpec::Data::Cost.new(75, 25, 2) + data[Api::UnitTypeId::SWARMHOSTMP] = Sc2::RSpec::Data::Cost.new(100, 75, 3) + data[Api::UnitTypeId::ULTRALISK] = Sc2::RSpec::Data::Cost.new(275, 200, 6) + data[Api::UnitTypeId::OVERLORDTRANSPORT] = Sc2::RSpec::Data::Cost.new(25, 25, 0) + data[Api::UnitTypeId::VIPER] = Sc2::RSpec::Data::Cost.new(100, 200, 3) + data[Api::UnitTypeId::ZERGLING] = Sc2::RSpec::Data::Cost.new(25, 0, 1) + data[Api::UnitTypeId::ADEPT] = Sc2::RSpec::Data::Cost.new(100, 25, 2) + data[Api::UnitTypeId::ARCHON] = Sc2::RSpec::Data::Cost.new(0, 0, 0) + data[Api::UnitTypeId::CARRIER] = Sc2::RSpec::Data::Cost.new(350, 250, 6) + data[Api::UnitTypeId::COLOSSUS] = Sc2::RSpec::Data::Cost.new(300, 200, 6) + data[Api::UnitTypeId::DARKTEMPLAR] = Sc2::RSpec::Data::Cost.new(125, 125, 2) + data[Api::UnitTypeId::DISRUPTOR] = Sc2::RSpec::Data::Cost.new(150, 150, 4) + data[Api::UnitTypeId::HIGHTEMPLAR] = Sc2::RSpec::Data::Cost.new(50, 150, 2) + data[Api::UnitTypeId::IMMORTAL] = Sc2::RSpec::Data::Cost.new(275, 100, 4) + data[Api::UnitTypeId::INTERCEPTOR] = Sc2::RSpec::Data::Cost.new(15, 0, 0) + data[Api::UnitTypeId::MOTHERSHIP] = Sc2::RSpec::Data::Cost.new(300, 300, 6) + data[Api::UnitTypeId::OBSERVER] = Sc2::RSpec::Data::Cost.new(25, 75, 1) + data[Api::UnitTypeId::ORACLE] = Sc2::RSpec::Data::Cost.new(150, 150, 3) + data[Api::UnitTypeId::PHOENIX] = Sc2::RSpec::Data::Cost.new(150, 100, 2) + data[Api::UnitTypeId::PROBE] = Sc2::RSpec::Data::Cost.new(50, 0, 1) + data[Api::UnitTypeId::SENTRY] = Sc2::RSpec::Data::Cost.new(50, 100, 2) + data[Api::UnitTypeId::STALKER] = Sc2::RSpec::Data::Cost.new(125, 50, 2) + data[Api::UnitTypeId::TEMPEST] = Sc2::RSpec::Data::Cost.new(250, 175, 5) + data[Api::UnitTypeId::VOIDRAY] = Sc2::RSpec::Data::Cost.new(250, 150, 4) + data[Api::UnitTypeId::WARPPRISM] = Sc2::RSpec::Data::Cost.new(250, 0, 2) + data[Api::UnitTypeId::ZEALOT] = Sc2::RSpec::Data::Cost.new(100, 0, 2) - def on_step + data end -end -RSpec.describe Api::UnitTypeData, type: :integration do context "when playing as Terran" do - let(:bot) { ExampleBot.new(race: :Terran, name: "testbot") } + let(:bot) { Sc2::Player::Bot.new(race: Api::Race::Terran, name: "") } - # TODO: WIP for testing units. - xit "can_afford? correctly calculates all units" do - start_game(players: [bot]) - step_to(100) - expect(bot.game_loop).to be >= 100 - end + it "map costs match live patch costs" do + start_game(players: [bot], map: "Goldenaura512V2AIE") - xit "a second example runs succesfully" do - start_game(players: [bot]) - step_to(100) - expect(bot.game_loop).to be >= 100 + # Ensure map data matches real-world patch + live_terran_data.each do |unit_type_id, cost| + mineral_cost, vespene_cost, food_required = cost.to_a # live data + unit_data = bot.unit_data(unit_type_id) # map data + expect(unit_data.mineral_cost).to eq(mineral_cost), "[#{unit_type_id}] mineral_cost mismatch #{unit_data.mineral_cost} vs #{mineral_cost}: #{unit_data.inspect}" + expect(unit_data.vespene_cost).to eq(vespene_cost), "[#{unit_type_id}] vespene_cost mismatch #{unit_data.vespene_cost} vs #{vespene_cost} #{unit_data.inspect}" + expect(unit_data.food_required).to eq(food_required), "[#{unit_type_id}] food_required mismatch #{unit_data.food_required} vs #{food_required} #{unit_data.inspect}" + end end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index ae869f1..a7ad5fb 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,7 +3,6 @@ require "sc2ai" require "factory_bot" -# Support: Factories require "support/factory_bot" Dir["#{__dir__}/support/shared_contexts/**/*.rb"].each { |f| require f } require "support/integration" diff --git a/spec/support/integration.rb b/spec/support/integration.rb index f2e4282..f52f7cd 100644 --- a/spec/support/integration.rb +++ b/spec/support/integration.rb @@ -7,7 +7,7 @@ RSpec.configure do |config| config.include_context Async::RSpec::Reactor, type: :integration - config.include Integration::MatchHelpers, type: :integration + config.include Sc2::RSpec::Integration::MatchHelpers, type: :integration # Launch two long-running sc2 clients before # Will re-use open clients diff --git a/spec/support/integration/data_helpers.rb b/spec/support/integration/data_helpers.rb new file mode 100644 index 0000000..cf0b69a --- /dev/null +++ b/spec/support/integration/data_helpers.rb @@ -0,0 +1,19 @@ +module Sc2 + module RSpec + module Data + class Cost + attr_accessor :mineral_cost, :vespene_cost, :food_required + + def initialize(m, v, f) + @mineral_cost = m + @vespene_cost = v + @food_required = f + end + + def to_a + [mineral_cost, vespene_cost, food_required] + end + end + end + end +end diff --git a/spec/support/integration/match_helpers.rb b/spec/support/integration/match_helpers.rb index c321d3b..db8cdbe 100644 --- a/spec/support/integration/match_helpers.rb +++ b/spec/support/integration/match_helpers.rb @@ -1,81 +1,87 @@ -module Integration - module MatchHelpers - attr_accessor :api_players +module Sc2 + module RSpec + module Integration + module MatchHelpers + attr_accessor :api_players - # Prevent bundled leaked io check for rspec/reactor, - # since we have long-running processes external to the examples: - def current_ios(gc: true) - [] - end + # Prevent bundled leaked io check for rspec/reactor, + # since we have long-running processes external to the examples: + def current_ios(gc: true) + [] + end - # The first player is the host - # @param map [Sc2::MapFile] default Flat48 - # @param players [Array] - def start_game(players:, map: nil) - # Setup players - raise "create_game missing players" if players.size == 0 - if players.size == 1 - players.push Sc2::Player::Computer.new(name: "CPU", race: Api::Race::Random, difficulty: Api::Difficulty::VeryEasy) - end - @api_players = players.select(&:requires_client?) + # The first player is the host + # @param map [Sc2::MapFile] default Flat48 + # @param players [Array] + def start_game(players:, map: nil) + # Setup players + raise "create_game missing players" if players.size == 0 + if players.size == 1 + players.push Sc2::Player::Computer.new(name: "CPU", race: Api::Race::Random, difficulty: Api::Difficulty::VeryEasy) + end + @api_players = players.select(&:requires_client?) - # Setup map - map ||= Sc2::MapFile.new(Pathname("data/setup/setup.SC2Map").realpath.to_s) - # Setup ports - port_config = Sc2::Ports.port_config_auto(num_players: api_players.length) + # Setup map + map ||= Pathname("data/setup/setup.SC2Map").realpath.to_s + map = MapFile.new(map.to_s) if map.is_a?(String) - # Connect players - api_players.each_with_index do |player, i| - attempt = 0 - until (client = Sc2::ClientManager.get(i)) - sleep(1) - attempt += 1 - raise "unable to start match for example. no clients to connect." if attempt > 60 - end - player.connect(host: client.host, port: client.port) - end + # Setup ports + port_config = Sc2::Ports.port_config_auto(num_players: api_players.length) - # Create server - api_players.first.create_game(map: map, players: players) + # Connect players + api_players.each_with_index do |player, i| + attempt = 0 + until (client = Sc2::ClientManager.get(i)) + sleep(1) + attempt += 1 + raise "unable to start match for example. no clients to connect." if attempt > 60 + end + player.connect(host: client.host, port: client.port) + end - # Join game - api_players.each_with_index do |player, i| - player.join_game( - server_host: Sc2::ClientManager.obtain(i).host, - port_config: port_config - ) + # Create server + api_players.first.create_game(map: map, players: players) - player.send(:prepare_start) - player.send(:refresh_state) - player.send(:started) - player.on_start - end - end + # Join game + api_players.each_with_index do |player, i| + player.join_game( + server_host: Sc2::ClientManager.obtain(i).host, + port_config: port_config + ) - # Will step players forward until we are >= `game_loop` - # @param game_loop [Integer] game_loop to reach - def step_to(game_loop) - while @api_players.first.game_loop <= game_loop.to_i - @api_players.each do |player| - player.send(:perform_actions) - player.send(:perform_debug_commands) - player.send(:step_forward) + player.send(:prepare_start) + player.send(:refresh_state) + player.send(:started) + player.on_start + end end - end - end - # End game and reset state for next one - def end_game - # Assume players are connected and haven't crashed - @api_players&.each do |player| - player.leave_game - player.disconnect - end + # Will step players forward until we are >= `game_loop` + # @param game_loop [Integer] game_loop to reach + def step_to(game_loop) + while @api_players.first.game_loop <= game_loop.to_i + @api_players.each do |player| + player.send(:perform_actions) + player.send(:perform_debug_commands) + player.send(:step_forward) + end + end + end - # Sc2::ClientManager.stop(0) - # Sc2::ClientManager.stop(1) - rescue - # no op + # End game and reset state for next one + def end_game + # Assume players are connected and haven't crashed + @api_players&.each do |player| + player.leave_game + player.disconnect + end + + # Sc2::ClientManager.stop(0) + # Sc2::ClientManager.stop(1) + rescue + # no op + end + end end end end