Compare commits
No commits in common. '92187abd81c2184c90763701cb024c45146ca6f6' and 'c63e0996931d91645dc93f05a57e668faa51d312' have entirely different histories.
92187abd81
...
c63e099693
@ -1 +1 @@
|
|||||||
3.3
|
3.2
|
||||||
|
@ -0,0 +1,196 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
require_relative "models"
|
||||||
|
require_relative "views"
|
||||||
|
|
||||||
|
require "roda"
|
||||||
|
|
||||||
|
module RankKing
|
||||||
|
module Models
|
||||||
|
require "sequel/model"
|
||||||
|
require_relative "db"
|
||||||
|
|
||||||
|
class Pool < Sequel::Model
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class App < Roda
|
||||||
|
opts[:check_dynamic_arity] = false
|
||||||
|
opts[:check_arity] = :warn
|
||||||
|
|
||||||
|
plugin :default_headers,
|
||||||
|
"Content-Type" => "text/html",
|
||||||
|
# "Strict-Transport-Security" => "max-age=16070400;", # Uncomment if only allowing https:// access
|
||||||
|
"X-Frame-Options" => "deny",
|
||||||
|
"X-Content-Type-Options" => "nosniff",
|
||||||
|
"X-XSS-Protection" => "1; mode=block"
|
||||||
|
|
||||||
|
plugin :content_security_policy do |csp|
|
||||||
|
csp.default_src :none
|
||||||
|
# csp.style_src :self, "https://cdn.jsdelivr.net"
|
||||||
|
csp.form_action :self
|
||||||
|
csp.script_src :self
|
||||||
|
csp.connect_src :self
|
||||||
|
csp.base_uri :none
|
||||||
|
csp.frame_ancestors :none
|
||||||
|
end
|
||||||
|
|
||||||
|
css_opts = {cache: false, style: :compressed}
|
||||||
|
# :nocov:
|
||||||
|
if ENV["RACK_ENV"] == 'development'
|
||||||
|
css_opts.merge!(source_map_embed: true, source_map_contents: true, source_map_file: ".")
|
||||||
|
end
|
||||||
|
plugin :render_coverage if defined?(SimpleCov)
|
||||||
|
# :nocov:
|
||||||
|
|
||||||
|
plugin :route_csrf
|
||||||
|
plugin :flash
|
||||||
|
# plugin :assets, css: "app.scss", css_opts: css_opts, timestamp_paths: true
|
||||||
|
# plugin :render, escape: true, layout: "./layout", :template_opts=>{chain_appends: !defined?(SimpleCov), freeze: true, skip_compiled_encoding_detection: true}
|
||||||
|
plugin :public
|
||||||
|
plugin :Integer_matcher_max
|
||||||
|
plugin :typecast_params_sized_integers, sizes: [64], default_size: 64
|
||||||
|
# plugin :hash_branch_view_subdir
|
||||||
|
plugin :custom_block_results
|
||||||
|
|
||||||
|
logger = if ENV["RACK_ENV"] == "test"
|
||||||
|
Class.new{def write(_) end}.new
|
||||||
|
else
|
||||||
|
$stderr
|
||||||
|
end
|
||||||
|
plugin :common_logger, logger
|
||||||
|
|
||||||
|
plugin :not_found do
|
||||||
|
Views::Layout.call(title: "File Not Found")
|
||||||
|
end
|
||||||
|
|
||||||
|
if ENV["RACK_ENV"] == "development"
|
||||||
|
plugin :exception_page
|
||||||
|
class RodaRequest
|
||||||
|
def assets
|
||||||
|
exception_page_assets
|
||||||
|
super
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
def self.freeze
|
||||||
|
Sequel::Model.freeze_descendents
|
||||||
|
DB.freeze
|
||||||
|
super
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
plugin :error_handler do |e|
|
||||||
|
case e
|
||||||
|
when Roda::RodaPlugins::RouteCsrf::InvalidToken
|
||||||
|
@page_title = "Invalid Security Token"
|
||||||
|
response.status = 400
|
||||||
|
view(content: "<p>An invalid security token was submitted with this request, and this request could not be processed.</p>")
|
||||||
|
else
|
||||||
|
$stderr.print "#{e.class}: #{e.message}\n"
|
||||||
|
$stderr.puts e.backtrace
|
||||||
|
next exception_page(e, assets: true) if ENV["RACK_ENV"] == "development"
|
||||||
|
# @page_title = "Internal Server Error"
|
||||||
|
# view(content: "")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
plugin :sessions,
|
||||||
|
key: "_App.session",
|
||||||
|
#cookie_options: {secure: ENV['RACK_ENV'] != 'test'}, # Uncomment if only allowing https:// access
|
||||||
|
secret: ENV.send((ENV["RACK_ENV"] == "development" ? :[] : :delete), "APP_SESSION_SECRET")
|
||||||
|
|
||||||
|
# if Unreloader.autoload?
|
||||||
|
# plugin :autoload_hash_branches
|
||||||
|
# autoload_hash_branch_dir("./routes")
|
||||||
|
# end
|
||||||
|
# Unreloader.autoload("routes", delete_hook: proc{|f| hash_branch(File.basename(f).delete_suffix(".rb"))}){}
|
||||||
|
|
||||||
|
class Template < Phlex::HTML
|
||||||
|
def initialize(block)
|
||||||
|
@block = block
|
||||||
|
end
|
||||||
|
|
||||||
|
def template
|
||||||
|
instance_eval(&@block)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# def render(&block)
|
||||||
|
# template = Template.new(block)
|
||||||
|
# instance_variables
|
||||||
|
# .reject { _1.start_with?("@_") }
|
||||||
|
# .each { template.instance_variable_set(_1, instance_variable_get(_1)) }
|
||||||
|
# Views::Layout.call do
|
||||||
|
# plain ""
|
||||||
|
# end
|
||||||
|
# # Views::Layout.call(template)
|
||||||
|
# # Views::Layout.call do |layout|
|
||||||
|
# # layout.page_title "New Pool"
|
||||||
|
# # plain ""
|
||||||
|
# # end
|
||||||
|
# end
|
||||||
|
|
||||||
|
def view(&block)
|
||||||
|
Class.new(Phlex::HTML, &block).call
|
||||||
|
end
|
||||||
|
|
||||||
|
route do |r|
|
||||||
|
|
||||||
|
# r.public
|
||||||
|
# r.assets
|
||||||
|
check_csrf!
|
||||||
|
|
||||||
|
r.root do
|
||||||
|
if DB[:pools].empty?
|
||||||
|
r.redirect "/pools/new"
|
||||||
|
else
|
||||||
|
render do
|
||||||
|
h1 { "ohai" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
r.on "pools" do
|
||||||
|
r.is "new" do
|
||||||
|
r.get do
|
||||||
|
@csrf_token = csrf_token(r.path)
|
||||||
|
view do
|
||||||
|
Views::Layout.new(title: "New Pool") do
|
||||||
|
Views::NewPool.new(csrf_token: @csrf_token)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
# Views::Layout.call(title: "New Pool") do
|
||||||
|
# render Views::NewPool.new(csrf_token: csrf_token(r.path))
|
||||||
|
# end
|
||||||
|
# layout = Views::Layout.new do |layout|
|
||||||
|
# layout.page_title "New Pool"
|
||||||
|
# render Views::NewPool.new(csrf_token: csrf_token(r.path))
|
||||||
|
# end
|
||||||
|
# layout.call
|
||||||
|
# @csrf_token = csrf_token(r.path)
|
||||||
|
# render do
|
||||||
|
# form(method: "post") do
|
||||||
|
# input(type: "hidden", name: "_csrf", value: @csrf_token)
|
||||||
|
# label(for: "name")
|
||||||
|
# input(type: "text", name: "name", id: "name", required: true)
|
||||||
|
# input(type: "submit", value: "Create")
|
||||||
|
# end
|
||||||
|
# end
|
||||||
|
end
|
||||||
|
|
||||||
|
r.post do
|
||||||
|
pool = Models::Pool.create(name: r.params.fetch("name"))
|
||||||
|
r.redirect "/pools/#{pool.id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
r.is Integer do |id|
|
||||||
|
pool = Models::Pool[id]
|
||||||
|
render do
|
||||||
|
h2 { pool.name }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,3 @@
|
|||||||
|
require "sequel/core"
|
||||||
|
|
||||||
|
DB = Sequel.connect(ENV.delete("DATABASE_URL"))
|
@ -1,38 +0,0 @@
|
|||||||
require_relative "rank_king/models"
|
|
||||||
require_relative "rank_king/open_skill"
|
|
||||||
|
|
||||||
module RankKing
|
|
||||||
OS = OpenSkill.new
|
|
||||||
Match = Data.define(:a, :b)
|
|
||||||
|
|
||||||
# rates all possible 1v1 games to find which generally decreases sigma
|
|
||||||
# regardless of the winner/loser - is this mathematically sound? I have
|
|
||||||
# no idea
|
|
||||||
def self.suggest_game(axis)
|
|
||||||
items = axis.pool.items
|
|
||||||
ratings = items.to_h { [_1.id, Rating.first(axis:, item: _1) || OS.rating] }
|
|
||||||
|
|
||||||
items.combination(2).sort_by {|combo|
|
|
||||||
game = combo.map { ratings.fetch(_1.id) }
|
|
||||||
Math.sqrt(
|
|
||||||
[game, game.reverse]
|
|
||||||
.map {|game| [game, OS.rate(*game).flatten] }
|
|
||||||
.sum {|pre,post| pre.zip(post).sum { (_1.sigma - _2.sigma)**2 }}
|
|
||||||
)
|
|
||||||
}.last
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.rank(axis, winner:, loser:)
|
|
||||||
fail unless [winner, loser].all? { _1.pool == axis.pool }
|
|
||||||
|
|
||||||
game = [winner, loser].map { Rating.first(axis:, item: _1) || OS.rating }
|
|
||||||
result = OS.rate(*game).flatten
|
|
||||||
|
|
||||||
DB.transaction do
|
|
||||||
Ranking.create(axis:, winner:, loser:)
|
|
||||||
Rating.update_or_create({axis:, item: winner}, result.fetch(0).to_h)
|
|
||||||
Rating.update_or_create({axis:, item: loser}, result.fetch(1).to_h)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
@ -1,41 +0,0 @@
|
|||||||
require "forwardable"
|
|
||||||
|
|
||||||
require "sequel"
|
|
||||||
|
|
||||||
require_relative "db"
|
|
||||||
require_relative "open_skill"
|
|
||||||
|
|
||||||
module RankKing
|
|
||||||
class Pool < Sequel::Model
|
|
||||||
one_to_many :items
|
|
||||||
one_to_many :axes, class: "RankKing::Axis"
|
|
||||||
end
|
|
||||||
|
|
||||||
class Item < Sequel::Model
|
|
||||||
many_to_one :pool
|
|
||||||
one_to_many :ratings
|
|
||||||
end
|
|
||||||
|
|
||||||
class Axis < Sequel::Model(DB[:axes])
|
|
||||||
many_to_one :pool
|
|
||||||
one_to_many :ratings
|
|
||||||
end
|
|
||||||
|
|
||||||
class Rating < Sequel::Model
|
|
||||||
many_to_one :item
|
|
||||||
many_to_one :axis
|
|
||||||
|
|
||||||
extend Forwardable
|
|
||||||
def_delegators :openskill, :ordinal, :with
|
|
||||||
|
|
||||||
def openskill
|
|
||||||
OpenSkill::Rating.new(mu: self.mu, sigma: self.sigma)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class Ranking < Sequel::Model
|
|
||||||
many_to_one :axis
|
|
||||||
many_to_one :winner, class: "RankKing::Item"
|
|
||||||
many_to_one :loser, class: "RankKing::Item"
|
|
||||||
end
|
|
||||||
end
|
|
@ -1,117 +0,0 @@
|
|||||||
module RankKing
|
|
||||||
class OpenSkill
|
|
||||||
# Gaussian curve where mu is the mean and sigma the stddev
|
|
||||||
Rating = Data.define(:mu, :sigma) do
|
|
||||||
def ordinal = self.mu - 3*self.sigma
|
|
||||||
end
|
|
||||||
|
|
||||||
TeamRating = Data.define(:mu, :sigma_sq, :team, :rank)
|
|
||||||
|
|
||||||
def initialize(tau: nil)
|
|
||||||
@z = 3.0
|
|
||||||
@mu = 25.0
|
|
||||||
@tau = tau || @mu / 300.0
|
|
||||||
@sigma = @mu / @z
|
|
||||||
|
|
||||||
@epsilon = 0.0001
|
|
||||||
@beta = @sigma / 2.0
|
|
||||||
end
|
|
||||||
|
|
||||||
def rate(*teams)
|
|
||||||
# allow for passing in single-rating teams
|
|
||||||
teams = teams.map { Array(_1) }
|
|
||||||
|
|
||||||
# tau keeps sigma from dropping too low so that ratings stay pliable
|
|
||||||
# after many games
|
|
||||||
tau_sq = @tau ** 2
|
|
||||||
teams = teams.map {|team|
|
|
||||||
team.map {|r| r.with(sigma: Math.sqrt(r.sigma ** 2 + tau_sq)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
rank = (0...teams.size).to_a
|
|
||||||
|
|
||||||
ordered_teams, tenet = self.class.unwind(teams, rank)
|
|
||||||
new_ratings = plackett_luce(ordered_teams, rank)
|
|
||||||
reordered_teams = self.class.unwind(new_ratings, tenet)
|
|
||||||
|
|
||||||
# TODO prevent sigma increase?
|
|
||||||
|
|
||||||
reordered_teams
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.unwind(src, rank)
|
|
||||||
fail unless src.size == rank.size
|
|
||||||
|
|
||||||
return [[], []] if src.empty?
|
|
||||||
|
|
||||||
src.each.with_index
|
|
||||||
.sort_by { rank.fetch(_2) }
|
|
||||||
.transpose
|
|
||||||
end
|
|
||||||
|
|
||||||
def plackett_luce(game, rank)
|
|
||||||
team_ratings = self.team_ratings(game)
|
|
||||||
c = util_c(team_ratings)
|
|
||||||
sum_q = util_sum_q(team_ratings, c)
|
|
||||||
a = util_a(team_ratings)
|
|
||||||
|
|
||||||
team_ratings.map.with_index {|x, i|
|
|
||||||
x_mu_over_ce = Math.exp(x.mu / c) # tmp1
|
|
||||||
|
|
||||||
omega_sum, delta_sum = team_ratings.each.with_index
|
|
||||||
.filter {|y,_| y.rank <= x.rank }
|
|
||||||
.inject([0, 0]) {|(omega, delta), (y, i)|
|
|
||||||
quotient = x_mu_over_ce / sum_q.fetch(i)
|
|
||||||
[
|
|
||||||
omega + (x == y ? 1 - quotient : -quotient) / a.fetch(i),
|
|
||||||
delta + (quotient * (1 - quotient)) / a.fetch(i),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
x_gamma = Math.sqrt(x.sigma_sq) / c
|
|
||||||
x_omega = omega_sum * (x.sigma_sq / c)
|
|
||||||
x_delta = x_gamma * delta_sum * (x.sigma_sq / c ** 2)
|
|
||||||
|
|
||||||
x.team.map {|team| Rating.new(
|
|
||||||
mu: team.mu + (team.sigma ** 2 / x.sigma_sq) * x_omega,
|
|
||||||
sigma: team.sigma * Math.sqrt([1 - (team.sigma ** 2 / x.sigma_sq) * x_delta, @epsilon].max),
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def rating(mu: nil, sigma: nil) #:: Rating
|
|
||||||
mu ||= @mu
|
|
||||||
sigma ||= mu / @z
|
|
||||||
Rating.new(mu: mu.to_f, sigma: sigma.to_f)
|
|
||||||
end
|
|
||||||
|
|
||||||
def team_ratings(game)
|
|
||||||
game.map.with_index {|team, i|
|
|
||||||
TeamRating.new(
|
|
||||||
mu: team.sum(&:mu),
|
|
||||||
sigma_sq: team.sum { _1.sigma ** 2 },
|
|
||||||
team:,
|
|
||||||
rank: i,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def util_a(team_ratings)
|
|
||||||
team_ratings.map {|q|
|
|
||||||
team_ratings.count {|i| i.rank == q.rank }
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def util_c(team_ratings)
|
|
||||||
Math.sqrt(team_ratings.sum { _1.sigma_sq + @beta ** 2 })
|
|
||||||
end
|
|
||||||
|
|
||||||
def util_sum_q(team_ratings, c)
|
|
||||||
team_ratings.map {|q|
|
|
||||||
team_ratings
|
|
||||||
.select {|i| i.rank >= q.rank }
|
|
||||||
.sum {|i| Math.exp(i.mu / c) }
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
@ -0,0 +1,50 @@
|
|||||||
|
require "phlex"
|
||||||
|
|
||||||
|
module RankKing
|
||||||
|
module Views
|
||||||
|
|
||||||
|
class Layout < Phlex::HTML
|
||||||
|
def initialize(title: nil)
|
||||||
|
@title = title
|
||||||
|
end
|
||||||
|
|
||||||
|
def template(&block)
|
||||||
|
doctype
|
||||||
|
html(lang: "en") do
|
||||||
|
head do
|
||||||
|
meta(charset: "utf-8")
|
||||||
|
meta(name: "viewport", content: "width=device-width, initial-scale=1")
|
||||||
|
title { ["Rank King", @title].compact.join(" - ") }
|
||||||
|
end
|
||||||
|
|
||||||
|
body do
|
||||||
|
header do
|
||||||
|
h1 { "Rank King" }
|
||||||
|
end
|
||||||
|
|
||||||
|
nav
|
||||||
|
|
||||||
|
main(&block)
|
||||||
|
|
||||||
|
footer
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class NewPool < Phlex::HTML
|
||||||
|
def initialize(csrf_token:)
|
||||||
|
@csrf_token = csrf_token
|
||||||
|
end
|
||||||
|
|
||||||
|
def template
|
||||||
|
form do
|
||||||
|
input(type: "hidden", name: "_csrf", value: @csrf_token)
|
||||||
|
label(for: "name")
|
||||||
|
input(type: "text", name: "name", id: "name", required: true)
|
||||||
|
input(type: "submit", value: "Create")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -1,179 +0,0 @@
|
|||||||
require "roda"
|
|
||||||
|
|
||||||
require_relative "rank_king"
|
|
||||||
|
|
||||||
module RankKing
|
|
||||||
class Web < Roda
|
|
||||||
plugin :named_templates
|
|
||||||
plugin :public
|
|
||||||
plugin :render
|
|
||||||
plugin :symbol_views
|
|
||||||
|
|
||||||
template(:layout) { <<~ERB }
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en-US">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=1" />
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="/static/main.css">
|
|
||||||
|
|
||||||
<!-- TODO
|
|
||||||
<link rel="icon" sizes="16x16 32x32 48x48" type="image/png" href="/icon.png" />
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/images/icon.png" />
|
|
||||||
<link rel="mask-icon" href="/images/icon.svg" color="#000000" />
|
|
||||||
-->
|
|
||||||
|
|
||||||
<title><%= ["Rank King", @page_title].compact.join(" - ") %></title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<header>
|
|
||||||
<a href="/"><h1>Rank King</h1></a>
|
|
||||||
<nav><!-- TODO --></nav>
|
|
||||||
</header>
|
|
||||||
<%= yield %>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
ERB
|
|
||||||
|
|
||||||
template(:pools) { <<~ERB }
|
|
||||||
<section>
|
|
||||||
<h1>Pools</h1>
|
|
||||||
<ul>
|
|
||||||
<% @pools.each do |pool| %>
|
|
||||||
<li><a href="/pools/<%= pool.id %>"><%= pool.name %></a></li>
|
|
||||||
<% end %>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
ERB
|
|
||||||
|
|
||||||
template(:pool) { <<~ERB }
|
|
||||||
<section>
|
|
||||||
<h1><%= @pool.name %></h1>
|
|
||||||
|
|
||||||
<h2>Axes</h2>
|
|
||||||
<ul>
|
|
||||||
<% @pool.axes.each do |axis| %>
|
|
||||||
<li><a href="/pools/<%= @pool.id %>/axes/<%= axis.id %>"><%= axis.name %></a></li>
|
|
||||||
<% end %>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>Items</h2>
|
|
||||||
<ol>
|
|
||||||
<% @pool.items.each do |item| %>
|
|
||||||
<li><%= item.title %></li>
|
|
||||||
<% end %>
|
|
||||||
</ol>
|
|
||||||
</section>
|
|
||||||
ERB
|
|
||||||
|
|
||||||
template(:axis) { <<~ERB }
|
|
||||||
<section>
|
|
||||||
<h1><%= @axis.pool.name %> - <%= @axis.name %></h1>
|
|
||||||
|
|
||||||
<h2>Items</h2>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<% @axis.ratings.sort_by { -_1.ordinal }.each.with_index do |rating, i| %>
|
|
||||||
<tr>
|
|
||||||
<td><%= i+1 %></td>
|
|
||||||
<td><%= rating.item.title %></td>
|
|
||||||
<td><%= rating.ordinal.round(1) %></td>
|
|
||||||
<td><%= rating.mu.round(1) %></td>
|
|
||||||
<td><%= rating.sigma.round(1) %></td>
|
|
||||||
</tr>
|
|
||||||
<% end %>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<form method="post" action="/pools/<%= @pool.id %>/axes/<%= @axis.id %>/rank">
|
|
||||||
<% @game.each.with_index do |item, i| %>
|
|
||||||
<label><input type="radio" name="winner" value="<%= item.id %>"><%= item.title %></label>
|
|
||||||
<input type="hidden" name="item_<%= i %>" value="<%= item.id %>">
|
|
||||||
<% end %>
|
|
||||||
<input type="submit">
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
ERB
|
|
||||||
|
|
||||||
template(:theme) { <<~ERB }
|
|
||||||
<section>
|
|
||||||
<div class="surface-samples">
|
|
||||||
<div class="surface1">1</div>
|
|
||||||
<div class="surface2">2</div>
|
|
||||||
<div class="surface3">3</div>
|
|
||||||
<div class="surface4">4</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<div class="text-samples">
|
|
||||||
<h1 class="text1">
|
|
||||||
<span class="swatch accent"></span>
|
|
||||||
Accent
|
|
||||||
</h1>
|
|
||||||
<h1 class="text1">
|
|
||||||
<span class="swatch text1"></span>
|
|
||||||
Text Color 1
|
|
||||||
</h1>
|
|
||||||
<h1 class="text2">
|
|
||||||
<span class="swatch text2"></span>
|
|
||||||
Text Color 2
|
|
||||||
</h1>
|
|
||||||
<br>
|
|
||||||
<p class="text1">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
|
|
||||||
<p class="text2">Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
ERB
|
|
||||||
|
|
||||||
route do |r|
|
|
||||||
r.on "static" do
|
|
||||||
r.public
|
|
||||||
end
|
|
||||||
|
|
||||||
r.root do
|
|
||||||
r.redirect "/pools"
|
|
||||||
end
|
|
||||||
|
|
||||||
r.on "pools" do
|
|
||||||
r.is do
|
|
||||||
@pools = Pool.all
|
|
||||||
:pools
|
|
||||||
end
|
|
||||||
|
|
||||||
r.on Integer do |id|
|
|
||||||
@pool = Pool[id]
|
|
||||||
|
|
||||||
r.is do
|
|
||||||
:pool
|
|
||||||
end
|
|
||||||
|
|
||||||
r.on "axes" do
|
|
||||||
r.on Integer do |id|
|
|
||||||
@axis = Axis[id]
|
|
||||||
|
|
||||||
r.is "rank" do
|
|
||||||
items = r.params.values_at("item_0", "item_1").map { Item[_1] }
|
|
||||||
winner, loser = items.partition { _1.id == r.params.fetch("winner").to_i }.flatten
|
|
||||||
RankKing.rank(@axis, winner:, loser:)
|
|
||||||
|
|
||||||
r.redirect "/pools/#{@pool.id}/axes/#{@axis.id}"
|
|
||||||
end
|
|
||||||
|
|
||||||
r.is do
|
|
||||||
@game = RankKing.suggest_game(@axis).shuffle
|
|
||||||
|
|
||||||
:axis
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
r.is "theme" do
|
|
||||||
:theme
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
@ -1,209 +0,0 @@
|
|||||||
html {
|
|
||||||
max-width: 70ch;
|
|
||||||
margin: auto;
|
|
||||||
line-height: 1.75;
|
|
||||||
font-size: 1.25em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* OKLCH Colors
|
|
||||||
*
|
|
||||||
* - https://web.dev/building-a-color-scheme/
|
|
||||||
* - https://evilmartians.com/chronicles/oklch-in-css-why-quit-rgb-hsl
|
|
||||||
* - https://oklch.com/
|
|
||||||
*/
|
|
||||||
|
|
||||||
:root {
|
|
||||||
/* https://coolors.co/palette/000814-001d3d-003566-ffc300-ffd60a */
|
|
||||||
|
|
||||||
--background-lightness: 23%;
|
|
||||||
--background-chroma: 0.07;
|
|
||||||
--background-hue: 252;
|
|
||||||
|
|
||||||
--text-lightness: 33%;
|
|
||||||
--text-chroma: 0.10;
|
|
||||||
--text-hue: 252;
|
|
||||||
|
|
||||||
--accent-lightness: 85%;
|
|
||||||
--accent-chroma: 0.17;
|
|
||||||
--accent-hue: 86;
|
|
||||||
--accent: oklch(var(--accent-lightness) var(--accent-chroma) var(--accent-hue));
|
|
||||||
|
|
||||||
--background: oklch(90% 0.10 var(--background-hue));
|
|
||||||
--text: oklch(10% var(--text-chroma) var(--text-hue));
|
|
||||||
|
|
||||||
--text1: oklch(10% var(--text-chroma) var(--text-hue));
|
|
||||||
--text2: oklch(35% calc(0.5 * var(--text-chroma)) var(--text-hue));
|
|
||||||
/* --selection: oklch(90% 0.17 var(--accent-hue)); */
|
|
||||||
|
|
||||||
--a-background: oklch(10% 0.05 var(--background-hue));
|
|
||||||
}
|
|
||||||
|
|
||||||
::selection {
|
|
||||||
background: var(--selection);
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--background);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* a, a:active, a:visited { */
|
|
||||||
/* color: var(--selection); */
|
|
||||||
/* background-color: var(--a-background); */
|
|
||||||
/* } */
|
|
||||||
|
|
||||||
.surface-samples {
|
|
||||||
display: grid;
|
|
||||||
--size: 20ch;
|
|
||||||
grid-template-columns: var(--size) var(--size);
|
|
||||||
grid-auto-rows: var(--size);
|
|
||||||
gap: 2ch;
|
|
||||||
|
|
||||||
@media (width <= 480px) { & {
|
|
||||||
--size: 40vw;
|
|
||||||
}}
|
|
||||||
|
|
||||||
& > * {
|
|
||||||
border-radius: 1rem;
|
|
||||||
display: grid;
|
|
||||||
place-content: center;
|
|
||||||
font-size: 3rem;
|
|
||||||
font-weight: 200;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-samples {
|
|
||||||
display: grid;
|
|
||||||
gap: 1.5ch;
|
|
||||||
|
|
||||||
& > h1 {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1ch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.accent {
|
|
||||||
color: var(--accent);
|
|
||||||
background-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.surface1 {
|
|
||||||
background-color: var(--surface1);
|
|
||||||
color: var(--text2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.surface2 {
|
|
||||||
background-color: var(--surface2);
|
|
||||||
color: var(--text2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.surface3 {
|
|
||||||
background-color: var(--surface3);
|
|
||||||
color: var(--text1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.surface4 {
|
|
||||||
background-color: var(--surface4);
|
|
||||||
color: var(--text1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text1 {
|
|
||||||
color: var(--text1);
|
|
||||||
@nest p& {
|
|
||||||
font-weight: 200;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.text2 {
|
|
||||||
color: var(--text2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.swatch {
|
|
||||||
display: inline-block;
|
|
||||||
flex-shrink: 0;
|
|
||||||
inline-size: 1.5ch;
|
|
||||||
block-size: 1.5ch;
|
|
||||||
border-radius: 50%;
|
|
||||||
|
|
||||||
&.text1 { background-color: var(--text1); }
|
|
||||||
&.text2 { background-color: var(--text2); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* HSL Colors
|
|
||||||
*
|
|
||||||
* - https://xeiaso.net/blog/xess-css-variables
|
|
||||||
* - https://web.dev/building-a-color-scheme/
|
|
||||||
*/
|
|
||||||
|
|
||||||
/*
|
|
||||||
:root {
|
|
||||||
--background-color: 43;
|
|
||||||
--text-color: 200;
|
|
||||||
--accent-color: 32;
|
|
||||||
|
|
||||||
--selection: hsl(var(--accent-color), 80%, 30%, 100%);
|
|
||||||
--selection-light: hsl(var(--accent-color), 50%, 80%, 100%);
|
|
||||||
--background: hsl(var(--background-color), 100%, 10%, 100%);
|
|
||||||
--background-light: hsl(var(--background-color), 10%, 95%, 100%);
|
|
||||||
--text: hsl(var(--text-color), 0%, 90%, 100%);
|
|
||||||
--text-light: hsl(var(--text-color), 90%, 5%, 100%);
|
|
||||||
--pre-background: hsl(var(--background-color), 90%, 5%, 100%);
|
|
||||||
--pre-background-light: hsl(var(--background-color), 10%, 80%, 100%);
|
|
||||||
--a-background: hsl(var(--background-color), 90%, 5%, 100%);
|
|
||||||
--a-background-light: hsl(var(--background-color), 30%, 90%, 100%);
|
|
||||||
--a-color: hsl(var(--accent-color), 70%, 85%, 100%);
|
|
||||||
--a-color-light: hsl(var(--accent-color), 80%, 10%, 100%);
|
|
||||||
--blockquote-border: 0.5ch solid hsl(var(--accent-color), 80%, 80%, 100%);
|
|
||||||
--blockquote-border-light: 0.5ch solid hsl(var(--accent-color), 50%, 30%, 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
::selection {
|
|
||||||
background: var(--selection);
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
background-color: var(--pre-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
a, a:active, a:visited {
|
|
||||||
color: var(--selection);
|
|
||||||
background-color: var(--a-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
border-left: var(--blockquote-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
::selection {
|
|
||||||
background: var(--selection-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--background-light);
|
|
||||||
color: var(--text-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
background-color: var(--pre-background-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
a, a:active, a:visited {
|
|
||||||
color: var(--a-color-light);
|
|
||||||
background-color: var(--a-background-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
border-left: var(--blockquote-border-light);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
@ -1,132 +0,0 @@
|
|||||||
require "rank_king/open_skill"
|
|
||||||
|
|
||||||
include RankKing
|
|
||||||
|
|
||||||
class TestRate < Minitest::Test
|
|
||||||
def setup
|
|
||||||
@os = OpenSkill.new(tau: 0)
|
|
||||||
@a1 = @os.rating(mu: 29.182, sigma: 4.782)
|
|
||||||
@b1 = @os.rating(mu: 27.174, sigma: 4.922)
|
|
||||||
@c1 = @os.rating(mu: 16.672, sigma: 6.217)
|
|
||||||
@d1 = @os.rating
|
|
||||||
@e1 = @os.rating
|
|
||||||
@f1 = @os.rating
|
|
||||||
@w1 = @os.rating(mu: 15)
|
|
||||||
@x1 = @os.rating(mu: 20)
|
|
||||||
@y1 = @os.rating(mu: 25)
|
|
||||||
@z1 = @os.rating(mu: 30)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_rate
|
|
||||||
a2, b2, c2, d2 = @os.rate([@a1], [@b1], [@c1], [@d1]).flatten
|
|
||||||
|
|
||||||
assert_rating a2, OpenSkill::Rating.new(mu: 30.209971908310553, sigma: 4.764898977359521)
|
|
||||||
assert_rating b2, OpenSkill::Rating.new(mu: 27.64460833689499, sigma: 4.882789305097372)
|
|
||||||
assert_rating c2, OpenSkill::Rating.new(mu: 17.403586731283518, sigma: 6.100723440599442)
|
|
||||||
assert_rating d2, OpenSkill::Rating.new(mu: 19.214790707434826, sigma: 7.8542613981643985)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_tau
|
|
||||||
# TODO
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def assert_rating(expected, actual)
|
|
||||||
assert_in_delta expected.mu, actual.mu, 0.0001, "Expected #{actual} to equal #{expected}"
|
|
||||||
assert_in_delta expected.sigma, actual.sigma, 0.0001, "Expected #{actual} to equal #{expected}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class TestRating < Minitest::Test
|
|
||||||
def test_initialize_rating
|
|
||||||
rating = OpenSkill.new.rating(mu: 12, sigma: 34)
|
|
||||||
assert_in_delta 12, rating.mu
|
|
||||||
assert_in_delta 34, rating.sigma
|
|
||||||
|
|
||||||
rating = OpenSkill.new.rating(mu: 42)
|
|
||||||
assert_in_delta 42, rating.mu
|
|
||||||
assert_in_delta 14, rating.sigma
|
|
||||||
|
|
||||||
rating = OpenSkill.new.rating(sigma: 6.283185)
|
|
||||||
assert_in_delta 25, rating.mu
|
|
||||||
assert_in_delta 6.283185, rating.sigma
|
|
||||||
|
|
||||||
rating = OpenSkill.new.rating(sigma: 0)
|
|
||||||
assert_in_delta 25, rating.mu
|
|
||||||
assert_in_delta 0, rating.sigma
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_ordinal
|
|
||||||
assert_in_delta (-1), OpenSkill::Rating.new(mu: 5, sigma: 2).ordinal
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class TestUtils < Minitest::Test
|
|
||||||
def setup
|
|
||||||
@os = OpenSkill.new
|
|
||||||
@r = @os.rating
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_team_ratings
|
|
||||||
# TODO
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_utils
|
|
||||||
team_1 = [@r]
|
|
||||||
team_2 = [@r, @r]
|
|
||||||
team_ratings = @os.team_ratings([team_1, team_2])
|
|
||||||
|
|
||||||
c = @os.util_c(team_ratings)
|
|
||||||
assert_in_delta 15.590239, c
|
|
||||||
|
|
||||||
sum_q = @os.util_sum_q(team_ratings, c)
|
|
||||||
[29.67892702634643, 24.70819334370875].zip(sum_q).each do |actual, expected|
|
|
||||||
assert_in_delta actual, expected
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_utils_5v5
|
|
||||||
team = Array.new(5) { @r }
|
|
||||||
team_ratings = @os.team_ratings([team, team])
|
|
||||||
|
|
||||||
c = @os.util_c(team_ratings)
|
|
||||||
assert_in_delta 27.003, c
|
|
||||||
|
|
||||||
sum_q = @os.util_sum_q(team_ratings, c)
|
|
||||||
[204.84, 102.42].zip(sum_q).each do |actual, expected|
|
|
||||||
assert_in_delta actual, expected, 0.01
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_util_a
|
|
||||||
team = [@r]
|
|
||||||
team_ratings = @os.team_ratings([team, team, team, team])
|
|
||||||
.zip([1, 1, 1, 4])
|
|
||||||
.map { _1.with(rank: _2) }
|
|
||||||
|
|
||||||
a = @os.util_a(team_ratings)
|
|
||||||
|
|
||||||
assert_equal [3, 3, 3, 1], a
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class TestUnwind < Minitest::Test
|
|
||||||
def test_unwind
|
|
||||||
assert_equal [[], []], OpenSkill.unwind([], [])
|
|
||||||
assert_equal [%w[a], [0]], OpenSkill.unwind(%w[a], [0])
|
|
||||||
assert_equal [%w[a b], [1, 0]], OpenSkill.unwind(%w[b a], [1, 0])
|
|
||||||
assert_equal [%w[b c a], [1, 2, 0]], OpenSkill.unwind(%w[a b c], [2, 0, 1])
|
|
||||||
assert_equal [%w[b d c a], [1, 3, 2, 0]], OpenSkill.unwind(%w[a b c d], [3, 0, 2, 1])
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_reverse
|
|
||||||
src = (?a..?z).to_a.shuffle
|
|
||||||
rank = (0..25).to_a.shuffle
|
|
||||||
trans, derank = OpenSkill.unwind(src, rank)
|
|
||||||
dst, dederank = OpenSkill.unwind(trans, derank)
|
|
||||||
|
|
||||||
assert_equal src, dst
|
|
||||||
refute_same rank, dederank
|
|
||||||
end
|
|
||||||
end
|
|
@ -1,27 +0,0 @@
|
|||||||
require "minitest"
|
|
||||||
|
|
||||||
require "rank_king"
|
|
||||||
|
|
||||||
class TestRankKing < Minitest::Test
|
|
||||||
def run(*args, &block)
|
|
||||||
DB.transaction(rollback: :always, auto_savepoint: true) { super }
|
|
||||||
end
|
|
||||||
|
|
||||||
def setup
|
|
||||||
@pool = Pool.create(name: "pool")
|
|
||||||
@axis = Axis.create(pool: @pool, name: "axis")
|
|
||||||
@items = (?a..?d).map { Item.create(pool: @pool, title: _1) }
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_rank_king
|
|
||||||
ratings = @pool.items.map {|item| Rating.first(axis: @axis, item:) || OS.rating }
|
|
||||||
original_sigma_sq = Math.sqrt(ratings.sum { _1.sigma ** 2 })
|
|
||||||
|
|
||||||
game = RankKing.suggest_game(@axis)
|
|
||||||
winner, loser = game.shuffle
|
|
||||||
RankKing.rank(@axis, winner:, loser:)
|
|
||||||
|
|
||||||
sigma_sq = Math.sqrt(@axis.ratings.sum { _1.sigma ** 2 })
|
|
||||||
assert original_sigma_sq > sigma_sq
|
|
||||||
end
|
|
||||||
end
|
|
Loading…
Reference in new issue