Last active
December 18, 2015 09:49
-
-
Save Youch/5764378 to your computer and use it in GitHub Desktop.
GW2 Match Info (Gicko2 Rating Projections, Current Match PPT)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
require 'open-uri' | |
require 'nokogiri' | |
require 'httparty' | |
## Ruby Classe to Gather WVW Info and Calculate Glicko2 Ratings | |
## | |
## Snowreap.5174 did all the hard work | |
## Youch just created the ruby stuff | |
## | |
## Usage: | |
## ruby api.rb report eu | |
## ruby api.rb report na | |
## ruby api.rb report eu | |
## ruby api.rb match na 'Ehmry Bay' | |
## ruby api.rb match eu "Miller's Sound [DE]" | |
## | |
## Helper Module for JSON to Hash Maps by Name and Id. Used below | |
## for the ObjectiveNames and WorldNames API. | |
## | |
module NameIdMap | |
def self.included(base) | |
base.extend(ClassMethods) | |
end | |
module ClassMethods | |
def find_by_name(name) | |
create_maps unless @by_name | |
@by_name[name] | |
end | |
def find_by_id(id) | |
create_maps unless @by_id | |
@by_id[id.to_i] | |
end | |
def all | |
create_maps unless @by_id | |
@by_id | |
end | |
private | |
def create_maps | |
name_key, id_key, data = map_definition | |
@by_id = {} | |
@by_name = {} | |
data.each { |d| | |
@by_id[d[id_key].to_i] = d[name_key] | |
@by_name[d[name_key]] = d[id_key].to_i | |
} | |
end | |
end | |
end | |
## | |
## Objective Name Lookup | |
## | |
class ObjectiveNames | |
include HTTParty | |
include NameIdMap | |
# Provide the raw data for NameIdMap | |
def self.map_definition | |
['name', 'id', get('https://api.guildwars2.com/v1/wvw/objective_names.json')] | |
end | |
# Score Value of WVW Objectives | |
def self.value(objective) | |
case objective | |
when 'Castle' | |
35 | |
when 'Keep' | |
25 | |
when 'Tower' | |
10 | |
else | |
5 | |
end | |
end | |
end | |
## | |
## World Name Lookup | |
## | |
class WorldNames | |
include HTTParty | |
include NameIdMap | |
# Provide the raw data for NameIdMap | |
def self.map_definition | |
['name', 'id', get('https://api.guildwars2.com/v1/world_names.json')] | |
end | |
end | |
## | |
## Information about a WVW Match | |
## | |
class Match | |
include HTTParty | |
attr_accessor :match_id, :red_world_id, :blue_world_id, :green_world_id, :red_world_score, :blue_world_score, :green_world_score | |
attr_accessor :details | |
def self.all | |
@all ||= load_matches | |
end | |
def self.find_by_world_id(world_id) | |
all.find { |m| m.red_world_id == world_id || m.blue_world_id == world_id || m.green_world_id == world_id } | |
end | |
def world_ids | |
[red_world_id, blue_world_id, green_world_id] | |
end | |
def map_name(name) | |
case name | |
when 'RedHome' | |
"#{WorldNames.find_by_id(red_world_id)} Borderland" | |
when 'BlueHome' | |
"#{WorldNames.find_by_id(blue_world_id)} Borderland" | |
when 'GreenHome' | |
"#{WorldNames.find_by_id(green_world_id)} Borderland" | |
when 'Center' | |
'Eternal Battle Grounds' | |
else | |
name | |
end | |
end | |
def score_for(world_id) | |
case world_id | |
when red_world_id | |
red_world_score | |
when green_world_id | |
green_world_score | |
when blue_world_id | |
blue_world_score | |
else | |
raise "Illegal World Id for Match" | |
end | |
end | |
def color_for(world_id) | |
case world_id | |
when red_world_id | |
"Red" | |
when green_world_id | |
"Green" | |
else | |
"Blue" | |
end | |
end | |
def ppt_for(world_id) | |
calculate_ppt unless @green_ppt | |
case world_id | |
when red_world_id | |
@red_ppt | |
when green_world_id | |
@green_ppt | |
else | |
@blue_ppt | |
end | |
end | |
private | |
## | |
## Load Matches from the WVW API | |
## | |
def self.load_matches | |
rows = [] | |
get('https://api.guildwars2.com/v1/wvw/matches.json')['wvw_matches'].each do |match| | |
m = Match.new | |
rows << m | |
m.match_id = match['wvw_match_id'] | |
m.red_world_id = match['red_world_id'] | |
m.blue_world_id = match['blue_world_id'] | |
m.green_world_id = match['green_world_id'] | |
m.details = get("https://api.guildwars2.com/v1/wvw/match_details.json?match_id=#{m.match_id}") | |
m.red_world_score = m.details['scores'][0] | |
m.blue_world_score = m.details['scores'][1] | |
m.green_world_score = m.details['scores'][2] | |
end | |
rows | |
end | |
## | |
## Calculate Current PPT | |
## | |
def calculate_ppt | |
@red_ppt = 0 | |
@blue_ppt = 0 | |
@green_ppt = 0 | |
details["maps"].each do |map| | |
map['objectives'].each do |objective| | |
obj_name = ObjectiveNames.find_by_id(objective['id']) | |
owner = objective['owner'] | |
value = ObjectiveNames.value(obj_name) | |
case owner | |
when "Red" | |
@red_ppt += value | |
when "Blue" | |
@blue_ppt += value | |
when "Green" | |
@green_ppt += value | |
end | |
end | |
end | |
end | |
end | |
## | |
## Combines info from the leaderboard and the API | |
## | |
class World | |
include HTTParty | |
attr_accessor :world_id, :name, :rank, :rating, :deviation, :volatility | |
attr_accessor :random_deviation, :mu, :phi, :sigma, :sigma1, :phi1, :mu1, :rating1, :deviation1, :rank1 | |
## | |
## Class Methods | |
## | |
def self.all | |
@all ||= load_server_ratings | |
end | |
def self.find_by_world_id(world_id) | |
all.find { |w| w.world_id == world_id } | |
end | |
def self.region=(value) | |
@region=value | |
end | |
def self.region | |
@region || 'na' | |
end | |
## | |
## Helpers | |
## | |
def current_match | |
@current_match ||= Match.find_by_world_id(self.world_id) | |
end | |
def current_score | |
current_match.score_for(world_id) | |
end | |
def current_opponents | |
(current_match.world_ids - [world_id]).map { |world_id| World.find_by_world_id(world_id) } | |
end | |
def evolution | |
rating1 - rating | |
end | |
def debug message | |
puts "\n***** #{message} ****** #{self.inspect} @current_score=#{current_score}" | |
end | |
## | |
## Calculate Future Projections | |
## | |
def self.project | |
all.each { |w| w.prepare_deviation } | |
all.each { |w| w.calculate } | |
all.sort { |a,b| b.rating1 <=> a.rating1 }.each_with_index { |w,index| w.rank1 = index + 1 } | |
all | |
end | |
def prepare_deviation | |
# Random Devitation | |
self.random_deviation = 40.0 + deviation | |
# Glicko-2 rating | |
self.mu = (rating - 1500.0) / 173.7178 | |
# Glicko-2 deviation | |
self.phi = deviation / 173.7178 | |
# Glicko-2 volatility | |
self.sigma = volatility | |
end | |
## | |
## Main Glicko2 Calculations (Snowreap.5174 did all the hard work here) | |
## | |
def calculate | |
v = 0.0 | |
delta = 0.0 | |
current_opponents.each do |opponent| | |
g = fn_g(opponent.phi) | |
e = fn_e(mu, opponent.mu, opponent.phi) | |
s = current_score.to_f / (current_score + opponent.current_score) | |
s = (Math.sin((s - 0.5) * Math::PI) + 1.0) / 2.0 | |
v += g * g * e * (1.0 - e) | |
delta += g * (s - e) | |
end | |
v = 1.0 / v | |
delta = v * delta | |
a = Math.log(sigma * sigma) | |
aa = a | |
if (delta * delta > phi * phi + v) | |
bb = Math.log(delta * delta - phi * phi - v) | |
else | |
k = 1.0 | |
while (fn_f(a - k * Math.sqrt(0.6 * 0.6), delta, phi, v, sigma) < 0.0) | |
k += 1.0 | |
end | |
bb = a - k * Math.sqrt(0.6 * 0.6) | |
end | |
fA = fn_f(aa, delta, phi, v, sigma) | |
fB = fn_f(bb, delta, phi, v, sigma) | |
while ((bb - aa).abs > 0.000001) | |
c = aa + (aa - bb) * fA / (fB - fA) | |
fC = fn_f(c, delta, phi, v, sigma) | |
if (fC * fB < 0.0) | |
aa = bb | |
fA = fB | |
else | |
fA = fA / 2.0 | |
end | |
bb = c | |
fB = fC | |
end | |
self.sigma1 = Math.exp(aa / 2.0) | |
phi_star = Math.sqrt(phi * phi + sigma1 * sigma1) | |
self.phi1 = 1.0 / Math.sqrt(1.0 / (phi_star * phi_star) + 1.0 / v) | |
self.mu1 = mu + phi1 * phi1 * delta / v # Delta = v Sigma[g(){s - E()}], therefore Sigma[G(){s - E()}] = Delta / v | |
self.rating1 = 173.7178 * mu1 + 1500.0 | |
self.deviation1 = 173.7178 * phi1 | |
end | |
def fn_g(phi) | |
1.0 / Math.sqrt(1.0 + 3.0*(phi*phi)/(Math::PI*Math::PI)) | |
end | |
def fn_e(mu, muj, phij) | |
1.0 / (1.0 + Math.exp(fn_g(phij) * (muj - mu))) | |
end | |
def fn_f(x, delta, phi, v, sigma) | |
n1 = Math.exp(x) * (delta * delta - phi * phi - v - Math.exp(x)) | |
d1 = phi * phi + v + Math.exp(x) | |
d1 = 2.0 * d1 * d1 | |
n2 = x - Math.log(sigma * sigma) | |
d2 = 0.6 * 0.6 | |
return n1 / d1 - n2 / d2 | |
end | |
## | |
## Scrape Ratings from Leaderboard | |
## | |
def self.load_server_ratings | |
rows = [] | |
# Ignore SSL Certificate because the open call doesn't | |
# work with certificates on windows (jruby or regular ruby) | |
ssl_options = { :ssl_verify_mode => OpenSSL::SSL::VERIFY_NONE } | |
doc = Nokogiri::HTML( | |
open("https://leaderboards.guildwars2.com/en/#{region}/wvw", ssl_options) | |
).css("table tr").each do |tr| | |
unless tr.css('.rank')[0].content =~ /Rank/ | |
world = World.new | |
rows << world | |
tr.css('.rank').first.content =~ /(\d+)/ | |
world.rank = $1.to_f | |
world.name = tr.css('.name').first.content.strip | |
tr.css('.rating').first.content =~ /(\d+\.\d+)/ | |
world.rating = $1.to_f | |
tr.css('.deviation').first.content =~ /(\d+\.\d+)/ | |
world.deviation = $1.to_f | |
tr.css('.volatility').first.content =~ /(\d+\.\d+)/ | |
world.volatility = $1.to_f | |
world.world_id = WorldNames.find_by_name(world.name) | |
end | |
end | |
rows | |
end | |
end | |
class MatchReport | |
def self.run(server_name = 'Ehmry Bay', region='na') | |
# Glicko2 Ratings | |
World.region = region | |
World.project | |
world_id = WorldNames.find_by_name(server_name) | |
match = Match.find_by_world_id(world_id) | |
match.world_ids.each do |world_id| | |
world = World.find_by_world_id(world_id) | |
name = '%-20s' % WorldNames.find_by_id(world_id) | |
score = '%6d' % match.score_for(world_id) | |
color = '%-5s' % match.color_for(world_id) | |
ppt = '+%3d' % match.ppt_for(world_id) | |
ev = '%+4.2f' % world.evolution | |
puts "#{name} #{color} #{score} #{ppt} PPT - Rating:#{ev}" | |
end | |
end | |
end | |
class RatingReport | |
def self.run(region='na') | |
World.region = region | |
World.project | |
puts "## ## World Old Rating New Rating Evolution Old Deviat New Deviat Travel Min Matchu Max Matchu" | |
puts "-- -- -------------------- ---------- ---------- ---------- ---------- ---------- --------- ---------- ----------" | |
World.all.each do |w| | |
# Old Rank and New Rank | |
r = ('%2i' % w.rank).rjust 2 | |
r1 = ('%2i' % w.rank1).rjust 2 | |
# World Name | |
name = w.name.ljust 20 | |
# Old and New Ratings | |
old =('%4.4f' % w.rating).rjust 9 | |
new = ('%4.4f' % w.rating1).rjust 9 | |
# Old and New Deviations | |
dev0 = ('%4.4f' % w.deviation).rjust 9 | |
dev = ('%4.4f' % w.deviation1).rjust 9 | |
# Rating Evolution | |
ev = w.evolution >=0 ? ('+%3.2f' % w.evolution).rjust(9) : (' %3.2f' % w.evolution).rjust(9) | |
# Range of possibilities for random rating | |
travel = w.deviation1 + 40 | |
trv = ('%4.4f' % travel) | |
# Min and Max Next Random Rating | |
min = ('%4.4f' % (w.rating1 - travel)).rjust 9 | |
max = ('%4.4f' % (w.rating1 + travel)).rjust 9 | |
puts "#{r} #{r1} #{name} #{old} #{new} #{ev} #{dev0} #{dev} #{trv} #{min} #{max}" | |
end | |
end | |
end | |
# Interactive Script Mode? | |
if __FILE__ == $0 | |
cmd = ARGV[0] || 'usage' | |
region = ARGV[1] || 'na' | |
world = ARGV[2] || 'Ehmry Bay' | |
if cmd=='rank' | |
RatingReport.run region | |
elsif cmd=='match' | |
MatchReport.run world, region | |
else | |
puts "-------------------------------------------------" | |
puts "USAGE" | |
puts "" | |
puts "World Rank Reports" | |
puts " ruby api.rb report eu" | |
puts " ruby api.rb report na" | |
puts "" | |
puts "Match Reports" | |
puts " ruby api.rb match na 'Ehmry Bay'" | |
puts " ruby api.rb match eu \"Miller's Sound [DE]\"" | |
puts | |
end | |
end | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Added usage and command line arguments to run as a script.