jumbotron 0.1.6 → 0.2.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/app/adapters/jumbotron/adapters/espn/nfl.rb +15 -0
  3. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/scoreboard.rb +14 -2
  4. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/teams.rb +2 -1
  5. data/app/adapters/jumbotron/adapters/sandbox/nfl.rb +135 -0
  6. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/acquisition_scope.rb +74 -0
  7. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/competition_odds.rb +82 -0
  8. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/current_observation_state.rb +53 -0
  9. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/line_acquisition_scope.rb +30 -0
  10. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/monotonic_observation.rb +113 -0
  11. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/observation_values.rb +53 -0
  12. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/outcome_candidates.rb +38 -0
  13. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/projection.rb +87 -0
  14. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/scoreboard.rb +115 -0
  15. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/synthetic_observation.rb +32 -0
  16. data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/timeline_observation.rb +119 -0
  17. data/app/deserializers/jumbotron/deserializers/clients/espn/competitor.rb +14 -1
  18. data/app/deserializers/jumbotron/deserializers/clients/espn/competitor_record.rb +27 -0
  19. data/app/deserializers/jumbotron/deserializers/clients/espn/venue.rb +10 -1
  20. data/app/deserializers/jumbotron/deserializers/clients/espn/venue_address.rb +27 -0
  21. data/app/deserializers/jumbotron/deserializers/public/register_sandbox_projection.rb +42 -0
  22. data/app/deserializers/jumbotron/deserializers/public/reset_sandbox_projection.rb +51 -0
  23. data/app/errors/jumbotron/error.rb +7 -0
  24. data/app/errors/jumbotron/errors/sandbox.rb +67 -0
  25. data/app/errors/jumbotron/sandbox_reset_rejected_error.rb +6 -0
  26. data/app/models/jumbotron/canonical.rb +20 -5
  27. data/app/models/jumbotron/game.rb +11 -0
  28. data/app/models/jumbotron/public/translate_outcome.rb +13 -7
  29. data/app/models/jumbotron/public.rb +31 -6
  30. data/app/models/jumbotron/sandbox_game_mapping.rb +24 -0
  31. data/app/models/jumbotron/sandbox_game_plan.rb +23 -0
  32. data/app/models/jumbotron/sandbox_projection.rb +30 -0
  33. data/app/models/jumbotron/sandbox_projection_data.rb +32 -0
  34. data/app/models/jumbotron/season.rb +10 -0
  35. data/app/providers/jumbotron/providers/sandbox/resources/competition_odds.rb +24 -0
  36. data/app/providers/jumbotron/providers/sandbox/resources/projection.rb +23 -0
  37. data/app/providers/jumbotron/providers/sandbox/resources/scoreboard.rb +24 -0
  38. data/app/services/jumbotron/services/adapters/execute_line_acquisition_scope.rb +2 -1
  39. data/app/services/jumbotron/services/adapters/resolve_game_update_policy.rb +1 -0
  40. data/app/services/jumbotron/services/adapters/select_eligible_games.rb +1 -1
  41. data/app/services/jumbotron/services/canonical/upsert_game_graph.rb +126 -0
  42. data/app/services/jumbotron/services/canonical/upsert_game_participant.rb +114 -6
  43. data/app/services/jumbotron/services/canonical/upsert_team.rb +24 -0
  44. data/app/services/jumbotron/services/canonical/upsert_venue.rb +33 -1
  45. data/app/services/jumbotron/services/public/assemble_public_game.rb +20 -2
  46. data/app/services/jumbotron/services/public/assemble_sandbox_projection.rb +30 -0
  47. data/app/services/jumbotron/services/public/assemble_sandbox_projection_reset.rb +49 -0
  48. data/app/services/jumbotron/services/sandbox/build_source_snapshot.rb +224 -0
  49. data/app/services/jumbotron/services/sandbox/project_game.rb +73 -0
  50. data/app/services/jumbotron/services/sandbox/register_projection.rb +76 -0
  51. data/app/services/jumbotron/services/sandbox/remove_empty_projection_geometry.rb +46 -0
  52. data/app/services/jumbotron/services/sandbox/remove_mapped_game.rb +29 -0
  53. data/app/services/jumbotron/services/sandbox/resolve_game_observation.rb +106 -0
  54. data/app/services/jumbotron/services/sandbox/seed_projected_game.rb +95 -0
  55. data/app/services/jumbotron/services/sandbox/select_line_basis.rb +69 -0
  56. data/app/services/jumbotron/services/sandbox/validate_projected_season.rb +77 -0
  57. data/app/workflows/jumbotron/workflows/public_read.rb +2 -1
  58. data/app/workflows/jumbotron/workflows/sandbox/register_projection_workflow.rb +33 -0
  59. data/app/workflows/jumbotron/workflows/sandbox/reset_projection_workflow.rb +158 -0
  60. data/app/workflows/jumbotron/workflows/synchronize_adapter_workflow.rb +3 -51
  61. data/db/migrate/20260828220001_add_game_presentation_contract_fields.rb +19 -0
  62. data/db/migrate/20260908000001_create_jumbotron_sandbox_game_plans.rb +16 -0
  63. data/db/migrate/20260908000002_create_jumbotron_sandbox_projections.rb +39 -0
  64. data/docs/upgrades/0.2.0.md +74 -0
  65. data/lib/jumbotron/client.rb +14 -0
  66. data/lib/jumbotron/version.rb +1 -1
  67. data/spec/factories/jumbotron/sandbox_game_mapping.rb +24 -0
  68. data/spec/factories/jumbotron/sandbox_game_plan.rb +10 -0
  69. data/spec/factories/jumbotron/sandbox_projection.rb +18 -0
  70. metadata +62 -2
