Skip to content

Commit a5179d8

Browse files
committed
feat: implement result report
1 parent 424ee84 commit a5179d8

File tree

9 files changed

+561
-4
lines changed

9 files changed

+561
-4
lines changed
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# frozen_string_literal: true
2+
3+
# Daily job that manages scrim result reporting lifecycle.
4+
#
5+
# Responsibilities:
6+
# 1. Initialize report records for accepted scrims that have passed their scheduled time
7+
# 2. Send reminders to orgs that haven't reported (at 24h and 48h before deadline)
8+
# 3. Expire reports where the deadline has passed without a submission
9+
#
10+
# Scheduled: daily at 10:00 UTC via sidekiq-scheduler
11+
class ScrimResultReminderJob < ApplicationJob
12+
queue_as :default
13+
14+
REMINDER_THRESHOLDS = [
15+
{ days_before_deadline: 1, label: '24h' },
16+
{ days_before_deadline: 3, label: '3 days' }
17+
].freeze
18+
19+
def perform
20+
initialize_pending_reports
21+
send_reminders
22+
expire_overdue_reports
23+
record_job_heartbeat
24+
rescue StandardError => e
25+
Rails.logger.error("[ScrimResultReminderJob] Failed: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
26+
raise
27+
end
28+
29+
private
30+
31+
# Creates ScrimResultReport records for accepted scrims that ended but haven't been reported yet
32+
def initialize_pending_reports
33+
accepted_past_scrims.find_each do |request|
34+
deadline = [request.proposed_at, Time.current].max + ScrimResultReport::DEADLINE_DAYS.days
35+
36+
[request.requesting_organization_id, request.target_organization_id].each do |org_id|
37+
ScrimResultReport.find_or_create_by!(
38+
scrim_request_id: request.id,
39+
organization_id: org_id
40+
) do |r|
41+
r.status = 'pending'
42+
r.deadline_at = deadline
43+
r.attempt_count = 0
44+
end
45+
rescue ActiveRecord::RecordNotUnique
46+
# Race condition — already exists, ignore
47+
end
48+
end
49+
end
50+
51+
def send_reminders
52+
REMINDER_THRESHOLDS.each do |threshold|
53+
window_start = threshold[:days_before_deadline].days.from_now
54+
window_end = window_start + 1.hour
55+
56+
ScrimResultReport
57+
.actionable
58+
.where(deadline_at: window_start..window_end)
59+
.includes(:organization, scrim_request: %i[requesting_organization target_organization])
60+
.find_each do |report|
61+
notify_pending_report(report, threshold[:label])
62+
end
63+
end
64+
end
65+
66+
def expire_overdue_reports
67+
ScrimResultReport.overdue.find_each do |report|
68+
report.update_columns(status: 'expired', updated_at: Time.current)
69+
Rails.logger.info("[ScrimResultReminderJob] Expired report id=#{report.id} org=#{report.organization_id}")
70+
end
71+
end
72+
73+
def accepted_past_scrims
74+
ScrimRequest
75+
.where(status: 'accepted')
76+
.where('proposed_at < ?', Time.current)
77+
.where(
78+
'NOT EXISTS (' \
79+
'SELECT 1 FROM scrim_result_reports srr ' \
80+
'WHERE srr.scrim_request_id = scrim_requests.id)'
81+
)
82+
end
83+
84+
def notify_pending_report(report, deadline_label)
85+
org = report.organization
86+
req = report.scrim_request
87+
opp = req.requesting_organization_id == org.id ? req.target_organization : req.requesting_organization
88+
89+
Rails.logger.info(
90+
"[ScrimResultReminderJob] Reminding org=#{org.id} (#{org.name}) " \
91+
"to report scrim_request=#{req.id} vs #{opp.name}#{deadline_label} before deadline"
92+
)
93+
94+
# TODO: replace with in-app/email notification when notification system is implemented
95+
# NotificationService.notify(org, :scrim_result_reminder, scrim_request: req, ...)
96+
rescue StandardError => e
97+
Rails.logger.warn("[ScrimResultReminderJob] Reminder failed for report=#{report.id}: #{e.message}")
98+
end
99+
end

app/models/scrim_result_report.rb

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# frozen_string_literal: true
2+
3+
# Stores one organization's reported outcome for a scrim series.
4+
#
5+
# Each ScrimRequest produces two reports — one per participating org.
6+
# The system compares both to determine if results match and marks the
7+
# confrontation as confirmed or disputed.
8+
#
9+
# Lifecycle:
10+
# pending → org has not reported yet
11+
# reported → this org reported, waiting for opponent
12+
# confirmed → both orgs reported matching results
13+
# disputed → reports conflict; orgs can re-report (up to MAX_ATTEMPTS)
14+
# unresolvable → MAX_ATTEMPTS exceeded with persistent conflict
15+
# expired → DEADLINE_DAYS passed without a report from this org
16+
class ScrimResultReport < ApplicationRecord
17+
STATUSES = %w[pending reported confirmed disputed unresolvable expired].freeze
18+
MAX_ATTEMPTS = 3
19+
DEADLINE_DAYS = 7
20+
21+
belongs_to :scrim_request
22+
belongs_to :organization
23+
24+
validates :status, inclusion: { in: STATUSES }
25+
validates :game_outcomes, presence: true, if: :reported_at?
26+
validate :outcomes_are_valid, if: :reported_at?
27+
validate :attempts_not_exceeded, if: :reported_at?
28+
29+
scope :actionable, -> { where(status: %w[pending disputed]) }
30+
scope :confirmed, -> { where(status: 'confirmed') }
31+
scope :overdue, -> { actionable.where('deadline_at < ?', Time.current) }
32+
scope :needs_reminder, lambda {
33+
actionable
34+
.where('deadline_at > ?', Time.current)
35+
.where('deadline_at < ?', (ScrimResultReport::DEADLINE_DAYS - 1).days.from_now)
36+
}
37+
38+
def series_winner_org_id
39+
return nil unless status == 'confirmed'
40+
41+
wins = game_outcomes.count('win')
42+
losses = game_outcomes.count('loss')
43+
wins > losses ? organization_id : opponent_organization_id
44+
end
45+
46+
def opponent_organization_id
47+
req = scrim_request
48+
req.requesting_organization_id == organization_id ? req.target_organization_id : req.requesting_organization_id
49+
end
50+
51+
def re_reportable?
52+
status == 'disputed' && attempt_count < MAX_ATTEMPTS
53+
end
54+
55+
def attempts_remaining
56+
MAX_ATTEMPTS - attempt_count
57+
end
58+
59+
private
60+
61+
def outcomes_are_valid
62+
return if game_outcomes.blank?
63+
64+
return if game_outcomes.all? { |o| %w[win loss].include?(o) }
65+
66+
errors.add(:game_outcomes, 'must only contain "win" or "loss"')
67+
end
68+
69+
def attempts_not_exceeded
70+
return unless attempt_count >= MAX_ATTEMPTS && status_was == 'disputed'
71+
72+
errors.add(:base, 'Maximum reporting attempts exceeded')
73+
end
74+
end

app/modules/matchmaking/services/match_suggestion_service.rb

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def find_candidate_windows
3939
.active
4040
.by_game(@game)
4141
.where.not(organization_id: @organization.id)
42-
.includes(:organization)
42+
.includes(organization: :players)
4343
end
4444

4545
def score_window(window)
@@ -75,7 +75,9 @@ def build_suggestion(window, score)
7575
region: org.region,
7676
tier: map_subscription_to_tier(org.subscription_plan),
7777
public_tagline: org.try(:public_tagline),
78-
discord_invite_url: org.try(:discord_invite_url)
78+
discord_invite_url: org.try(:discord_invite_url),
79+
avg_tier: compute_avg_tier(org),
80+
**compute_record(org)
7981
},
8082
availability_window: {
8183
id: window.id,
@@ -84,11 +86,45 @@ def build_suggestion(window, score)
8486
time_range: window.time_range_display,
8587
start_hour: window.start_hour,
8688
end_hour: window.end_hour,
87-
timezone: window.timezone
89+
timezone: window.timezone,
90+
focus_area: window.focus_area,
91+
draft_type: window.draft_type,
92+
tier_preference: window.tier_preference
8893
}
8994
}
9095
end
9196

