diff --git a/backend/app/controllers/api/v1/matches_controller.rb b/backend/app/controllers/api/v1/matches_controller.rb index 0de3457..30da598 100644 --- a/backend/app/controllers/api/v1/matches_controller.rb +++ b/backend/app/controllers/api/v1/matches_controller.rb @@ -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, diff --git a/backend/app/controllers/api/v1/sports_controller.rb b/backend/app/controllers/api/v1/sports_controller.rb new file mode 100644 index 0000000..2f9a57a --- /dev/null +++ b/backend/app/controllers/api/v1/sports_controller.rb @@ -0,0 +1,9 @@ +module Api + module V1 + class SportsController < BaseController + def index + render json: Sports::Catalog.as_api_list + end + end + end +end diff --git a/backend/app/controllers/api/v1/teams_controller.rb b/backend/app/controllers/api/v1/teams_controller.rb index f3b8b4d..89d0773 100644 --- a/backend/app/controllers/api/v1/teams_controller.rb +++ b/backend/app/controllers/api/v1/teams_controller.rb @@ -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, diff --git a/backend/app/controllers/public/regia_controller.rb b/backend/app/controllers/public/regia_controller.rb index 40bf960..f37b98a 100644 --- a/backend/app/controllers/public/regia_controller.rb +++ b/backend/app/controllers/public/regia_controller.rb @@ -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) diff --git a/backend/app/domain/sports/board_type.rb b/backend/app/domain/sports/board_type.rb new file mode 100644 index 0000000..eca4ae1 --- /dev/null +++ b/backend/app/domain/sports/board_type.rb @@ -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 diff --git a/backend/app/domain/sports/catalog.rb b/backend/app/domain/sports/catalog.rb new file mode 100644 index 0000000..84a6024 --- /dev/null +++ b/backend/app/domain/sports/catalog.rb @@ -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 diff --git a/backend/app/domain/sports/overlay_kind.rb b/backend/app/domain/sports/overlay_kind.rb new file mode 100644 index 0000000..8d6d07d --- /dev/null +++ b/backend/app/domain/sports/overlay_kind.rb @@ -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 diff --git a/backend/app/domain/sports/rules_schema.rb b/backend/app/domain/sports/rules_schema.rb new file mode 100644 index 0000000..498acbb --- /dev/null +++ b/backend/app/domain/sports/rules_schema.rb @@ -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 diff --git a/backend/app/helpers/public/live_helper.rb b/backend/app/helpers/public/live_helper.rb index e5e50e5..e6e043e 100644 --- a/backend/app/helpers/public/live_helper.rb +++ b/backend/app/helpers/public/live_helper.rb @@ -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 diff --git a/backend/app/helpers/public/regia_helper.rb b/backend/app/helpers/public/regia_helper.rb new file mode 100644 index 0000000..4657b96 --- /dev/null +++ b/backend/app/helpers/public/regia_helper.rb @@ -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 diff --git a/backend/app/models/match.rb b/backend/app/models/match.rb index 854482b..b089e08 100644 --- a/backend/app/models/match.rb +++ b/backend/app/models/match.rb @@ -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 diff --git a/backend/app/models/score_state.rb b/backend/app/models/score_state.rb index 425a512..60adff0 100644 --- a/backend/app/models/score_state.rb +++ b/backend/app/models/score_state.rb @@ -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 diff --git a/backend/app/models/team.rb b/backend/app/models/team.rb index 72a893d..335af05 100644 --- a/backend/app/models/team.rb +++ b/backend/app/models/team.rb @@ -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]) diff --git a/backend/app/services/scoring/apply_action.rb b/backend/app/services/scoring/apply_action.rb index 74a8d21..fa0d5a3 100644 --- a/backend/app/services/scoring/apply_action.rb +++ b/backend/app/services/scoring/apply_action.rb @@ -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 diff --git a/backend/app/services/scoring/engine.rb b/backend/app/services/scoring/engine.rb new file mode 100644 index 0000000..e333c6e --- /dev/null +++ b/backend/app/services/scoring/engine.rb @@ -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 diff --git a/backend/app/services/scoring/engines/base_engine.rb b/backend/app/services/scoring/engines/base_engine.rb new file mode 100644 index 0000000..ed2132b --- /dev/null +++ b/backend/app/services/scoring/engines/base_engine.rb @@ -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 diff --git a/backend/app/services/scoring/engines/basket_engine.rb b/backend/app/services/scoring/engines/basket_engine.rb new file mode 100644 index 0000000..610c604 --- /dev/null +++ b/backend/app/services/scoring/engines/basket_engine.rb @@ -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 diff --git a/backend/app/services/scoring/engines/generic_engine.rb b/backend/app/services/scoring/engines/generic_engine.rb new file mode 100644 index 0000000..6953f37 --- /dev/null +++ b/backend/app/services/scoring/engines/generic_engine.rb @@ -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 diff --git a/backend/app/services/scoring/engines/period_engine_mixin.rb b/backend/app/services/scoring/engines/period_engine_mixin.rb new file mode 100644 index 0000000..b5005e1 --- /dev/null +++ b/backend/app/services/scoring/engines/period_engine_mixin.rb @@ -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 diff --git a/backend/app/services/scoring/engines/racket_engine.rb b/backend/app/services/scoring/engines/racket_engine.rb new file mode 100644 index 0000000..330259a --- /dev/null +++ b/backend/app/services/scoring/engines/racket_engine.rb @@ -0,0 +1,7 @@ +module Scoring + module Engines + # Fase 1: stessa logica del volley (set + punti). + class RacketEngine < VolleyEngine + end + end +end diff --git a/backend/app/services/scoring/engines/timed_engine.rb b/backend/app/services/scoring/engines/timed_engine.rb new file mode 100644 index 0000000..357c7da --- /dev/null +++ b/backend/app/services/scoring/engines/timed_engine.rb @@ -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 diff --git a/backend/app/services/scoring/engines/timer_engine.rb b/backend/app/services/scoring/engines/timer_engine.rb new file mode 100644 index 0000000..20e8ea5 --- /dev/null +++ b/backend/app/services/scoring/engines/timer_engine.rb @@ -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 diff --git a/backend/app/services/scoring/engines/volley_engine.rb b/backend/app/services/scoring/engines/volley_engine.rb new file mode 100644 index 0000000..64c8573 --- /dev/null +++ b/backend/app/services/scoring/engines/volley_engine.rb @@ -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 diff --git a/backend/app/services/scoring/rules.rb b/backend/app/services/scoring/rules.rb index bebaeda..054c502 100644 --- a/backend/app/services/scoring/rules.rb +++ b/backend/app/services/scoring/rules.rb @@ -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 diff --git a/backend/app/services/scoring/sync_state.rb b/backend/app/services/scoring/sync_state.rb index 7a90799..3e77cfa 100644 --- a/backend/app/services/scoring/sync_state.rb +++ b/backend/app/services/scoring/sync_state.rb @@ -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 diff --git a/backend/app/views/public/regia/_basket.html.erb b/backend/app/views/public/regia/_basket.html.erb new file mode 100644 index 0000000..eeb6f72 --- /dev/null +++ b/backend/app/views/public/regia/_basket.html.erb @@ -0,0 +1,30 @@ +<% data = @score.data || {} %> +

<%= live_score_period_label(@match, @score) %>

+

<%= live_score_points_label(@match, @score) %>

+ +

Cronometro: <%= format_clock_secs(data["clock_secs"]) %>

+ +
+
+
<%= @team.name %>
+
<%= data["home_score"] || @score.home_points %>
+ + + + +
+
<%= format_clock_secs(data["clock_secs"]) %>
+
+
<%= @match.opponent_name %>
+
<%= data["away_score"] || @score.away_points %>
+ + + + +
+
+
+ + + +
diff --git a/backend/app/views/public/regia/_generic.html.erb b/backend/app/views/public/regia/_generic.html.erb new file mode 100644 index 0000000..f05a6cb --- /dev/null +++ b/backend/app/views/public/regia/_generic.html.erb @@ -0,0 +1,19 @@ +

Punteggio libero

+

<%= live_score_points_label(@match, @score) %>

+ + +
+
+
<%= @team.name %>
+
<%= @score.home_points %>
+ + +
+
+
+
<%= @match.opponent_name %>
+
<%= @score.away_points %>
+ + +
+
diff --git a/backend/app/views/public/regia/_racket.html.erb b/backend/app/views/public/regia/_racket.html.erb new file mode 100644 index 0000000..4b1a065 --- /dev/null +++ b/backend/app/views/public/regia/_racket.html.erb @@ -0,0 +1 @@ +<%= render "public/regia/volley" %> diff --git a/backend/app/views/public/regia/_timed.html.erb b/backend/app/views/public/regia/_timed.html.erb new file mode 100644 index 0000000..6fde355 --- /dev/null +++ b/backend/app/views/public/regia/_timed.html.erb @@ -0,0 +1,26 @@ +<% data = @score.data || {} %> +

<%= live_score_period_label(@match, @score) %>

+

<%= live_score_points_label(@match, @score) %>

+ +

Cronometro: <%= format_clock_secs(data["clock_secs"]) %>

+ +
+
+
<%= @team.name %>
+
<%= data["home_score"] || @score.home_points %>
+ + +
+
<%= format_clock_secs(data["clock_secs"]) %>
+
+
<%= @match.opponent_name %>
+
<%= data["away_score"] || @score.away_points %>
+ + +
+
+
+ + + +
diff --git a/backend/app/views/public/regia/_timer.html.erb b/backend/app/views/public/regia/_timer.html.erb new file mode 100644 index 0000000..6fe52af --- /dev/null +++ b/backend/app/views/public/regia/_timer.html.erb @@ -0,0 +1,10 @@ +<% data = @score.data || {} %> +

Cronometro

+ + +

<%= format_clock_secs(data["clock_secs"], count_up: true) %>

+ +
+ + +
diff --git a/backend/app/views/public/regia/_volley.html.erb b/backend/app/views/public/regia/_volley.html.erb new file mode 100644 index 0000000..ee4c053 --- /dev/null +++ b/backend/app/views/public/regia/_volley.html.erb @@ -0,0 +1,24 @@ +

<%= live_score_sets_label(@score) || "—" %>

+

<%= live_score_points_label(@match, @score) %>

+

> + Parziali: <%= live_score_partials_label(@score) %> +

+ +
+
+
<%= @team.name %>
+
<%= @score.home_points %>
+ + +
+
+ <%= Scoring::Rules.from_match(@match).points_target(@score.current_set) %> pt +
+
+
<%= @match.opponent_name %>
+
<%= @score.away_points %>
+ + +
+
+ diff --git a/backend/app/views/public/regia/show.html.erb b/backend/app/views/public/regia/show.html.erb index 42ef9a3..8e3dd0d 100644 --- a/backend/app/views/public/regia/show.html.erb +++ b/backend/app/views/public/regia/show.html.erb @@ -9,6 +9,7 @@

<%= @team.name %> vs <%= @match.opponent_name %>

-

Regia · Set <%= @score.current_set %>

+

Regia · <%= regia_header_subtitle(@match, @score) %>

<%= @stream_closed ? "Terminata" : (@session.paused? ? "In pausa" : (@on_air ? "In onda" : "In attesa")) %> @@ -51,30 +52,7 @@
-

<%= live_score_sets_label(@score) || "—" %>

-

<%= live_score_points_label(@match, @score) %>

-

> - Parziali: <%= live_score_partials_label(@score) %> -