@@ -8,23 +8,34 @@ module Jumbotron
8
8
  validate :participant_input, required: true
9
9
  validate :observed_at, required: true
10
10
  validate :change_set, required: true
11
+ validate :preserve_existing_team, required: false
11
12
 
12
13
  def call
13
- team_result = UpsertTeam.call(
14
+ team = existing_team if preserve_existing_team
15
+ team_result = team || UpsertTeam.call(
14
16
  team_input: Jumbotron::Canonical::TeamInput.new(
15
17
  provider_identities: participant_input.provider_identities,
16
18
  name: participant_input.team_name,
17
- nickname: participant_input.team_nickname
19
+ nickname: participant_input.team_nickname,
20
+ abbreviation: participant_input.team_abbreviation
18
21
  ),
19
22
  observed_at: observed_at,
20
23
  change_set: change_set
21
24
  )
22
- unless team_result.success?
25
+ unless team
26
+ unless team_result.success?
27
+ context.fail!(application_error: team_result.errors.first)
28
+ return
29
+ end
30
+
31
+ team = team_result.data[:team]
32
+ end
33
+
34
+ if team.nil?
23
35
  context.fail!(application_error: team_result.errors.first)
24
36
  return
25
37
  end
26
38
 
27
- team = team_result.data[:team]
28
39
  participant = game.game_participants.lock.find_by(team_id: team.id)
29
40
  context.game_participant = resolve_participant(participant, team)
30
41
  rescue ActiveRecord::RecordInvalid => e
@@ -37,6 +48,16 @@ module Jumbotron
37
48
 
38
49
  private
39
50
 
51
+ def existing_team
52
+ ref = participant_input.provider_identities.first
53
+ identity = Jumbotron::ProviderIdentity.find_by(
54
+ provider: ref.provider,
55
+ object_namespace: ref.namespace,
56
+ provider_id: ref.id
57
+ )
58
+ identity&.target if identity&.target.is_a?(Jumbotron::Team)
59
+ end
60
+
40
61
  def resolve_participant(participant, team)
41
62
  if participant
42
63
  update_existing!(participant)
@@ -46,16 +67,18 @@ module Jumbotron
46
67
  end
47
68
 
48
69
  def create_new!(team)
49
- gp = game.game_participants.create!(
70
+ attrs = {
50
71
  team: team,
51
72
  role: participant_input.role,
52
73
  score: participant_input.score,
53
74
  result: participant_input.result,
54
75
  observed_at: observed_at,
55
76
  changed_at: observed_at
56
- )
77
+ }.merge(initial_record_attrs)
78
+ gp = game.game_participants.create!(attrs)
57
79
  change_set.record(subject: gp, attribute: "role", previous: nil, new_value: gp.role)
58
80
  change_set.record(subject: gp, attribute: "score", previous: nil, new_value: gp.score)
81
+ record_create_history!(gp)
59
82
  gp
60
83
  end
61
84
 
@@ -78,11 +101,96 @@ module Jumbotron
78
101
  participant.public_send("#{key}=", value)
79
102
  material = true
80
103
  end
104
+ material |= apply_record_snapshots!(participant)
81
105
  participant.observed_at = observed_at
82
106
  participant.changed_at = observed_at if material
83
107
  participant.save!
84
108
  participant
85
109
  end
110
+
111
+ def initial_record_attrs
112
+ summary = participant_input.record_summary
113
+ return {} if summary.blank?
114
+
115
+ attrs = {
116
+ record_summary_current: summary,
117
+ record_observed_at: observed_at
118
+ }
119
+ if game.lifecycle == "completed"
120
+ attrs[:record_summary_post_game] = summary
121
+ elsif game.lifecycle != "cancelled"
122
+ attrs[:record_summary_entering] = summary
123
+ end
124
+ attrs
125
+ end
126
+
127
+ def record_create_history!(participant)
128
+ %i[
129
+ record_summary_entering
130
+ record_summary_current
131
+ record_summary_post_game
132
+ ].each do |attr|
133
+ value = participant.public_send(attr)
134
+ next if value.blank?
135
+
136
+ change_set.record(subject: participant, attribute: attr.to_s, previous: nil, new_value: value)
137
+ end
138
+ end
139
+
140
+ def apply_record_snapshots!(participant)
141
+ summary = participant_input.record_summary
142
+ return false if summary.blank?
143
+
144
+ material = false
145
+ if participant.record_summary_current != summary
146
+ change_set.record(
147
+ subject: participant,
148
+ attribute: "record_summary_current",
149
+ previous: participant.record_summary_current,
150
+ new_value: summary
151
+ )
152
+ participant.record_summary_current = summary
153
+ material = true
154
+ end
155
+ participant.record_observed_at = observed_at
156
+
157
+ if game.lifecycle == "completed"
158
+ material |= apply_post_game_record!(participant, summary)
159
+ elsif game.lifecycle != "cancelled"
160
+ material |= assign_entering_record!(participant, summary)
161
+ end
162
+ material
163
+ end
164
+
165
+ def assign_entering_record!(participant, summary) # rubocop:disable Naming/PredicateMethod
166
+ return false if participant.record_summary_entering == summary
167
+
168
+ change_set.record(
169
+ subject: participant,
170
+ attribute: "record_summary_entering",
171
+ previous: participant.record_summary_entering,
172
+ new_value: summary
173
+ )
174
+ participant.record_summary_entering = summary
175
+ true
176
+ end
177
+
178
+ # Authoritative post-game only: never invent W–L; if total still equals
179
+ # entering on a completed observation, leave post_game nil (Strategy B).
180
+ def apply_post_game_record!(participant, summary) # rubocop:disable Naming/PredicateMethod
181
+ entering = participant.record_summary_entering
182
+ return false if entering.present? && summary == entering
183
+ return false if participant.record_summary_post_game == summary
184
+
185
+ change_set.record(
186
+ subject: participant,
187
+ attribute: "record_summary_post_game",
188
+ previous: participant.record_summary_post_game,
189
+ new_value: summary
190
+ )
191
+ participant.record_summary_post_game = summary
192
+ true
193
+ end
86
194
  end
87
195
  end
88
196
  end
@@ -58,6 +58,7 @@ module Jumbotron
58
58
  team.changed_at = observed_at
59
59
  end
60
60
  apply_nickname!(team)
61
+ apply_abbreviation!(team)
61
62
  # key is immutable after assignment — never regenerate from name or provider id
62
63
  team.observed_at = observed_at
63
64
  team.save!
@@ -68,6 +69,7 @@ module Jumbotron
68
69
  team = Team.create!(
69
70
  name: team_input.name,
70
71
  nickname: team_input.nickname,
72
+ abbreviation: team_input.abbreviation,
71
73
  key: allocate_key!(team_input.name),
72
74
  observed_at: observed_at,
73
75
  changed_at: observed_at
@@ -82,6 +84,14 @@ module Jumbotron
82
84
  new_value: team.nickname
83
85
  )
84
86
  end
87
+ if team.abbreviation.present?
88
+ change_set.record(
89
+ subject: team,
90
+ attribute: "abbreviation",
91
+ previous: nil,
92
+ new_value: team.abbreviation
93
+ )
94
+ end
85
95
  team
86
96
  end
87
97
 
@@ -99,6 +109,20 @@ module Jumbotron
99
109
  team.changed_at = observed_at
100
110
  end
101
111
 
112
+ def apply_abbreviation!(team)
113
+ return if team_input.abbreviation.nil?
114
+ return if team.abbreviation == team_input.abbreviation
115
+
116
+ change_set.record(
117
+ subject: team,
118
+ attribute: "abbreviation",
119
+ previous: team.abbreviation,
120
+ new_value: team_input.abbreviation
121
+ )
122
+ team.abbreviation = team_input.abbreviation
123
+ team.changed_at = observed_at
124
+ end
125
+
102
126
  def allocate_key!(name)
103
127
  base = Jumbotron::TeamKey.normalize(name)
104
128
  return base unless Team.exists?(key: base)
@@ -52,6 +52,7 @@ module Jumbotron
52
52
 
53
53
  def update_existing!(venue_id)
54
54
  venue = Venue.lock.find(venue_id)
55
+ material = false
55
56
  if venue.name != venue_input.name
56
57
  change_set.record(
57
58
  subject: venue,
@@ -60,8 +61,10 @@ module Jumbotron
60
61
  new_value: venue_input.name
61
62
  )
62
63
  venue.name = venue_input.name
63
- venue.changed_at = observed_at
64
+ material = true
64
65
  end
66
+ material |= apply_location!(venue)
67
+ venue.changed_at = observed_at if material
65
68
  venue.observed_at = observed_at
66
69
  venue.save!
67
70
  venue
@@ -70,13 +73,42 @@ module Jumbotron
70
73
  def create_new!
71
74
  venue = Venue.create!(
72
75
  name: venue_input.name,
76
+ city: venue_input.city,
77
+ region: venue_input.region,
73
78
  observed_at: observed_at,
74
79
  changed_at: observed_at
75
80
  )
76
81
  change_set.record(subject: venue, attribute: "name", previous: nil, new_value: venue.name)
82
+ if venue.city.present?
83
+ change_set.record(subject: venue, attribute: "city", previous: nil, new_value: venue.city)
84
+ end
85
+ if venue.region.present?
86
+ change_set.record(subject: venue, attribute: "region", previous: nil, new_value: venue.region)
87
+ end
77
88
  venue
78
89
  end
79
90
 
91
+ def apply_location!(venue)
92
+ material = false
93
+ {
94
+ city: venue_input.city,
95
+ region: venue_input.region
96
+ }.each do |key, value|
97
+ next if value.nil?
98
+ next if venue.public_send(key) == value
99
+
100
+ change_set.record(
101
+ subject: venue,
102
+ attribute: key.to_s,
103
+ previous: venue.public_send(key),
104
+ new_value: value
105
+ )
106
+ venue.public_send("#{key}=", value)
107
+ material = true
108
+ end
109
+ material
110
+ end
111
+
80
112
  def recover_after_race!
81
113
  ref = venue_input.provider_identities.first
82
114
  identity = ProviderIdentity.find_by!(
@@ -61,7 +61,12 @@ module Jumbotron
61
61
  def build_venue(venue)
62
62
  return if venue.nil?
63
63
 
64
- Jumbotron::Public::Venue.new(id: venue.id, name: venue.name)
64
+ Jumbotron::Public::Venue.new(
65
+ id: venue.id,
66
+ name: venue.name,
67
+ city: venue.city,
68
+ region: venue.region
69
+ )
65
70
  end
66
71
 
67
72
  def build_participant(participant)
@@ -69,15 +74,28 @@ module Jumbotron
69
74
  role: participant.role,
70
75
  score: participant.score,
71
76
  result: participant.result,
77
+ record: resolve_presentation_record(participant),
72
78
  team: Jumbotron::Public::Team.new(
73
79
  id: participant.team.id,
74
80
  name: participant.team.name,
75
81
  nickname: participant.team.nickname,
76
- key: participant.team.key
82
+ key: participant.team.key,
83
+ abbreviation: participant.team.abbreviation
77
84
  )
78
85
  )
79
86
  end
80
87
 
88
+ # Lifecycle-resolved game-time record for presentation. Never falls back to
89
+ # entering when completed post-game is missing.
90
+ def resolve_presentation_record(participant)
91
+ case game.lifecycle
92
+ when "scheduled", "in_progress", "postponed", "suspended"
93
+ participant.record_summary_entering
94
+ when "completed"
95
+ participant.record_summary_post_game
96
+ end
97
+ end
98
+
81
99
  def map_currents(lines)
82
100
  return [] unless Array(includes).include?(:current_lines)
83
101
 
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Public
6
+ class AssembleSandboxProjection < CommandTower::Services::ApplicationService
7
+ validate :projection, required: true
8
+
9
+ def call
10
+ context.public_projection = Jumbotron::Public::SandboxProjection.new(
11
+ name: projection.name,
12
+ source: selector(projection.source_season, projection.source_anchor_phase.name),
13
+ sandbox: selector(projection.sandbox_season, projection.source_anchor_phase.name)
14
+ )
15
+ end
16
+
17
+ private
18
+
19
+ def selector(season, phase_name)
20
+ Jumbotron::Public::SandboxScheduleSelector.new(
21
+ sport: season.league.sport.name,
22
+ league: season.league.name,
23
+ season: season.name,
24
+ season_phase: phase_name
25
+ )
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Public
6
+ class AssembleSandboxProjectionReset < CommandTower::Services::ApplicationService
7
+ validate :projection, required: true
8
+ validate :snapshot, required: true
9
+ validate :counts, required: true
10
+ validate :reset_at, required: true
11
+
12
+ def call
13
+ context.public_reset = Jumbotron::Public::SandboxProjectionReset.new(
14
+ projection: projection_identity,
15
+ source_anchor_date: snapshot.source_anchor_date,
16
+ target_anchor_date: snapshot.target_anchor_date,
17
+ date_shift_days: snapshot.date_shift_days,
18
+ projected_game_count: snapshot.games.count,
19
+ added_game_count: counts.fetch(:added),
20
+ updated_game_count: counts.fetch(:updated),
21
+ removed_game_count: counts.fetch(:removed),
22
+ start_week_game_count: counts.fetch(:start_week),
23
+ naturally_locked_game_count: counts.fetch(:naturally_locked),
24
+ reset_at: reset_at
25
+ )
26
+ end
27
+
28
+ private
29
+
30
+ def projection_identity
31
+ Jumbotron::Public::SandboxProjection.new(
32
+ name: projection.name,
33
+ source: selector(projection.source_season, projection.source_anchor_phase.name),
34
+ sandbox: selector(projection.sandbox_season, projection.source_anchor_phase.name)
35
+ )
36
+ end
37
+
38
+ def selector(season, phase_name)
39
+ Jumbotron::Public::SandboxScheduleSelector.new(
40
+ sport: season.league.sport.name,
41
+ league: season.league.name,
42
+ season: season.name,
43
+ season_phase: phase_name
44
+ )
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ # This service validates and snapshots the complete source projection atomically.
7
+ # rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength
8
+ module Jumbotron
9
+ module Services
10
+ module Sandbox
11
+ class BuildSourceSnapshot < CommandTower::Services::ApplicationService
12
+ validate :projection, required: true
13
+ validate :start_week, required: true
14
+ validate :anchor_day, required: true
15
+ validate :now, required: true
16
+
17
+ def call
18
+ source_games = load_source_games
19
+ return if source_games.nil?
20
+
21
+ calendar = resolve_calendar(source_games)
22
+ return if calendar.nil?
23
+
24
+ raw_games = source_games.map { |game| raw_game(game) }
25
+ return if context.failure?
26
+
27
+ fingerprint = fingerprint_for(raw_games, calendar)
28
+ projected_games = raw_games.map { |raw| projected_game(raw, calendar, fingerprint) }
29
+ return if context.failure?
30
+
31
+ context.snapshot = Jumbotron::SandboxProjectionData::Snapshot.new(
32
+ games: projected_games,
33
+ source_anchor_date: calendar.fetch(:source_anchor_date),
34
+ target_anchor_date: calendar.fetch(:target_anchor_date),
35
+ date_shift_days: calendar.fetch(:date_shift_days),
36
+ fingerprint: fingerprint,
37
+ start_week: start_week
38
+ )
39
+ end
40
+
41
+ private
42
+
43
+ def load_source_games
44
+ games = projection.source_season.games.includes(
45
+ :schedule_group,
46
+ :season_phase,
47
+ game_participants: { team: :provider_identities }
48
+ ).order(:scheduled_at, :id).to_a
49
+ return games if games.present? && games.all? { |game| complete_game?(game) }
50
+
51
+ reject("sandbox_projection_incomplete", "source season contains incomplete game structure")
52
+ nil
53
+ end
54
+
55
+ def complete_game?(game)
56
+ game.scheduled_at.present? &&
57
+ game.schedule_group&.kind == "week" &&
58
+ game.schedule_group.number.present? &&
59
+ game.season_phase.present? &&
60
+ %w[home away].all? { |role| participant(game, role).present? }
61
+ end
62
+
63
+ def resolve_calendar(games)
64
+ group = anchor_groups.one? ? anchor_groups.first : nil
65
+ return reject_calendar("source_week_not_found", "start week is missing or ambiguous") if group.nil?
66
+
67
+ group_games = games.select { |game| game.schedule_group_id == group.id }
68
+ return reject_calendar("source_week_not_found", "start week has no games") if group_games.empty?
69
+ return reject_calendar("sandbox_period_overlap", "source week windows overlap") unless non_overlapping?(games)
70
+
71
+ zone = ActiveSupport::TimeZone[projection.calendar_time_zone]
72
+ dates = group_games.map { |game| game.scheduled_at.in_time_zone(zone).to_date }
73
+ first_date = dates.min
74
+ last_date = dates.max
75
+ span = (last_date - first_date).to_i + 1
76
+ return reject_calendar("anchor_day_out_of_range", "anchor day exceeds source week span") if anchor_day > span
77
+
78
+ source_anchor = first_date + (anchor_day - 1)
79
+ target_anchor = now.in_time_zone(zone).to_date
80
+ {
81
+ zone: zone,
82
+ source_anchor_date: source_anchor,
83
+ target_anchor_date: target_anchor,
84
+ date_shift_days: (target_anchor - source_anchor).to_i
85
+ }
86
+ end
87
+
88
+ def anchor_groups
89
+ projection.source_anchor_phase.schedule_groups.where(
90
+ kind: "week",
91
+ number: start_week
92
+ ).to_a
93
+ end
94
+
95
+ def non_overlapping?(games)
96
+ windows = games.group_by(&:schedule_group_id).values.map do |members|
97
+ times = members.map(&:scheduled_at)
98
+ times.minmax
99
+ end.sort_by(&:first)
100
+ windows.each_cons(2).all? { |left, right| left.last < right.first }
101
+ end
102
+
103
+ def raw_game(game)
104
+ source_spread, source_total = source_consensus(game)
105
+ {
106
+ game: game,
107
+ source_spread: source_spread,
108
+ source_total: source_total,
109
+ home: team_data(participant(game, "home").team),
110
+ away: team_data(participant(game, "away").team)
111
+ }
112
+ rescue ArgumentError => e
113
+ reject("sandbox_projection_incomplete", e.message)
114
+ nil
115
+ end
116
+
117
+ def source_consensus(game)
118
+ result = Jumbotron::Services::Canonical::DeriveConsensusLine.call(game: game)
119
+ return [nil, nil] unless result.success?
120
+
121
+ lines = Array(result.data[:consensus_lines])
122
+ spread = lines.find { |line| line.market == "spread" && line.outcome == "home" }
123
+ total = lines.find { |line| line.market == "total" && line.outcome == "over" }
124
+ [spread&.line_value, total&.line_value]
125
+ end
126
+
127
+ def participant(game, role)
128
+ game.game_participants.find { |row| row.role == role }
129
+ end
130
+
131
+ def team_data(team)
132
+ identity = team.provider_identities.find do |candidate|
133
+ candidate.provider == "espn" && candidate.object_namespace == "team"
134
+ end
135
+ raise ArgumentError, "source team is missing ESPN identity" if identity.nil?
136
+
137
+ Jumbotron::SandboxProjectionData::Team.new(
138
+ provider_id: identity.provider_id,
139
+ name: team.name,
140
+ nickname: team.nickname,
141
+ abbreviation: team.abbreviation
142
+ )
143
+ end
144
+
145
+ def fingerprint_for(raw_games, calendar)
146
+ facts = raw_games.compact.map do |raw|
147
+ game = raw.fetch(:game)
148
+ [
149
+ game.id,
150
+ game.scheduled_at.iso8601,
151
+ game.season_phase.name,
152
+ game.schedule_group.number,
153
+ raw[:source_spread]&.to_s,
154
+ raw[:source_total]&.to_s
155
+ ]
156
+ end
157
+ Digest::SHA256.hexdigest(
158
+ JSON.generate(
159
+ version: 1,
160
+ projection: projection.name,
161
+ start_week: start_week,
162
+ anchor_day: anchor_day,
163
+ now: now.iso8601,
164
+ shift: calendar.fetch(:date_shift_days),
165
+ games: facts
166
+ )
167
+ )
168
+ end
169
+
170
+ def projected_game(raw, calendar, fingerprint)
171
+ game = raw.fetch(:game)
172
+ line = Jumbotron::Services::Sandbox::SelectLineBasis.call(
173
+ source_game_id: game.id,
174
+ reset_fingerprint: fingerprint,
175
+ source_spread: raw[:source_spread],
176
+ source_total: raw[:source_total]
177
+ )
178
+ unless line.success?
179
+ reject("sandbox_projection_incomplete", "sandbox line basis could not be selected")
180
+ return
181
+ end
182
+
183
+ Jumbotron::SandboxProjectionData::Game.new(
184
+ source_game_id: game.id,
185
+ event_id: "#{projection.name}:source-game:#{game.id}",
186
+ competition_id: "#{projection.name}:source-competition:#{game.id}",
187
+ scheduled_at: shift_time(game.scheduled_at, calendar),
188
+ season: projection.sandbox_season.name,
189
+ season_phase: game.season_phase.name,
190
+ week_number: game.schedule_group.number,
191
+ week_name: game.schedule_group.name,
192
+ neutral_site: game.neutral_site,
193
+ home: raw.fetch(:home),
194
+ away: raw.fetch(:away),
195
+ home_spread: line.data[:home_spread],
196
+ total: line.data[:total],
197
+ line_fingerprint: line.data[:line_fingerprint],
198
+ line_source: line.data[:line_source]
199
+ )
200
+ end
201
+
202
+ def shift_time(time, calendar)
203
+ time.in_time_zone(calendar.fetch(:zone))
204
+ .advance(days: calendar.fetch(:date_shift_days))
205
+ .utc
206
+ end
207
+
208
+ def reject_calendar(code, message)
209
+ reject(code, message)
210
+ nil
211
+ end
212
+
213
+ def reject(code, message)
214
+ context.fail!(
215
+ application_error: Jumbotron::Errors::Sandbox::ProjectionRejectedError.new(
216
+ details: { code: code, message: message }
217
+ )
218
+ )
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end
224
+ # rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength