jumbotron 0.1.7 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/app/adapters/jumbotron/adapters/sandbox/nfl.rb +135 -0
  3. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/acquisition_scope.rb +74 -0
  4. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/competition_odds.rb +82 -0
  5. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/current_observation_state.rb +53 -0
  6. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/line_acquisition_scope.rb +30 -0
  7. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/monotonic_observation.rb +113 -0
  8. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/observation_values.rb +53 -0
  9. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/outcome_candidates.rb +38 -0
  10. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/projection.rb +87 -0
  11. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/scoreboard.rb +115 -0
  12. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/synthetic_observation.rb +32 -0
  13. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/timeline_observation.rb +119 -0
  14. data/app/deserializers/jumbotron/deserializers/public/register_sandbox_projection.rb +42 -0
  15. data/app/deserializers/jumbotron/deserializers/public/reset_sandbox_projection.rb +51 -0
  16. data/app/deserializers/jumbotron/deserializers/public/sandbox_projection.rb +17 -0
  17. data/app/errors/jumbotron/error.rb +7 -0
  18. data/app/errors/jumbotron/errors/sandbox.rb +67 -0
  19. data/app/errors/jumbotron/sandbox_reset_rejected_error.rb +6 -0
  20. data/app/models/jumbotron/game.rb +11 -0
  21. data/app/models/jumbotron/public/translate_outcome.rb +13 -7
  22. data/app/models/jumbotron/public.rb +19 -1
  23. data/app/models/jumbotron/sandbox_game_mapping.rb +24 -0
  24. data/app/models/jumbotron/sandbox_game_plan.rb +23 -0
  25. data/app/models/jumbotron/sandbox_projection.rb +30 -0
  26. data/app/models/jumbotron/sandbox_projection_data.rb +32 -0
  27. data/app/models/jumbotron/season.rb +10 -0
  28. data/app/providers/jumbotron/providers/sandbox/resources/competition_odds.rb +24 -0
  29. data/app/providers/jumbotron/providers/sandbox/resources/projection.rb +23 -0
  30. data/app/providers/jumbotron/providers/sandbox/resources/scoreboard.rb +24 -0
  31. data/app/services/jumbotron/services/adapters/execute_line_acquisition_scope.rb +2 -1
  32. data/app/services/jumbotron/services/canonical/upsert_game_graph.rb +126 -0
  33. data/app/services/jumbotron/services/canonical/upsert_game_participant.rb +23 -3
  34. data/app/services/jumbotron/services/public/assemble_sandbox_projection.rb +39 -0
  35. data/app/services/jumbotron/services/public/assemble_sandbox_projection_reset.rb +58 -0
  36. data/app/services/jumbotron/services/public/read_sandbox_projection.rb +32 -0
  37. data/app/services/jumbotron/services/sandbox/build_source_snapshot.rb +224 -0
  38. data/app/services/jumbotron/services/sandbox/project_game.rb +73 -0
  39. data/app/services/jumbotron/services/sandbox/register_projection.rb +76 -0
  40. data/app/services/jumbotron/services/sandbox/remove_empty_projection_geometry.rb +46 -0
  41. data/app/services/jumbotron/services/sandbox/remove_mapped_game.rb +29 -0
  42. data/app/services/jumbotron/services/sandbox/resolve_game_observation.rb +106 -0
  43. data/app/services/jumbotron/services/sandbox/seed_projected_game.rb +95 -0
  44. data/app/services/jumbotron/services/sandbox/select_line_basis.rb +69 -0
  45. data/app/services/jumbotron/services/sandbox/validate_projected_season.rb +77 -0
  46. data/app/workflows/jumbotron/workflows/public_read.rb +2 -1
  47. data/app/workflows/jumbotron/workflows/sandbox/read_projection_workflow.rb +28 -0
  48. data/app/workflows/jumbotron/workflows/sandbox/register_projection_workflow.rb +33 -0
  49. data/app/workflows/jumbotron/workflows/sandbox/reset_projection_workflow.rb +158 -0
  50. data/app/workflows/jumbotron/workflows/synchronize_adapter_workflow.rb +3 -51
  51. data/db/migrate/20260908000001_create_jumbotron_sandbox_game_plans.rb +16 -0
  52. data/db/migrate/20260908000002_create_jumbotron_sandbox_projections.rb +39 -0
  53. data/docs/upgrades/0.2.0.md +74 -0
  54. data/docs/upgrades/0.3.0.md +31 -0
  55. data/lib/jumbotron/client.rb +21 -0
  56. data/lib/jumbotron/version.rb +1 -1
  57. data/spec/factories/jumbotron/sandbox_game_mapping.rb +24 -0
  58. data/spec/factories/jumbotron/sandbox_game_plan.rb +10 -0
  59. data/spec/factories/jumbotron/sandbox_projection.rb +18 -0
  60. metadata +63 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 59e55efb1ece4d237011869ce7416fd4cda99a2e95feb914c21e93a3f23aecaa
