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
@@ -7,6 +7,16 @@ module Jumbotron
7
7
  has_many :schedule_groups, inverse_of: :season, dependent: :restrict_with_exception
8
8
  has_many :games, inverse_of: :season, dependent: :restrict_with_exception
9
9
  has_many :provider_identities, as: :target, inverse_of: :target, dependent: :restrict_with_exception
10
+ has_many :source_sandbox_projections,
11
+ class_name: "Jumbotron::SandboxProjection",
12
+ foreign_key: :source_season_id,
13
+ inverse_of: :source_season,
14
+ dependent: :restrict_with_exception
15
+ has_one :sandbox_projection,
16
+ class_name: "Jumbotron::SandboxProjection",
17
+ foreign_key: :sandbox_season_id,
18
+ inverse_of: :sandbox_season,
19
+ dependent: :restrict_with_exception
10
20
 
11
21
  validates :name, presence: true
12
22
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Providers
5
+ module Sandbox
6
+ module Resources
7
+ class CompetitionOdds
8
+ def self.acquire(adapter:, **query)
9
+ return invalid_adapter unless adapter.provider == "sandbox"
10
+
11
+ CommandTower::Clients::ClientResult.success(output: query.symbolize_keys)
12
+ end
13
+
14
+ def self.invalid_adapter
15
+ CommandTower::Clients::ClientResult.failure(
16
+ error: ArgumentError.new("sandbox provider requires a sandbox adapter")
17
+ )
18
+ end
19
+ private_class_method :invalid_adapter
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Providers
5
+ module Sandbox
6
+ module Resources
7
+ module Projection
8
+ module_function
9
+
10
+ def acquire(adapter:, **projection)
11
+ unless adapter.provider.to_s == "sandbox"
12
+ return CommandTower::Clients::ClientResult.failure(
13
+ error: ArgumentError.new("sandbox projection requires sandbox adapter")
14
+ )
15
+ end
16
+
17
+ CommandTower::Clients::ClientResult.success(output: projection)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Providers
5
+ module Sandbox
6
+ module Resources
7
+ class Scoreboard
8
+ def self.acquire(adapter:, **query)
9
+ return invalid_adapter unless adapter.provider == "sandbox"
10
+
11
+ CommandTower::Clients::ClientResult.success(output: query.symbolize_keys)
12
+ end
13
+
14
+ def self.invalid_adapter
15
+ CommandTower::Clients::ClientResult.failure(
16
+ error: ArgumentError.new("sandbox provider requires a sandbox adapter")
17
+ )
18
+ end
19
+ private_class_method :invalid_adapter
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -6,6 +6,7 @@ module Jumbotron
6
6
  class ExecuteLineAcquisitionScope < CommandTower::Services::ApplicationService
7
7
  validate :adapter, required: true
8
8
  validate :acquisition, is_a: Hash, required: true
9
+ validate :observed_at, required: false
9
10
 
10
11
  def call
11
12
  if adapter.provider_cooling_down?
@@ -43,7 +44,7 @@ module Jumbotron
43
44
 
44
45
  ingest = adapter.translate_line_odds(
45
46
  provider_result.output,
46
- observed_at: Time.current,
47
+ observed_at: observed_at || Time.current,
47
48
  acquisition: acquisition
48
49
  )
