Introduce architettura multi-sport con catalogo, engine scoring e overlay.

Catalogo sport in YAML, board implicito, engine per volley/basket/timed/racket/timer/generic, regia e app Android allineate.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-09 20:11:21 +02:00
parent 53f9a6a884
commit e727069d43
56 changed files with 1783 additions and 223 deletions

View File

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

View 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

View 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

View 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

View File

@@ -0,0 +1,7 @@
module Scoring
module Engines
# Fase 1: stessa logica del volley (set + punti).
class RacketEngine < VolleyEngine
end
end
end

View 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

View 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

View 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