Introduce architettura multi-sport con catalogo, engine scoring e overlay.
Catalogo sport in YAML, board implicito, engine per volley/basket/timed/racket/timer/generic, regia e app Android allineate. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -16,7 +16,10 @@ module Api
|
||||
end
|
||||
|
||||
def create
|
||||
match = @team.matches.create!(match_params)
|
||||
attrs = match_params.to_h
|
||||
attrs["sport_key"] = @team.sport_key if attrs["sport_key"].blank?
|
||||
normalize_scoring_rules!(attrs)
|
||||
match = @team.matches.create!(attrs)
|
||||
attach_opponent_logo(match)
|
||||
render json: match_json(match), status: :created
|
||||
end
|
||||
@@ -59,11 +62,15 @@ module Api
|
||||
end
|
||||
|
||||
def match_params
|
||||
params.require(:match).permit(
|
||||
:opponent_name, :location, :scheduled_at, :sport, :sets_to_win,
|
||||
:category, :opponent_primary_color,
|
||||
scoring_rules: %i[points_per_set points_deciding_set min_point_lead]
|
||||
p = params.require(:match).permit(
|
||||
:opponent_name, :location, :scheduled_at, :sport, :sport_key, :sets_to_win,
|
||||
:category, :opponent_primary_color, :overlay_kind,
|
||||
scoring_rules: {}
|
||||
)
|
||||
if p[:sport].present? && p[:sport_key].blank?
|
||||
p[:sport_key] = p.delete(:sport)
|
||||
end
|
||||
p
|
||||
end
|
||||
|
||||
def normalize_scoring_rules!(attrs)
|
||||
@@ -71,6 +78,9 @@ module Api
|
||||
|
||||
rules = attrs["scoring_rules"]
|
||||
attrs["scoring_rules"] = {} if rules.blank?
|
||||
if attrs["sets_to_win"].present? && attrs["scoring_rules"].is_a?(Hash)
|
||||
attrs["scoring_rules"]["sets_to_win"] ||= attrs["sets_to_win"]
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_opponent_color!(attrs)
|
||||
@@ -97,9 +107,15 @@ module Api
|
||||
opponent_name: match.opponent_name,
|
||||
location: match.location,
|
||||
scheduled_at: match.scheduled_at,
|
||||
sport: match.sport,
|
||||
sport: match.sport_key,
|
||||
sport_key: match.sport_key,
|
||||
sport_label: match.sport_label,
|
||||
board_type: match.effective_board_type,
|
||||
overlay_kind: match.overlay_kind,
|
||||
effective_overlay_kind: match.effective_overlay_kind,
|
||||
sets_to_win: match.sets_to_win,
|
||||
scoring_rules: match.scoring_rules.presence,
|
||||
effective_scoring_rules: match.effective_scoring_rules,
|
||||
category: match.category,
|
||||
home_primary_color: team.effective_primary_color,
|
||||
home_secondary_color: team.effective_secondary_color,
|
||||
|
||||
9
backend/app/controllers/api/v1/sports_controller.rb
Normal file
9
backend/app/controllers/api/v1/sports_controller.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
module Api
|
||||
module V1
|
||||
class SportsController < BaseController
|
||||
def index
|
||||
render json: Sports::Catalog.as_api_list
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -21,10 +21,11 @@ module Api
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
club = Club.create!(name: team_params[:name], sport: team_params[:sport] || "volleyball",
|
||||
sport_key = Sports::Catalog.normalize_key(team_params[:sport_key] || team_params[:sport] || "pallavolo")
|
||||
club = Club.create!(name: team_params[:name], sport: sport_key,
|
||||
primary_color: "#e53935", secondary_color: "#ffffff")
|
||||
ClubMembership.create!(user: current_user, club: club, role: "owner")
|
||||
team = club.teams.create!(name: team_params[:name], sport: club.sport)
|
||||
team = club.teams.create!(name: team_params[:name], sport_key: sport_key)
|
||||
Billing::AssignPlan.call(club: club, plan_slug: "free")
|
||||
render json: team_json(team), status: :created
|
||||
end
|
||||
@@ -74,7 +75,11 @@ module Api
|
||||
private
|
||||
|
||||
def team_params
|
||||
p = params.require(:team).permit(:name, :sport, :logo_url, :primary_color, :secondary_color)
|
||||
p = params.require(:team).permit(:name, :sport, :sport_key, :logo_url, :primary_color, :secondary_color)
|
||||
if p[:sport].present? && p[:sport_key].blank?
|
||||
p[:sport_key] = p.delete(:sport)
|
||||
end
|
||||
p
|
||||
if p[:primary_color].present?
|
||||
p[:primary_color] = normalize_hex_color(p[:primary_color], p[:primary_color])
|
||||
end
|
||||
@@ -96,7 +101,10 @@ module Api
|
||||
data = {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
sport: team.sport,
|
||||
sport: team.sport_key,
|
||||
sport_key: team.sport_key,
|
||||
sport_label: team.sport_label,
|
||||
board_type: team.effective_board_type,
|
||||
logo_url: api_absolute_url(team.effective_logo_url),
|
||||
primary_color: team.effective_primary_color,
|
||||
secondary_color: team.effective_secondary_color,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
module Public
|
||||
class RegiaController < SiteBaseController
|
||||
include Public::LiveHelper
|
||||
include Public::RegiaHelper
|
||||
include MediamtxPlayback
|
||||
|
||||
layout "regia"
|
||||
@@ -13,9 +14,7 @@ module Public
|
||||
def show
|
||||
@match = @session.match
|
||||
@team = @match.team
|
||||
@score = @session.score_state || @session.create_score_state!(
|
||||
home_sets: 0, away_sets: 0, home_points: 0, away_points: 0, current_set: 1, set_partials: []
|
||||
)
|
||||
@score = @session.score_state || Scoring::Engine.ensure_score_for(@session)
|
||||
@rules = Scoring::Rules.from_match(@match)
|
||||
@stream_closed = @session.terminal?
|
||||
@on_air = !@stream_closed && mediamtx_stream_playable?(@session)
|
||||
|
||||
11
backend/app/domain/sports/board_type.rb
Normal file
11
backend/app/domain/sports/board_type.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Sports
|
||||
class BoardType
|
||||
ALL = %w[volley basket timed racket timer generic].freeze
|
||||
|
||||
def self.valid?(value)
|
||||
ALL.include?(value.to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
94
backend/app/domain/sports/catalog.rb
Normal file
94
backend/app/domain/sports/catalog.rb
Normal file
@@ -0,0 +1,94 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Sports
|
||||
class Catalog
|
||||
LEGACY_SPORT_MAP = {
|
||||
"volleyball" => "pallavolo"
|
||||
}.freeze
|
||||
|
||||
class << self
|
||||
def all
|
||||
@all ||= load_entries
|
||||
end
|
||||
|
||||
def keys
|
||||
all.keys
|
||||
end
|
||||
|
||||
def find(key)
|
||||
normalized = normalize_key(key)
|
||||
entry = all[normalized]
|
||||
raise KeyError, "Sport sconosciuto: #{key}" unless entry
|
||||
|
||||
entry.merge(key: normalized)
|
||||
end
|
||||
|
||||
def find_optional(key)
|
||||
normalized = normalize_key(key)
|
||||
entry = all[normalized]
|
||||
return nil unless entry
|
||||
|
||||
entry.merge(key: normalized)
|
||||
end
|
||||
|
||||
def board_for(key)
|
||||
find(key)[:board]
|
||||
end
|
||||
|
||||
def overlay_for(key)
|
||||
find(key)[:overlay]
|
||||
end
|
||||
|
||||
def allowed_overlays_for(key)
|
||||
find(key)[:allowed_overlays]
|
||||
end
|
||||
|
||||
def default_rules_for(key)
|
||||
deep_stringify_keys(find(key)[:default_rules] || {})
|
||||
end
|
||||
|
||||
def normalize_key(key)
|
||||
raw = key.to_s.strip
|
||||
return "pallavolo" if raw.blank?
|
||||
|
||||
LEGACY_SPORT_MAP.fetch(raw, raw)
|
||||
end
|
||||
|
||||
def as_api_list
|
||||
all.map do |key, entry|
|
||||
{
|
||||
key: key,
|
||||
label: entry[:label],
|
||||
board: entry[:board],
|
||||
overlay: entry[:overlay],
|
||||
allowed_overlays: entry[:allowed_overlays],
|
||||
default_rules: entry[:default_rules] || {}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_entries
|
||||
path = Rails.root.join("config/sports.yml")
|
||||
raw = YAML.safe_load(File.read(path), permitted_classes: [], aliases: true) || {}
|
||||
raw.transform_keys(&:to_s).transform_values do |entry|
|
||||
entry.deep_symbolize_keys
|
||||
end.freeze
|
||||
end
|
||||
|
||||
def deep_stringify_keys(value)
|
||||
case value
|
||||
when Hash
|
||||
value.each_with_object({}) do |(k, v), h|
|
||||
h[k.to_s] = deep_stringify_keys(v)
|
||||
end
|
||||
when Array
|
||||
value.map { |v| deep_stringify_keys(v) }
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
11
backend/app/domain/sports/overlay_kind.rb
Normal file
11
backend/app/domain/sports/overlay_kind.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Sports
|
||||
class OverlayKind
|
||||
ALL = %w[none timer volley basket timed racket].freeze
|
||||
|
||||
def self.valid?(value)
|
||||
ALL.include?(value.to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
38
backend/app/domain/sports/rules_schema.rb
Normal file
38
backend/app/domain/sports/rules_schema.rb
Normal file
@@ -0,0 +1,38 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Sports
|
||||
class RulesSchema
|
||||
INTEGER_RULES = {
|
||||
"volley" => %w[sets_to_win points_per_set points_deciding_set min_point_lead],
|
||||
"racket" => %w[sets_to_win points_per_set points_deciding_set min_point_lead],
|
||||
"basket" => %w[periods period_duration_secs overtime_duration_secs],
|
||||
"timed" => %w[periods period_duration_secs overtime_duration_secs],
|
||||
"timer" => [],
|
||||
"generic" => []
|
||||
}.freeze
|
||||
|
||||
def self.validate!(board_type, rules)
|
||||
board = board_type.to_s
|
||||
return if board == "generic" || board == "timer"
|
||||
|
||||
hash = rules.is_a?(Hash) ? rules : {}
|
||||
INTEGER_RULES.fetch(board, []).each do |key|
|
||||
val = hash[key] || hash[key.to_sym]
|
||||
next if val.blank?
|
||||
|
||||
unless val.is_a?(Integer) || val.to_s.match?(/\A\d+\z/)
|
||||
raise ArgumentError, "#{key} non valido"
|
||||
end
|
||||
int_val = val.to_i
|
||||
raise ArgumentError, "#{key} deve essere positivo" if int_val < 1
|
||||
end
|
||||
end
|
||||
|
||||
def self.validate(board_type, rules)
|
||||
validate!(board_type, rules)
|
||||
true
|
||||
rescue ArgumentError
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,13 +1,30 @@
|
||||
module Public
|
||||
module LiveHelper
|
||||
def live_score_sets_label(score_state)
|
||||
def live_score_sets_label(score_state, match = nil)
|
||||
return nil unless score_state
|
||||
|
||||
"Set #{score_state.current_set} · Set vinti #{score_state.home_sets}-#{score_state.away_sets}"
|
||||
board = match&.effective_board_type || score_state.effective_board_type
|
||||
case board
|
||||
when "basket", "timed"
|
||||
live_score_period_label(match, score_state)
|
||||
when "timer"
|
||||
"Cronometro"
|
||||
when "generic"
|
||||
"Punteggio"
|
||||
else
|
||||
"Set #{score_state.current_set} · Set vinti #{score_state.home_sets}-#{score_state.away_sets}"
|
||||
end
|
||||
end
|
||||
|
||||
def live_score_period_label(match, score_state)
|
||||
return "—" unless score_state
|
||||
|
||||
score_state.current_period_label
|
||||
end
|
||||
|
||||
def live_score_partials_label(score_state)
|
||||
return nil unless score_state
|
||||
return nil unless score_state.effective_board_type.in?(%w[volley racket])
|
||||
|
||||
partials = Array(score_state.set_partials)
|
||||
return nil if partials.empty?
|
||||
@@ -58,7 +75,17 @@ module Public
|
||||
def live_score_points_label(match, score_state)
|
||||
return "—" unless score_state
|
||||
|
||||
"#{match.team.name} #{score_state.home_points} - #{score_state.away_points} #{match.opponent_name}"
|
||||
board = match.effective_board_type
|
||||
home = case board
|
||||
when "basket", "timed" then score_state.basket_home_score
|
||||
else score_state.home_points
|
||||
end
|
||||
away = case board
|
||||
when "basket", "timed" then score_state.basket_away_score
|
||||
else score_state.away_points
|
||||
end
|
||||
|
||||
"#{match.team.name} #{home} - #{away} #{match.opponent_name}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
32
backend/app/helpers/public/regia_helper.rb
Normal file
32
backend/app/helpers/public/regia_helper.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
module Public
|
||||
module RegiaHelper
|
||||
def regia_board_partial(match)
|
||||
board = match.effective_board_type
|
||||
partial = "public/regia/#{board}"
|
||||
lookup_context.exists?(partial, [], true) ? partial : "public/regia/volley"
|
||||
end
|
||||
|
||||
def regia_header_subtitle(match, score)
|
||||
board = match.effective_board_type
|
||||
case board
|
||||
when "basket", "timed"
|
||||
live_score_period_label(match, score)
|
||||
when "timer"
|
||||
"Cronometro"
|
||||
when "generic"
|
||||
"Punteggio"
|
||||
else
|
||||
"Set #{score.current_set}"
|
||||
end
|
||||
end
|
||||
|
||||
def format_clock_secs(secs, count_up: false)
|
||||
total = secs.to_i
|
||||
return count_up ? "0:00" : "—" if total <= 0 && !count_up
|
||||
|
||||
m = total / 60
|
||||
s = total % 60
|
||||
format("%d:%02d", m, s)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -9,13 +9,16 @@ class Match < ApplicationRecord
|
||||
|
||||
validates :opponent_name, presence: true
|
||||
validates :sets_to_win, numericality: { greater_than: 0, less_than: 6 }
|
||||
validates :sport_key, presence: true
|
||||
validates :opponent_primary_color, format: { with: Brandable::HEX_COLOR }, allow_blank: true
|
||||
validate :scoring_rules_shape, if: -> { scoring_rules.present? }
|
||||
validate :sport_key_known
|
||||
validate :overlay_kind_allowed
|
||||
validate :scoring_rules_shape
|
||||
validate :opponent_logo_file_type, if: -> { opponent_logo_file.attached? }
|
||||
|
||||
# category: campionato / descrizione torneo (facoltativo, es. "Serie C").
|
||||
before_validation :normalize_sport_key
|
||||
before_validation :inherit_sport_from_team, on: :create
|
||||
|
||||
# Partite ancora da giocare in diretta (orario nel futuro).
|
||||
scope :scheduled_for_live, -> {
|
||||
where.not(scheduled_at: nil).where("scheduled_at >= ?", Time.zone.now)
|
||||
}
|
||||
@@ -78,8 +81,6 @@ class Match < ApplicationRecord
|
||||
opponent_primary_color.presence || DEFAULT_OPPONENT_COLOR
|
||||
end
|
||||
|
||||
# Hub app coach: dirette da riprendere o partite programmate future.
|
||||
# Le bozze «Avversario» abbandonate (senza sessione) non compaiono.
|
||||
def coach_hub_visible?
|
||||
active = active_stream_session
|
||||
return true if active&.resumable?
|
||||
@@ -90,8 +91,72 @@ class Match < ApplicationRecord
|
||||
scheduled_upcoming?
|
||||
end
|
||||
|
||||
def effective_board_type
|
||||
Sports::Catalog.board_for(sport_key)
|
||||
end
|
||||
|
||||
def effective_overlay_kind
|
||||
kind = overlay_kind.presence || Sports::Catalog.overlay_for(sport_key)
|
||||
allowed = Sports::Catalog.allowed_overlays_for(sport_key)
|
||||
allowed.include?(kind) ? kind : Sports::Catalog.overlay_for(sport_key)
|
||||
end
|
||||
|
||||
def effective_scoring_rules
|
||||
merged = Sports::Catalog.default_rules_for(sport_key).deep_merge(normalized_scoring_rules)
|
||||
merged["sets_to_win"] = sets_to_win if merged["sets_to_win"].blank? && sets_to_win.present?
|
||||
merged
|
||||
end
|
||||
|
||||
def sport_label
|
||||
Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize
|
||||
end
|
||||
|
||||
# Alias legacy API/spec (colonna rinominata in sport_key).
|
||||
def sport
|
||||
sport_key
|
||||
end
|
||||
|
||||
def sport=(value)
|
||||
self.sport_key = Sports::Catalog.normalize_key(value)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_sport_key
|
||||
self.sport_key = Sports::Catalog.normalize_key(sport_key)
|
||||
end
|
||||
|
||||
def inherit_sport_from_team
|
||||
self.sport_key = team.sport_key if sport_key.blank? && team.present?
|
||||
end
|
||||
|
||||
def sport_key_known
|
||||
return if sport_key.blank?
|
||||
return if Sports::Catalog.find_optional(sport_key)
|
||||
|
||||
errors.add(:sport_key, "non valido")
|
||||
end
|
||||
|
||||
def overlay_kind_allowed
|
||||
return if overlay_kind.blank?
|
||||
|
||||
unless Sports::OverlayKind.valid?(overlay_kind)
|
||||
errors.add(:overlay_kind, "non valido")
|
||||
return
|
||||
end
|
||||
|
||||
allowed = Sports::Catalog.allowed_overlays_for(sport_key)
|
||||
errors.add(:overlay_kind, "non consentito per questo sport") unless allowed.include?(overlay_kind)
|
||||
end
|
||||
|
||||
def normalized_scoring_rules
|
||||
return {} unless scoring_rules.is_a?(Hash)
|
||||
|
||||
scoring_rules.each_with_object({}) do |(k, v), h|
|
||||
h[k.to_s] = v
|
||||
end
|
||||
end
|
||||
|
||||
def opponent_logo_file_type
|
||||
return if opponent_logo_file.content_type.in?(%w[image/png image/jpeg image/webp])
|
||||
|
||||
@@ -99,17 +164,8 @@ class Match < ApplicationRecord
|
||||
end
|
||||
|
||||
def scoring_rules_shape
|
||||
rules = scoring_rules.is_a?(Hash) ? scoring_rules : {}
|
||||
%w[points_per_set points_deciding_set min_point_lead].each do |key|
|
||||
val = rules[key] || rules[key.to_sym]
|
||||
next if val.blank?
|
||||
|
||||
unless val.is_a?(Integer) || val.to_s.match?(/\A\d+\z/)
|
||||
errors.add(:scoring_rules, "#{key} non valido")
|
||||
next
|
||||
end
|
||||
int_val = val.to_i
|
||||
errors.add(:scoring_rules, "#{key} deve essere positivo") if int_val < 1
|
||||
end
|
||||
Sports::RulesSchema.validate!(effective_board_type, normalized_scoring_rules)
|
||||
rescue ArgumentError => e
|
||||
errors.add(:scoring_rules, e.message)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
class ScoreState < ApplicationRecord
|
||||
belongs_to :stream_session
|
||||
|
||||
before_validation :sync_board_type_from_match, on: :create
|
||||
|
||||
def as_cable_payload
|
||||
{
|
||||
payload = {
|
||||
type: "score_update",
|
||||
board_type: effective_board_type,
|
||||
home_sets: home_sets,
|
||||
away_sets: away_sets,
|
||||
home_points: home_points,
|
||||
@@ -11,7 +14,74 @@ class ScoreState < ApplicationRecord
|
||||
current_set: current_set,
|
||||
set_partials: set_partials || [],
|
||||
timeout_home: timeout_home,
|
||||
timeout_away: timeout_away
|
||||
timeout_away: timeout_away,
|
||||
period: period,
|
||||
data: data || {}
|
||||
}
|
||||
|
||||
case effective_board_type
|
||||
when "basket", "timed"
|
||||
payload[:home_points] = basket_home_score
|
||||
payload[:away_points] = basket_away_score
|
||||
payload[:period] = current_period_label
|
||||
payload[:clock_secs] = clock_secs
|
||||
payload[:clock_running] = clock_running?
|
||||
when "timer"
|
||||
payload[:clock_secs] = clock_secs
|
||||
payload[:clock_running] = clock_running?
|
||||
end
|
||||
|
||||
payload
|
||||
end
|
||||
|
||||
def effective_board_type
|
||||
board_type.presence || stream_session&.match&.effective_board_type || "volley"
|
||||
end
|
||||
|
||||
def basket_home_score
|
||||
(data || {})["home_score"].to_i
|
||||
end
|
||||
|
||||
def basket_away_score
|
||||
(data || {})["away_score"].to_i
|
||||
end
|
||||
|
||||
def clock_secs
|
||||
(data || {})["clock_secs"].to_i
|
||||
end
|
||||
|
||||
def clock_running?
|
||||
!!(data || {})["clock_running"]
|
||||
end
|
||||
|
||||
def current_period
|
||||
(data || {})["period"].to_i.positive? ? (data || {})["period"].to_i : 1
|
||||
end
|
||||
|
||||
def current_period_label
|
||||
case effective_board_type
|
||||
when "basket"
|
||||
ot = (data || {})["overtime"]
|
||||
return "OT#{current_period - rules_periods}" if ot
|
||||
|
||||
"Q#{current_period}"
|
||||
when "timed"
|
||||
ot = (data || {})["overtime"]
|
||||
return "Suppl." if ot
|
||||
|
||||
"#{current_period}° tempo"
|
||||
else
|
||||
period.presence || "#{current_set}° set"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_board_type_from_match
|
||||
self.board_type = stream_session.match.effective_board_type if board_type.blank? && stream_session&.match
|
||||
end
|
||||
|
||||
def rules_periods
|
||||
stream_session&.match&.effective_scoring_rules&.dig("periods").to_i
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,8 +12,12 @@ class Team < ApplicationRecord
|
||||
has_one_attached :photo_file
|
||||
|
||||
validates :name, presence: true
|
||||
validates :sport_key, presence: true
|
||||
validate :sport_key_known
|
||||
validate :photo_file_type, if: -> { photo_file.attached? }
|
||||
|
||||
before_validation :normalize_sport_key
|
||||
|
||||
def subscription
|
||||
club.subscription
|
||||
end
|
||||
@@ -43,8 +47,35 @@ class Team < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def sport_label
|
||||
Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize
|
||||
end
|
||||
|
||||
def effective_board_type
|
||||
Sports::Catalog.board_for(sport_key)
|
||||
end
|
||||
|
||||
def sport
|
||||
sport_key
|
||||
end
|
||||
|
||||
def sport=(value)
|
||||
self.sport_key = Sports::Catalog.normalize_key(value)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_sport_key
|
||||
self.sport_key = Sports::Catalog.normalize_key(sport_key) if sport_key.present?
|
||||
end
|
||||
|
||||
def sport_key_known
|
||||
return if sport_key.blank?
|
||||
return if Sports::Catalog.find_optional(sport_key)
|
||||
|
||||
errors.add(:sport_key, "non valido")
|
||||
end
|
||||
|
||||
def photo_file_type
|
||||
return if photo_file.content_type.in?(%w[image/png image/jpeg image/webp])
|
||||
|
||||
|
||||
@@ -6,105 +6,10 @@ module Scoring
|
||||
@session = session
|
||||
@match = session.match
|
||||
@action = action.to_s
|
||||
@rules = Rules.from_match(@match)
|
||||
end
|
||||
|
||||
def call
|
||||
score = @session.score_state || @session.create_score_state!(
|
||||
home_sets: 0, away_sets: 0, home_points: 0, away_points: 0, current_set: 1, set_partials: []
|
||||
)
|
||||
|
||||
case @action
|
||||
when "home_point"
|
||||
apply_point(score, :home)
|
||||
when "away_point"
|
||||
apply_point(score, :away)
|
||||
when "home_undo"
|
||||
apply_undo(score, :home)
|
||||
when "away_undo"
|
||||
apply_undo(score, :away)
|
||||
when "close_set"
|
||||
apply_close_set(score)
|
||||
else
|
||||
raise ArgumentError, "Azione non valida"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def apply_point(score, side)
|
||||
if side == :home
|
||||
score.home_points += 1
|
||||
else
|
||||
score.away_points += 1
|
||||
end
|
||||
score.save!
|
||||
broadcast(score)
|
||||
winner = @rules.set_winner_from_points(
|
||||
home_points: score.home_points,
|
||||
away_points: score.away_points,
|
||||
current_set: score.current_set
|
||||
)
|
||||
Result.new(score: score, set_won: winner.present?, match_won: false, winner: winner)
|
||||
end
|
||||
|
||||
def apply_undo(score, side)
|
||||
if side == :home
|
||||
score.home_points = [score.home_points - 1, 0].max
|
||||
else
|
||||
score.away_points = [score.away_points - 1, 0].max
|
||||
end
|
||||
score.save!
|
||||
broadcast(score)
|
||||
Result.new(score: score, set_won: false, match_won: false, winner: nil)
|
||||
end
|
||||
|
||||
def apply_close_set(score)
|
||||
winner = @rules.set_winner_from_points(
|
||||
home_points: score.home_points,
|
||||
away_points: score.away_points,
|
||||
current_set: score.current_set
|
||||
)
|
||||
winner ||= score.home_points > score.away_points ? :home : :away if score.home_points != score.away_points
|
||||
return Result.new(score: score, set_won: false, match_won: false, winner: nil) unless winner
|
||||
|
||||
close_set_for(score, winner)
|
||||
end
|
||||
|
||||
def close_set_for(score, winner)
|
||||
partials = Array(score.set_partials)
|
||||
partials << {
|
||||
"set" => score.current_set,
|
||||
"home" => score.home_points,
|
||||
"away" => score.away_points
|
||||
}
|
||||
|
||||
if winner == :home
|
||||
score.home_sets += 1
|
||||
else
|
||||
score.away_sets += 1
|
||||
end
|
||||
|
||||
match_winner = @rules.match_winner(home_sets: score.home_sets, away_sets: score.away_sets)
|
||||
|
||||
score.assign_attributes(
|
||||
set_partials: partials,
|
||||
home_points: 0,
|
||||
away_points: 0,
|
||||
current_set: score.current_set + 1
|
||||
)
|
||||
score.save!
|
||||
broadcast(score)
|
||||
Result.new(
|
||||
score: score,
|
||||
set_won: true,
|
||||
match_won: match_winner.present?,
|
||||
winner: match_winner || winner
|
||||
)
|
||||
end
|
||||
|
||||
def broadcast(score)
|
||||
SessionChannel.broadcast_message(@session, score.as_cable_payload)
|
||||
Engine.for(@match).apply(session: @session, action: @action)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
26
backend/app/services/scoring/engine.rb
Normal file
26
backend/app/services/scoring/engine.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
module Scoring
|
||||
class Engine
|
||||
ENGINES = {
|
||||
"volley" => Engines::VolleyEngine,
|
||||
"racket" => Engines::RacketEngine,
|
||||
"generic" => Engines::GenericEngine,
|
||||
"basket" => Engines::BasketEngine,
|
||||
"timed" => Engines::TimedEngine,
|
||||
"timer" => Engines::TimerEngine
|
||||
}.freeze
|
||||
|
||||
def self.for(match)
|
||||
board = match.effective_board_type
|
||||
klass = ENGINES.fetch(board) { Engines::GenericEngine }
|
||||
klass.new(match: match)
|
||||
end
|
||||
|
||||
def self.for_session(session)
|
||||
for(session.match)
|
||||
end
|
||||
|
||||
def self.ensure_score_for(session)
|
||||
for(session.match).ensure_score(session)
|
||||
end
|
||||
end
|
||||
end
|
||||
45
backend/app/services/scoring/engines/base_engine.rb
Normal file
45
backend/app/services/scoring/engines/base_engine.rb
Normal file
@@ -0,0 +1,45 @@
|
||||
module Scoring
|
||||
module Engines
|
||||
class BaseEngine
|
||||
Result = Scoring::ApplyAction::Result
|
||||
|
||||
def initialize(match:)
|
||||
@match = match
|
||||
end
|
||||
|
||||
def apply(session:, action:)
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
def sync(session:, payload:)
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
def ensure_score(session)
|
||||
board = @match.effective_board_type
|
||||
score = session.score_state
|
||||
return score if score
|
||||
|
||||
attrs = {
|
||||
board_type: board,
|
||||
home_sets: 0,
|
||||
away_sets: 0,
|
||||
home_points: 0,
|
||||
away_points: 0,
|
||||
current_set: 1,
|
||||
set_partials: [],
|
||||
data: default_data
|
||||
}
|
||||
session.create_score_state!(attrs)
|
||||
end
|
||||
|
||||
def default_data
|
||||
{}
|
||||
end
|
||||
|
||||
def broadcast(session, score)
|
||||
SessionChannel.broadcast_message(session, score.as_cable_payload)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
32
backend/app/services/scoring/engines/basket_engine.rb
Normal file
32
backend/app/services/scoring/engines/basket_engine.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
module Scoring
|
||||
module Engines
|
||||
class BasketEngine < BaseEngine
|
||||
include PeriodEngineMixin
|
||||
|
||||
def apply(session:, action:)
|
||||
score = ensure_score(session)
|
||||
|
||||
case action.to_s
|
||||
when "home_point" then adjust_score(score, :home, 1)
|
||||
when "away_point" then adjust_score(score, :away, 1)
|
||||
when "home_undo" then adjust_score(score, :home, -1)
|
||||
when "away_undo" then adjust_score(score, :away, -1)
|
||||
when "home_point_2" then adjust_score(score, :home, 2)
|
||||
when "away_point_2" then adjust_score(score, :away, 2)
|
||||
when "home_point_3" then adjust_score(score, :home, 3)
|
||||
when "away_point_3" then adjust_score(score, :away, 3)
|
||||
when "advance_period" then advance_period(score)
|
||||
when "clock_toggle" then toggle_clock(score)
|
||||
when "clock_reset" then reset_clock(score)
|
||||
when "clock_tick" then tick_clock(score)
|
||||
else
|
||||
raise ArgumentError, "Azione non valida"
|
||||
end
|
||||
end
|
||||
|
||||
def sync(session:, payload:)
|
||||
sync_period(session: session, payload: payload.stringify_keys)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
42
backend/app/services/scoring/engines/generic_engine.rb
Normal file
42
backend/app/services/scoring/engines/generic_engine.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
module Scoring
|
||||
module Engines
|
||||
class GenericEngine < BaseEngine
|
||||
def apply(session:, action:)
|
||||
score = ensure_score(session)
|
||||
|
||||
case action.to_s
|
||||
when "home_point" then adjust(score, :home, 1)
|
||||
when "away_point" then adjust(score, :away, 1)
|
||||
when "home_undo" then adjust(score, :home, -1)
|
||||
when "away_undo" then adjust(score, :away, -1)
|
||||
else
|
||||
raise ArgumentError, "Azione non valida"
|
||||
end
|
||||
end
|
||||
|
||||
def sync(session:, payload:)
|
||||
score = ensure_score(session)
|
||||
attrs = {}
|
||||
%w[home_points away_points].each do |key|
|
||||
attrs[key] = payload[key] if payload.key?(key)
|
||||
end
|
||||
score.update!(attrs)
|
||||
broadcast(session, score)
|
||||
score
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def adjust(score, side, delta)
|
||||
if side == :home
|
||||
score.home_points = [score.home_points + delta, 0].max
|
||||
else
|
||||
score.away_points = [score.away_points + delta, 0].max
|
||||
end
|
||||
score.save!
|
||||
broadcast(score.stream_session, score)
|
||||
Result.new(score: score, set_won: false, match_won: false, winner: nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
117
backend/app/services/scoring/engines/period_engine_mixin.rb
Normal file
117
backend/app/services/scoring/engines/period_engine_mixin.rb
Normal file
@@ -0,0 +1,117 @@
|
||||
module Scoring
|
||||
module Engines
|
||||
module PeriodEngineMixin
|
||||
private
|
||||
|
||||
def period_rules
|
||||
@period_rules ||= Rules.from_match(@match)
|
||||
end
|
||||
|
||||
def period_data(score)
|
||||
score.data.is_a?(Hash) ? score.data.deep_dup : {}
|
||||
end
|
||||
|
||||
def default_data
|
||||
rules = period_rules
|
||||
{
|
||||
"period" => 1,
|
||||
"clock_secs" => rules.period_duration_secs,
|
||||
"clock_running" => false,
|
||||
"home_score" => 0,
|
||||
"away_score" => 0,
|
||||
"overtime" => false
|
||||
}
|
||||
end
|
||||
|
||||
def ensure_period_data(score)
|
||||
data = period_data(score)
|
||||
default_data.each { |k, v| data[k] = v unless data.key?(k) }
|
||||
data
|
||||
end
|
||||
|
||||
def save_period_score!(score, data)
|
||||
score.assign_attributes(
|
||||
data: data,
|
||||
home_points: data["home_score"].to_i,
|
||||
away_points: data["away_score"].to_i,
|
||||
period: period_label(data)
|
||||
)
|
||||
score.save!
|
||||
broadcast(score.stream_session, score)
|
||||
end
|
||||
|
||||
def period_label(data)
|
||||
period = data["period"].to_i
|
||||
return "OT" if data["overtime"]
|
||||
|
||||
board = @match.effective_board_type
|
||||
board == "basket" ? "Q#{period}" : "#{period}° tempo"
|
||||
end
|
||||
|
||||
def adjust_score(score, side, delta)
|
||||
data = ensure_period_data(score)
|
||||
key = side == :home ? "home_score" : "away_score"
|
||||
data[key] = [data[key].to_i + delta, 0].max
|
||||
save_period_score!(score, data)
|
||||
Result.new(score: score, set_won: false, match_won: false, winner: nil)
|
||||
end
|
||||
|
||||
def advance_period(score)
|
||||
data = ensure_period_data(score)
|
||||
rules = period_rules
|
||||
max_periods = rules.periods
|
||||
|
||||
if data["overtime"]
|
||||
data["period"] = data["period"].to_i + 1
|
||||
data["clock_secs"] = rules.overtime_duration_secs
|
||||
elsif data["period"].to_i >= max_periods
|
||||
data["overtime"] = true
|
||||
data["period"] = max_periods + 1
|
||||
data["clock_secs"] = rules.overtime_duration_secs
|
||||
else
|
||||
data["period"] = data["period"].to_i + 1
|
||||
data["clock_secs"] = rules.period_duration_secs
|
||||
end
|
||||
data["clock_running"] = false
|
||||
save_period_score!(score, data)
|
||||
Result.new(score: score, set_won: false, match_won: false, winner: nil)
|
||||
end
|
||||
|
||||
def toggle_clock(score)
|
||||
data = ensure_period_data(score)
|
||||
data["clock_running"] = !data["clock_running"]
|
||||
save_period_score!(score, data)
|
||||
Result.new(score: score, set_won: false, match_won: false, winner: nil)
|
||||
end
|
||||
|
||||
def reset_clock(score)
|
||||
data = ensure_period_data(score)
|
||||
rules = period_rules
|
||||
duration = data["overtime"] ? rules.overtime_duration_secs : rules.period_duration_secs
|
||||
data["clock_secs"] = duration
|
||||
data["clock_running"] = false
|
||||
save_period_score!(score, data)
|
||||
Result.new(score: score, set_won: false, match_won: false, winner: nil)
|
||||
end
|
||||
|
||||
def tick_clock(score, secs: 1)
|
||||
data = ensure_period_data(score)
|
||||
return Result.new(score: score, set_won: false, match_won: false, winner: nil) unless data["clock_running"]
|
||||
|
||||
data["clock_secs"] = [data["clock_secs"].to_i - secs, 0].max
|
||||
save_period_score!(score, data)
|
||||
Result.new(score: score, set_won: false, match_won: false, winner: nil)
|
||||
end
|
||||
|
||||
def sync_period(session:, payload:)
|
||||
score = ensure_score(session)
|
||||
data = ensure_period_data(score)
|
||||
%w[period clock_secs clock_running home_score away_score overtime].each do |key|
|
||||
data[key] = payload[key] if payload.key?(key)
|
||||
end
|
||||
save_period_score!(score, data)
|
||||
score
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
7
backend/app/services/scoring/engines/racket_engine.rb
Normal file
7
backend/app/services/scoring/engines/racket_engine.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
module Scoring
|
||||
module Engines
|
||||
# Fase 1: stessa logica del volley (set + punti).
|
||||
class RacketEngine < VolleyEngine
|
||||
end
|
||||
end
|
||||
end
|
||||
28
backend/app/services/scoring/engines/timed_engine.rb
Normal file
28
backend/app/services/scoring/engines/timed_engine.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
module Scoring
|
||||
module Engines
|
||||
class TimedEngine < BaseEngine
|
||||
include PeriodEngineMixin
|
||||
|
||||
def apply(session:, action:)
|
||||
score = ensure_score(session)
|
||||
|
||||
case action.to_s
|
||||
when "home_point" then adjust_score(score, :home, 1)
|
||||
when "away_point" then adjust_score(score, :away, 1)
|
||||
when "home_undo" then adjust_score(score, :home, -1)
|
||||
when "away_undo" then adjust_score(score, :away, -1)
|
||||
when "advance_period" then advance_period(score)
|
||||
when "clock_toggle" then toggle_clock(score)
|
||||
when "clock_reset" then reset_clock(score)
|
||||
when "clock_tick" then tick_clock(score)
|
||||
else
|
||||
raise ArgumentError, "Azione non valida"
|
||||
end
|
||||
end
|
||||
|
||||
def sync(session:, payload:)
|
||||
sync_period(session: session, payload: payload.stringify_keys)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
49
backend/app/services/scoring/engines/timer_engine.rb
Normal file
49
backend/app/services/scoring/engines/timer_engine.rb
Normal file
@@ -0,0 +1,49 @@
|
||||
module Scoring
|
||||
module Engines
|
||||
class TimerEngine < BaseEngine
|
||||
def apply(session:, action:)
|
||||
score = ensure_score(session)
|
||||
data = timer_data(score)
|
||||
|
||||
case action.to_s
|
||||
when "clock_toggle"
|
||||
data["clock_running"] = !data["clock_running"]
|
||||
when "clock_reset"
|
||||
data["clock_secs"] = 0
|
||||
data["clock_running"] = false
|
||||
when "clock_tick"
|
||||
data["clock_secs"] = data["clock_secs"].to_i + 1 if data["clock_running"]
|
||||
else
|
||||
raise ArgumentError, "Azione non valida"
|
||||
end
|
||||
|
||||
score.update!(data: data)
|
||||
broadcast(session, score)
|
||||
Result.new(score: score, set_won: false, match_won: false, winner: nil)
|
||||
end
|
||||
|
||||
def sync(session:, payload:)
|
||||
score = ensure_score(session)
|
||||
data = timer_data(score)
|
||||
%w[clock_secs clock_running].each do |key|
|
||||
data[key] = payload[key] if payload.key?(key)
|
||||
end
|
||||
score.update!(data: data)
|
||||
broadcast(session, score)
|
||||
score
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def default_data
|
||||
{ "clock_secs" => 0, "clock_running" => false }
|
||||
end
|
||||
|
||||
def timer_data(score)
|
||||
base = default_data
|
||||
stored = score.data.is_a?(Hash) ? score.data : {}
|
||||
base.merge(stored)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
105
backend/app/services/scoring/engines/volley_engine.rb
Normal file
105
backend/app/services/scoring/engines/volley_engine.rb
Normal file
@@ -0,0 +1,105 @@
|
||||
module Scoring
|
||||
module Engines
|
||||
class VolleyEngine < BaseEngine
|
||||
def apply(session:, action:)
|
||||
@rules = Rules.from_match(@match)
|
||||
score = ensure_score(session)
|
||||
|
||||
case action.to_s
|
||||
when "home_point" then apply_point(score, :home)
|
||||
when "away_point" then apply_point(score, :away)
|
||||
when "home_undo" then apply_undo(score, :home)
|
||||
when "away_undo" then apply_undo(score, :away)
|
||||
when "close_set" then apply_close_set(score)
|
||||
else
|
||||
raise ArgumentError, "Azione non valida"
|
||||
end
|
||||
end
|
||||
|
||||
def sync(session:, payload:)
|
||||
score = ensure_score(session)
|
||||
attrs = {}
|
||||
%w[home_sets away_sets home_points away_points current_set].each do |key|
|
||||
attrs[key] = payload[key] if payload.key?(key)
|
||||
end
|
||||
attrs[:set_partials] = payload["set_partials"] if payload.key?("set_partials")
|
||||
score.update!(attrs)
|
||||
broadcast(session, score)
|
||||
score
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def apply_point(score, side)
|
||||
if side == :home
|
||||
score.home_points += 1
|
||||
else
|
||||
score.away_points += 1
|
||||
end
|
||||
score.save!
|
||||
broadcast(score.stream_session, score)
|
||||
winner = @rules.set_winner_from_points(
|
||||
home_points: score.home_points,
|
||||
away_points: score.away_points,
|
||||
current_set: score.current_set
|
||||
)
|
||||
Result.new(score: score, set_won: winner.present?, match_won: false, winner: winner)
|
||||
end
|
||||
|
||||
def apply_undo(score, side)
|
||||
if side == :home
|
||||
score.home_points = [score.home_points - 1, 0].max
|
||||
else
|
||||
score.away_points = [score.away_points - 1, 0].max
|
||||
end
|
||||
score.save!
|
||||
broadcast(score.stream_session, score)
|
||||
Result.new(score: score, set_won: false, match_won: false, winner: nil)
|
||||
end
|
||||
|
||||
def apply_close_set(score)
|
||||
winner = @rules.set_winner_from_points(
|
||||
home_points: score.home_points,
|
||||
away_points: score.away_points,
|
||||
current_set: score.current_set
|
||||
)
|
||||
winner ||= score.home_points > score.away_points ? :home : :away if score.home_points != score.away_points
|
||||
return Result.new(score: score, set_won: false, match_won: false, winner: nil) unless winner
|
||||
|
||||
close_set_for(score, winner)
|
||||
end
|
||||
|
||||
def close_set_for(score, winner)
|
||||
partials = Array(score.set_partials)
|
||||
partials << {
|
||||
"set" => score.current_set,
|
||||
"home" => score.home_points,
|
||||
"away" => score.away_points
|
||||
}
|
||||
|
||||
if winner == :home
|
||||
score.home_sets += 1
|
||||
else
|
||||
score.away_sets += 1
|
||||
end
|
||||
|
||||
match_winner = @rules.match_winner(home_sets: score.home_sets, away_sets: score.away_sets)
|
||||
|
||||
score.assign_attributes(
|
||||
set_partials: partials,
|
||||
home_points: 0,
|
||||
away_points: 0,
|
||||
current_set: score.current_set + 1
|
||||
)
|
||||
score.save!
|
||||
broadcast(score.stream_session, score)
|
||||
Result.new(
|
||||
score: score,
|
||||
set_won: true,
|
||||
match_won: match_winner.present?,
|
||||
winner: match_winner || winner
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,13 +3,31 @@ module Scoring
|
||||
Side = Struct.new(:home, :away, keyword_init: true)
|
||||
|
||||
def self.from_match(match)
|
||||
overrides = match.scoring_rules.is_a?(Hash) ? match.scoring_rules : {}
|
||||
new(
|
||||
sets_to_win: match.sets_to_win,
|
||||
points_per_set: overrides["points_per_set"] || overrides[:points_per_set] || 25,
|
||||
points_deciding_set: overrides["points_deciding_set"] || overrides[:points_deciding_set] || 15,
|
||||
min_point_lead: overrides["min_point_lead"] || overrides[:min_point_lead] || 2
|
||||
)
|
||||
rules = match.effective_scoring_rules
|
||||
board = match.effective_board_type
|
||||
|
||||
case board
|
||||
when "volley", "racket"
|
||||
new(
|
||||
sets_to_win: rules["sets_to_win"].to_i,
|
||||
points_per_set: rules["points_per_set"].to_i,
|
||||
points_deciding_set: rules["points_deciding_set"].to_i,
|
||||
min_point_lead: rules["min_point_lead"].to_i
|
||||
)
|
||||
when "basket", "timed"
|
||||
PeriodRules.new(
|
||||
periods: rules["periods"].to_i,
|
||||
period_duration_secs: rules["period_duration_secs"].to_i,
|
||||
overtime_duration_secs: rules["overtime_duration_secs"].to_i
|
||||
)
|
||||
else
|
||||
new(
|
||||
sets_to_win: rules["sets_to_win"].to_i.positive? ? rules["sets_to_win"].to_i : 3,
|
||||
points_per_set: rules["points_per_set"].to_i.positive? ? rules["points_per_set"].to_i : 25,
|
||||
points_deciding_set: rules["points_deciding_set"].to_i.positive? ? rules["points_deciding_set"].to_i : 15,
|
||||
min_point_lead: rules["min_point_lead"].to_i.positive? ? rules["min_point_lead"].to_i : 2
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(sets_to_win:, points_per_set: 25, points_deciding_set: 15, min_point_lead: 2)
|
||||
@@ -42,5 +60,15 @@ module Scoring
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
class PeriodRules
|
||||
attr_reader :periods, :period_duration_secs, :overtime_duration_secs
|
||||
|
||||
def initialize(periods:, period_duration_secs:, overtime_duration_secs: 300)
|
||||
@periods = periods
|
||||
@period_duration_secs = period_duration_secs
|
||||
@overtime_duration_secs = overtime_duration_secs
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,27 +1,13 @@
|
||||
module Scoring
|
||||
# Sincronizza lo stato completo del tabellone (app mobile / Action Cable).
|
||||
class SyncState
|
||||
SCORE_KEYS = %w[home_sets away_sets home_points away_points current_set].freeze
|
||||
|
||||
def initialize(session:, payload:)
|
||||
@session = session
|
||||
@payload = payload.stringify_keys
|
||||
end
|
||||
|
||||
def call
|
||||
score = @session.score_state || @session.create_score_state!(
|
||||
home_sets: 0, away_sets: 0, home_points: 0, away_points: 0, current_set: 1, set_partials: []
|
||||
)
|
||||
|
||||
attrs = {}
|
||||
SCORE_KEYS.each do |key|
|
||||
attrs[key] = @payload[key] if @payload.key?(key)
|
||||
end
|
||||
attrs[:set_partials] = @payload["set_partials"] if @payload.key?("set_partials")
|
||||
|
||||
score.update!(attrs)
|
||||
SessionChannel.broadcast_message(@session, score.as_cable_payload)
|
||||
score
|
||||
Engine.for(@session.match).sync(session: @session, payload: @payload)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
30
backend/app/views/public/regia/_basket.html.erb
Normal file
30
backend/app/views/public/regia/_basket.html.erb
Normal file
@@ -0,0 +1,30 @@
|
||||
<% data = @score.data || {} %>
|
||||
<p class="regia-sets" id="sets-line"><%= live_score_period_label(@match, @score) %></p>
|
||||
<p class="regia-score-line" id="score-line"><%= live_score_points_label(@match, @score) %></p>
|
||||
<p class="regia-partials" id="partials-line" hidden></p>
|
||||
<p class="regia-clock-line" id="clock-line">Cronometro: <%= format_clock_secs(data["clock_secs"]) %></p>
|
||||
|
||||
<div class="regia-team-row">
|
||||
<div>
|
||||
<div class="regia-team-name"><%= @team.name %></div>
|
||||
<div class="regia-points" id="home-points"><%= data["home_score"] || @score.home_points %></div>
|
||||
<button type="button" class="regia-btn regia-btn--point" data-action="home_point" style="width:100%;margin-top:4px">+1</button>
|
||||
<button type="button" class="regia-btn regia-btn--point" data-action="home_point_2" style="width:100%;margin-top:4px">+2</button>
|
||||
<button type="button" class="regia-btn regia-btn--point" data-action="home_point_3" style="width:100%;margin-top:4px">+3</button>
|
||||
<button type="button" class="regia-btn regia-btn--minus" data-action="home_undo" style="width:100%;margin-top:4px">−</button>
|
||||
</div>
|
||||
<div class="regia-center-hint" id="center-hint"><%= format_clock_secs(data["clock_secs"]) %></div>
|
||||
<div>
|
||||
<div class="regia-team-name"><%= @match.opponent_name %></div>
|
||||
<div class="regia-points" id="away-points"><%= data["away_score"] || @score.away_points %></div>
|
||||
<button type="button" class="regia-btn regia-btn--point" data-action="away_point" style="width:100%;margin-top:4px">+1</button>
|
||||
<button type="button" class="regia-btn regia-btn--point" data-action="away_point_2" style="width:100%;margin-top:4px">+2</button>
|
||||
<button type="button" class="regia-btn regia-btn--point" data-action="away_point_3" style="width:100%;margin-top:4px">+3</button>
|
||||
<button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:4px">−</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="regia-clock-actions">
|
||||
<button type="button" class="regia-btn regia-btn--outline" data-action="clock_toggle"><%= data["clock_running"] ? "Pausa" : "Avvia" %> cronometro</button>
|
||||
<button type="button" class="regia-btn regia-btn--outline" data-action="clock_reset">Reset</button>
|
||||
<button type="button" class="regia-btn regia-btn--yellow" data-action="advance_period">Prossimo quarto</button>
|
||||
</div>
|
||||
19
backend/app/views/public/regia/_generic.html.erb
Normal file
19
backend/app/views/public/regia/_generic.html.erb
Normal file
@@ -0,0 +1,19 @@
|
||||
<p class="regia-sets" id="sets-line">Punteggio libero</p>
|
||||
<p class="regia-score-line" id="score-line"><%= live_score_points_label(@match, @score) %></p>
|
||||
<p class="regia-partials" id="partials-line" hidden></p>
|
||||
|
||||
<div class="regia-team-row">
|
||||
<div>
|
||||
<div class="regia-team-name"><%= @team.name %></div>
|
||||
<div class="regia-points" id="home-points"><%= @score.home_points %></div>
|
||||
<button type="button" class="regia-btn regia-btn--point" data-action="home_point" style="width:100%;margin-top:8px">+1</button>
|
||||
<button type="button" class="regia-btn regia-btn--minus" data-action="home_undo" style="width:100%;margin-top:6px">−</button>
|
||||
</div>
|
||||
<div class="regia-center-hint" id="center-hint">—</div>
|
||||
<div>
|
||||
<div class="regia-team-name"><%= @match.opponent_name %></div>
|
||||
<div class="regia-points" id="away-points"><%= @score.away_points %></div>
|
||||
<button type="button" class="regia-btn regia-btn--point" data-action="away_point" style="width:100%;margin-top:8px">+1</button>
|
||||
<button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:6px">−</button>
|
||||
</div>
|
||||
</div>
|
||||
1
backend/app/views/public/regia/_racket.html.erb
Normal file
1
backend/app/views/public/regia/_racket.html.erb
Normal file
@@ -0,0 +1 @@
|
||||
<%= render "public/regia/volley" %>
|
||||
26
backend/app/views/public/regia/_timed.html.erb
Normal file
26
backend/app/views/public/regia/_timed.html.erb
Normal file
@@ -0,0 +1,26 @@
|
||||
<% data = @score.data || {} %>
|
||||
<p class="regia-sets" id="sets-line"><%= live_score_period_label(@match, @score) %></p>
|
||||
<p class="regia-score-line" id="score-line"><%= live_score_points_label(@match, @score) %></p>
|
||||
<p class="regia-partials" id="partials-line" hidden></p>
|
||||
<p class="regia-clock-line" id="clock-line">Cronometro: <%= format_clock_secs(data["clock_secs"]) %></p>
|
||||
|
||||
<div class="regia-team-row">
|
||||
<div>
|
||||
<div class="regia-team-name"><%= @team.name %></div>
|
||||
<div class="regia-points" id="home-points"><%= data["home_score"] || @score.home_points %></div>
|
||||
<button type="button" class="regia-btn regia-btn--point" data-action="home_point" style="width:100%;margin-top:8px">+1</button>
|
||||
<button type="button" class="regia-btn regia-btn--minus" data-action="home_undo" style="width:100%;margin-top:6px">−</button>
|
||||
</div>
|
||||
<div class="regia-center-hint" id="center-hint"><%= format_clock_secs(data["clock_secs"]) %></div>
|
||||
<div>
|
||||
<div class="regia-team-name"><%= @match.opponent_name %></div>
|
||||
<div class="regia-points" id="away-points"><%= data["away_score"] || @score.away_points %></div>
|
||||
<button type="button" class="regia-btn regia-btn--point" data-action="away_point" style="width:100%;margin-top:8px">+1</button>
|
||||
<button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:6px">−</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="regia-clock-actions">
|
||||
<button type="button" class="regia-btn regia-btn--outline" data-action="clock_toggle"><%= data["clock_running"] ? "Pausa" : "Avvia" %> cronometro</button>
|
||||
<button type="button" class="regia-btn regia-btn--outline" data-action="clock_reset">Reset</button>
|
||||
<button type="button" class="regia-btn regia-btn--yellow" data-action="advance_period">Prossimo tempo</button>
|
||||
</div>
|
||||
10
backend/app/views/public/regia/_timer.html.erb
Normal file
10
backend/app/views/public/regia/_timer.html.erb
Normal file
@@ -0,0 +1,10 @@
|
||||
<% data = @score.data || {} %>
|
||||
<p class="regia-sets" id="sets-line">Cronometro</p>
|
||||
<p class="regia-score-line" id="score-line" hidden></p>
|
||||
<p class="regia-partials" id="partials-line" hidden></p>
|
||||
<p class="regia-clock-line" id="clock-line"><%= format_clock_secs(data["clock_secs"], count_up: true) %></p>
|
||||
|
||||
<div class="regia-clock-actions" style="margin-top:16px">
|
||||
<button type="button" class="regia-btn regia-btn--outline" data-action="clock_toggle"><%= data["clock_running"] ? "Pausa" : "Avvia" %></button>
|
||||
<button type="button" class="regia-btn regia-btn--outline" data-action="clock_reset">Reset</button>
|
||||
</div>
|
||||
24
backend/app/views/public/regia/_volley.html.erb
Normal file
24
backend/app/views/public/regia/_volley.html.erb
Normal file
@@ -0,0 +1,24 @@
|
||||
<p class="regia-sets" id="sets-line"><%= live_score_sets_label(@score) || "—" %></p>
|
||||
<p class="regia-score-line" id="score-line"><%= live_score_points_label(@match, @score) %></p>
|
||||
<p class="regia-partials" id="partials-line"<%= " hidden" unless live_score_partials_label(@score).present? %>>
|
||||
Parziali: <%= live_score_partials_label(@score) %>
|
||||
</p>
|
||||
|
||||
<div class="regia-team-row">
|
||||
<div>
|
||||
<div class="regia-team-name"><%= @team.name %></div>
|
||||
<div class="regia-points" id="home-points"><%= @score.home_points %></div>
|
||||
<button type="button" class="regia-btn regia-btn--point" data-action="home_point" style="width:100%;margin-top:8px">+1</button>
|
||||
<button type="button" class="regia-btn regia-btn--minus" data-action="home_undo" style="width:100%;margin-top:6px">−</button>
|
||||
</div>
|
||||
<div class="regia-center-hint" id="center-hint">
|
||||
<%= Scoring::Rules.from_match(@match).points_target(@score.current_set) %> pt
|
||||
</div>
|
||||
<div>
|
||||
<div class="regia-team-name"><%= @match.opponent_name %></div>
|
||||
<div class="regia-points" id="away-points"><%= @score.away_points %></div>
|
||||
<button type="button" class="regia-btn regia-btn--point" data-action="away_point" style="width:100%;margin-top:8px">+1</button>
|
||||
<button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:6px">−</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="regia-btn regia-btn--yellow" data-action="close_set" style="margin-top:12px">Chiudi set</button>
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
<div class="regia-page" id="regia-app"
|
||||
data-token="<%= j params[:token] %>"
|
||||
data-board="<%= @match.effective_board_type %>"
|
||||
data-status-url="<%= j public_regia_status_path(params[:token]) %>"
|
||||
data-score-url="<%= j public_regia_score_path(params[:token]) %>"
|
||||
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
|
||||
@@ -20,13 +21,13 @@
|
||||
data-away-name="<%= j @match.opponent_name %>"
|
||||
data-share-url="<%= j @share_url %>"
|
||||
data-share-title="<%= j @share_title %>"
|
||||
data-points-target="<%= Scoring::Rules.from_match(@match).points_target(@score.current_set) %>"
|
||||
data-points-target="<%= @match.effective_board_type.in?(%w[volley racket]) ? Scoring::Rules.from_match(@match).points_target(@score.current_set) : 0 %>"
|
||||
data-stream-closed="<%= @stream_closed %>"
|
||||
data-initial-paused="<%= @session.paused? %>">
|
||||
|
||||
<header class="regia-header">
|
||||
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
|
||||
<p>Regia · Set <%= @score.current_set %></p>
|
||||
<p>Regia · <%= regia_header_subtitle(@match, @score) %></p>
|
||||
<span id="regia-badge" class="regia-badge <%= @stream_closed ? 'regia-badge--ended' : (@session.paused? ? 'regia-badge--wait' : (@on_air ? 'regia-badge--live' : 'regia-badge--wait')) %>">
|
||||
<%= @stream_closed ? "Terminata" : (@session.paused? ? "In pausa" : (@on_air ? "In onda" : "In attesa")) %>
|
||||
</span>
|
||||
@@ -51,30 +52,7 @@
|
||||
</div>
|
||||
|
||||
<section class="regia-scoreboard">
|
||||
<p class="regia-sets" id="sets-line"><%= live_score_sets_label(@score) || "—" %></p>
|
||||
<p class="regia-score-line" id="score-line"><%= live_score_points_label(@match, @score) %></p>
|
||||
<p class="regia-partials" id="partials-line"<%= " hidden" unless live_score_partials_label(@score).present? %>>
|
||||
Parziali: <%= live_score_partials_label(@score) %>
|
||||
</p>
|
||||
|
||||
<div class="regia-team-row">
|
||||
<div>
|
||||
<div class="regia-team-name"><%= @team.name %></div>
|
||||
<div class="regia-points" id="home-points"><%= @score.home_points %></div>
|
||||
<button type="button" class="regia-btn regia-btn--point" data-action="home_point" style="width:100%;margin-top:8px">+1</button>
|
||||
<button type="button" class="regia-btn regia-btn--minus" data-action="home_undo" style="width:100%;margin-top:6px">−</button>
|
||||
</div>
|
||||
<div style="text-align:center;color:var(--regia-muted);font-size:0.72rem;padding-top:24px">
|
||||
<%= Scoring::Rules.from_match(@match).points_target(@score.current_set) %> pt
|
||||
</div>
|
||||
<div>
|
||||
<div class="regia-team-name"><%= @match.opponent_name %></div>
|
||||
<div class="regia-points" id="away-points"><%= @score.away_points %></div>
|
||||
<button type="button" class="regia-btn regia-btn--point" data-action="away_point" style="width:100%;margin-top:8px">+1</button>
|
||||
<button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:6px">−</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="regia-btn regia-btn--yellow" data-action="close_set" style="margin-top:12px">Chiudi set</button>
|
||||
<%= render regia_board_partial(@match) %>
|
||||
</section>
|
||||
|
||||
<% unless @stream_closed %>
|
||||
@@ -96,4 +74,4 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/regia.js?v=4"></script>
|
||||
<script src="/regia.js?v=5"></script>
|
||||
|
||||
Reference in New Issue
Block a user