49
50
  persisted = Canonical::PersistLineObservations.call(ingest: ingest)
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Canonical
6
+ class UpsertGameGraph < CommandTower::Services::ApplicationService
7
+ validate :game_input, required: true
8
+ validate :league, required: true
9
+ validate :observed_at, required: true
10
+ validate :change_set, required: true
11
+
12
+ def call
13
+ season = resolve(EnsureSeason.call(league: league, year: game_input.season_year), :season)
14
+ return if season.nil?
15
+
16
+ phase = resolve(
17
+ EnsureSeasonPhase.call(season: season, phase_key: game_input.season_phase_key),
18
+ :season_phase
19
+ )
20
+ return if phase.nil?
21
+
22
+ group = resolve_group(season, phase)
23
+ return if context.failure?
24
+
25
+ venue = resolve(
26
+ UpsertVenue.call(
27
+ venue_input: game_input.venue,
28
+ observed_at: observed_at,
29
+ change_set: change_set
30
+ ),
31
+ :venue,
32
+ allow_nil: true
33
+ )
34
+ return if context.failure?
35
+
36
+ game = upsert_game(season, phase, group, venue)
37
+ return if game.nil?
38
+ return unless attach_identities?(game)
39
+ return unless upsert_participants?(game)
40
+
41
+ context.game = game
42
+ end
43
+
44
+ private
45
+
46
+ def resolve_group(season, phase)
47
+ resolve(
48
+ EnsureScheduleGroup.call(
49
+ season: season,
50
+ season_phase: phase,
51
+ schedule_group_input: game_input.schedule_group
52
+ ),
53
+ :schedule_group,
54
+ allow_nil: true
55
+ )
56
+ end
57
+
58
+ def upsert_game(season, phase, group, venue)
59
+ resolve(
60
+ UpsertGame.call(
61
+ game_input: game_input,
62
+ league: league,
63
+ season: season,
64
+ season_phase: phase,
65
+ schedule_group: group,
66
+ venue: venue,
67
+ observed_at: observed_at,
68
+ change_set: change_set
69
+ ),
70
+ :game
71
+ )
72
+ end
73
+
74
+ def attach_identities?(game)
75
+ result = AttachProviderIdentities.call(target: game, refs: game_input.provider_identities)
76
+ return true if result.success?
77
+
78
+ fail_from(result)
79
+ false
80
+ end
81
+
82
+ def upsert_participants?(game)
83
+ game_input.participants.each do |participant_input|
84
+ result = UpsertGameParticipant.call(
85
+ game: game,
86
+ participant_input: participant_input,
87
+ observed_at: observed_at,
88
+ change_set: change_set,
89
+ preserve_existing_team: sandbox_game_input?
90
+ )
91
+ unless result.success?
92
+ fail_from(result)
93
+ return false
94
+ end
95
+ end
96
+ true
97
+ end
98
+
99
+ def sandbox_game_input?
100
+ game_input.provider_identities.any? { |identity| identity.provider == "sandbox" }
101
+ end
102
+
103
+ def resolve(result, key, allow_nil: false)
104
+ unless result.success?
105
+ fail_from(result)
106
+ return
107
+ end
108
+
109
+ value = result.data[key]
110
+ return value if allow_nil || !value.nil?
111
+
112
+ context.fail!(
113
+ application_error: Jumbotron::Errors::Canonical::MutationFailedError.new(
114
+ details: { message: "#{key} was not resolved" }
115
+ )
116
+ )
117
+ nil
118
+ end
119
+
120
+ def fail_from(result)
121
+ context.fail!(application_error: Array(result.errors).first)
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -8,9 +8,11 @@ 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,
@@ -20,12 +22,20 @@ module Jumbotron
20
22
  observed_at: observed_at,
21
23
  change_set: change_set
22
24
  )
23
- 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?
24
35
  context.fail!(application_error: team_result.errors.first)
25
36
  return
26
37
  end
27
38
 
28
- team = team_result.data[:team]
29
39
  participant = game.game_participants.lock.find_by(team_id: team.id)
30
40
  context.game_participant = resolve_participant(participant, team)
31
41
  rescue ActiveRecord::RecordInvalid => e
@@ -38,6 +48,16 @@ module Jumbotron
38
48
 
39
49
  private
40
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
+
41
61
  def resolve_participant(participant, team)
42
62
  if participant
43
63
  update_existing!(participant)
@@ -0,0 +1,39 @@
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_label: source_label,
13
+ source: selector(projection.source_season, projection.source_anchor_phase.name),
14
+ sandbox: selector(projection.sandbox_season, projection.source_anchor_phase.name)
15
+ )
16
+ end
17
+
18
+ private
19
+
20
+ def source_label
21
+ [
22
+ projection.source_season.league.name.upcase,
23
+ projection.source_season.name,
24
+ projection.source_anchor_phase.name.titleize
25
+ ].join(" ")
26
+ end
27
+
28
+ def selector(season, phase_name)
29
+ Jumbotron::Public::SandboxScheduleSelector.new(
30
+ sport: season.league.sport.name,
31
+ league: season.league.name,
32
+ season: season.name,
33
+ season_phase: phase_name
34
+ )
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,58 @@
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_label: source_label,
34
+ source: selector(projection.source_season, projection.source_anchor_phase.name),
35
+ sandbox: selector(projection.sandbox_season, projection.source_anchor_phase.name)
36
+ )
37
+ end
38
+
39
+ def source_label
40
+ [
41
+ projection.source_season.league.name.upcase,
42
+ projection.source_season.name,
43
+ projection.source_anchor_phase.name.titleize
44
+ ].join(" ")
45
+ end
46
+
47
+ def selector(season, phase_name)
48
+ Jumbotron::Public::SandboxScheduleSelector.new(
49
+ sport: season.league.sport.name,
50
+ league: season.league.name,
51
+ season: season.name,
52
+ season_phase: phase_name
53
+ )
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Public
6
+ class ReadSandboxProjection < CommandTower::Services::ApplicationService
7
+ validate :name, required: true
8
+
9
+ def call
10
+ projection = Jumbotron::SandboxProjection.includes(
11
+ :source_anchor_phase,
12
+ source_season: { league: :sport },
13
+ sandbox_season: { league: :sport }
14
+ ).find_by(name:)
15
+ return reject if projection.nil?
16
+
17
+ context.projection = projection
18
+ end
19
+
20
+ private
21
+
22
+ def reject
23
+ context.fail!(
24
+ application_error: Jumbotron::Errors::Public::NotFoundError.new(
25
+ details: { code: "sandbox_not_found", message: "sandbox projection was not found" }
26
+ )
27
+ )
28
+ end
29
+ end
30
+ end
31
+ end
32
+ 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