Introduce architettura multi-sport con catalogo, engine scoring e overlay.
Catalogo sport in YAML, board implicito, engine per volley/basket/timed/racket/timer/generic, regia e app Android allineate. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -16,7 +16,10 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
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)
|
attach_opponent_logo(match)
|
||||||
render json: match_json(match), status: :created
|
render json: match_json(match), status: :created
|
||||||
end
|
end
|
||||||
@@ -59,11 +62,15 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
def match_params
|
def match_params
|
||||||
params.require(:match).permit(
|
p = params.require(:match).permit(
|
||||||
:opponent_name, :location, :scheduled_at, :sport, :sets_to_win,
|
:opponent_name, :location, :scheduled_at, :sport, :sport_key, :sets_to_win,
|
||||||
:category, :opponent_primary_color,
|
:category, :opponent_primary_color, :overlay_kind,
|
||||||
scoring_rules: %i[points_per_set points_deciding_set min_point_lead]
|
scoring_rules: {}
|
||||||
)
|
)
|
||||||
|
if p[:sport].present? && p[:sport_key].blank?
|
||||||
|
p[:sport_key] = p.delete(:sport)
|
||||||
|
end
|
||||||
|
p
|
||||||
end
|
end
|
||||||
|
|
||||||
def normalize_scoring_rules!(attrs)
|
def normalize_scoring_rules!(attrs)
|
||||||
@@ -71,6 +78,9 @@ module Api
|
|||||||
|
|
||||||
rules = attrs["scoring_rules"]
|
rules = attrs["scoring_rules"]
|
||||||
attrs["scoring_rules"] = {} if rules.blank?
|
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
|
end
|
||||||
|
|
||||||
def normalize_opponent_color!(attrs)
|
def normalize_opponent_color!(attrs)
|
||||||
@@ -97,9 +107,15 @@ module Api
|
|||||||
opponent_name: match.opponent_name,
|
opponent_name: match.opponent_name,
|
||||||
location: match.location,
|
location: match.location,
|
||||||
scheduled_at: match.scheduled_at,
|
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,
|
sets_to_win: match.sets_to_win,
|
||||||
scoring_rules: match.scoring_rules.presence,
|
scoring_rules: match.scoring_rules.presence,
|
||||||
|
effective_scoring_rules: match.effective_scoring_rules,
|
||||||
category: match.category,
|
category: match.category,
|
||||||
home_primary_color: team.effective_primary_color,
|
home_primary_color: team.effective_primary_color,
|
||||||
home_secondary_color: team.effective_secondary_color,
|
home_secondary_color: team.effective_secondary_color,
|
||||||
|
|||||||
9
backend/app/controllers/api/v1/sports_controller.rb
Normal file
9
backend/app/controllers/api/v1/sports_controller.rb
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class SportsController < BaseController
|
||||||
|
def index
|
||||||
|
render json: Sports::Catalog.as_api_list
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -21,10 +21,11 @@ module Api
|
|||||||
}, status: :unprocessable_entity
|
}, status: :unprocessable_entity
|
||||||
end
|
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")
|
primary_color: "#e53935", secondary_color: "#ffffff")
|
||||||
ClubMembership.create!(user: current_user, club: club, role: "owner")
|
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")
|
Billing::AssignPlan.call(club: club, plan_slug: "free")
|
||||||
render json: team_json(team), status: :created
|
render json: team_json(team), status: :created
|
||||||
end
|
end
|
||||||
@@ -74,7 +75,11 @@ module Api
|
|||||||
private
|
private
|
||||||
|
|
||||||
def team_params
|
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?
|
if p[:primary_color].present?
|
||||||
p[:primary_color] = normalize_hex_color(p[:primary_color], p[:primary_color])
|
p[:primary_color] = normalize_hex_color(p[:primary_color], p[:primary_color])
|
||||||
end
|
end
|
||||||
@@ -96,7 +101,10 @@ module Api
|
|||||||
data = {
|
data = {
|
||||||
id: team.id,
|
id: team.id,
|
||||||
name: team.name,
|
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),
|
logo_url: api_absolute_url(team.effective_logo_url),
|
||||||
primary_color: team.effective_primary_color,
|
primary_color: team.effective_primary_color,
|
||||||
secondary_color: team.effective_secondary_color,
|
secondary_color: team.effective_secondary_color,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
module Public
|
module Public
|
||||||
class RegiaController < SiteBaseController
|
class RegiaController < SiteBaseController
|
||||||
include Public::LiveHelper
|
include Public::LiveHelper
|
||||||
|
include Public::RegiaHelper
|
||||||
include MediamtxPlayback
|
include MediamtxPlayback
|
||||||
|
|
||||||
layout "regia"
|
layout "regia"
|
||||||
@@ -13,9 +14,7 @@ module Public
|
|||||||
def show
|
def show
|
||||||
@match = @session.match
|
@match = @session.match
|
||||||
@team = @match.team
|
@team = @match.team
|
||||||
@score = @session.score_state || @session.create_score_state!(
|
@score = @session.score_state || Scoring::Engine.ensure_score_for(@session)
|
||||||
home_sets: 0, away_sets: 0, home_points: 0, away_points: 0, current_set: 1, set_partials: []
|
|
||||||
)
|
|
||||||
@rules = Scoring::Rules.from_match(@match)
|
@rules = Scoring::Rules.from_match(@match)
|
||||||
@stream_closed = @session.terminal?
|
@stream_closed = @session.terminal?
|
||||||
@on_air = !@stream_closed && mediamtx_stream_playable?(@session)
|
@on_air = !@stream_closed && mediamtx_stream_playable?(@session)
|
||||||
|
|||||||
11
backend/app/domain/sports/board_type.rb
Normal file
11
backend/app/domain/sports/board_type.rb
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Sports
|
||||||
|
class BoardType
|
||||||
|
ALL = %w[volley basket timed racket timer generic].freeze
|
||||||
|
|
||||||
|
def self.valid?(value)
|
||||||
|
ALL.include?(value.to_s)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
94
backend/app/domain/sports/catalog.rb
Normal file
94
backend/app/domain/sports/catalog.rb
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Sports
|
||||||
|
class Catalog
|
||||||
|
LEGACY_SPORT_MAP = {
|
||||||
|
"volleyball" => "pallavolo"
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def all
|
||||||
|
@all ||= load_entries
|
||||||
|
end
|
||||||
|
|
||||||
|
def keys
|
||||||
|
all.keys
|
||||||
|
end
|
||||||
|
|
||||||
|
def find(key)
|
||||||
|
normalized = normalize_key(key)
|
||||||
|
entry = all[normalized]
|
||||||
|
raise KeyError, "Sport sconosciuto: #{key}" unless entry
|
||||||
|
|
||||||
|
entry.merge(key: normalized)
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_optional(key)
|
||||||
|
normalized = normalize_key(key)
|
||||||
|
entry = all[normalized]
|
||||||
|
return nil unless entry
|
||||||
|
|
||||||
|
entry.merge(key: normalized)
|
||||||
|
end
|
||||||
|
|
||||||
|
def board_for(key)
|
||||||
|
find(key)[:board]
|
||||||
|
end
|
||||||
|
|
||||||
|
def overlay_for(key)
|
||||||
|
find(key)[:overlay]
|
||||||
|
end
|
||||||
|
|
||||||
|
def allowed_overlays_for(key)
|
||||||
|
find(key)[:allowed_overlays]
|
||||||
|
end
|
||||||
|
|
||||||
|
def default_rules_for(key)
|
||||||
|
deep_stringify_keys(find(key)[:default_rules] || {})
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_key(key)
|
||||||
|
raw = key.to_s.strip
|
||||||
|
return "pallavolo" if raw.blank?
|
||||||
|
|
||||||
|
LEGACY_SPORT_MAP.fetch(raw, raw)
|
||||||
|
end
|
||||||
|
|
||||||
|
def as_api_list
|
||||||
|
all.map do |key, entry|
|
||||||
|
{
|
||||||
|
key: key,
|
||||||
|
label: entry[:label],
|
||||||
|
board: entry[:board],
|
||||||
|
overlay: entry[:overlay],
|
||||||
|
allowed_overlays: entry[:allowed_overlays],
|
||||||
|
default_rules: entry[:default_rules] || {}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def load_entries
|
||||||
|
path = Rails.root.join("config/sports.yml")
|
||||||
|
raw = YAML.safe_load(File.read(path), permitted_classes: [], aliases: true) || {}
|
||||||
|
raw.transform_keys(&:to_s).transform_values do |entry|
|
||||||
|
entry.deep_symbolize_keys
|
||||||
|
end.freeze
|
||||||
|
end
|
||||||
|
|
||||||
|
def deep_stringify_keys(value)
|
||||||
|
case value
|
||||||
|
when Hash
|
||||||
|
value.each_with_object({}) do |(k, v), h|
|
||||||
|
h[k.to_s] = deep_stringify_keys(v)
|
||||||
|
end
|
||||||
|
when Array
|
||||||
|
value.map { |v| deep_stringify_keys(v) }
|
||||||
|
else
|
||||||
|
value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
11
backend/app/domain/sports/overlay_kind.rb
Normal file
11
backend/app/domain/sports/overlay_kind.rb
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Sports
|
||||||
|
class OverlayKind
|
||||||
|
ALL = %w[none timer volley basket timed racket].freeze
|
||||||
|
|
||||||
|
def self.valid?(value)
|
||||||
|
ALL.include?(value.to_s)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
38
backend/app/domain/sports/rules_schema.rb
Normal file
38
backend/app/domain/sports/rules_schema.rb
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Sports
|
||||||
|
class RulesSchema
|
||||||
|
INTEGER_RULES = {
|
||||||
|
"volley" => %w[sets_to_win points_per_set points_deciding_set min_point_lead],
|
||||||
|
"racket" => %w[sets_to_win points_per_set points_deciding_set min_point_lead],
|
||||||
|
"basket" => %w[periods period_duration_secs overtime_duration_secs],
|
||||||
|
"timed" => %w[periods period_duration_secs overtime_duration_secs],
|
||||||
|
"timer" => [],
|
||||||
|
"generic" => []
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
def self.validate!(board_type, rules)
|
||||||
|
board = board_type.to_s
|
||||||
|
return if board == "generic" || board == "timer"
|
||||||
|
|
||||||
|
hash = rules.is_a?(Hash) ? rules : {}
|
||||||
|
INTEGER_RULES.fetch(board, []).each do |key|
|
||||||
|
val = hash[key] || hash[key.to_sym]
|
||||||
|
next if val.blank?
|
||||||
|
|
||||||
|
unless val.is_a?(Integer) || val.to_s.match?(/\A\d+\z/)
|
||||||
|
raise ArgumentError, "#{key} non valido"
|
||||||
|
end
|
||||||
|
int_val = val.to_i
|
||||||
|
raise ArgumentError, "#{key} deve essere positivo" if int_val < 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.validate(board_type, rules)
|
||||||
|
validate!(board_type, rules)
|
||||||
|
true
|
||||||
|
rescue ArgumentError
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,13 +1,30 @@
|
|||||||
module Public
|
module Public
|
||||||
module LiveHelper
|
module LiveHelper
|
||||||
def live_score_sets_label(score_state)
|
def live_score_sets_label(score_state, match = nil)
|
||||||
return nil unless score_state
|
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
|
end
|
||||||
|
|
||||||
def live_score_partials_label(score_state)
|
def live_score_partials_label(score_state)
|
||||||
return nil unless 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)
|
partials = Array(score_state.set_partials)
|
||||||
return nil if partials.empty?
|
return nil if partials.empty?
|
||||||
@@ -58,7 +75,17 @@ module Public
|
|||||||
def live_score_points_label(match, score_state)
|
def live_score_points_label(match, score_state)
|
||||||
return "—" unless 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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
32
backend/app/helpers/public/regia_helper.rb
Normal file
32
backend/app/helpers/public/regia_helper.rb
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
module Public
|
||||||
|
module RegiaHelper
|
||||||
|
def regia_board_partial(match)
|
||||||
|
board = match.effective_board_type
|
||||||
|
partial = "public/regia/#{board}"
|
||||||
|
lookup_context.exists?(partial, [], true) ? partial : "public/regia/volley"
|
||||||
|
end
|
||||||
|
|
||||||
|
def regia_header_subtitle(match, score)
|
||||||
|
board = match.effective_board_type
|
||||||
|
case board
|
||||||
|
when "basket", "timed"
|
||||||
|
live_score_period_label(match, score)
|
||||||
|
when "timer"
|
||||||
|
"Cronometro"
|
||||||
|
when "generic"
|
||||||
|
"Punteggio"
|
||||||
|
else
|
||||||
|
"Set #{score.current_set}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_clock_secs(secs, count_up: false)
|
||||||
|
total = secs.to_i
|
||||||
|
return count_up ? "0:00" : "—" if total <= 0 && !count_up
|
||||||
|
|
||||||
|
m = total / 60
|
||||||
|
s = total % 60
|
||||||
|
format("%d:%02d", m, s)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -9,13 +9,16 @@ class Match < ApplicationRecord
|
|||||||
|
|
||||||
validates :opponent_name, presence: true
|
validates :opponent_name, presence: true
|
||||||
validates :sets_to_win, numericality: { greater_than: 0, less_than: 6 }
|
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
|
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? }
|
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, -> {
|
scope :scheduled_for_live, -> {
|
||||||
where.not(scheduled_at: nil).where("scheduled_at >= ?", Time.zone.now)
|
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
|
opponent_primary_color.presence || DEFAULT_OPPONENT_COLOR
|
||||||
end
|
end
|
||||||
|
|
||||||
# Hub app coach: dirette da riprendere o partite programmate future.
|
|
||||||
# Le bozze «Avversario» abbandonate (senza sessione) non compaiono.
|
|
||||||
def coach_hub_visible?
|
def coach_hub_visible?
|
||||||
active = active_stream_session
|
active = active_stream_session
|
||||||
return true if active&.resumable?
|
return true if active&.resumable?
|
||||||
@@ -90,8 +91,72 @@ class Match < ApplicationRecord
|
|||||||
scheduled_upcoming?
|
scheduled_upcoming?
|
||||||
end
|
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
|
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
|
def opponent_logo_file_type
|
||||||
return if opponent_logo_file.content_type.in?(%w[image/png image/jpeg image/webp])
|
return if opponent_logo_file.content_type.in?(%w[image/png image/jpeg image/webp])
|
||||||
|
|
||||||
@@ -99,17 +164,8 @@ class Match < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def scoring_rules_shape
|
def scoring_rules_shape
|
||||||
rules = scoring_rules.is_a?(Hash) ? scoring_rules : {}
|
Sports::RulesSchema.validate!(effective_board_type, normalized_scoring_rules)
|
||||||
%w[points_per_set points_deciding_set min_point_lead].each do |key|
|
rescue ArgumentError => e
|
||||||
val = rules[key] || rules[key.to_sym]
|
errors.add(:scoring_rules, e.message)
|
||||||
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
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
class ScoreState < ApplicationRecord
|
class ScoreState < ApplicationRecord
|
||||||
belongs_to :stream_session
|
belongs_to :stream_session
|
||||||
|
|
||||||
|
before_validation :sync_board_type_from_match, on: :create
|
||||||
|
|
||||||
def as_cable_payload
|
def as_cable_payload
|
||||||
{
|
payload = {
|
||||||
type: "score_update",
|
type: "score_update",
|
||||||
|
board_type: effective_board_type,
|
||||||
home_sets: home_sets,
|
home_sets: home_sets,
|
||||||
away_sets: away_sets,
|
away_sets: away_sets,
|
||||||
home_points: home_points,
|
home_points: home_points,
|
||||||
@@ -11,7 +14,74 @@ class ScoreState < ApplicationRecord
|
|||||||
current_set: current_set,
|
current_set: current_set,
|
||||||
set_partials: set_partials || [],
|
set_partials: set_partials || [],
|
||||||
timeout_home: timeout_home,
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -12,8 +12,12 @@ class Team < ApplicationRecord
|
|||||||
has_one_attached :photo_file
|
has_one_attached :photo_file
|
||||||
|
|
||||||
validates :name, presence: true
|
validates :name, presence: true
|
||||||
|
validates :sport_key, presence: true
|
||||||
|
validate :sport_key_known
|
||||||
validate :photo_file_type, if: -> { photo_file.attached? }
|
validate :photo_file_type, if: -> { photo_file.attached? }
|
||||||
|
|
||||||
|
before_validation :normalize_sport_key
|
||||||
|
|
||||||
def subscription
|
def subscription
|
||||||
club.subscription
|
club.subscription
|
||||||
end
|
end
|
||||||
@@ -43,8 +47,35 @@ class Team < ApplicationRecord
|
|||||||
end
|
end
|
||||||
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
|
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
|
def photo_file_type
|
||||||
return if photo_file.content_type.in?(%w[image/png image/jpeg image/webp])
|
return if photo_file.content_type.in?(%w[image/png image/jpeg image/webp])
|
||||||
|
|
||||||
|
|||||||
@@ -6,105 +6,10 @@ module Scoring
|
|||||||
@session = session
|
@session = session
|
||||||
@match = session.match
|
@match = session.match
|
||||||
@action = action.to_s
|
@action = action.to_s
|
||||||
@rules = Rules.from_match(@match)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def call
|
def call
|
||||||
score = @session.score_state || @session.create_score_state!(
|
Engine.for(@match).apply(session: @session, action: @action)
|
||||||
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)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
26
backend/app/services/scoring/engine.rb
Normal file
26
backend/app/services/scoring/engine.rb
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
module Scoring
|
||||||
|
class Engine
|
||||||
|
ENGINES = {
|
||||||
|
"volley" => Engines::VolleyEngine,
|
||||||
|
"racket" => Engines::RacketEngine,
|
||||||
|
"generic" => Engines::GenericEngine,
|
||||||
|
"basket" => Engines::BasketEngine,
|
||||||
|
"timed" => Engines::TimedEngine,
|
||||||
|
"timer" => Engines::TimerEngine
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
def self.for(match)
|
||||||
|
board = match.effective_board_type
|
||||||
|
klass = ENGINES.fetch(board) { Engines::GenericEngine }
|
||||||
|
klass.new(match: match)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.for_session(session)
|
||||||
|
for(session.match)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.ensure_score_for(session)
|
||||||
|
for(session.match).ensure_score(session)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
45
backend/app/services/scoring/engines/base_engine.rb
Normal file
45
backend/app/services/scoring/engines/base_engine.rb
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
module Scoring
|
||||||
|
module Engines
|
||||||
|
class BaseEngine
|
||||||
|
Result = Scoring::ApplyAction::Result
|
||||||
|
|
||||||
|
def initialize(match:)
|
||||||
|
@match = match
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply(session:, action:)
|
||||||
|
raise NotImplementedError
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync(session:, payload:)
|
||||||
|
raise NotImplementedError
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_score(session)
|
||||||
|
board = @match.effective_board_type
|
||||||
|
score = session.score_state
|
||||||
|
return score if score
|
||||||
|
|
||||||
|
attrs = {
|
||||||
|
board_type: board,
|
||||||
|
home_sets: 0,
|
||||||
|
away_sets: 0,
|
||||||
|
home_points: 0,
|
||||||
|
away_points: 0,
|
||||||
|
current_set: 1,
|
||||||
|
set_partials: [],
|
||||||
|
data: default_data
|
||||||
|
}
|
||||||
|
session.create_score_state!(attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
def default_data
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast(session, score)
|
||||||
|
SessionChannel.broadcast_message(session, score.as_cable_payload)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
32
backend/app/services/scoring/engines/basket_engine.rb
Normal file
32
backend/app/services/scoring/engines/basket_engine.rb
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
module Scoring
|
||||||
|
module Engines
|
||||||
|
class BasketEngine < BaseEngine
|
||||||
|
include PeriodEngineMixin
|
||||||
|
|
||||||
|
def apply(session:, action:)
|
||||||
|
score = ensure_score(session)
|
||||||
|
|
||||||
|
case action.to_s
|
||||||
|
when "home_point" then adjust_score(score, :home, 1)
|
||||||
|
when "away_point" then adjust_score(score, :away, 1)
|
||||||
|
when "home_undo" then adjust_score(score, :home, -1)
|
||||||
|
when "away_undo" then adjust_score(score, :away, -1)
|
||||||
|
when "home_point_2" then adjust_score(score, :home, 2)
|
||||||
|
when "away_point_2" then adjust_score(score, :away, 2)
|
||||||
|
when "home_point_3" then adjust_score(score, :home, 3)
|
||||||
|
when "away_point_3" then adjust_score(score, :away, 3)
|
||||||
|
when "advance_period" then advance_period(score)
|
||||||
|
when "clock_toggle" then toggle_clock(score)
|
||||||
|
when "clock_reset" then reset_clock(score)
|
||||||
|
when "clock_tick" then tick_clock(score)
|
||||||
|
else
|
||||||
|
raise ArgumentError, "Azione non valida"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync(session:, payload:)
|
||||||
|
sync_period(session: session, payload: payload.stringify_keys)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
42
backend/app/services/scoring/engines/generic_engine.rb
Normal file
42
backend/app/services/scoring/engines/generic_engine.rb
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
module Scoring
|
||||||
|
module Engines
|
||||||
|
class GenericEngine < BaseEngine
|
||||||
|
def apply(session:, action:)
|
||||||
|
score = ensure_score(session)
|
||||||
|
|
||||||
|
case action.to_s
|
||||||
|
when "home_point" then adjust(score, :home, 1)
|
||||||
|
when "away_point" then adjust(score, :away, 1)
|
||||||
|
when "home_undo" then adjust(score, :home, -1)
|
||||||
|
when "away_undo" then adjust(score, :away, -1)
|
||||||
|
else
|
||||||
|
raise ArgumentError, "Azione non valida"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync(session:, payload:)
|
||||||
|
score = ensure_score(session)
|
||||||
|
attrs = {}
|
||||||
|
%w[home_points away_points].each do |key|
|
||||||
|
attrs[key] = payload[key] if payload.key?(key)
|
||||||
|
end
|
||||||
|
score.update!(attrs)
|
||||||
|
broadcast(session, score)
|
||||||
|
score
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def adjust(score, side, delta)
|
||||||
|
if side == :home
|
||||||
|
score.home_points = [score.home_points + delta, 0].max
|
||||||
|
else
|
||||||
|
score.away_points = [score.away_points + delta, 0].max
|
||||||
|
end
|
||||||
|
score.save!
|
||||||
|
broadcast(score.stream_session, score)
|
||||||
|
Result.new(score: score, set_won: false, match_won: false, winner: nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
117
backend/app/services/scoring/engines/period_engine_mixin.rb
Normal file
117
backend/app/services/scoring/engines/period_engine_mixin.rb
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
module Scoring
|
||||||
|
module Engines
|
||||||
|
module PeriodEngineMixin
|
||||||
|
private
|
||||||
|
|
||||||
|
def period_rules
|
||||||
|
@period_rules ||= Rules.from_match(@match)
|
||||||
|
end
|
||||||
|
|
||||||
|
def period_data(score)
|
||||||
|
score.data.is_a?(Hash) ? score.data.deep_dup : {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def default_data
|
||||||
|
rules = period_rules
|
||||||
|
{
|
||||||
|
"period" => 1,
|
||||||
|
"clock_secs" => rules.period_duration_secs,
|
||||||
|
"clock_running" => false,
|
||||||
|
"home_score" => 0,
|
||||||
|
"away_score" => 0,
|
||||||
|
"overtime" => false
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_period_data(score)
|
||||||
|
data = period_data(score)
|
||||||
|
default_data.each { |k, v| data[k] = v unless data.key?(k) }
|
||||||
|
data
|
||||||
|
end
|
||||||
|
|
||||||
|
def save_period_score!(score, data)
|
||||||
|
score.assign_attributes(
|
||||||
|
data: data,
|
||||||
|
home_points: data["home_score"].to_i,
|
||||||
|
away_points: data["away_score"].to_i,
|
||||||
|
period: period_label(data)
|
||||||
|
)
|
||||||
|
score.save!
|
||||||
|
broadcast(score.stream_session, score)
|
||||||
|
end
|
||||||
|
|
||||||
|
def period_label(data)
|
||||||
|
period = data["period"].to_i
|
||||||
|
return "OT" if data["overtime"]
|
||||||
|
|
||||||
|
board = @match.effective_board_type
|
||||||
|
board == "basket" ? "Q#{period}" : "#{period}° tempo"
|
||||||
|
end
|
||||||
|
|
||||||
|
def adjust_score(score, side, delta)
|
||||||
|
data = ensure_period_data(score)
|
||||||
|
key = side == :home ? "home_score" : "away_score"
|
||||||
|
data[key] = [data[key].to_i + delta, 0].max
|
||||||
|
save_period_score!(score, data)
|
||||||
|
Result.new(score: score, set_won: false, match_won: false, winner: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def advance_period(score)
|
||||||
|
data = ensure_period_data(score)
|
||||||
|
rules = period_rules
|
||||||
|
max_periods = rules.periods
|
||||||
|
|
||||||
|
if data["overtime"]
|
||||||
|
data["period"] = data["period"].to_i + 1
|
||||||
|
data["clock_secs"] = rules.overtime_duration_secs
|
||||||
|
elsif data["period"].to_i >= max_periods
|
||||||
|
data["overtime"] = true
|
||||||
|
data["period"] = max_periods + 1
|
||||||
|
data["clock_secs"] = rules.overtime_duration_secs
|
||||||
|
else
|
||||||
|
data["period"] = data["period"].to_i + 1
|
||||||
|
data["clock_secs"] = rules.period_duration_secs
|
||||||
|
end
|
||||||
|
data["clock_running"] = false
|
||||||
|
save_period_score!(score, data)
|
||||||
|
Result.new(score: score, set_won: false, match_won: false, winner: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def toggle_clock(score)
|
||||||
|
data = ensure_period_data(score)
|
||||||
|
data["clock_running"] = !data["clock_running"]
|
||||||
|
save_period_score!(score, data)
|
||||||
|
Result.new(score: score, set_won: false, match_won: false, winner: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def reset_clock(score)
|
||||||
|
data = ensure_period_data(score)
|
||||||
|
rules = period_rules
|
||||||
|
duration = data["overtime"] ? rules.overtime_duration_secs : rules.period_duration_secs
|
||||||
|
data["clock_secs"] = duration
|
||||||
|
data["clock_running"] = false
|
||||||
|
save_period_score!(score, data)
|
||||||
|
Result.new(score: score, set_won: false, match_won: false, winner: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def tick_clock(score, secs: 1)
|
||||||
|
data = ensure_period_data(score)
|
||||||
|
return Result.new(score: score, set_won: false, match_won: false, winner: nil) unless data["clock_running"]
|
||||||
|
|
||||||
|
data["clock_secs"] = [data["clock_secs"].to_i - secs, 0].max
|
||||||
|
save_period_score!(score, data)
|
||||||
|
Result.new(score: score, set_won: false, match_won: false, winner: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync_period(session:, payload:)
|
||||||
|
score = ensure_score(session)
|
||||||
|
data = ensure_period_data(score)
|
||||||
|
%w[period clock_secs clock_running home_score away_score overtime].each do |key|
|
||||||
|
data[key] = payload[key] if payload.key?(key)
|
||||||
|
end
|
||||||
|
save_period_score!(score, data)
|
||||||
|
score
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
7
backend/app/services/scoring/engines/racket_engine.rb
Normal file
7
backend/app/services/scoring/engines/racket_engine.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
module Scoring
|
||||||
|
module Engines
|
||||||
|
# Fase 1: stessa logica del volley (set + punti).
|
||||||
|
class RacketEngine < VolleyEngine
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
28
backend/app/services/scoring/engines/timed_engine.rb
Normal file
28
backend/app/services/scoring/engines/timed_engine.rb
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
module Scoring
|
||||||
|
module Engines
|
||||||
|
class TimedEngine < BaseEngine
|
||||||
|
include PeriodEngineMixin
|
||||||
|
|
||||||
|
def apply(session:, action:)
|
||||||
|
score = ensure_score(session)
|
||||||
|
|
||||||
|
case action.to_s
|
||||||
|
when "home_point" then adjust_score(score, :home, 1)
|
||||||
|
when "away_point" then adjust_score(score, :away, 1)
|
||||||
|
when "home_undo" then adjust_score(score, :home, -1)
|
||||||
|
when "away_undo" then adjust_score(score, :away, -1)
|
||||||
|
when "advance_period" then advance_period(score)
|
||||||
|
when "clock_toggle" then toggle_clock(score)
|
||||||
|
when "clock_reset" then reset_clock(score)
|
||||||
|
when "clock_tick" then tick_clock(score)
|
||||||
|
else
|
||||||
|
raise ArgumentError, "Azione non valida"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync(session:, payload:)
|
||||||
|
sync_period(session: session, payload: payload.stringify_keys)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
49
backend/app/services/scoring/engines/timer_engine.rb
Normal file
49
backend/app/services/scoring/engines/timer_engine.rb
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
module Scoring
|
||||||
|
module Engines
|
||||||
|
class TimerEngine < BaseEngine
|
||||||
|
def apply(session:, action:)
|
||||||
|
score = ensure_score(session)
|
||||||
|
data = timer_data(score)
|
||||||
|
|
||||||
|
case action.to_s
|
||||||
|
when "clock_toggle"
|
||||||
|
data["clock_running"] = !data["clock_running"]
|
||||||
|
when "clock_reset"
|
||||||
|
data["clock_secs"] = 0
|
||||||
|
data["clock_running"] = false
|
||||||
|
when "clock_tick"
|
||||||
|
data["clock_secs"] = data["clock_secs"].to_i + 1 if data["clock_running"]
|
||||||
|
else
|
||||||
|
raise ArgumentError, "Azione non valida"
|
||||||
|
end
|
||||||
|
|
||||||
|
score.update!(data: data)
|
||||||
|
broadcast(session, score)
|
||||||
|
Result.new(score: score, set_won: false, match_won: false, winner: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync(session:, payload:)
|
||||||
|
score = ensure_score(session)
|
||||||
|
data = timer_data(score)
|
||||||
|
%w[clock_secs clock_running].each do |key|
|
||||||
|
data[key] = payload[key] if payload.key?(key)
|
||||||
|
end
|
||||||
|
score.update!(data: data)
|
||||||
|
broadcast(session, score)
|
||||||
|
score
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def default_data
|
||||||
|
{ "clock_secs" => 0, "clock_running" => false }
|
||||||
|
end
|
||||||
|
|
||||||
|
def timer_data(score)
|
||||||
|
base = default_data
|
||||||
|
stored = score.data.is_a?(Hash) ? score.data : {}
|
||||||
|
base.merge(stored)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
105
backend/app/services/scoring/engines/volley_engine.rb
Normal file
105
backend/app/services/scoring/engines/volley_engine.rb
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
module Scoring
|
||||||
|
module Engines
|
||||||
|
class VolleyEngine < BaseEngine
|
||||||
|
def apply(session:, action:)
|
||||||
|
@rules = Rules.from_match(@match)
|
||||||
|
score = ensure_score(session)
|
||||||
|
|
||||||
|
case action.to_s
|
||||||
|
when "home_point" then apply_point(score, :home)
|
||||||
|
when "away_point" then apply_point(score, :away)
|
||||||
|
when "home_undo" then apply_undo(score, :home)
|
||||||
|
when "away_undo" then apply_undo(score, :away)
|
||||||
|
when "close_set" then apply_close_set(score)
|
||||||
|
else
|
||||||
|
raise ArgumentError, "Azione non valida"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync(session:, payload:)
|
||||||
|
score = ensure_score(session)
|
||||||
|
attrs = {}
|
||||||
|
%w[home_sets away_sets home_points away_points current_set].each do |key|
|
||||||
|
attrs[key] = payload[key] if payload.key?(key)
|
||||||
|
end
|
||||||
|
attrs[:set_partials] = payload["set_partials"] if payload.key?("set_partials")
|
||||||
|
score.update!(attrs)
|
||||||
|
broadcast(session, score)
|
||||||
|
score
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def apply_point(score, side)
|
||||||
|
if side == :home
|
||||||
|
score.home_points += 1
|
||||||
|
else
|
||||||
|
score.away_points += 1
|
||||||
|
end
|
||||||
|
score.save!
|
||||||
|
broadcast(score.stream_session, score)
|
||||||
|
winner = @rules.set_winner_from_points(
|
||||||
|
home_points: score.home_points,
|
||||||
|
away_points: score.away_points,
|
||||||
|
current_set: score.current_set
|
||||||
|
)
|
||||||
|
Result.new(score: score, set_won: winner.present?, match_won: false, winner: winner)
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply_undo(score, side)
|
||||||
|
if side == :home
|
||||||
|
score.home_points = [score.home_points - 1, 0].max
|
||||||
|
else
|
||||||
|
score.away_points = [score.away_points - 1, 0].max
|
||||||
|
end
|
||||||
|
score.save!
|
||||||
|
broadcast(score.stream_session, score)
|
||||||
|
Result.new(score: score, set_won: false, match_won: false, winner: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply_close_set(score)
|
||||||
|
winner = @rules.set_winner_from_points(
|
||||||
|
home_points: score.home_points,
|
||||||
|
away_points: score.away_points,
|
||||||
|
current_set: score.current_set
|
||||||
|
)
|
||||||
|
winner ||= score.home_points > score.away_points ? :home : :away if score.home_points != score.away_points
|
||||||
|
return Result.new(score: score, set_won: false, match_won: false, winner: nil) unless winner
|
||||||
|
|
||||||
|
close_set_for(score, winner)
|
||||||
|
end
|
||||||
|
|
||||||
|
def close_set_for(score, winner)
|
||||||
|
partials = Array(score.set_partials)
|
||||||
|
partials << {
|
||||||
|
"set" => score.current_set,
|
||||||
|
"home" => score.home_points,
|
||||||
|
"away" => score.away_points
|
||||||
|
}
|
||||||
|
|
||||||
|
if winner == :home
|
||||||
|
score.home_sets += 1
|
||||||
|
else
|
||||||
|
score.away_sets += 1
|
||||||
|
end
|
||||||
|
|
||||||
|
match_winner = @rules.match_winner(home_sets: score.home_sets, away_sets: score.away_sets)
|
||||||
|
|
||||||
|
score.assign_attributes(
|
||||||
|
set_partials: partials,
|
||||||
|
home_points: 0,
|
||||||
|
away_points: 0,
|
||||||
|
current_set: score.current_set + 1
|
||||||
|
)
|
||||||
|
score.save!
|
||||||
|
broadcast(score.stream_session, score)
|
||||||
|
Result.new(
|
||||||
|
score: score,
|
||||||
|
set_won: true,
|
||||||
|
match_won: match_winner.present?,
|
||||||
|
winner: match_winner || winner
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -3,13 +3,31 @@ module Scoring
|
|||||||
Side = Struct.new(:home, :away, keyword_init: true)
|
Side = Struct.new(:home, :away, keyword_init: true)
|
||||||
|
|
||||||
def self.from_match(match)
|
def self.from_match(match)
|
||||||
overrides = match.scoring_rules.is_a?(Hash) ? match.scoring_rules : {}
|
rules = match.effective_scoring_rules
|
||||||
new(
|
board = match.effective_board_type
|
||||||
sets_to_win: match.sets_to_win,
|
|
||||||
points_per_set: overrides["points_per_set"] || overrides[:points_per_set] || 25,
|
case board
|
||||||
points_deciding_set: overrides["points_deciding_set"] || overrides[:points_deciding_set] || 15,
|
when "volley", "racket"
|
||||||
min_point_lead: overrides["min_point_lead"] || overrides[:min_point_lead] || 2
|
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
|
end
|
||||||
|
|
||||||
def initialize(sets_to_win:, points_per_set: 25, points_deciding_set: 15, min_point_lead: 2)
|
def initialize(sets_to_win:, points_per_set: 25, points_deciding_set: 15, min_point_lead: 2)
|
||||||
@@ -42,5 +60,15 @@ module Scoring
|
|||||||
|
|
||||||
nil
|
nil
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,27 +1,13 @@
|
|||||||
module Scoring
|
module Scoring
|
||||||
# Sincronizza lo stato completo del tabellone (app mobile / Action Cable).
|
# Sincronizza lo stato completo del tabellone (app mobile / Action Cable).
|
||||||
class SyncState
|
class SyncState
|
||||||
SCORE_KEYS = %w[home_sets away_sets home_points away_points current_set].freeze
|
|
||||||
|
|
||||||
def initialize(session:, payload:)
|
def initialize(session:, payload:)
|
||||||
@session = session
|
@session = session
|
||||||
@payload = payload.stringify_keys
|
@payload = payload.stringify_keys
|
||||||
end
|
end
|
||||||
|
|
||||||
def call
|
def call
|
||||||
score = @session.score_state || @session.create_score_state!(
|
Engine.for(@session.match).sync(session: @session, payload: @payload)
|
||||||
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
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
30
backend/app/views/public/regia/_basket.html.erb
Normal file
30
backend/app/views/public/regia/_basket.html.erb
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<% data = @score.data || {} %>
|
||||||
|
<p class="regia-sets" id="sets-line"><%= live_score_period_label(@match, @score) %></p>
|
||||||
|
<p class="regia-score-line" id="score-line"><%= live_score_points_label(@match, @score) %></p>
|
||||||
|
<p class="regia-partials" id="partials-line" hidden></p>
|
||||||
|
<p class="regia-clock-line" id="clock-line">Cronometro: <%= format_clock_secs(data["clock_secs"]) %></p>
|
||||||
|
|
||||||
|
<div class="regia-team-row">
|
||||||
|
<div>
|
||||||
|
<div class="regia-team-name"><%= @team.name %></div>
|
||||||
|
<div class="regia-points" id="home-points"><%= data["home_score"] || @score.home_points %></div>
|
||||||
|
<button type="button" class="regia-btn regia-btn--point" data-action="home_point" style="width:100%;margin-top:4px">+1</button>
|
||||||
|
<button type="button" class="regia-btn regia-btn--point" data-action="home_point_2" style="width:100%;margin-top:4px">+2</button>
|
||||||
|
<button type="button" class="regia-btn regia-btn--point" data-action="home_point_3" style="width:100%;margin-top:4px">+3</button>
|
||||||
|
<button type="button" class="regia-btn regia-btn--minus" data-action="home_undo" style="width:100%;margin-top:4px">−</button>
|
||||||
|
</div>
|
||||||
|
<div class="regia-center-hint" id="center-hint"><%= format_clock_secs(data["clock_secs"]) %></div>
|
||||||
|
<div>
|
||||||
|
<div class="regia-team-name"><%= @match.opponent_name %></div>
|
||||||
|
<div class="regia-points" id="away-points"><%= data["away_score"] || @score.away_points %></div>
|
||||||
|
<button type="button" class="regia-btn regia-btn--point" data-action="away_point" style="width:100%;margin-top:4px">+1</button>
|
||||||
|
<button type="button" class="regia-btn regia-btn--point" data-action="away_point_2" style="width:100%;margin-top:4px">+2</button>
|
||||||
|
<button type="button" class="regia-btn regia-btn--point" data-action="away_point_3" style="width:100%;margin-top:4px">+3</button>
|
||||||
|
<button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:4px">−</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="regia-clock-actions">
|
||||||
|
<button type="button" class="regia-btn regia-btn--outline" data-action="clock_toggle"><%= data["clock_running"] ? "Pausa" : "Avvia" %> cronometro</button>
|
||||||
|
<button type="button" class="regia-btn regia-btn--outline" data-action="clock_reset">Reset</button>
|
||||||
|
<button type="button" class="regia-btn regia-btn--yellow" data-action="advance_period">Prossimo quarto</button>
|
||||||
|
</div>
|
||||||
19
backend/app/views/public/regia/_generic.html.erb
Normal file
19
backend/app/views/public/regia/_generic.html.erb
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<p class="regia-sets" id="sets-line">Punteggio libero</p>
|
||||||
|
<p class="regia-score-line" id="score-line"><%= live_score_points_label(@match, @score) %></p>
|
||||||
|
<p class="regia-partials" id="partials-line" hidden></p>
|
||||||
|
|
||||||
|
<div class="regia-team-row">
|
||||||
|
<div>
|
||||||
|
<div class="regia-team-name"><%= @team.name %></div>
|
||||||
|
<div class="regia-points" id="home-points"><%= @score.home_points %></div>
|
||||||
|
<button type="button" class="regia-btn regia-btn--point" data-action="home_point" style="width:100%;margin-top:8px">+1</button>
|
||||||
|
<button type="button" class="regia-btn regia-btn--minus" data-action="home_undo" style="width:100%;margin-top:6px">−</button>
|
||||||
|
</div>
|
||||||
|
<div class="regia-center-hint" id="center-hint">—</div>
|
||||||
|
<div>
|
||||||
|
<div class="regia-team-name"><%= @match.opponent_name %></div>
|
||||||
|
<div class="regia-points" id="away-points"><%= @score.away_points %></div>
|
||||||
|
<button type="button" class="regia-btn regia-btn--point" data-action="away_point" style="width:100%;margin-top:8px">+1</button>
|
||||||
|
<button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:6px">−</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
1
backend/app/views/public/regia/_racket.html.erb
Normal file
1
backend/app/views/public/regia/_racket.html.erb
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<%= render "public/regia/volley" %>
|
||||||
26
backend/app/views/public/regia/_timed.html.erb
Normal file
26
backend/app/views/public/regia/_timed.html.erb
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<% data = @score.data || {} %>
|
||||||
|
<p class="regia-sets" id="sets-line"><%= live_score_period_label(@match, @score) %></p>
|
||||||
|
<p class="regia-score-line" id="score-line"><%= live_score_points_label(@match, @score) %></p>
|
||||||
|
<p class="regia-partials" id="partials-line" hidden></p>
|
||||||
|
<p class="regia-clock-line" id="clock-line">Cronometro: <%= format_clock_secs(data["clock_secs"]) %></p>
|
||||||
|
|
||||||
|
<div class="regia-team-row">
|
||||||
|
<div>
|
||||||
|
<div class="regia-team-name"><%= @team.name %></div>
|
||||||
|
<div class="regia-points" id="home-points"><%= data["home_score"] || @score.home_points %></div>
|
||||||
|
<button type="button" class="regia-btn regia-btn--point" data-action="home_point" style="width:100%;margin-top:8px">+1</button>
|
||||||
|
<button type="button" class="regia-btn regia-btn--minus" data-action="home_undo" style="width:100%;margin-top:6px">−</button>
|
||||||
|
</div>
|
||||||
|
<div class="regia-center-hint" id="center-hint"><%= format_clock_secs(data["clock_secs"]) %></div>
|
||||||
|
<div>
|
||||||
|
<div class="regia-team-name"><%= @match.opponent_name %></div>
|
||||||
|
<div class="regia-points" id="away-points"><%= data["away_score"] || @score.away_points %></div>
|
||||||
|
<button type="button" class="regia-btn regia-btn--point" data-action="away_point" style="width:100%;margin-top:8px">+1</button>
|
||||||
|
<button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:6px">−</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="regia-clock-actions">
|
||||||
|
<button type="button" class="regia-btn regia-btn--outline" data-action="clock_toggle"><%= data["clock_running"] ? "Pausa" : "Avvia" %> cronometro</button>
|
||||||
|
<button type="button" class="regia-btn regia-btn--outline" data-action="clock_reset">Reset</button>
|
||||||
|
<button type="button" class="regia-btn regia-btn--yellow" data-action="advance_period">Prossimo tempo</button>
|
||||||
|
</div>
|
||||||
10
backend/app/views/public/regia/_timer.html.erb
Normal file
10
backend/app/views/public/regia/_timer.html.erb
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<% data = @score.data || {} %>
|
||||||
|
<p class="regia-sets" id="sets-line">Cronometro</p>
|
||||||
|
<p class="regia-score-line" id="score-line" hidden></p>
|
||||||
|
<p class="regia-partials" id="partials-line" hidden></p>
|
||||||
|
<p class="regia-clock-line" id="clock-line"><%= format_clock_secs(data["clock_secs"], count_up: true) %></p>
|
||||||
|
|
||||||
|
<div class="regia-clock-actions" style="margin-top:16px">
|
||||||
|
<button type="button" class="regia-btn regia-btn--outline" data-action="clock_toggle"><%= data["clock_running"] ? "Pausa" : "Avvia" %></button>
|
||||||
|
<button type="button" class="regia-btn regia-btn--outline" data-action="clock_reset">Reset</button>
|
||||||
|
</div>
|
||||||
24
backend/app/views/public/regia/_volley.html.erb
Normal file
24
backend/app/views/public/regia/_volley.html.erb
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<p class="regia-sets" id="sets-line"><%= live_score_sets_label(@score) || "—" %></p>
|
||||||
|
<p class="regia-score-line" id="score-line"><%= live_score_points_label(@match, @score) %></p>
|
||||||
|
<p class="regia-partials" id="partials-line"<%= " hidden" unless live_score_partials_label(@score).present? %>>
|
||||||
|
Parziali: <%= live_score_partials_label(@score) %>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="regia-team-row">
|
||||||
|
<div>
|
||||||
|
<div class="regia-team-name"><%= @team.name %></div>
|
||||||
|
<div class="regia-points" id="home-points"><%= @score.home_points %></div>
|
||||||
|
<button type="button" class="regia-btn regia-btn--point" data-action="home_point" style="width:100%;margin-top:8px">+1</button>
|
||||||
|
<button type="button" class="regia-btn regia-btn--minus" data-action="home_undo" style="width:100%;margin-top:6px">−</button>
|
||||||
|
</div>
|
||||||
|
<div class="regia-center-hint" id="center-hint">
|
||||||
|
<%= Scoring::Rules.from_match(@match).points_target(@score.current_set) %> pt
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="regia-team-name"><%= @match.opponent_name %></div>
|
||||||
|
<div class="regia-points" id="away-points"><%= @score.away_points %></div>
|
||||||
|
<button type="button" class="regia-btn regia-btn--point" data-action="away_point" style="width:100%;margin-top:8px">+1</button>
|
||||||
|
<button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:6px">−</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="regia-btn regia-btn--yellow" data-action="close_set" style="margin-top:12px">Chiudi set</button>
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
<div class="regia-page" id="regia-app"
|
<div class="regia-page" id="regia-app"
|
||||||
data-token="<%= j params[:token] %>"
|
data-token="<%= j params[:token] %>"
|
||||||
|
data-board="<%= @match.effective_board_type %>"
|
||||||
data-status-url="<%= j public_regia_status_path(params[:token]) %>"
|
data-status-url="<%= j public_regia_status_path(params[:token]) %>"
|
||||||
data-score-url="<%= j public_regia_score_path(params[:token]) %>"
|
data-score-url="<%= j public_regia_score_path(params[:token]) %>"
|
||||||
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
|
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
|
||||||
@@ -20,13 +21,13 @@
|
|||||||
data-away-name="<%= j @match.opponent_name %>"
|
data-away-name="<%= j @match.opponent_name %>"
|
||||||
data-share-url="<%= j @share_url %>"
|
data-share-url="<%= j @share_url %>"
|
||||||
data-share-title="<%= j @share_title %>"
|
data-share-title="<%= j @share_title %>"
|
||||||
data-points-target="<%= Scoring::Rules.from_match(@match).points_target(@score.current_set) %>"
|
data-points-target="<%= @match.effective_board_type.in?(%w[volley racket]) ? Scoring::Rules.from_match(@match).points_target(@score.current_set) : 0 %>"
|
||||||
data-stream-closed="<%= @stream_closed %>"
|
data-stream-closed="<%= @stream_closed %>"
|
||||||
data-initial-paused="<%= @session.paused? %>">
|
data-initial-paused="<%= @session.paused? %>">
|
||||||
|
|
||||||
<header class="regia-header">
|
<header class="regia-header">
|
||||||
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
|
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
|
||||||
<p>Regia · Set <%= @score.current_set %></p>
|
<p>Regia · <%= regia_header_subtitle(@match, @score) %></p>
|
||||||
<span id="regia-badge" class="regia-badge <%= @stream_closed ? 'regia-badge--ended' : (@session.paused? ? 'regia-badge--wait' : (@on_air ? 'regia-badge--live' : 'regia-badge--wait')) %>">
|
<span id="regia-badge" class="regia-badge <%= @stream_closed ? 'regia-badge--ended' : (@session.paused? ? 'regia-badge--wait' : (@on_air ? 'regia-badge--live' : 'regia-badge--wait')) %>">
|
||||||
<%= @stream_closed ? "Terminata" : (@session.paused? ? "In pausa" : (@on_air ? "In onda" : "In attesa")) %>
|
<%= @stream_closed ? "Terminata" : (@session.paused? ? "In pausa" : (@on_air ? "In onda" : "In attesa")) %>
|
||||||
</span>
|
</span>
|
||||||
@@ -51,30 +52,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="regia-scoreboard">
|
<section class="regia-scoreboard">
|
||||||
<p class="regia-sets" id="sets-line"><%= live_score_sets_label(@score) || "—" %></p>
|
<%= render regia_board_partial(@match) %>
|
||||||
<p class="regia-score-line" id="score-line"><%= live_score_points_label(@match, @score) %></p>
|
|
||||||
<p class="regia-partials" id="partials-line"<%= " hidden" unless live_score_partials_label(@score).present? %>>
|
|
||||||
Parziali: <%= live_score_partials_label(@score) %>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="regia-team-row">
|
|
||||||
<div>
|
|
||||||
<div class="regia-team-name"><%= @team.name %></div>
|
|
||||||
<div class="regia-points" id="home-points"><%= @score.home_points %></div>
|
|
||||||
<button type="button" class="regia-btn regia-btn--point" data-action="home_point" style="width:100%;margin-top:8px">+1</button>
|
|
||||||
<button type="button" class="regia-btn regia-btn--minus" data-action="home_undo" style="width:100%;margin-top:6px">−</button>
|
|
||||||
</div>
|
|
||||||
<div style="text-align:center;color:var(--regia-muted);font-size:0.72rem;padding-top:24px">
|
|
||||||
<%= Scoring::Rules.from_match(@match).points_target(@score.current_set) %> pt
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="regia-team-name"><%= @match.opponent_name %></div>
|
|
||||||
<div class="regia-points" id="away-points"><%= @score.away_points %></div>
|
|
||||||
<button type="button" class="regia-btn regia-btn--point" data-action="away_point" style="width:100%;margin-top:8px">+1</button>
|
|
||||||
<button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:6px">−</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="regia-btn regia-btn--yellow" data-action="close_set" style="margin-top:12px">Chiudi set</button>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<% unless @stream_closed %>
|
<% unless @stream_closed %>
|
||||||
@@ -96,4 +74,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/regia.js?v=4"></script>
|
<script src="/regia.js?v=5"></script>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ module App
|
|||||||
class Application < Rails::Application
|
class Application < Rails::Application
|
||||||
config.load_defaults 7.2
|
config.load_defaults 7.2
|
||||||
config.autoload_lib(ignore: %w[assets tasks])
|
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.api_only = false
|
||||||
config.active_job.queue_adapter = :sidekiq
|
config.active_job.queue_adapter = :sidekiq
|
||||||
config.time_zone = "Europe/Rome"
|
config.time_zone = "Europe/Rome"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Rails.application.routes.draw do
|
|||||||
post "auth/logout", to: "auth#logout"
|
post "auth/logout", to: "auth#logout"
|
||||||
post "auth/refresh", to: "auth#refresh"
|
post "auth/refresh", to: "auth#refresh"
|
||||||
get "auth/me", to: "auth#me"
|
get "auth/me", to: "auth#me"
|
||||||
|
get "sports", to: "sports#index"
|
||||||
|
|
||||||
get "invitations/:token", to: "invitations#show"
|
get "invitations/:token", to: "invitations#show"
|
||||||
post "invitations/:token/accept", to: "invitations#accept"
|
post "invitations/:token/accept", to: "invitations#accept"
|
||||||
|
|||||||
183
backend/config/sports.yml
Normal file
183
backend/config/sports.yml
Normal file
@@ -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: {}
|
||||||
52
backend/db/migrate/20260609100000_multi_sport.rb
Normal file
52
backend/db/migrate/20260609100000_multi_sport.rb
Normal file
@@ -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
|
||||||
@@ -223,3 +223,23 @@
|
|||||||
.regia-modal__card h2 { margin: 0 0 8px; font-size: 1.1rem; }
|
.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__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-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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
const cfg = {
|
const cfg = {
|
||||||
token: root.dataset.token,
|
token: root.dataset.token,
|
||||||
|
board: root.dataset.board || "volley",
|
||||||
statusUrl: root.dataset.statusUrl,
|
statusUrl: root.dataset.statusUrl,
|
||||||
scoreUrl: root.dataset.scoreUrl,
|
scoreUrl: root.dataset.scoreUrl,
|
||||||
pauseUrl: root.dataset.pauseUrl,
|
pauseUrl: root.dataset.pauseUrl,
|
||||||
@@ -88,26 +89,52 @@
|
|||||||
.join(" · ");
|
.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) {
|
function applyScorePayload(data) {
|
||||||
const s = data.score;
|
const s = data.score;
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
|
|
||||||
const homePts = s.home_points ?? s.homePoints;
|
const board = s.board_type || s.boardType || cfg.board;
|
||||||
const awayPts = s.away_points ?? s.awayPoints;
|
const homePts = s.home_points ?? s.homePoints ?? 0;
|
||||||
|
const awayPts = s.away_points ?? s.awayPoints ?? 0;
|
||||||
const homeSets = s.home_sets ?? s.homeSets;
|
const homeSets = s.home_sets ?? s.homeSets;
|
||||||
const awaySets = s.away_sets ?? s.awaySets;
|
const awaySets = s.away_sets ?? s.awaySets;
|
||||||
const currentSet = s.current_set ?? s.currentSet;
|
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");
|
const awayEl = document.getElementById("away-points");
|
||||||
els.homePoints.textContent = homePts;
|
const centerHint = document.getElementById("center-hint");
|
||||||
if (awayEl) awayEl.textContent = awayPts;
|
const clockLine = document.getElementById("clock-line");
|
||||||
els.setsLine.textContent = `Set ${currentSet} · Set vinti ${homeSets}-${awaySets}`;
|
|
||||||
els.scoreLine.textContent = `${cfg.homeName} ${homePts} - ${awayPts} ${cfg.awayName}`;
|
|
||||||
|
|
||||||
const partialsText = formatPartials(s.set_partials || s.setPartials);
|
if (els.homePoints) els.homePoints.textContent = homePts;
|
||||||
if (partialsText) {
|
if (awayEl) awayEl.textContent = awayPts;
|
||||||
els.partialsLine.textContent = `Parziali: ${partialsText}`;
|
|
||||||
els.partialsLine.hidden = false;
|
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);
|
applyScorePayload(data);
|
||||||
setBadge(data);
|
setBadge(data);
|
||||||
|
|
||||||
if (data.set_won && action !== "close_set") {
|
if (cfg.board === "volley" || cfg.board === "racket") {
|
||||||
const winner = data.winner === "home" ? cfg.homeName : cfg.awayName;
|
if (data.set_won && action !== "close_set") {
|
||||||
openModal("Set vinto", `${winner} ha vinto il set. Chiudere il set?`, () => postScore("close_set"));
|
const winner = data.winner === "home" ? cfg.homeName : cfg.awayName;
|
||||||
} else if (data.match_won) {
|
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;
|
const winner = data.winner === "home" ? cfg.homeName : cfg.awayName;
|
||||||
openModal("Partita vinta", `${winner} ha vinto la partita. Chiudere la diretta?`, () => stopStream());
|
openModal("Partita vinta", `${winner} ha vinto la partita. Chiudere la diretta?`, () => stopStream());
|
||||||
}
|
}
|
||||||
|
|||||||
31
backend/spec/domain/sports/catalog_spec.rb
Normal file
31
backend/spec/domain/sports/catalog_spec.rb
Normal file
@@ -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
|
||||||
19
backend/spec/requests/api/v1/sports_spec.rb
Normal file
19
backend/spec/requests/api/v1/sports_spec.rb
Normal file
@@ -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
|
||||||
18
backend/spec/services/scoring/generic_engine_spec.rb
Normal file
18
backend/spec/services/scoring/generic_engine_spec.rb
Normal file
@@ -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
|
||||||
49
docs/MULTI_SPORT.md
Normal file
49
docs/MULTI_SPORT.md
Normal file
@@ -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`.
|
||||||
@@ -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.AuthTokens
|
||||||
import com.matchlivetv.match_live_tv.domain.Match
|
import com.matchlivetv.match_live_tv.domain.Match
|
||||||
import com.matchlivetv.match_live_tv.domain.ScoringRules
|
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.StreamSession
|
||||||
import com.matchlivetv.match_live_tv.domain.Team
|
import com.matchlivetv.match_live_tv.domain.Team
|
||||||
import com.matchlivetv.match_live_tv.domain.User
|
import com.matchlivetv.match_live_tv.domain.User
|
||||||
@@ -30,10 +31,31 @@ data class UserDto(
|
|||||||
fun toDomain() = User(id, email, name, role ?: "coach")
|
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<String> = emptyList(),
|
||||||
|
@Json(name = "default_rules") val defaultRules: Map<String, Int> = emptyMap(),
|
||||||
|
) {
|
||||||
|
fun toDomain() = SportOption(
|
||||||
|
key = key,
|
||||||
|
label = label,
|
||||||
|
board = board,
|
||||||
|
overlay = overlay,
|
||||||
|
allowedOverlays = allowedOverlays,
|
||||||
|
defaultRules = defaultRules,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
data class TeamDto(
|
data class TeamDto(
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val sport: String? = null,
|
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 = "can_stream") val canStream: Boolean? = true,
|
||||||
@Json(name = "club_name") val clubName: String? = null,
|
@Json(name = "club_name") val clubName: String? = null,
|
||||||
@Json(name = "youtube_enabled") val youtubeEnabled: Boolean? = false,
|
@Json(name = "youtube_enabled") val youtubeEnabled: Boolean? = false,
|
||||||
@@ -54,7 +76,9 @@ data class TeamDto(
|
|||||||
fun toDomain() = Team(
|
fun toDomain() = Team(
|
||||||
id = id,
|
id = id,
|
||||||
name = name,
|
name = name,
|
||||||
sport = sport ?: "volleyball",
|
sport = sportKey ?: sport ?: "pallavolo",
|
||||||
|
sportKey = sportKey ?: sport ?: "pallavolo",
|
||||||
|
boardType = boardType ?: "volley",
|
||||||
canStream = canStream ?: true,
|
canStream = canStream ?: true,
|
||||||
clubName = clubName,
|
clubName = clubName,
|
||||||
logoUrl = logoUrl,
|
logoUrl = logoUrl,
|
||||||
@@ -82,6 +106,12 @@ data class MatchDto(
|
|||||||
val location: String? = null,
|
val location: String? = null,
|
||||||
@Json(name = "scheduled_at") val scheduledAt: String? = null,
|
@Json(name = "scheduled_at") val scheduledAt: String? = null,
|
||||||
@Json(name = "sets_to_win") val setsToWin: Int? = 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). */
|
/** Campionato / descrizione torneo (facoltativo). */
|
||||||
val category: String? = null,
|
val category: String? = null,
|
||||||
@Json(name = "scoring_rules") val scoringRules: ScoringRulesBody? = null,
|
@Json(name = "scoring_rules") val scoringRules: ScoringRulesBody? = null,
|
||||||
@@ -103,6 +133,11 @@ data class MatchDto(
|
|||||||
location = location,
|
location = location,
|
||||||
scheduledAt = scheduledAt,
|
scheduledAt = scheduledAt,
|
||||||
setsToWin = setsToWin ?: 3,
|
setsToWin = setsToWin ?: 3,
|
||||||
|
sportKey = sportKey ?: sport ?: "pallavolo",
|
||||||
|
sportLabel = sportLabel,
|
||||||
|
boardType = boardType ?: "volley",
|
||||||
|
overlayKind = overlayKind,
|
||||||
|
effectiveOverlayKind = effectiveOverlayKind ?: overlayKind ?: "volley",
|
||||||
category = category,
|
category = category,
|
||||||
scoringRules = scoringRules?.toDomain(),
|
scoringRules = scoringRules?.toDomain(),
|
||||||
activeSessionId = activeSessionId,
|
activeSessionId = activeSessionId,
|
||||||
@@ -212,34 +247,51 @@ data class UpdateMatchBody(
|
|||||||
data class UpdateMatchRequest(val match: UpdateMatchBody)
|
data class UpdateMatchRequest(val match: UpdateMatchBody)
|
||||||
|
|
||||||
data class ScoringRulesBody(
|
data class ScoringRulesBody(
|
||||||
@Json(name = "points_per_set") val pointsPerSet: Int,
|
@Json(name = "sets_to_win") val setsToWin: Int? = null,
|
||||||
@Json(name = "points_deciding_set") val pointsDecidingSet: Int,
|
@Json(name = "points_per_set") val pointsPerSet: Int? = null,
|
||||||
@Json(name = "min_point_lead") val minPointLead: Int,
|
@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(
|
fun toDomain() = ScoringRules(
|
||||||
pointsPerSet = pointsPerSet,
|
pointsPerSet = pointsPerSet ?: 25,
|
||||||
pointsDecidingSet = pointsDecidingSet,
|
pointsDecidingSet = pointsDecidingSet ?: 15,
|
||||||
minPointLead = minPointLead,
|
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,
|
pointsPerSet = pointsPerSet,
|
||||||
pointsDecidingSet = pointsDecidingSet,
|
pointsDecidingSet = pointsDecidingSet,
|
||||||
minPointLead = minPointLead,
|
minPointLead = minPointLead,
|
||||||
|
periods = periods,
|
||||||
|
periodDurationSecs = periodDurationSecs,
|
||||||
|
overtimeDurationSecs = overtimeDurationSecs,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun ScoringRulesBody.toMap(): Map<String, Int> = mapOf(
|
fun ScoringRulesBody.toMap(): Map<String, Int> = buildMap {
|
||||||
"points_per_set" to pointsPerSet,
|
setsToWin?.let { put("sets_to_win", it) }
|
||||||
"points_deciding_set" to pointsDecidingSet,
|
pointsPerSet?.let { put("points_per_set", it) }
|
||||||
"min_point_lead" to minPointLead,
|
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(
|
data class UpdateMatchBodyFull(
|
||||||
@Json(name = "opponent_name") val opponentName: String,
|
@Json(name = "opponent_name") val opponentName: String,
|
||||||
val location: String? = null,
|
val location: String? = null,
|
||||||
@Json(name = "scheduled_at") val scheduledAt: String? = null,
|
@Json(name = "scheduled_at") val scheduledAt: String? = null,
|
||||||
@Json(name = "sets_to_win") val setsToWin: Int,
|
@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,
|
val category: String? = null,
|
||||||
@Json(name = "opponent_primary_color") val opponentPrimaryColor: String? = null,
|
@Json(name = "opponent_primary_color") val opponentPrimaryColor: String? = null,
|
||||||
@Json(name = "scoring_rules") val scoringRules: Map<String, Int>? = null,
|
@Json(name = "scoring_rules") val scoringRules: Map<String, Int>? = null,
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ interface MatchLiveApi {
|
|||||||
@POST("auth/logout")
|
@POST("auth/logout")
|
||||||
suspend fun logout()
|
suspend fun logout()
|
||||||
|
|
||||||
|
@GET("sports")
|
||||||
|
suspend fun sports(): List<SportDto>
|
||||||
|
|
||||||
@GET("teams")
|
@GET("teams")
|
||||||
suspend fun teams(): List<TeamDto>
|
suspend fun teams(): List<TeamDto>
|
||||||
|
|
||||||
|
|||||||
@@ -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.UpdateTeamBody
|
||||||
import com.matchlivetv.match_live_tv.data.api.UpdateTeamRequest
|
import com.matchlivetv.match_live_tv.data.api.UpdateTeamRequest
|
||||||
import com.matchlivetv.match_live_tv.domain.Match
|
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.Team
|
||||||
import com.matchlivetv.match_live_tv.domain.coachHubVisible
|
import com.matchlivetv.match_live_tv.domain.coachHubVisible
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -25,6 +26,9 @@ class MatchRepository(
|
|||||||
private val tokenStore: TokenStore,
|
private val tokenStore: TokenStore,
|
||||||
private val appContext: Context,
|
private val appContext: Context,
|
||||||
) {
|
) {
|
||||||
|
suspend fun fetchSports(): List<SportOption> =
|
||||||
|
api.sports().map { it.toDomain() }
|
||||||
|
|
||||||
suspend fun fetchTeams(): List<Team> =
|
suspend fun fetchTeams(): List<Team> =
|
||||||
api.teams().map { it.toDomain() }
|
api.teams().map { it.toDomain() }
|
||||||
|
|
||||||
@@ -115,6 +119,8 @@ class MatchRepository(
|
|||||||
category: String?,
|
category: String?,
|
||||||
opponentPrimaryColor: String?,
|
opponentPrimaryColor: String?,
|
||||||
scoringRules: ScoringRulesBody?,
|
scoringRules: ScoringRulesBody?,
|
||||||
|
sportKey: String? = null,
|
||||||
|
overlayKind: String? = null,
|
||||||
opponentLogoUri: Uri? = null,
|
opponentLogoUri: Uri? = null,
|
||||||
): Match {
|
): Match {
|
||||||
val body = UpdateMatchBodyFull(
|
val body = UpdateMatchBodyFull(
|
||||||
@@ -122,9 +128,11 @@ class MatchRepository(
|
|||||||
location = location?.takeIf { it.isNotBlank() },
|
location = location?.takeIf { it.isNotBlank() },
|
||||||
scheduledAt = scheduledAt,
|
scheduledAt = scheduledAt,
|
||||||
setsToWin = setsToWin,
|
setsToWin = setsToWin,
|
||||||
|
sportKey = sportKey,
|
||||||
|
overlayKind = overlayKind,
|
||||||
category = category?.takeIf { it.isNotBlank() },
|
category = category?.takeIf { it.isNotBlank() },
|
||||||
opponentPrimaryColor = opponentPrimaryColor,
|
opponentPrimaryColor = opponentPrimaryColor,
|
||||||
scoringRules = scoringRules?.toMap() ?: emptyMap(),
|
scoringRules = scoringRules?.toMap().takeIf { !it.isNullOrEmpty() },
|
||||||
)
|
)
|
||||||
if (opponentLogoUri != null) {
|
if (opponentLogoUri != null) {
|
||||||
return api.updateMatchMultipart(
|
return api.updateMatchMultipart(
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ data class MatchScoringRules(
|
|||||||
val pointsPerSet: Int = 25,
|
val pointsPerSet: Int = 25,
|
||||||
val pointsDecidingSet: Int = 15,
|
val pointsDecidingSet: Int = 15,
|
||||||
val minPointLead: Int = 2,
|
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
|
val maxSets: Int get() = (setsToWin * 2) - 1
|
||||||
|
|
||||||
@@ -35,6 +39,18 @@ data class MatchScoringRules(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,21 @@ data class AuthTokens(
|
|||||||
val refreshToken: String,
|
val refreshToken: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class SportOption(
|
||||||
|
val key: String,
|
||||||
|
val label: String,
|
||||||
|
val board: String,
|
||||||
|
val overlay: String,
|
||||||
|
val allowedOverlays: List<String>,
|
||||||
|
val defaultRules: Map<String, Int> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
data class Team(
|
data class Team(
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val sport: String,
|
val sport: String,
|
||||||
|
val sportKey: String = sport,
|
||||||
|
val boardType: String = "volley",
|
||||||
val clubName: String? = null,
|
val clubName: String? = null,
|
||||||
val logoUrl: String? = null,
|
val logoUrl: String? = null,
|
||||||
val primaryColor: String? = null,
|
val primaryColor: String? = null,
|
||||||
@@ -59,6 +70,9 @@ data class ScoringRules(
|
|||||||
val pointsPerSet: Int = 25,
|
val pointsPerSet: Int = 25,
|
||||||
val pointsDecidingSet: Int = 15,
|
val pointsDecidingSet: Int = 15,
|
||||||
val minPointLead: Int = 2,
|
val minPointLead: Int = 2,
|
||||||
|
val periods: Int = 4,
|
||||||
|
val periodDurationSecs: Int = 600,
|
||||||
|
val overtimeDurationSecs: Int = 300,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Match(
|
data class Match(
|
||||||
@@ -68,6 +82,11 @@ data class Match(
|
|||||||
val opponentName: String,
|
val opponentName: String,
|
||||||
val location: String? = null,
|
val location: String? = null,
|
||||||
val scheduledAt: 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,
|
val setsToWin: Int = 3,
|
||||||
/** Campionato / descrizione torneo (facoltativo). */
|
/** Campionato / descrizione torneo (facoltativo). */
|
||||||
val category: String? = null,
|
val category: String? = null,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ class OverlayCanvasRenderer(context: Context) {
|
|||||||
private val elements: List<OverlayElement> = listOf(
|
private val elements: List<OverlayElement> = listOf(
|
||||||
WatermarkElement(context),
|
WatermarkElement(context),
|
||||||
ScoreboardElement(),
|
ScoreboardElement(),
|
||||||
|
CompactScoreboardElement(),
|
||||||
TimerElement(),
|
TimerElement(),
|
||||||
SponsorElement(),
|
SponsorElement(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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) {
|
fun BroadcastPhase.toOverlayStatus(): BroadcastOverlayStatus = when (this) {
|
||||||
BroadcastPhase.LIVE -> BroadcastOverlayStatus.LIVE
|
BroadcastPhase.LIVE -> BroadcastOverlayStatus.LIVE
|
||||||
BroadcastPhase.PAUSED -> BroadcastOverlayStatus.PAUSED
|
BroadcastPhase.PAUSED -> BroadcastOverlayStatus.PAUSED
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package com.matchlivetv.match_live_tv.streaming.overlay
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Stato grafico dell'overlay video. Indipendente dalla logica sportiva:
|
* 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(
|
data class ScoreboardSetColumn(
|
||||||
val setNumber: Int,
|
val setNumber: Int,
|
||||||
@@ -21,6 +21,38 @@ data class ScoreboardState(
|
|||||||
val awayLogoUrl: String? = null,
|
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 {
|
enum class BroadcastOverlayStatus {
|
||||||
PREVIEW,
|
PREVIEW,
|
||||||
CONNECTING,
|
CONNECTING,
|
||||||
@@ -29,18 +61,22 @@ enum class BroadcastOverlayStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data class OverlayState(
|
data class OverlayState(
|
||||||
|
val overlayKind: OverlayKind = OverlayKind.VOLLEY,
|
||||||
val scoreboard: ScoreboardState? = null,
|
val scoreboard: ScoreboardState? = null,
|
||||||
|
val compactScoreboard: CompactScoreboardState? = null,
|
||||||
val watermarkVisible: Boolean = true,
|
val watermarkVisible: Boolean = true,
|
||||||
val broadcastStatus: BroadcastOverlayStatus = BroadcastOverlayStatus.LIVE,
|
val broadcastStatus: BroadcastOverlayStatus = BroadcastOverlayStatus.LIVE,
|
||||||
val timerText: String? = null,
|
val timerText: String? = null,
|
||||||
val sponsorText: String? = null,
|
val sponsorText: String? = null,
|
||||||
) {
|
) {
|
||||||
fun fingerprint(): Int {
|
fun fingerprint(): Int {
|
||||||
var result = watermarkVisible.hashCode()
|
var result = overlayKind.hashCode()
|
||||||
|
result = 31 * result + watermarkVisible.hashCode()
|
||||||
result = 31 * result + broadcastStatus.hashCode()
|
result = 31 * result + broadcastStatus.hashCode()
|
||||||
result = 31 * result + (timerText?.hashCode() ?: 0)
|
result = 31 * result + (timerText?.hashCode() ?: 0)
|
||||||
result = 31 * result + (sponsorText?.hashCode() ?: 0)
|
result = 31 * result + (sponsorText?.hashCode() ?: 0)
|
||||||
result = 31 * result + (scoreboard?.fingerprint() ?: 0)
|
result = 31 * result + (scoreboard?.fingerprint() ?: 0)
|
||||||
|
result = 31 * result + (compactScoreboard?.fingerprint() ?: 0)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,4 +99,14 @@ private fun ScoreboardState.fingerprint(): Int {
|
|||||||
return result
|
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
|
private fun logoLoadedKey(url: String?): Int = if (OverlayLogoCache.get(url) != null) 1 else 0
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class ScoreboardElement : OverlayElement {
|
|||||||
private val liveRect = RectF()
|
private val liveRect = RectF()
|
||||||
|
|
||||||
override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) {
|
override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) {
|
||||||
|
if (state.overlayKind !in setOf(OverlayKind.VOLLEY, OverlayKind.RACKET)) return
|
||||||
val board = state.scoreboard ?: return
|
val board = state.scoreboard ?: return
|
||||||
if (board.columns.isEmpty()) return
|
if (board.columns.isEmpty()) return
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class TimerElement : OverlayElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) {
|
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
|
val text = state.timerText?.takeIf { it.isNotBlank() } ?: return
|
||||||
paint.textSize = layout.canvasHeight * 0.05f
|
paint.textSize = layout.canvasHeight * 0.05f
|
||||||
val x = layout.marginPx.toFloat()
|
val x = layout.marginPx.toFloat()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class WatermarkElement(context: Context) : OverlayElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) {
|
override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) {
|
||||||
|
if (state.overlayKind == OverlayKind.NONE) return
|
||||||
if (!state.watermarkVisible) return
|
if (!state.watermarkVisible) return
|
||||||
|
|
||||||
val targetWidth = (layout.canvasWidth * LOGO_WIDTH_RATIO).roundToInt().coerceIn(28, 72)
|
val targetWidth = (layout.canvasWidth * LOGO_WIDTH_RATIO).roundToInt().coerceIn(28, 72)
|
||||||
|
|||||||
@@ -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.BroadcastConfig
|
||||||
import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
|
import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
|
||||||
import com.matchlivetv.match_live_tv.streaming.LivePreviewView
|
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.OverlayLogoCache
|
||||||
import com.matchlivetv.match_live_tv.streaming.overlay.OverlayState
|
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.toOverlayStatus
|
||||||
import com.matchlivetv.match_live_tv.streaming.overlay.toScoreboardState
|
import com.matchlivetv.match_live_tv.streaming.overlay.toScoreboardState
|
||||||
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
|
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
|
||||||
@@ -219,23 +222,50 @@ fun BroadcastScreen(
|
|||||||
val currentMatch = match ?: return@LaunchedEffect
|
val currentMatch = match ?: return@LaunchedEffect
|
||||||
val homeLogoUrl = resolveMediaUrl(currentMatch.homeLogoUrl)
|
val homeLogoUrl = resolveMediaUrl(currentMatch.homeLogoUrl)
|
||||||
val awayLogoUrl = resolveMediaUrl(currentMatch.opponentLogoUrl)
|
val awayLogoUrl = resolveMediaUrl(currentMatch.opponentLogoUrl)
|
||||||
container.broadcastCoordinator.updateOverlay(
|
val overlayKind = OverlayKind.fromApi(currentMatch.effectiveOverlayKind)
|
||||||
OverlayState(
|
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(
|
scoreboard = score.toScoreboardState(
|
||||||
homeTeamName = currentMatch.teamName,
|
homeTeamName = currentMatch.teamName,
|
||||||
awayTeamName = currentMatch.opponentName,
|
awayTeamName = currentMatch.opponentName,
|
||||||
homeAccentColor = parseColorHex(currentMatch.homePrimaryColor, 0xFFFF2D2D.toInt()),
|
homeAccentColor = homeColor,
|
||||||
awayAccentColor = parseColorHex(
|
awayAccentColor = awayColor,
|
||||||
currentMatch.opponentPrimaryColor,
|
|
||||||
0xFF1E3A8A.toInt(),
|
|
||||||
),
|
|
||||||
homeLogoUrl = homeLogoUrl,
|
homeLogoUrl = homeLogoUrl,
|
||||||
awayLogoUrl = awayLogoUrl,
|
awayLogoUrl = awayLogoUrl,
|
||||||
),
|
),
|
||||||
watermarkVisible = true,
|
watermarkVisible = true,
|
||||||
broadcastStatus = metrics.phase.toOverlayStatus(),
|
broadcastStatus = metrics.phase.toOverlayStatus(),
|
||||||
),
|
)
|
||||||
)
|
}
|
||||||
|
container.broadcastCoordinator.updateOverlay(overlayState)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
|||||||
@@ -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.AppContainer
|
||||||
import com.matchlivetv.match_live_tv.data.api.ScoringRulesBody
|
import com.matchlivetv.match_live_tv.data.api.ScoringRulesBody
|
||||||
import com.matchlivetv.match_live_tv.domain.Match
|
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.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.MatchPrimaryButton
|
||||||
import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton
|
import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton
|
||||||
import com.matchlivetv.match_live_tv.ui.matches.MatchOutlinedField
|
import com.matchlivetv.match_live_tv.ui.matches.MatchOutlinedField
|
||||||
@@ -111,6 +113,19 @@ fun StepMatchScreen(
|
|||||||
var pointsPerSetText by remember { mutableStateOf(pointsPerSet.toString()) }
|
var pointsPerSetText by remember { mutableStateOf(pointsPerSet.toString()) }
|
||||||
var pointsDecidingSetText by remember { mutableStateOf(pointsDecidingSet.toString()) }
|
var pointsDecidingSetText by remember { mutableStateOf(pointsDecidingSet.toString()) }
|
||||||
var saving by remember { mutableStateOf(false) }
|
var saving by remember { mutableStateOf(false) }
|
||||||
|
var sports by remember { mutableStateOf<List<SportOption>>(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(
|
Column(
|
||||||
Modifier
|
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))
|
Spacer(Modifier.height(20.dp))
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
@@ -195,7 +239,7 @@ fun StepMatchScreen(
|
|||||||
Switch(checked = customRules, onCheckedChange = { customRules = it })
|
Switch(checked = customRules, onCheckedChange = { customRules = it })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (customRules) {
|
if (customRules && boardType in setOf("volley", "racket")) {
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
Text("Set da vincere la partita", style = MaterialTheme.typography.bodyMedium)
|
Text("Set da vincere la partita", style = MaterialTheme.typography.bodyMedium)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
@@ -262,8 +306,9 @@ fun StepMatchScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val resolvedSets = if (customRules) setsToWin else DEFAULT_SETS_TO_WIN
|
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(
|
ScoringRulesBody(
|
||||||
|
setsToWin = resolvedSets,
|
||||||
pointsPerSet = pointsPerSet,
|
pointsPerSet = pointsPerSet,
|
||||||
pointsDecidingSet = pointsDecidingSet,
|
pointsDecidingSet = pointsDecidingSet,
|
||||||
minPointLead = existingRules?.minPointLead ?: DEFAULT_MIN_POINT_LEAD,
|
minPointLead = existingRules?.minPointLead ?: DEFAULT_MIN_POINT_LEAD,
|
||||||
@@ -298,6 +343,10 @@ fun StepMatchScreen(
|
|||||||
category = campionato.trim(),
|
category = campionato.trim(),
|
||||||
opponentPrimaryColor = opponentPrimaryColor,
|
opponentPrimaryColor = opponentPrimaryColor,
|
||||||
scoringRules = resolvedRules,
|
scoringRules = resolvedRules,
|
||||||
|
sportKey = selectedSportKey,
|
||||||
|
overlayKind = selectedOverlay.takeIf {
|
||||||
|
it != currentSport?.overlay
|
||||||
|
},
|
||||||
opponentLogoUri = opponentLogoUri,
|
opponentLogoUri = opponentLogoUri,
|
||||||
)
|
)
|
||||||
}.onSuccess { updated ->
|
}.onSuccess { updated ->
|
||||||
|
|||||||
Reference in New Issue
Block a user