- -
-
-
<%= @team.name %>
-
<%= @score.home_points %>
- - -
-
- <%= Scoring::Rules.from_match(@match).points_target(@score.current_set) %> pt -
-
-
<%= @match.opponent_name %>
-
<%= @score.away_points %>
- - -
-
- + <%= render regia_board_partial(@match) %>
<% unless @stream_closed %> @@ -96,4 +74,4 @@ - + diff --git a/backend/config/application.rb b/backend/config/application.rb index 8387b2a..41700da 100644 --- a/backend/config/application.rb +++ b/backend/config/application.rb @@ -22,6 +22,8 @@ module App class Application < Rails::Application config.load_defaults 7.2 config.autoload_lib(ignore: %w[assets tasks]) + config.autoload_paths << Rails.root.join("app/domain") + config.eager_load_paths << Rails.root.join("app/domain") config.api_only = false config.active_job.queue_adapter = :sidekiq config.time_zone = "Europe/Rome" diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 7f4e58d..7f949dd 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -10,6 +10,7 @@ Rails.application.routes.draw do post "auth/logout", to: "auth#logout" post "auth/refresh", to: "auth#refresh" get "auth/me", to: "auth#me" + get "sports", to: "sports#index" get "invitations/:token", to: "invitations#show" post "invitations/:token/accept", to: "invitations#accept" diff --git a/backend/config/sports.yml b/backend/config/sports.yml new file mode 100644 index 0000000..b934261 --- /dev/null +++ b/backend/config/sports.yml @@ -0,0 +1,183 @@ +# Catalogo sport Match Live TV — board e overlay impliciti, regole standard. +# board: volley | basket | timed | racket | timer | generic +# overlay: none | timer | volley | basket | timed | racket + +pallavolo: + label: Pallavolo + board: volley + overlay: volley + allowed_overlays: [volley, timer, none] + default_rules: + sets_to_win: 3 + points_per_set: 25 + points_deciding_set: 15 + min_point_lead: 2 + +beach_volley: + label: Beach Volley + board: volley + overlay: volley + allowed_overlays: [volley, timer, none] + default_rules: + sets_to_win: 2 + points_per_set: 21 + points_deciding_set: 15 + min_point_lead: 2 + +sitting_volley: + label: Sitting Volley + board: volley + overlay: volley + allowed_overlays: [volley, timer, none] + default_rules: + sets_to_win: 3 + points_per_set: 25 + points_deciding_set: 15 + min_point_lead: 2 + +basket: + label: Basket + board: basket + overlay: basket + allowed_overlays: [basket, timer, none] + default_rules: + periods: 4 + period_duration_secs: 600 + overtime_duration_secs: 300 + +calcio_a_5: + label: Calcio a 5 + board: timed + overlay: timed + allowed_overlays: [timed, timer, none] + default_rules: + periods: 2 + period_duration_secs: 1200 + overtime_duration_secs: 300 + +pallamano: + label: Pallamano + board: timed + overlay: timed + allowed_overlays: [timed, timer, none] + default_rules: + periods: 2 + period_duration_secs: 1800 + overtime_duration_secs: 300 + +hockey_indoor: + label: Hockey Indoor + board: timed + overlay: timed + allowed_overlays: [timed, timer, none] + default_rules: + periods: 2 + period_duration_secs: 1200 + overtime_duration_secs: 300 + +floorball: + label: Floorball + board: timed + overlay: timed + allowed_overlays: [timed, timer, none] + default_rules: + periods: 3 + period_duration_secs: 1200 + overtime_duration_secs: 300 + +tennis: + label: Tennis + board: racket + overlay: racket + allowed_overlays: [racket, timer, none] + default_rules: + sets_to_win: 2 + points_per_set: 6 + points_deciding_set: 7 + min_point_lead: 2 + +tennis_tavolo: + label: Tennis Tavolo + board: racket + overlay: racket + allowed_overlays: [racket, timer, none] + default_rules: + sets_to_win: 3 + points_per_set: 11 + points_deciding_set: 11 + min_point_lead: 2 + +badminton: + label: Badminton + board: racket + overlay: racket + allowed_overlays: [racket, timer, none] + default_rules: + sets_to_win: 2 + points_per_set: 21 + points_deciding_set: 21 + min_point_lead: 2 + +padel: + label: Padel + board: racket + overlay: racket + allowed_overlays: [racket, timer, none] + default_rules: + sets_to_win: 2 + points_per_set: 6 + points_deciding_set: 7 + min_point_lead: 2 + +pickleball: + label: Pickleball + board: racket + overlay: racket + allowed_overlays: [racket, timer, none] + default_rules: + sets_to_win: 2 + points_per_set: 11 + points_deciding_set: 11 + min_point_lead: 2 + +ginnastica_artistica: + label: Ginnastica Artistica + board: timer + overlay: timer + allowed_overlays: [timer, none] + default_rules: {} + +ginnastica_ritmica: + label: Ginnastica Ritmica + board: timer + overlay: timer + allowed_overlays: [timer, none] + default_rules: {} + +danza_sportiva: + label: Danza Sportiva + board: timer + overlay: timer + allowed_overlays: [timer, none] + default_rules: {} + +cheerleading: + label: Cheerleading + board: timer + overlay: timer + allowed_overlays: [timer, none] + default_rules: {} + +pattinaggio: + label: Pattinaggio + board: timer + overlay: timer + allowed_overlays: [timer, none] + default_rules: {} + +altro: + label: Altro + board: generic + overlay: none + allowed_overlays: [none, timer] + default_rules: {} diff --git a/backend/db/migrate/20260609100000_multi_sport.rb b/backend/db/migrate/20260609100000_multi_sport.rb new file mode 100644 index 0000000..e641abb --- /dev/null +++ b/backend/db/migrate/20260609100000_multi_sport.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class MultiSport < ActiveRecord::Migration[7.2] + def up + rename_column :teams, :sport, :sport_key + rename_column :matches, :sport, :sport_key + + change_column_default :teams, :sport_key, from: "volleyball", to: "pallavolo" + change_column_default :matches, :sport_key, from: "volleyball", to: "pallavolo" + + execute <<~SQL.squish + UPDATE teams SET sport_key = 'pallavolo' WHERE sport_key = 'volleyball' OR sport_key IS NULL OR sport_key = ''; + UPDATE matches SET sport_key = 'pallavolo' WHERE sport_key = 'volleyball' OR sport_key IS NULL OR sport_key = ''; + SQL + + add_column :matches, :overlay_kind, :string + add_column :score_states, :board_type, :string, null: false, default: "volley" + add_column :score_states, :data, :jsonb, null: false, default: {} + + execute <<~SQL.squish + UPDATE matches + SET scoring_rules = scoring_rules || jsonb_build_object('sets_to_win', sets_to_win) + WHERE sets_to_win IS NOT NULL + AND NOT (scoring_rules ? 'sets_to_win'); + SQL + + execute <<~SQL.squish + UPDATE score_states ss + SET board_type = 'volley' + FROM stream_sessions s + JOIN matches m ON m.id = s.match_id + WHERE ss.stream_session_id = s.id; + SQL + end + + def down + execute <<~SQL.squish + UPDATE teams SET sport_key = 'volleyball' WHERE sport_key = 'pallavolo'; + UPDATE matches SET sport_key = 'volleyball' WHERE sport_key = 'pallavolo'; + SQL + + change_column_default :teams, :sport_key, from: "pallavolo", to: "volleyball" + change_column_default :matches, :sport_key, from: "pallavolo", to: "volleyball" + + rename_column :teams, :sport_key, :sport + rename_column :matches, :sport_key, :sport + + remove_column :matches, :overlay_kind + remove_column :score_states, :board_type + remove_column :score_states, :data + end +end diff --git a/backend/public/regia.css b/backend/public/regia.css index 03fa9a5..e07fdc7 100644 --- a/backend/public/regia.css +++ b/backend/public/regia.css @@ -223,3 +223,23 @@ .regia-modal__card h2 { margin: 0 0 8px; font-size: 1.1rem; } .regia-modal__card p { margin: 0 0 16px; color: var(--regia-muted); font-size: 0.9rem; } .regia-modal__actions { display: flex; flex-direction: column; gap: 8px; } + +.regia-center-hint { + text-align: center; + color: var(--regia-muted); + font-size: 0.72rem; + padding-top: 24px; +} + +.regia-clock-line { + margin: 0 0 12px; + color: var(--regia-muted); + font-size: 0.9rem; +} + +.regia-clock-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} diff --git a/backend/public/regia.js b/backend/public/regia.js index e8aba6a..d8e3d37 100644 --- a/backend/public/regia.js +++ b/backend/public/regia.js @@ -4,6 +4,7 @@ const cfg = { token: root.dataset.token, + board: root.dataset.board || "volley", statusUrl: root.dataset.statusUrl, scoreUrl: root.dataset.scoreUrl, pauseUrl: root.dataset.pauseUrl, @@ -88,26 +89,52 @@ .join(" · "); } + function formatClock(secs, countUp) { + const total = parseInt(secs, 10) || 0; + if (total <= 0 && !countUp) return "0:00"; + const m = Math.floor(total / 60); + const s = total % 60; + return `${m}:${String(s).padStart(2, "0")}`; + } + function applyScorePayload(data) { const s = data.score; if (!s) return; - const homePts = s.home_points ?? s.homePoints; - const awayPts = s.away_points ?? s.awayPoints; + const board = s.board_type || s.boardType || cfg.board; + const homePts = s.home_points ?? s.homePoints ?? 0; + const awayPts = s.away_points ?? s.awayPoints ?? 0; const homeSets = s.home_sets ?? s.homeSets; const awaySets = s.away_sets ?? s.awaySets; const currentSet = s.current_set ?? s.currentSet; - + const period = s.period; + const clockSecs = s.clock_secs ?? s.clockSecs ?? s.data?.clock_secs; const awayEl = document.getElementById("away-points"); - els.homePoints.textContent = homePts; - if (awayEl) awayEl.textContent = awayPts; - els.setsLine.textContent = `Set ${currentSet} · Set vinti ${homeSets}-${awaySets}`; - els.scoreLine.textContent = `${cfg.homeName} ${homePts} - ${awayPts} ${cfg.awayName}`; + const centerHint = document.getElementById("center-hint"); + const clockLine = document.getElementById("clock-line"); - const partialsText = formatPartials(s.set_partials || s.setPartials); - if (partialsText) { - els.partialsLine.textContent = `Parziali: ${partialsText}`; - els.partialsLine.hidden = false; + if (els.homePoints) els.homePoints.textContent = homePts; + if (awayEl) awayEl.textContent = awayPts; + + if (board === "basket" || board === "timed") { + if (els.setsLine) els.setsLine.textContent = period || "—"; + if (els.scoreLine) els.scoreLine.textContent = `${cfg.homeName} ${homePts} - ${awayPts} ${cfg.awayName}`; + if (centerHint) centerHint.textContent = formatClock(clockSecs); + if (clockLine) clockLine.textContent = `Cronometro: ${formatClock(clockSecs)}`; + } else if (board === "timer") { + if (els.setsLine) els.setsLine.textContent = "Cronometro"; + if (clockLine) clockLine.textContent = formatClock(clockSecs, true); + } else if (board === "generic") { + if (els.setsLine) els.setsLine.textContent = "Punteggio libero"; + if (els.scoreLine) els.scoreLine.textContent = `${cfg.homeName} ${homePts} - ${awayPts} ${cfg.awayName}`; + } else { + if (els.setsLine) els.setsLine.textContent = `Set ${currentSet} · Set vinti ${homeSets}-${awaySets}`; + if (els.scoreLine) els.scoreLine.textContent = `${cfg.homeName} ${homePts} - ${awayPts} ${cfg.awayName}`; + const partialsText = formatPartials(s.set_partials || s.setPartials); + if (els.partialsLine && partialsText) { + els.partialsLine.textContent = `Parziali: ${partialsText}`; + els.partialsLine.hidden = false; + } } } @@ -157,10 +184,13 @@ applyScorePayload(data); setBadge(data); - if (data.set_won && action !== "close_set") { - const winner = data.winner === "home" ? cfg.homeName : cfg.awayName; - openModal("Set vinto", `${winner} ha vinto il set. Chiudere il set?`, () => postScore("close_set")); - } else if (data.match_won) { + if (cfg.board === "volley" || cfg.board === "racket") { + if (data.set_won && action !== "close_set") { + const winner = data.winner === "home" ? cfg.homeName : cfg.awayName; + openModal("Set vinto", `${winner} ha vinto il set. Chiudere il set?`, () => postScore("close_set")); + } + } + if (data.match_won) { const winner = data.winner === "home" ? cfg.homeName : cfg.awayName; openModal("Partita vinta", `${winner} ha vinto la partita. Chiudere la diretta?`, () => stopStream()); } diff --git a/backend/spec/domain/sports/catalog_spec.rb b/backend/spec/domain/sports/catalog_spec.rb new file mode 100644 index 0000000..348000d --- /dev/null +++ b/backend/spec/domain/sports/catalog_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Sports::Catalog do + describe ".find" do + it "restituisce pallavolo con board volley" do + entry = described_class.find("pallavolo") + expect(entry[:board]).to eq("volley") + expect(entry[:overlay]).to eq("volley") + expect(entry[:default_rules]["sets_to_win"]).to eq(3) + end + + it "mappa legacy volleyball" do + expect(described_class.normalize_key("volleyball")).to eq("pallavolo") + end + + it "altro usa board generic" do + entry = described_class.find("altro") + expect(entry[:board]).to eq("generic") + expect(entry[:overlay]).to eq("none") + end + end + + describe ".as_api_list" do + it "include tutti gli sport configurati" do + keys = described_class.as_api_list.map { |e| e[:key] } + expect(keys).to include("pallavolo", "basket", "calcio_a_5", "tennis", "ginnastica_artistica", "altro") + end + end +end diff --git a/backend/spec/requests/api/v1/sports_spec.rb b/backend/spec/requests/api/v1/sports_spec.rb new file mode 100644 index 0000000..1597d14 --- /dev/null +++ b/backend/spec/requests/api/v1/sports_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Api::V1::Sports", type: :request do + let!(:user) { User.create!(email: "s@example.com", password: "secret123", name: "U", role: "coach") } + + it "GET /api/v1/sports restituisce il catalogo" do + post "/api/v1/auth/login", params: { email: user.email, password: "secret123" } + token = response.parsed_body["access_token"] + get "/api/v1/sports", headers: { "Authorization" => "Bearer #{token}" } + expect(response).to have_http_status(:ok) + body = JSON.parse(response.body) + expect(body).to be_an(Array) + pallavolo = body.find { |s| s["key"] == "pallavolo" } + expect(pallavolo["board"]).to eq("volley") + expect(pallavolo["allowed_overlays"]).to include("volley", "timer", "none") + end +end diff --git a/backend/spec/services/scoring/generic_engine_spec.rb b/backend/spec/services/scoring/generic_engine_spec.rb new file mode 100644 index 0000000..f535049 --- /dev/null +++ b/backend/spec/services/scoring/generic_engine_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Scoring::Engines::GenericEngine do + let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } + let!(:team) { club.teams.create!(name: "T", sport: "altro") } + let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "altro") } + let!(:user) { User.create!(email: "g@example.com", password: "secret123", name: "Coach") } + let!(:session) { StreamSession.create!(match: match, user: user, status: "live", platform: "matchlivetv") } + + it "incrementa solo il contatore senza chiudere set" do + engine = described_class.new(match: match) + result = engine.apply(session: session, action: "home_point") + expect(result.score.home_points).to eq(1) + expect(result.set_won).to be(false) + end +end diff --git a/docs/MULTI_SPORT.md b/docs/MULTI_SPORT.md new file mode 100644 index 0000000..3d84a98 --- /dev/null +++ b/docs/MULTI_SPORT.md @@ -0,0 +1,49 @@ +# Multi-sport — Match Live TV + +## Concetti + +| Concetto | Descrizione | +|----------|-------------| +| `sport_key` | Disciplina (pallavolo, basket, tennis…) — sulla squadra e snapshot sulla partita | +| `board_type` | Macro-tabellone **implicito** dal catalogo (`volley`, `basket`, `timed`, `racket`, `timer`, `generic`) | +| `overlay_kind` | Layout video sulla partita (`none`, `timer`, `volley`, `basket`, `timed`, `racket`) | +| `scoring_rules` | Regole effettive della partita (override sui default del catalogo) | + +## Catalogo + +Definito in [`backend/config/sports.yml`](../backend/config/sports.yml), caricato da `Sports::Catalog`. + +- Nessuna tabella `sports` in DB +- API: `GET /api/v1/sports` + +## Scoring + +`Scoring::Engine.for(match)` seleziona l'engine: + +| Board | Engine | Azioni principali | +|-------|--------|-------------------| +| volley | VolleyEngine | +1, −1, chiudi set | +| racket | RacketEngine | come volley (fase 1) | +| generic | GenericEngine | +1, −1 (nessuna chiusura set) | +| basket | BasketEngine | +1/+2/+3, quarti, cronometro | +| timed | TimedEngine | +1, −1, tempi, cronometro | +| timer | TimerEngine | solo cronometro | + +`score_states.data` (JSONB) per basket/timed/timer. + +## Overlay + +Scelto per partita (`overlay_kind`), limitato da `allowed_overlays` dello sport. + +Rendering Android: `OverlayKind` in `OverlayState` → `ScoreboardElement`, `CompactScoreboardElement`, `TimerElement`. + +## Migrazione + +- `teams.sport` → `sport_key` (`volleyball` → `pallavolo`) +- `matches.sport` → `sport_key` +- `matches.overlay_kind` (nullable) +- `score_states.board_type`, `score_states.data` + +## Regia web + +Partial per board in `app/views/public/regia/_*.html.erb`, `data-board` su `#regia-app`. diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt index 5753145..00a4d4c 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt @@ -3,6 +3,7 @@ package com.matchlivetv.match_live_tv.data.api import com.matchlivetv.match_live_tv.domain.AuthTokens import com.matchlivetv.match_live_tv.domain.Match import com.matchlivetv.match_live_tv.domain.ScoringRules +import com.matchlivetv.match_live_tv.domain.SportOption import com.matchlivetv.match_live_tv.domain.StreamSession import com.matchlivetv.match_live_tv.domain.Team import com.matchlivetv.match_live_tv.domain.User @@ -30,10 +31,31 @@ data class UserDto( fun toDomain() = User(id, email, name, role ?: "coach") } +data class SportDto( + val key: String, + val label: String, + val board: String, + val overlay: String, + @Json(name = "allowed_overlays") val allowedOverlays: List = emptyList(), + @Json(name = "default_rules") val defaultRules: Map = emptyMap(), +) { + fun toDomain() = SportOption( + key = key, + label = label, + board = board, + overlay = overlay, + allowedOverlays = allowedOverlays, + defaultRules = defaultRules, + ) +} + data class TeamDto( val id: String, val name: String, val sport: String? = null, + @Json(name = "sport_key") val sportKey: String? = null, + @Json(name = "board_type") val boardType: String? = null, + @Json(name = "sport_label") val sportLabel: String? = null, @Json(name = "can_stream") val canStream: Boolean? = true, @Json(name = "club_name") val clubName: String? = null, @Json(name = "youtube_enabled") val youtubeEnabled: Boolean? = false, @@ -54,7 +76,9 @@ data class TeamDto( fun toDomain() = Team( id = id, name = name, - sport = sport ?: "volleyball", + sport = sportKey ?: sport ?: "pallavolo", + sportKey = sportKey ?: sport ?: "pallavolo", + boardType = boardType ?: "volley", canStream = canStream ?: true, clubName = clubName, logoUrl = logoUrl, @@ -82,6 +106,12 @@ data class MatchDto( val location: String? = null, @Json(name = "scheduled_at") val scheduledAt: String? = null, @Json(name = "sets_to_win") val setsToWin: Int? = null, + @Json(name = "sport_key") val sportKey: String? = null, + val sport: String? = null, + @Json(name = "sport_label") val sportLabel: String? = null, + @Json(name = "board_type") val boardType: String? = null, + @Json(name = "overlay_kind") val overlayKind: String? = null, + @Json(name = "effective_overlay_kind") val effectiveOverlayKind: String? = null, /** Campionato / descrizione torneo (facoltativo). */ val category: String? = null, @Json(name = "scoring_rules") val scoringRules: ScoringRulesBody? = null, @@ -103,6 +133,11 @@ data class MatchDto( location = location, scheduledAt = scheduledAt, setsToWin = setsToWin ?: 3, + sportKey = sportKey ?: sport ?: "pallavolo", + sportLabel = sportLabel, + boardType = boardType ?: "volley", + overlayKind = overlayKind, + effectiveOverlayKind = effectiveOverlayKind ?: overlayKind ?: "volley", category = category, scoringRules = scoringRules?.toDomain(), activeSessionId = activeSessionId, @@ -212,34 +247,51 @@ data class UpdateMatchBody( data class UpdateMatchRequest(val match: UpdateMatchBody) data class ScoringRulesBody( - @Json(name = "points_per_set") val pointsPerSet: Int, - @Json(name = "points_deciding_set") val pointsDecidingSet: Int, - @Json(name = "min_point_lead") val minPointLead: Int, + @Json(name = "sets_to_win") val setsToWin: Int? = null, + @Json(name = "points_per_set") val pointsPerSet: Int? = null, + @Json(name = "points_deciding_set") val pointsDecidingSet: Int? = null, + @Json(name = "min_point_lead") val minPointLead: Int? = null, + val periods: Int? = null, + @Json(name = "period_duration_secs") val periodDurationSecs: Int? = null, + @Json(name = "overtime_duration_secs") val overtimeDurationSecs: Int? = null, ) { fun toDomain() = ScoringRules( - pointsPerSet = pointsPerSet, - pointsDecidingSet = pointsDecidingSet, - minPointLead = minPointLead, + pointsPerSet = pointsPerSet ?: 25, + pointsDecidingSet = pointsDecidingSet ?: 15, + minPointLead = minPointLead ?: 2, + periods = periods ?: 4, + periodDurationSecs = periodDurationSecs ?: 600, + overtimeDurationSecs = overtimeDurationSecs ?: 300, ) } -fun ScoringRules.toBody() = ScoringRulesBody( +fun ScoringRules.toBody(setsToWin: Int? = null) = ScoringRulesBody( + setsToWin = setsToWin, pointsPerSet = pointsPerSet, pointsDecidingSet = pointsDecidingSet, minPointLead = minPointLead, + periods = periods, + periodDurationSecs = periodDurationSecs, + overtimeDurationSecs = overtimeDurationSecs, ) -fun ScoringRulesBody.toMap(): Map = mapOf( - "points_per_set" to pointsPerSet, - "points_deciding_set" to pointsDecidingSet, - "min_point_lead" to minPointLead, -) +fun ScoringRulesBody.toMap(): Map = buildMap { + setsToWin?.let { put("sets_to_win", it) } + pointsPerSet?.let { put("points_per_set", it) } + pointsDecidingSet?.let { put("points_deciding_set", it) } + minPointLead?.let { put("min_point_lead", it) } + periods?.let { put("periods", it) } + periodDurationSecs?.let { put("period_duration_secs", it) } + overtimeDurationSecs?.let { put("overtime_duration_secs", it) } +} data class UpdateMatchBodyFull( @Json(name = "opponent_name") val opponentName: String, val location: String? = null, @Json(name = "scheduled_at") val scheduledAt: String? = null, @Json(name = "sets_to_win") val setsToWin: Int, + @Json(name = "sport_key") val sportKey: String? = null, + @Json(name = "overlay_kind") val overlayKind: String? = null, val category: String? = null, @Json(name = "opponent_primary_color") val opponentPrimaryColor: String? = null, @Json(name = "scoring_rules") val scoringRules: Map? = null, diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt index dfaee82..8c32c16 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt @@ -23,6 +23,9 @@ interface MatchLiveApi { @POST("auth/logout") suspend fun logout() + @GET("sports") + suspend fun sports(): List + @GET("teams") suspend fun teams(): List diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt index 030305c..c03c891 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt @@ -15,6 +15,7 @@ import com.matchlivetv.match_live_tv.data.api.UpdateMatchRequestFull import com.matchlivetv.match_live_tv.data.api.UpdateTeamBody import com.matchlivetv.match_live_tv.data.api.UpdateTeamRequest import com.matchlivetv.match_live_tv.domain.Match +import com.matchlivetv.match_live_tv.domain.SportOption import com.matchlivetv.match_live_tv.domain.Team import com.matchlivetv.match_live_tv.domain.coachHubVisible import kotlinx.coroutines.flow.first @@ -25,6 +26,9 @@ class MatchRepository( private val tokenStore: TokenStore, private val appContext: Context, ) { + suspend fun fetchSports(): List = + api.sports().map { it.toDomain() } + suspend fun fetchTeams(): List = api.teams().map { it.toDomain() } @@ -115,6 +119,8 @@ class MatchRepository( category: String?, opponentPrimaryColor: String?, scoringRules: ScoringRulesBody?, + sportKey: String? = null, + overlayKind: String? = null, opponentLogoUri: Uri? = null, ): Match { val body = UpdateMatchBodyFull( @@ -122,9 +128,11 @@ class MatchRepository( location = location?.takeIf { it.isNotBlank() }, scheduledAt = scheduledAt, setsToWin = setsToWin, + sportKey = sportKey, + overlayKind = overlayKind, category = category?.takeIf { it.isNotBlank() }, opponentPrimaryColor = opponentPrimaryColor, - scoringRules = scoringRules?.toMap() ?: emptyMap(), + scoringRules = scoringRules?.toMap().takeIf { !it.isNullOrEmpty() }, ) if (opponentLogoUri != null) { return api.updateMatchMultipart( diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/MatchScoringRules.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/MatchScoringRules.kt index bdab095..d41c7db 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/MatchScoringRules.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/MatchScoringRules.kt @@ -10,6 +10,10 @@ data class MatchScoringRules( val pointsPerSet: Int = 25, val pointsDecidingSet: Int = 15, val minPointLead: Int = 2, + val periods: Int = 4, + val periodDurationSecs: Int = 600, + val overtimeDurationSecs: Int = 300, + val boardType: String = "volley", ) { val maxSets: Int get() = (setsToWin * 2) - 1 @@ -35,6 +39,18 @@ data class MatchScoringRules( } companion object { - fun fromMatch(match: Match) = MatchScoringRules(setsToWin = match.setsToWin) + fun fromMatch(match: Match): MatchScoringRules { + val rules = match.scoringRules + return MatchScoringRules( + setsToWin = match.setsToWin, + pointsPerSet = rules?.pointsPerSet ?: 25, + pointsDecidingSet = rules?.pointsDecidingSet ?: 15, + minPointLead = rules?.minPointLead ?: 2, + periods = rules?.periods ?: 4, + periodDurationSecs = rules?.periodDurationSecs ?: 600, + overtimeDurationSecs = rules?.overtimeDurationSecs ?: 300, + boardType = match.boardType, + ) + } } } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt index 81ddcda..ace84c1 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt @@ -12,10 +12,21 @@ data class AuthTokens( val refreshToken: String, ) +data class SportOption( + val key: String, + val label: String, + val board: String, + val overlay: String, + val allowedOverlays: List, + val defaultRules: Map = emptyMap(), +) + data class Team( val id: String, val name: String, val sport: String, + val sportKey: String = sport, + val boardType: String = "volley", val clubName: String? = null, val logoUrl: String? = null, val primaryColor: String? = null, @@ -59,6 +70,9 @@ data class ScoringRules( val pointsPerSet: Int = 25, val pointsDecidingSet: Int = 15, val minPointLead: Int = 2, + val periods: Int = 4, + val periodDurationSecs: Int = 600, + val overtimeDurationSecs: Int = 300, ) data class Match( @@ -68,6 +82,11 @@ data class Match( val opponentName: String, val location: String? = null, val scheduledAt: String? = null, + val sportKey: String = "pallavolo", + val sportLabel: String? = null, + val boardType: String = "volley", + val overlayKind: String? = null, + val effectiveOverlayKind: String = "volley", val setsToWin: Int = 3, /** Campionato / descrizione torneo (facoltativo). */ val category: String? = null, diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/CompactScoreboardElement.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/CompactScoreboardElement.kt new file mode 100644 index 0000000..4cd8089 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/CompactScoreboardElement.kt @@ -0,0 +1,61 @@ +package com.matchlivetv.match_live_tv.streaming.overlay + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Typeface +import android.text.TextPaint +import kotlin.math.roundToInt + +/** Tabellone compatto per basket, timed e sport a punteggio semplice. */ +class CompactScoreboardElement : OverlayElement { + + private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.argb(210, 12, 12, 12) + } + private val labelPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + } + private val metaPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#AAAAAA") + typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + } + private val scorePaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + textAlign = Paint.Align.CENTER + } + + override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) { + val compact = state.compactScoreboard ?: return + val kind = state.overlayKind + if (kind !in setOf(OverlayKind.BASKET, OverlayKind.TIMED, OverlayKind.RACKET, OverlayKind.VOLLEY)) return + + val w = (layout.canvasWidth * 0.42f).roundToInt().coerceAtLeast(220) + val h = (layout.canvasHeight * 0.11f).roundToInt().coerceAtLeast(72) + val left = layout.marginPx.toFloat() + val top = layout.marginPx.toFloat() + val rect = RectF(left, top, left + w, top + h) + canvas.drawRoundRect(rect, 12f, 12f, backgroundPaint) + + labelPaint.textSize = (h * 0.18f).coerceAtLeast(14f) + scorePaint.textSize = (h * 0.34f).coerceAtLeast(22f) + metaPaint.textSize = (h * 0.16f).coerceAtLeast(12f) + + val midY = rect.centerY() + canvas.drawText(compact.homeTeamName, rect.left + 16f, midY - 6f, labelPaint) + canvas.drawText(compact.awayTeamName, rect.left + 16f, midY + labelPaint.textSize + 4f, labelPaint) + canvas.drawText( + "${compact.homeScore} - ${compact.awayScore}", + rect.right - 56f, + midY + scorePaint.textSize * 0.35f, + scorePaint, + ) + val meta = listOfNotNull(compact.periodLabel, compact.clockText).joinToString(" · ") + if (meta.isNotBlank()) { + canvas.drawText(meta, rect.left + 16f, rect.bottom - 10f, metaPaint) + } + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayCanvasRenderer.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayCanvasRenderer.kt index ae79f7d..fa4d41e 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayCanvasRenderer.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayCanvasRenderer.kt @@ -15,6 +15,7 @@ class OverlayCanvasRenderer(context: Context) { private val elements: List = listOf( WatermarkElement(context), ScoreboardElement(), + CompactScoreboardElement(), TimerElement(), SponsorElement(), ) diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayMappings.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayMappings.kt index 9032ff6..4962b69 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayMappings.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayMappings.kt @@ -42,6 +42,13 @@ fun ScoreState.toScoreboardState( ) } +fun formatOverlayClock(secs: Int, countUp: Boolean = false): String { + if (secs <= 0 && !countUp) return "0:00" + val m = secs / 60 + val s = secs % 60 + return "%d:%02d".format(m, s) +} + fun BroadcastPhase.toOverlayStatus(): BroadcastOverlayStatus = when (this) { BroadcastPhase.LIVE -> BroadcastOverlayStatus.LIVE BroadcastPhase.PAUSED -> BroadcastOverlayStatus.PAUSED diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayState.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayState.kt index 3bdeb28..c7c1bbe 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayState.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayState.kt @@ -2,7 +2,7 @@ package com.matchlivetv.match_live_tv.streaming.overlay /** * Stato grafico dell'overlay video. Indipendente dalla logica sportiva: - * il tabellone è descritto da [ScoreboardState], non da regole di gioco. + * il tabellone è descritto da [ScoreboardState] / [CompactScoreboardState]. */ data class ScoreboardSetColumn( val setNumber: Int, @@ -21,6 +21,38 @@ data class ScoreboardState( val awayLogoUrl: String? = null, ) +data class CompactScoreboardState( + val homeTeamName: String, + val awayTeamName: String, + val homeScore: Int, + val awayScore: Int, + val periodLabel: String? = null, + val clockText: String? = null, + val homeAccentColor: Int = 0xFFFF2D2D.toInt(), + val awayAccentColor: Int = 0xFF1E3A8A.toInt(), +) + +enum class OverlayKind { + NONE, + TIMER, + VOLLEY, + BASKET, + TIMED, + RACKET, + ; + + companion object { + fun fromApi(value: String?): OverlayKind = when (value?.lowercase()) { + "none" -> NONE + "timer" -> TIMER + "basket" -> BASKET + "timed" -> TIMED + "racket" -> RACKET + else -> VOLLEY + } + } +} + enum class BroadcastOverlayStatus { PREVIEW, CONNECTING, @@ -29,18 +61,22 @@ enum class BroadcastOverlayStatus { } data class OverlayState( + val overlayKind: OverlayKind = OverlayKind.VOLLEY, val scoreboard: ScoreboardState? = null, + val compactScoreboard: CompactScoreboardState? = null, val watermarkVisible: Boolean = true, val broadcastStatus: BroadcastOverlayStatus = BroadcastOverlayStatus.LIVE, val timerText: String? = null, val sponsorText: String? = null, ) { fun fingerprint(): Int { - var result = watermarkVisible.hashCode() + var result = overlayKind.hashCode() + result = 31 * result + watermarkVisible.hashCode() result = 31 * result + broadcastStatus.hashCode() result = 31 * result + (timerText?.hashCode() ?: 0) result = 31 * result + (sponsorText?.hashCode() ?: 0) result = 31 * result + (scoreboard?.fingerprint() ?: 0) + result = 31 * result + (compactScoreboard?.fingerprint() ?: 0) return result } } @@ -63,4 +99,14 @@ private fun ScoreboardState.fingerprint(): Int { return result } +private fun CompactScoreboardState.fingerprint(): Int { + var result = homeTeamName.hashCode() + result = 31 * result + awayTeamName.hashCode() + result = 31 * result + homeScore + result = 31 * result + awayScore + result = 31 * result + (periodLabel?.hashCode() ?: 0) + result = 31 * result + (clockText?.hashCode() ?: 0) + return result +} + private fun logoLoadedKey(url: String?): Int = if (OverlayLogoCache.get(url) != null) 1 else 0 diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/ScoreboardElement.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/ScoreboardElement.kt index 20d78f8..9ce7a32 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/ScoreboardElement.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/ScoreboardElement.kt @@ -62,6 +62,7 @@ class ScoreboardElement : OverlayElement { private val liveRect = RectF() override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) { + if (state.overlayKind !in setOf(OverlayKind.VOLLEY, OverlayKind.RACKET)) return val board = state.scoreboard ?: return if (board.columns.isEmpty()) return diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/TimerElement.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/TimerElement.kt index 865c7e2..294cc28 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/TimerElement.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/TimerElement.kt @@ -14,6 +14,7 @@ class TimerElement : OverlayElement { } override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) { + if (state.overlayKind != OverlayKind.TIMER && state.timerText.isNullOrBlank()) return val text = state.timerText?.takeIf { it.isNotBlank() } ?: return paint.textSize = layout.canvasHeight * 0.05f val x = layout.marginPx.toFloat() diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/WatermarkElement.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/WatermarkElement.kt index 6bb2843..ab91228 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/WatermarkElement.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/WatermarkElement.kt @@ -16,6 +16,7 @@ class WatermarkElement(context: Context) : OverlayElement { } override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) { + if (state.overlayKind == OverlayKind.NONE) return if (!state.watermarkVisible) return val targetWidth = (layout.canvasWidth * LOGO_WIDTH_RATIO).roundToInt().coerceIn(28, 72) diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt index 572ba7a..a724165 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt @@ -39,8 +39,11 @@ import com.matchlivetv.match_live_tv.domain.StreamSession import com.matchlivetv.match_live_tv.streaming.BroadcastConfig import com.matchlivetv.match_live_tv.streaming.BroadcastPhase import com.matchlivetv.match_live_tv.streaming.LivePreviewView +import com.matchlivetv.match_live_tv.streaming.overlay.CompactScoreboardState +import com.matchlivetv.match_live_tv.streaming.overlay.OverlayKind import com.matchlivetv.match_live_tv.streaming.overlay.OverlayLogoCache import com.matchlivetv.match_live_tv.streaming.overlay.OverlayState +import com.matchlivetv.match_live_tv.streaming.overlay.formatOverlayClock import com.matchlivetv.match_live_tv.streaming.overlay.toOverlayStatus import com.matchlivetv.match_live_tv.streaming.overlay.toScoreboardState import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton @@ -219,23 +222,50 @@ fun BroadcastScreen( val currentMatch = match ?: return@LaunchedEffect val homeLogoUrl = resolveMediaUrl(currentMatch.homeLogoUrl) val awayLogoUrl = resolveMediaUrl(currentMatch.opponentLogoUrl) - container.broadcastCoordinator.updateOverlay( - OverlayState( + val overlayKind = OverlayKind.fromApi(currentMatch.effectiveOverlayKind) + val homeColor = parseColorHex(currentMatch.homePrimaryColor, 0xFFFF2D2D.toInt()) + val awayColor = parseColorHex(currentMatch.opponentPrimaryColor, 0xFF1E3A8A.toInt()) + val overlayState = when (overlayKind) { + OverlayKind.NONE -> OverlayState( + overlayKind = overlayKind, + watermarkVisible = false, + broadcastStatus = metrics.phase.toOverlayStatus(), + ) + OverlayKind.TIMER -> OverlayState( + overlayKind = overlayKind, + timerText = formatOverlayClock(score.homePoints, countUp = true), + watermarkVisible = true, + broadcastStatus = metrics.phase.toOverlayStatus(), + ) + OverlayKind.BASKET, OverlayKind.TIMED -> OverlayState( + overlayKind = overlayKind, + compactScoreboard = CompactScoreboardState( + homeTeamName = currentMatch.teamName, + awayTeamName = currentMatch.opponentName, + homeScore = score.homePoints, + awayScore = score.awayPoints, + periodLabel = "${score.currentSet}°", + homeAccentColor = homeColor, + awayAccentColor = awayColor, + ), + watermarkVisible = true, + broadcastStatus = metrics.phase.toOverlayStatus(), + ) + else -> OverlayState( + overlayKind = overlayKind, scoreboard = score.toScoreboardState( homeTeamName = currentMatch.teamName, awayTeamName = currentMatch.opponentName, - homeAccentColor = parseColorHex(currentMatch.homePrimaryColor, 0xFFFF2D2D.toInt()), - awayAccentColor = parseColorHex( - currentMatch.opponentPrimaryColor, - 0xFF1E3A8A.toInt(), - ), + homeAccentColor = homeColor, + awayAccentColor = awayColor, homeLogoUrl = homeLogoUrl, awayLogoUrl = awayLogoUrl, ), watermarkVisible = true, broadcastStatus = metrics.phase.toOverlayStatus(), - ), - ) + ) + } + container.broadcastCoordinator.updateOverlay(overlayState) } LaunchedEffect(Unit) { diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt index cb95bba..478c990 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt @@ -31,7 +31,9 @@ import com.matchlivetv.match_live_tv.core.normalizeHexColor import com.matchlivetv.match_live_tv.data.AppContainer import com.matchlivetv.match_live_tv.data.api.ScoringRulesBody import com.matchlivetv.match_live_tv.domain.Match +import com.matchlivetv.match_live_tv.domain.SportOption import com.matchlivetv.match_live_tv.domain.Team +import com.matchlivetv.match_live_tv.streaming.overlay.OverlayKind import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton import com.matchlivetv.match_live_tv.ui.matches.MatchOutlinedField @@ -111,6 +113,19 @@ fun StepMatchScreen( var pointsPerSetText by remember { mutableStateOf(pointsPerSet.toString()) } var pointsDecidingSetText by remember { mutableStateOf(pointsDecidingSet.toString()) } var saving by remember { mutableStateOf(false) } + var sports by remember { mutableStateOf>(emptyList()) } + var selectedSportKey by remember { mutableStateOf(match.sportKey) } + var selectedOverlay by remember { mutableStateOf(match.overlayKind ?: match.effectiveOverlayKind) } + + LaunchedEffect(Unit) { + runCatching { container.matchRepository.fetchSports() } + .onSuccess { sports = it } + } + + val currentSport = sports.find { it.key == selectedSportKey } + ?: sports.find { it.key == homeTeam?.sportKey } + val allowedOverlays = currentSport?.allowedOverlays.orEmpty() + val boardType = currentSport?.board ?: homeTeam?.boardType ?: match.boardType Column( Modifier @@ -174,6 +189,35 @@ fun StepMatchScreen( ) } + Spacer(Modifier.height(16.dp)) + WizardReadOnlyField( + label = "Sport", + value = currentSport?.label ?: match.sportLabel ?: selectedSportKey, + ) + if (allowedOverlays.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + Text("Overlay video", style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(8.dp)) + allowedOverlays.forEach { overlayKey -> + val selected = selectedOverlay == overlayKey + val label = OverlayKind.fromApi(overlayKey).name.lowercase() + .replaceFirstChar { it.uppercase() } + if (selected) { + MatchPrimaryButton( + label = label, + onClick = { selectedOverlay = overlayKey }, + modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp), + ) + } else { + MatchSecondaryButton( + label = label, + onClick = { selectedOverlay = overlayKey }, + modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp), + ) + } + } + } + Spacer(Modifier.height(20.dp)) Row( Modifier.fillMaxWidth(), @@ -195,7 +239,7 @@ fun StepMatchScreen( Switch(checked = customRules, onCheckedChange = { customRules = it }) } - if (customRules) { + if (customRules && boardType in setOf("volley", "racket")) { Spacer(Modifier.height(16.dp)) Text("Set da vincere la partita", style = MaterialTheme.typography.bodyMedium) Spacer(Modifier.height(8.dp)) @@ -262,8 +306,9 @@ fun StepMatchScreen( } val resolvedSets = if (customRules) setsToWin else DEFAULT_SETS_TO_WIN - val resolvedRules: ScoringRulesBody? = if (customRules) { + val resolvedRules: ScoringRulesBody? = if (customRules && boardType in setOf("volley", "racket")) { ScoringRulesBody( + setsToWin = resolvedSets, pointsPerSet = pointsPerSet, pointsDecidingSet = pointsDecidingSet, minPointLead = existingRules?.minPointLead ?: DEFAULT_MIN_POINT_LEAD, @@ -298,6 +343,10 @@ fun StepMatchScreen( category = campionato.trim(), opponentPrimaryColor = opponentPrimaryColor, scoringRules = resolvedRules, + sportKey = selectedSportKey, + overlayKind = selectedOverlay.takeIf { + it != currentSport?.overlay + }, opponentLogoUri = opponentLogoUri, ) }.onSuccess { updated ->