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:
2026-06-09 20:11:21 +02:00
parent 53f9a6a884
commit e727069d43
56 changed files with 1783 additions and 223 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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])