Add Elo rating system with match entry and CSV upload
- Implement EloCalculator service for 2v2 Euchre matches - Create background job processing with GoodJob - Add single match entry form - Add CSV upload for batch match entry - Create player rankings view - Add database schema for matches and elo snapshots
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module EuchreCamp
|
||||
module Actions
|
||||
module Admin
|
||||
module Matches
|
||||
class Create < EuchreCamp::Action
|
||||
include Deps[repo: 'repositories.matches']
|
||||
|
||||
def handle(request, response)
|
||||
match_params = request.params.to_h
|
||||
match_params[:played_at] = Time.parse(match_params[:played_at]) if match_params[:played_at]
|
||||
|
||||
# Create the match
|
||||
match = repo.create(match_params)
|
||||
|
||||
# Enqueue background job to calculate Elo
|
||||
EuchreCamp::Jobs::CalculateEloJob.perform_later(match[:id])
|
||||
|
||||
response.redirect_to("/admin/matches")
|
||||
rescue StandardError => e
|
||||
# Handle validation errors or other issues
|
||||
puts "Error: #{e.message}"
|
||||
puts e.backtrace.join("\n")
|
||||
response.redirect_to("/admin/matches/new")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module EuchreCamp
|
||||
module Actions
|
||||
module Admin
|
||||
module Matches
|
||||
class Index < EuchreCamp::Action
|
||||
include Deps[repo: 'repositories.matches']
|
||||
|
||||
def handle(_request, response)
|
||||
matches = repo.all
|
||||
response.render(view, matches: matches)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module EuchreCamp
|
||||
module Actions
|
||||
module Admin
|
||||
module Matches
|
||||
class New < EuchreCamp::Action
|
||||
include Deps[repo: 'repositories.players']
|
||||
|
||||
def handle(_request, response)
|
||||
players = repo.all
|
||||
response.render(view, players: players)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,86 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "csv"
|
||||
|
||||
module EuchreCamp
|
||||
module Actions
|
||||
module Admin
|
||||
module Matches
|
||||
class ProcessUpload < EuchreCamp::Action
|
||||
include Deps[repo: 'repositories.matches']
|
||||
|
||||
def handle(request, response)
|
||||
# In Hanami, file uploads are in request.params
|
||||
file = request.params[:csv_file]
|
||||
|
||||
unless file && file[:tempfile]
|
||||
response.redirect_to("/admin/matches/upload")
|
||||
return
|
||||
end
|
||||
|
||||
matches_created = 0
|
||||
errors = []
|
||||
|
||||
# Parse CSV
|
||||
CSV.foreach(file[:tempfile].path, headers: true, header_converters: :symbol) do |row|
|
||||
begin
|
||||
# Map CSV columns to our database structure
|
||||
# Expected columns: played_at, team_1_p1_name, team_1_p2_name, team_1_score, team_2_p1_name, team_2_p2_name, team_2_score
|
||||
|
||||
# Find or create players by name
|
||||
player_1 = find_or_create_player(row[:team_1_p1_name])
|
||||
player_2 = find_or_create_player(row[:team_1_p2_name])
|
||||
player_3 = find_or_create_player(row[:team_2_p1_name])
|
||||
player_4 = find_or_create_player(row[:team_2_p2_name])
|
||||
|
||||
played_at = begin
|
||||
Time.parse(row[:played_at])
|
||||
rescue
|
||||
Time.now
|
||||
end
|
||||
|
||||
match_data = {
|
||||
played_at: played_at,
|
||||
team_1_p1_id: player_1[:id],
|
||||
team_1_p2_id: player_2[:id],
|
||||
team_1_score: row[:team_1_score].to_i,
|
||||
team_2_p1_id: player_3[:id],
|
||||
team_2_p2_id: player_4[:id],
|
||||
team_2_score: row[:team_2_score].to_i,
|
||||
status: 'completed'
|
||||
}
|
||||
|
||||
match = repo.create(match_data)
|
||||
EuchreCamp::Jobs::CalculateEloJob.perform_later(match[:id])
|
||||
matches_created += 1
|
||||
|
||||
rescue => e
|
||||
errors << "Row #{row}: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
response.flash[:message] = "Created #{matches_created} matches.#{' Errors: ' + errors.join(', ') if errors.any?}"
|
||||
response.redirect_to("/admin/matches")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_or_create_player(name)
|
||||
rom = EuchreCamp::App["persistence.rom"]
|
||||
return nil unless name
|
||||
|
||||
name = name.strip
|
||||
player = rom.relations[:players].where(name: name).one
|
||||
|
||||
if player
|
||||
player
|
||||
else
|
||||
rom.relations[:players].insert(name: name, rating: 0, current_elo: 1000)
|
||||
rom.relations[:players].where(name: name).one
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "csv"
|
||||
|
||||
module EuchreCamp
|
||||
module Actions
|
||||
module Admin
|
||||
module Matches
|
||||
class Upload < EuchreCamp::Action
|
||||
def handle(request, response)
|
||||
response.render(view)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module EuchreCamp
|
||||
module Actions
|
||||
module Players
|
||||
class Rankings < EuchreCamp::Action
|
||||
include Deps[repo: 'repositories.players']
|
||||
|
||||
def handle(_request, response)
|
||||
# Get all players and sort by current Elo
|
||||
players = repo.all.sort_by { |p| -p.current_elo }
|
||||
response.render(view, players: players)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,86 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module EuchreCamp
|
||||
module Jobs
|
||||
class CalculateEloJob
|
||||
def self.perform_later(match_id)
|
||||
# Enqueue job using GoodJob's database directly
|
||||
rom = EuchreCamp::App["persistence.rom"]
|
||||
|
||||
job_params = {
|
||||
queue_name: "default",
|
||||
priority: 0,
|
||||
serialized_params: { "match_id" => match_id }.to_json,
|
||||
scheduled_at: Time.now,
|
||||
created_at: Time.now,
|
||||
updated_at: Time.now,
|
||||
job_class: "EuchreCamp::Jobs::CalculateEloJob",
|
||||
executions_count: 0
|
||||
}
|
||||
|
||||
rom.relations[:good_jobs].insert(job_params)
|
||||
end
|
||||
|
||||
def self.perform(match_id)
|
||||
# This is the actual job execution
|
||||
match = find_match(match_id)
|
||||
return unless match
|
||||
|
||||
# Get current player ratings
|
||||
player_ratings = fetch_player_ratings(match)
|
||||
|
||||
# Calculate new ratings
|
||||
calculation_results = EuchreCamp::Services::EloCalculator.calculate(
|
||||
player_ratings: player_ratings,
|
||||
team_1_players: [match[:team_1_p1_id], match[:team_1_p2_id]],
|
||||
team_2_players: [match[:team_2_p1_id], match[:team_2_p2_id]],
|
||||
team_1_score: match[:team_1_score],
|
||||
team_2_score: match[:team_2_score]
|
||||
)
|
||||
|
||||
# Update player ratings and create snapshots
|
||||
update_player_ratings(calculation_results, match_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def self.find_match(match_id)
|
||||
rom = EuchreCamp::App["persistence.rom"]
|
||||
rom.relations[:matches].by_pk(match_id).one
|
||||
end
|
||||
|
||||
def self.fetch_player_ratings(match)
|
||||
rom = EuchreCamp::App["persistence.rom"]
|
||||
player_ids = [
|
||||
match[:team_1_p1_id],
|
||||
match[:team_1_p2_id],
|
||||
match[:team_2_p1_id],
|
||||
match[:team_2_p2_id]
|
||||
]
|
||||
|
||||
players = rom.relations[:players].where(id: player_ids).to_a
|
||||
players.each_with_object({}) do |player, hash|
|
||||
hash[player[:id]] = player[:current_elo] || 1000
|
||||
end
|
||||
end
|
||||
|
||||
def self.update_player_ratings(results, match_id)
|
||||
rom = EuchreCamp::App["persistence.rom"]
|
||||
|
||||
results.each do |player_id, data|
|
||||
# Update player's current Elo
|
||||
rom.relations[:players].where(id: player_id).update(current_elo: data[:rating_after])
|
||||
|
||||
# Create Elo snapshot record
|
||||
rom.relations[:elo_snapshots].insert(
|
||||
player_id: player_id,
|
||||
match_id: match_id,
|
||||
rating_before: data[:rating_before],
|
||||
rating_after: data[:rating_after],
|
||||
rating_change: data[:rating_change]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rom-repository'
|
||||
|
||||
module EuchreCamp
|
||||
module Repositories
|
||||
class Matches < ::EuchreCamp::Repository[:matches]
|
||||
commands :create, update: :by_pk, delete: :by_pk
|
||||
|
||||
def all
|
||||
matches.to_a
|
||||
end
|
||||
|
||||
def by_id(id)
|
||||
matches.by_pk(id).one!
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,80 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module EuchreCamp
|
||||
module Services
|
||||
class EloCalculator
|
||||
# Standard Elo K-factor
|
||||
K_FACTOR = 32
|
||||
|
||||
# Calculate Elo ratings for a 2v2 Euchre match
|
||||
#
|
||||
# @param player_ratings [Hash] player_id => current_rating
|
||||
# @param team_1_players [Array<Integer>] player IDs for team 1
|
||||
# @param team_2_players [Array<Integer>] player IDs for team 2
|
||||
# @param team_1_score [Integer] score for team 1
|
||||
# @param team_2_score [Integer] score for team 2
|
||||
# @return [Hash] player_id => { rating_before:, rating_after:, rating_change: }
|
||||
def self.calculate(player_ratings:, team_1_players:, team_2_players:, team_1_score:, team_2_score:)
|
||||
# Validate inputs
|
||||
raise ArgumentError, "Team 1 must have exactly 2 players" unless team_1_players.length == 2
|
||||
raise ArgumentError, "Team 2 must have exactly 2 players" unless team_2_players.length == 2
|
||||
|
||||
# Determine winner (first to 10 points)
|
||||
team_1_wins = team_1_score >= 10
|
||||
team_2_wins = team_2_score >= 10
|
||||
|
||||
unless team_1_wins || team_2_wins
|
||||
raise ArgumentError, "One team must reach 10 points to win"
|
||||
end
|
||||
|
||||
if team_1_wins && team_2_wins
|
||||
raise ArgumentError, "Only one team can win (both reached 10 points)"
|
||||
end
|
||||
|
||||
winner = team_1_wins ? :team_1 : :team_2
|
||||
|
||||
# Calculate average ratings for each team
|
||||
team_1_avg = team_1_players.map { |id| player_ratings[id] }.sum / 2.0
|
||||
team_2_avg = team_2_players.map { |id| player_ratings[id] }.sum / 2.0
|
||||
|
||||
# Calculate expected scores using Elo formula
|
||||
# E = 1 / (1 + 10^((R_opp - R_team) / 400))
|
||||
expected_team_1 = 1.0 / (1.0 + 10.0**((team_2_avg - team_1_avg) / 400.0))
|
||||
expected_team_2 = 1.0 / (1.0 + 10.0**((team_1_avg - team_2_avg) / 400.0))
|
||||
|
||||
# Actual scores (1 for win, 0 for loss)
|
||||
actual_team_1 = team_1_wins ? 1.0 : 0.0
|
||||
actual_team_2 = team_2_wins ? 1.0 : 0.0
|
||||
|
||||
# Calculate rating changes for each player
|
||||
results = {}
|
||||
|
||||
# Team 1 players
|
||||
team_1_players.each do |player_id|
|
||||
old_rating = player_ratings[player_id]
|
||||
rating_change = K_FACTOR * (actual_team_1 - expected_team_1)
|
||||
new_rating = (old_rating + rating_change).round
|
||||
results[player_id] = {
|
||||
rating_before: old_rating,
|
||||
rating_after: new_rating,
|
||||
rating_change: rating_change.round
|
||||
}
|
||||
end
|
||||
|
||||
# Team 2 players
|
||||
team_2_players.each do |player_id|
|
||||
old_rating = player_ratings[player_id]
|
||||
rating_change = K_FACTOR * (actual_team_2 - expected_team_2)
|
||||
new_rating = (old_rating + rating_change).round
|
||||
results[player_id] = {
|
||||
rating_before: old_rating,
|
||||
rating_after: new_rating,
|
||||
rating_change: rating_change.round
|
||||
}
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
<h1>Match Administration</h1>
|
||||
|
||||
<p><a href="/admin/matches/new">Enter New Match</a> | <a href="/admin/matches/upload">Upload CSV</a></p>
|
||||
|
||||
<% if matches.any? %>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Date</th>
|
||||
<th>Team 1</th>
|
||||
<th>Score</th>
|
||||
<th>Team 2</th>
|
||||
<th>Score</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% matches.each do |match| %>
|
||||
<tr>
|
||||
<td><%= match.id %></td>
|
||||
<td><%= match.played_at&.strftime('%Y-%m-%d %H:%M') %></td>
|
||||
<td><%= match.team_1_p1_id %>/<%= match.team_1_p2_id %></td>
|
||||
<td><%= match.team_1_score %></td>
|
||||
<td><%= match.team_2_p1_id %>/<%= match.team_2_p2_id %></td>
|
||||
<td><%= match.team_2_score %></td>
|
||||
<td><%= match.status %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p>No matches entered yet.</p>
|
||||
<% end %>
|
||||
|
||||
<p><a href="/admin/players">Manage Players</a></p>
|
||||
@@ -0,0 +1,66 @@
|
||||
<h1>Enter New Match</h1>
|
||||
|
||||
<form action="/admin/matches" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="<%= csrf_token %>">
|
||||
|
||||
<div>
|
||||
<label for="played_at">Date/Time:</label>
|
||||
<input type="datetime-local" id="played_at" name="played_at" required value="<%= Time.now.strftime('%Y-%m-%dT%H:%M') %>">
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend>Team 1</legend>
|
||||
<div>
|
||||
<label for="team_1_p1_id">Player 1:</label>
|
||||
<select id="team_1_p1_id" name="team_1_p1_id" required>
|
||||
<option value="">Select player...</option>
|
||||
<% players.each do |player| %>
|
||||
<option value="<%= player.id %>"><%= player.name %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="team_1_p2_id">Player 2:</label>
|
||||
<select id="team_1_p2_id" name="team_1_p2_id" required>
|
||||
<option value="">Select player...</option>
|
||||
<% players.each do |player| %>
|
||||
<option value="<%= player.id %>"><%= player.name %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="team_1_score">Score:</label>
|
||||
<input type="number" id="team_1_score" name="team_1_score" min="0" max="10" required value="10">
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Team 2</legend>
|
||||
<div>
|
||||
<label for="team_2_p1_id">Player 1:</label>
|
||||
<select id="team_2_p1_id" name="team_2_p1_id" required>
|
||||
<option value="">Select player...</option>
|
||||
<% players.each do |player| %>
|
||||
<option value="<%= player.id %>"><%= player.name %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="team_2_p2_id">Player 2:</label>
|
||||
<select id="team_2_p2_id" name="team_2_p2_id" required>
|
||||
<option value="">Select player...</option>
|
||||
<% players.each do |player| %>
|
||||
<option value="<%= player.id %>"><%= player.name %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="team_2_score">Score:</label>
|
||||
<input type="number" id="team_2_score" name="team_2_score" min="0" max="10" required value="7">
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit">Save Match</button>
|
||||
</form>
|
||||
|
||||
<p><a href="/admin/matches">Back to Matches</a></p>
|
||||
@@ -0,0 +1,26 @@
|
||||
<h1>Upload Matches (CSV)</h1>
|
||||
|
||||
<p>Upload a CSV file with the following columns:</p>
|
||||
<pre>
|
||||
played_at, team_1_p1_name, team_1_p2_name, team_1_score, team_2_p1_name, team_2_p2_name, team_2_score
|
||||
</pre>
|
||||
|
||||
<form action="/admin/matches/process_upload" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf_token" value="<%= csrf_token %>">
|
||||
|
||||
<div>
|
||||
<label for="csv_file">CSV File:</label>
|
||||
<input type="file" id="csv_file" name="csv_file" accept=".csv" required>
|
||||
</div>
|
||||
|
||||
<button type="submit">Upload</button>
|
||||
</form>
|
||||
|
||||
<p><a href="/admin/matches">Back to Matches</a></p>
|
||||
|
||||
<h2>Example CSV:</h2>
|
||||
<pre>
|
||||
played_at,team_1_p1_name,team_1_p2_name,team_1_score,team_2_p1_name,team_2_p2_name,team_2_score
|
||||
2026-03-26 10:00,Alice,Bob,10,Charlie,David,7
|
||||
2026-03-26 10:30,Alice,Bob,10,Charlie,David,7
|
||||
</pre>
|
||||
@@ -0,0 +1,28 @@
|
||||
<h1>Player Rankings</h1>
|
||||
|
||||
<% if players.any? %>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rank</th>
|
||||
<th>Player</th>
|
||||
<th>Elo Rating</th>
|
||||
<th>Original Rating</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% players.each_with_index do |player, index| %>
|
||||
<tr>
|
||||
<td><%= index + 1 %></td>
|
||||
<td><%= player.name %></td>
|
||||
<td><strong><%= player.current_elo %></strong></td>
|
||||
<td><%= player.rating %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p>No players found.</p>
|
||||
<% end %>
|
||||
|
||||
<p><a href="/admin/players">Admin</a></p>
|
||||
@@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module EuchreCamp
|
||||
module Views
|
||||
module Admin
|
||||
module Matches
|
||||
class Index < EuchreCamp::View
|
||||
expose :matches
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module EuchreCamp
|
||||
module Views
|
||||
module Admin
|
||||
module Matches
|
||||
class New < EuchreCamp::View
|
||||
expose :players
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module EuchreCamp
|
||||
module Views
|
||||
module Admin
|
||||
module Matches
|
||||
class Upload < EuchreCamp::View
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module EuchreCamp
|
||||
module Views
|
||||
module Players
|
||||
class Rankings < EuchreCamp::View
|
||||
expose :players
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user