97+
TIER_SCORE = {
98+
'CHALLENGER' => 9, 'GRANDMASTER' => 8, 'MASTER' => 7,
99+
'DIAMOND' => 6, 'EMERALD' => 5, 'PLATINUM' => 4,
100+
'GOLD' => 3, 'SILVER' => 2, 'BRONZE' => 1, 'IRON' => 0
101+
}.freeze
102+
103+
TIER_LABEL = {
104+
9 => 'Challenger', 8 => 'Grandmaster', 7 => 'Master',
105+
6 => 'Diamond', 5 => 'Emerald', 4 => 'Platinum',
106+
3 => 'Gold', 2 => 'Silver', 1 => 'Bronze', 0 => 'Iron'
107+
}.freeze
108+
109+
# Returns confirmed W/L from cross-org validated reports only
110+
def compute_record(org)
111+
confirmed = ScrimResultReport.confirmed.where(organization: org)
112+
won = confirmed.count { |r| r.series_winner_org_id == org.id }
113+
lost = confirmed.size - won
114+
{ scrims_won: won, scrims_lost: lost, total_scrims: confirmed.size }
115+
rescue StandardError
116+
{ scrims_won: nil, scrims_lost: nil, total_scrims: nil }
117+
end
118+
119+
def compute_avg_tier(org)
120+
players = org.players.active.select(:solo_queue_tier)
121+
scores = players.map { |p| TIER_SCORE[p.solo_queue_tier.to_s.upcase] || 0 }
122+
return nil if scores.empty?
123+
124+
avg = (scores.sum.to_f / scores.size).round
125+
TIER_LABEL[avg]
126+
end
127+
92128
def map_subscription_to_tier(plan)
93129
case plan
94130
when 'professional', 'enterprise' then 'professional'
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# frozen_string_literal: true
2+
3+
module Scrims
4+
module Controllers
5+
# Handles submission and retrieval of scrim series result reports.
6+
#
7+
# POST /api/v1/scrims/scrims/:scrim_id/result — submit outcome
8+
# GET /api/v1/scrims/scrims/:scrim_id/result — fetch current report status
9+
class ScrimResultReportsController < Api::V1::BaseController
10+
before_action :set_authorized_scrim
11+
before_action :set_scrim_request
12+
13+
# GET /api/v1/scrims/scrims/:scrim_id/result
14+
def show
15+
my_report = report_for(current_organization)
16+
opponent_report = report_for(opponent_organization)
17+
18+
render_success({
19+
my_report: serialize_report(my_report),
20+
opponent_report: serialize_opponent_report(opponent_report),
21+
status: combined_status(my_report, opponent_report),
22+
deadline_at: my_report&.deadline_at&.iso8601,
23+
attempts_remaining: my_report ? my_report.attempts_remaining : ScrimResultReport::MAX_ATTEMPTS,
24+
max_attempts: ScrimResultReport::MAX_ATTEMPTS,
25+
games_planned: @scrim_request&.games_planned
26+
})
27+
end
28+
29+
# POST /api/v1/scrims/scrims/:scrim_id/result
30+
def create
31+
unless @scrim_request
32+
return render_error(
33+
message: 'This scrim is not linked to a matchmaking request and cannot use cross-org result reporting.',
34+
code: 'NO_SCRIM_REQUEST',
35+
status: :unprocessable_entity
36+
)
37+
end
38+
39+
outcomes = params[:game_outcomes]
40+
unless outcomes.is_a?(Array) && outcomes.present?
41+
return render_error(
42+
message: 'game_outcomes must be a non-empty array of "win"/"loss" values',
43+
code: 'INVALID_PARAMS',
44+
status: :unprocessable_entity
45+
)
46+
end
47+
48+
result = ScrimResultValidationService.new(
49+
scrim_request: @scrim_request,
50+
organization: current_organization,
51+
game_outcomes: outcomes.map(&:to_s)
52+
).call
53+
54+
if result[:status] == :error
55+
render_error(message: result[:message], code: 'VALIDATION_ERROR', status: :unprocessable_entity)
56+
else
57+
render_success({
58+
status: result[:status],
59+
report: serialize_report(result[:report]),
60+
message: status_message(result[:status])
61+
})
62+
end
63+
end
64+
65+
private
66+
67+
def set_authorized_scrim
68+
@scrim = current_organization.scrims.find_by(id: params[:scrim_id])
69+
render_error(message: 'Scrim not found', code: 'NOT_FOUND', status: :not_found) unless @scrim
70+
end
71+
72+
def set_scrim_request
73+
return unless @scrim
74+
75+
@scrim_request = ScrimRequest.find_by(id: @scrim.scrim_request_id)
76+
end
77+
78+
def opponent_organization
79+
return nil unless @scrim_request
80+
81+
opp_id = if @scrim_request.requesting_organization_id == current_organization.id
82+
@scrim_request.target_organization_id
83+
else
84+
@scrim_request.requesting_organization_id
85+
end
86+
Organization.find_by(id: opp_id)
87+
end
88+
89+
def report_for(org)
90+
return nil unless @scrim_request && org
91+
92+
ScrimResultReport.find_by(scrim_request: @scrim_request, organization: org)
93+
end
94+
95+
def combined_status(my, opponent)
96+
return 'no_request' unless @scrim_request
97+
return 'pending' unless my
98+
return my.status if %w[confirmed unresolvable expired].include?(my.status)
99+
return 'waiting_opponent' if my.status == 'reported' && (!opponent || opponent.status == 'pending')
100+
101+
my.status
102+
end
103+
104+
def serialize_report(report)
105+
return nil unless report
106+
107+
{
108+
id: report.id,
109+
status: report.status,
110+
game_outcomes: report.game_outcomes,
111+
reported_at: report.reported_at&.iso8601,
112+
confirmed_at: report.confirmed_at&.iso8601,
113+
deadline_at: report.deadline_at&.iso8601,
114+
attempt_count: report.attempt_count,
115+
attempts_remaining: report.attempts_remaining
116+
}
117+
end
118+
119+
# Only exposes confirmation status to avoid leaking opponent's reported outcomes
120+
# before both sides have submitted (prevents copying the opponent's report).
121+
def serialize_opponent_report(report)
122+
return nil unless report
123+
124+
exposable = %w[confirmed unresolvable expired]
125+
{
126+
status: report.status,
127+
has_reported: report.reported_at?,
128+
confirmed_at: report.confirmed_at&.iso8601,
129+
# Only expose outcomes once both have reported (no oracle attack)
130+
game_outcomes: exposable.include?(report.status) ? report.game_outcomes : nil
131+
}
132+
end
133+
134+
def status_message(status)
135+
{
136+
reported: 'Result submitted. Waiting for opponent to report.',
137+
confirmed: 'Results match! Series result confirmed.',
138+
disputed: 'Results conflict with opponent\'s report. Both teams must re-report. ' \
139+
"#{ScrimResultReport::MAX_ATTEMPTS} attempts total.",
140+
unresolvable: 'Maximum attempts reached with conflicting reports. Result marked unresolvable.'
141+
}[status] || 'Report received.'
142+
end
143+
end
144+
end
145+
end

0 commit comments

Comments
 (0)