4
- data.tar.gz: 2199925a6f4a46d2db581c63e2a5eb55b81a26483195a396a4b7c111ae41bee1
3
+ metadata.gz: 48040072e10a2363780b93b40c36c51e00683c247d616ae4cf8e0b245cca1815
4
+ data.tar.gz: 56fdebbcc73ab653ebf1293d7bc718bece9c24e035552e10a402d3d0cf4a34a2
5
5
  SHA512:
6
- metadata.gz: ae7c4b2c06eed8291bafcb279940f0ca45ad5b33a62cc5970a5c1d353eae0c4d8bde4173e62811d13dade3038107940a828f37b2b3e1c1d6a7f2c118f62d5447
7
- data.tar.gz: 8ecd32a04d755e5eb0422fccbb98b10beead9f3b88de8f8e885f2b8e4f458af6a672d1bdf9a63040b88e352186cea328565263dff18d001298b1a0cf97389748
6
+ metadata.gz: 22e5abd65bafd78af1bb38568088c5c7180321e28dc527cd8dec35ea61300d55f789a3e8723d35f3df00ddc3ef3ae7bddd8bbb7e6a4e1aac256eb0665bf24275
7
+ data.tar.gz: 3deca6bd03bb5f95ddb485306ebd68fb33ced87215df67002a19f84f383ba748182d437d0581fa065043fe44bc60d605f2773af70a85884a596ab44c82a4d9ab
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Adapters
5
+ module Sandbox
6
+ class Nfl < Base
7
+ TERMINAL_LIFECYCLES = %w[completed cancelled].freeze
8
+ INTERRUPTED_LIFECYCLES = %w[postponed suspended].freeze
9
+
10
+ adapter_id "sandbox_nfl"
11
+
12
+ provider :sandbox
13
+ sport "football"
14
+ league "nfl-sandbox"
15
+
16
+ endpoint :scoreboard,
17
+ resource: Providers::Sandbox::Resources::Scoreboard,
18
+ transformer: NflHelpers::Scoreboard
19
+ endpoint :projection,
20
+ resource: Providers::Sandbox::Resources::Projection,
21
+ transformer: NflHelpers::Projection
22
+
23
+ policy :far_future,
24
+ type: :future_game_update,
25
+ cadence: Cadence.new(every: 1, unit: :week),
26
+ eligible: lambda { |game, now:|
27
+ TERMINAL_LIFECYCLES.exclude?(game.lifecycle) &&
28
+ game.scheduled_at > now + 6.weeks
29
+ }
30
+
31
+ policy :near_future,
32
+ type: :future_game_update,
33
+ cadence: Cadence.new(every: 1, unit: :day),
34
+ eligible: lambda { |game, now:|
35
+ return false if TERMINAL_LIFECYCLES.include?(game.lifecycle)
36
+
37
+ game.scheduled_at.between?(now + 2.weeks, now + 6.weeks)
38
+ }
39
+
40
+ policy :upcoming,
41
+ type: :future_game_update,
42
+ cadence: Cadence.new(every: 1, unit: :hour),
43
+ eligible: lambda { |game, now:|
44
+ return false if TERMINAL_LIFECYCLES.include?(game.lifecycle)
45
+
46
+ game.scheduled_at > now && game.scheduled_at < now + 2.weeks
47
+ }
48
+
49
+ policy :live,
50
+ type: :live_game_update,
51
+ cadence: Cadence.new(every: 1, unit: :minute),
52
+ eligible: lambda { |game, now:|
53
+ return false if TERMINAL_LIFECYCLES.include?(game.lifecycle)
54
+ return false if INTERRUPTED_LIFECYCLES.include?(game.lifecycle)
55
+
56
+ now >= game.scheduled_at
57
+ }
58
+
59
+ policy :interrupted,
60
+ type: :interrupted_game_update,
61
+ cadence: Cadence.new(every: 1, unit: :hour),
62
+ eligible: ->(game, **) { INTERRUPTED_LIFECYCLES.include?(game.lifecycle) }
63
+
64
+ policy :post_final_record,
65
+ type: :post_final_game_update,
66
+ cadence: Cadence.new(every: 15, unit: :minute),
67
+ eligible: lambda { |game, now:|
68
+ return false unless game.lifecycle == "completed"
69
+ return false if game.changed_at.nil? || game.changed_at < now - 6.hours
70
+
71
+ participants = game.game_participants
72
+ participants.present? &&
73
+ participants.any? { |participant| participant.record_summary_post_game.blank? }
74
+ }
75
+
76
+ policy :far_future_lines,
77
+ type: :future_line_update,
78
+ cadence: Cadence.new(every: 1, unit: :week),
79
+ eligible: lambda { |game, now:|
80
+ TERMINAL_LIFECYCLES.exclude?(game.lifecycle) &&
81
+ game.scheduled_at > now + 6.weeks
82
+ }
83
+
84
+ policy :near_future_lines,
85
+ type: :future_line_update,
86
+ cadence: Cadence.new(every: 1, unit: :day),
87
+ eligible: lambda { |game, now:|
88
+ return false if TERMINAL_LIFECYCLES.include?(game.lifecycle)
89
+
90
+ game.scheduled_at.between?(now + 2.weeks, now + 6.weeks)
91
+ }
92
+
93
+ policy :upcoming_lines,
94
+ type: :future_line_update,
95
+ cadence: Cadence.new(every: 1, unit: :hour),
96
+ eligible: lambda { |game, now:|
97
+ return false if TERMINAL_LIFECYCLES.include?(game.lifecycle)
98
+
99
+ game.scheduled_at > now && game.scheduled_at < now + 2.weeks
100
+ }
101
+
102
+ policy :in_progress_lines,
103
+ type: :live_line_update,
104
+ cadence: Cadence.new(every: 1, unit: :hour),
105
+ eligible: lambda { |game, now:|
106
+ game.lifecycle == "in_progress" && now >= game.scheduled_at
107
+ }
108
+
109
+ policy :interrupted_lines,
110
+ type: :interrupted_line_update,
111
+ cadence: Cadence.new(every: 1, unit: :hour),
112
+ eligible: ->(game, **) { INTERRUPTED_LIFECYCLES.include?(game.lifecycle) }
113
+
114
+ register!
115
+
116
+ def self.acquisition_scope_for(game)
117
+ NflHelpers::AcquisitionScope.call(game)
118
+ end
119
+
120
+ def self.line_acquisition_scope_for(game)
121
+ NflHelpers::LineAcquisitionScope.call(game)
122
+ end
123
+
124
+ def self.acquire_line_odds(acquisition)
125
+ parts = acquisition.to_h.symbolize_keys
126
+ Providers::Sandbox::Resources::CompetitionOdds.acquire(adapter: self, **parts)
127
+ end
128
+
129
+ def self.translate_line_odds(odds, observed_at:, acquisition:)
130
+ NflHelpers::CompetitionOdds.call(odds, observed_at: observed_at, acquisition: acquisition)
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Adapters
5
+ module Sandbox
6
+ module NflHelpers
7
+ module AcquisitionScope
8
+ module_function
9
+
10
+ def call(game) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
11
+ group = game.schedule_group
12
+ event = identity_for(game, "event")
13
+ competition = identity_for(game, "competition")
14
+ home = participant_payload_for(game, "home")
15
+ away = participant_payload_for(game, "away")
16
+ return unless valid?(group, event, home, away)
17
+
18
+ {
19
+ endpoint: :scoreboard,
20
+ acquisition: {
21
+ game_id: game.id,
22
+ event_id: event.provider_id,
23
+ competition_id: competition&.provider_id,
24
+ scheduled_at: game.scheduled_at.iso8601,
25
+ season_year: season_identity(game.season.name),
26
+ season_phase_key: game.season_phase.name,
27
+ week_number: group.number,
28
+ home: home,
29
+ away: away
30
+ }
31
+ }
32
+ rescue ArgumentError, NoMethodError, TypeError
33
+ nil
34
+ end
35
+
36
+ def season_identity(value)
37
+ Integer(value, exception: false) || value.to_s
38
+ end
39
+ private_class_method :season_identity
40
+
41
+ def identity_for(game, namespace)
42
+ game.provider_identities.find do |identity|
43
+ identity.provider == "sandbox" && identity.object_namespace == namespace
44
+ end
45
+ end
46
+ private_class_method :identity_for
47
+
48
+ def participant_payload_for(game, role) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
49
+ participant = game.game_participants.find { |row| row.role == role }
50
+ return if participant.nil?
51
+
52
+ identity = participant.team.provider_identities.find do |candidate|
53
+ candidate.provider == "espn" && candidate.object_namespace == "team"
54
+ end
55
+ return if identity.nil?
56
+
57
+ {
58
+ provider_id: identity.provider_id,
59
+ name: participant.team.name,
60
+ nickname: participant.team.nickname,
61
+ abbreviation: participant.team.abbreviation
62
+ }
63
+ end
64
+ private_class_method :participant_payload_for
65
+
66
+ def valid?(group, event, home, away)
67
+ group&.kind == "week" && group.number.present? && event.present? && home.present? && away.present?
68
+ end
69
+ private_class_method :valid?
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module Jumbotron
6
+ module Adapters
7
+ module Sandbox
8
+ module NflHelpers
9
+ class CompetitionOdds
10
+ BOOKMAKER_ID = "synthetic-consensus"
11
+ BOOKMAKER_NAME = "Sandbox Sportsbook"
12
+
13
+ def self.call(payload, observed_at:, acquisition:) # rubocop:disable Metrics/MethodLength
14
+ event_id, competition_id = identity_values(payload, acquisition)
15
+ values = payload.symbolize_keys.reverse_merge(acquisition.symbolize_keys)
16
+
17
+ Canonical::LineIngestInput.new(
18
+ observed_at: observed_at,
19
+ provider: "sandbox",
20
+ adapter_scope: "sandbox_nfl",
21
+ game_identities: game_identities(event_id, competition_id),
22
+ observations: observations(
23
+ home_spread: BigDecimal(values.fetch(:home_spread).to_s),
24
+ total: BigDecimal(values.fetch(:total).to_s)
25
+ )
26
+ )
27
+ rescue ArgumentError, KeyError => e
28
+ raise TransformError, "invalid sandbox line observation: #{e.message}"
29
+ end
30
+
31
+ def self.identity_values(payload, acquisition)
32
+ data = payload.symbolize_keys
33
+ scope = acquisition.symbolize_keys
34
+ event_id = data[:event_id].presence || scope.fetch(:event_id)
35
+ [event_id, data[:competition_id].presence || scope[:competition_id] || event_id]
36
+ end
37
+ private_class_method :identity_values
38
+
39
+ def self.game_identities(event_id, competition_id)
40
+ [
41
+ Canonical::ProviderIdentityRef.new(provider: "sandbox", namespace: "event", id: event_id.to_s),
42
+ Canonical::ProviderIdentityRef.new(
43
+ provider: "sandbox",
44
+ namespace: "competition",
45
+ id: competition_id.to_s
46
+ )
47
+ ]
48
+ end
49
+ private_class_method :game_identities
50
+
51
+ def self.observations(home_spread:, total:) # rubocop:disable Metrics/MethodLength
52
+ identity = Canonical::ProviderIdentityRef.new(
53
+ provider: "sandbox",
54
+ namespace: "bookmaker",
55
+ id: BOOKMAKER_ID
56
+ )
57
+ [
58
+ line(identity, "spread", "home", home_spread),
59
+ line(identity, "spread", "away", -home_spread),
60
+ line(identity, "total", "over", total),
61
+ line(identity, "total", "under", total)
62
+ ]
63
+ end
64
+ private_class_method :observations
65
+
66
+ def self.line(identity, market, outcome, value)
67
+ Canonical::LineObservationInput.new(
68
+ bookmaker_identity: identity,
69
+ bookmaker_name: BOOKMAKER_NAME,
70
+ market: market,
71
+ outcome: outcome,
72
+ source: "observed",
73
+ line_value: BigDecimal(value.to_s),
74
+ price_american: -110
75
+ )
76
+ end
77
+ private_class_method :line
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Adapters
5
+ module Sandbox
6
+ module NflHelpers
7
+ module CurrentObservationState
8
+ module_function
9
+
10
+ def call(game)
11
+ SyntheticObservation::Current.new(
12
+ lifecycle: game.lifecycle,
13
+ progress: progress(game),
14
+ home_score: participant(game, "home").score,
15
+ away_score: participant(game, "away").score
16
+ )
17
+ end
18
+
19
+ def participant(game, role)
20
+ game.game_participants.find { |row| row.role == role } ||
21
+ raise(ArgumentError, "sandbox game requires #{role} participant")
22
+ end
23
+ private_class_method :participant
24
+
25
+ def progress(game)
26
+ return if game.progress_state.nil?
27
+
28
+ Jumbotron::Canonical::GameProgress.new(
29
+ state: game.progress_state,
30
+ segment: Jumbotron::Canonical::GameSegment.new(
31
+ kind: game.progress_segment_kind,
32
+ number: game.progress_segment_number
33
+ ),
34
+ clock: clock(game)
35
+ )
36
+ end
37
+ private_class_method :progress
38
+
39
+ def clock(game)
40
+ return if game.progress_clock_mode.nil?
41
+
42
+ Jumbotron::Canonical::GameClock.new(
43
+ mode: game.progress_clock_mode,
44
+ seconds: game.progress_clock_seconds,
45
+ display: game.progress_clock_display
46
+ )
47
+ end
48
+ private_class_method :clock
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Adapters
5
+ module Sandbox
6
+ module NflHelpers
7
+ module LineAcquisitionScope
8
+ module_function
9
+
10
+ def call(game) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
11
+ identities = game.provider_identities.index_by(&:object_namespace)
12
+ event = identities["event"]
13
+ mapping = game.sandbox_game_mapping
14
+ return if event.nil? || event.provider != "sandbox" || event.provider_id.blank? || mapping.nil?
15
+
16
+ {
17
+ endpoint: :competition_odds,
18
+ acquisition: {
19
+ event_id: event.provider_id,
20
+ competition_id: identities["competition"]&.provider_id || event.provider_id,
21
+ home_spread: mapping.home_spread.to_s,
22
+ total: mapping.total.to_s
23
+ }
24
+ }
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Adapters
5
+ module Sandbox
6
+ module NflHelpers
7
+ module MonotonicObservation
8
+ ACTIVE_ORDINALS = { 1 => 1, 2 => 2, 3 => 4, 4 => 5 }.freeze
9
+
10
+ module_function
11
+
12
+ def call(proposed:, current:, plan:)
13
+ validate_current_scores!(current, plan)
14
+ proposed_ordinal = ordinal(proposed.lifecycle, proposed.progress)
15
+ current_ordinal = ordinal(current.lifecycle, current.progress)
16
+ state = select_state(proposed, current, proposed_ordinal, current_ordinal)
17
+ build_observation(state, proposed, current, plan)
18
+ end
19
+
20
+ def select_state(proposed, current, proposed_ordinal, current_ordinal)
21
+ return current if current_ordinal > proposed_ordinal
22
+ return proposed unless current_ordinal == proposed_ordinal
23
+ return proposed unless active_progress?(proposed.progress) && active_progress?(current.progress)
24
+
25
+ proposed.with(progress: clamp_clock(proposed.progress, current.progress))
26
+ end
27
+ private_class_method :select_state
28
+
29
+ def build_observation(state, proposed, current, plan)
30
+ return completed_observation(plan) if state.lifecycle == "completed"
31
+ return state if state.lifecycle == "scheduled"
32
+
33
+ ObservationValues.observation(
34
+ lifecycle: state.lifecycle,
35
+ progress: state.progress,
36
+ home_score: [proposed.home_score || 0, current.home_score || 0].max,
37
+ away_score: [proposed.away_score || 0, current.away_score || 0].max
38
+ )
39
+ end
40
+ private_class_method :build_observation
41
+
42
+ def completed_observation(plan)
43
+ home_result, away_result = ObservationValues.results(plan.home_score, plan.away_score)
44
+ ObservationValues.observation(
45
+ lifecycle: "completed",
46
+ progress: nil,
47
+ home_score: plan.home_score,
48
+ away_score: plan.away_score,
49
+ home_result: home_result,
50
+ away_result: away_result
51
+ )
52
+ end
53
+ private_class_method :completed_observation
54
+
55
+ def active_progress?(progress)
56
+ progress&.state == "active"
57
+ end
58
+ private_class_method :active_progress?
59
+
60
+ def clamp_clock(proposed, current)
61
+ proposed_seconds = proposed.clock&.seconds
62
+ current_seconds = current.clock&.seconds
63
+ return proposed if proposed_seconds.nil? || current_seconds.nil? || proposed_seconds <= current_seconds
64
+
65
+ proposed.with(clock: current.clock)
66
+ end
67
+ private_class_method :clamp_clock
68
+
69
+ def ordinal(lifecycle, progress)
70
+ return 0 if lifecycle == "scheduled"
71
+ return 6 if lifecycle == "completed"
72
+
73
+ validate_in_progress!(lifecycle, progress)
74
+ return 3 if progress.state == "intermission" && progress.segment.number == 2
75
+
76
+ active_ordinal(progress)
77
+ end
78
+ private_class_method :ordinal
79
+
80
+ def validate_in_progress!(lifecycle, progress)
81
+ raise ArgumentError, "unsupported sandbox lifecycle #{lifecycle.inspect}" unless lifecycle == "in_progress"
82
+ return if %w[active intermission].include?(progress&.state)
83
+
84
+ raise ArgumentError, "in-progress sandbox game requires canonical progress"
85
+ end
86
+ private_class_method :validate_in_progress!
87
+
88
+ def active_ordinal(progress)
89
+ return ACTIVE_ORDINALS.fetch(progress.segment.number) if progress.state == "active"
90
+
91
+ raise ArgumentError, "sandbox intermission must follow quarter two"
92
+ rescue KeyError
93
+ raise ArgumentError, "sandbox game requires quarter progress from 1 through 4"
94
+ end
95
+ private_class_method :active_ordinal
96
+
97
+ def validate_current_scores!(current, plan)
98
+ return if valid_score?(current.home_score, plan.home_score) &&
99
+ valid_score?(current.away_score, plan.away_score)
100
+
101
+ raise ArgumentError, "persisted sandbox score exceeds selected final"
102
+ end
103
+ private_class_method :validate_current_scores!
104
+
105
+ def valid_score?(score, final_score)
106
+ score.nil? || score <= final_score
107
+ end
108
+ private_class_method :valid_score?
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Adapters
5
+ module Sandbox
6
+ module NflHelpers
7
+ module ObservationValues
8
+ module_function
9
+
10
+ def observation(**attributes)
11
+ defaults = {
12
+ progress: nil,
13
+ home_score: nil,
14
+ away_score: nil,
15
+ home_result: nil,
16
+ away_result: nil
17
+ }
18
+ SyntheticObservation::Observation.new(**defaults, **attributes)
19
+ end
20
+
21
+ def progress(state:, quarter:, clock: nil)
22
+ Canonical::GameProgress.new(
23
+ state: state,
24
+ segment: Canonical::GameSegment.new(kind: "quarter", number: quarter),
25
+ clock: clock
26
+ )
27
+ end
28
+
29
+ def clock(local_seconds:, quarter_seconds:, game_clock_seconds:)
30
+ elapsed = ((local_seconds / quarter_seconds.to_f) * game_clock_seconds).floor
31
+ remaining = [game_clock_seconds - elapsed, 0].max
32
+ Canonical::GameClock.new(
33
+ mode: "remaining",
34
+ seconds: remaining,
35
+ display: format("%<minutes>d:%<seconds>02d", minutes: remaining / 60, seconds: remaining % 60)
36
+ )
37
+ end
38
+
39
+ def score_at(final_score, fraction)
40
+ (final_score * fraction).floor
41
+ end
42
+
43
+ def results(home_score, away_score)
44
+ return %w[win loss] if home_score > away_score
45
+ return %w[loss win] if away_score > home_score
46
+
47
+ %w[tie tie]
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module Jumbotron
6
+ module Adapters
7
+ module Sandbox
8
+ module NflHelpers
9
+ module OutcomeCandidates
10
+ Outcome = Data.define(:home_score, :away_score)
11
+ SCORE_OFFSETS = [[0, 0], [3, 0], [0, 3], [-3, 0], [0, -3]].freeze
12
+
13
+ module_function
14
+
15
+ def call(home_spread:, total:)
16
+ spread_value = BigDecimal(home_spread.to_s)
17
+ total_value = BigDecimal(total.to_s)
18
+ home = ((total_value - spread_value) / 2).round
19
+ away = ((total_value + spread_value) / 2).round
20
+
21
+ SCORE_OFFSETS.filter_map do |home_offset, away_offset|
22
+ candidate(home + home_offset, away + away_offset)
23
+ end.uniq
24
+ rescue ArgumentError, TypeError
25
+ []
26
+ end
27
+
28
+ def candidate(home_score, away_score)
29
+ return if home_score.negative? || away_score.negative? || home_score == away_score
30
+
31
+ Outcome.new(home_score: home_score, away_score: away_score)
32
+ end
33
+ private_class_method :candidate
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Jumbotron
6
+ module Adapters
7
+ module Sandbox
8
+ module NflHelpers
9
+ module Projection
10
+ module_function
11
+
12
+ def call(payload, observed_at:)
13
+ data = payload.symbolize_keys
14
+ Canonical::SyncInput.new(
15
+ observed_at: observed_at,
16
+ games: [game_input(data)],
17
+ teams: []
18
+ )
19
+ rescue ArgumentError, KeyError, TypeError => e
20
+ raise TransformError, "invalid sandbox projection: #{e.message}"
21
+ end
22
+
23
+ def game_input(data) # rubocop:disable Metrics/MethodLength
24
+ Canonical::GameInput.new(
25
+ provider_identities: game_identities(data),
26
+ scheduled_at: Time.iso8601(data.fetch(:scheduled_at).to_s),
27
+ lifecycle: "scheduled",
28
+ neutral_site: data.fetch(:neutral_site, false),
29
+ season_year: data.fetch(:season),
30
+ season_phase_key: data.fetch(:season_phase),
31
+ schedule_group: schedule_group(data),
32
+ venue: nil,
33
+ participants: %i[home away].map { |role| participant(data.fetch(role), role) },
34
+ progress: nil
35
+ )
36
+ end
37
+ private_class_method :game_input
38
+
39
+ def game_identities(data)
40
+ event_id = data.fetch(:event_id).to_s
41
+ competition_id = data.fetch(:competition_id, event_id).to_s
42
+ [
43
+ Canonical::ProviderIdentityRef.new(provider: "sandbox", namespace: "event", id: event_id),
44
+ Canonical::ProviderIdentityRef.new(
45
+ provider: "sandbox",
46
+ namespace: "competition",
47
+ id: competition_id
48
+ )
49
+ ].uniq
50
+ end
51
+ private_class_method :game_identities
52
+
53
+ def schedule_group(data)
54
+ number = Integer(data.fetch(:week_number))
55
+ Canonical::ScheduleGroupInput.new(
56
+ kind: "week",
57
+ number: number,
58
+ name: data.fetch(:week_name).to_s
59
+ )
60
+ end
61
+ private_class_method :schedule_group
62
+
63
+ def participant(raw, role) # rubocop:disable Metrics/MethodLength
64
+ data = raw.symbolize_keys
65
+ Canonical::ParticipantInput.new(
66
+ provider_identities: [
67
+ Canonical::ProviderIdentityRef.new(
68
+ provider: "espn",
69
+ namespace: "team",
70
+ id: data.fetch(:provider_id).to_s
71
+ )
72
+ ],
73
+ team_name: data.fetch(:name).to_s,
74
+ team_nickname: data[:nickname].presence,
75
+ team_abbreviation: data[:abbreviation].presence,
76
+ role: role.to_s,
77
+ score: nil,
78
+ result: nil,
79
+ record_summary: nil
80
+ )
81
+ end
82
+ private_class_method :participant
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end