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.
- checksums.yaml +4 -4
- data/app/adapters/jumbotron/adapters/espn/nfl.rb +15 -0
- data/app/adapters/jumbotron/adapters/espn/nfl_helpers/scoreboard.rb +14 -2
- data/app/adapters/jumbotron/adapters/espn/nfl_helpers/teams.rb +2 -1
- data/app/adapters/jumbotron/adapters/sandbox/nfl.rb +135 -0
- data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/acquisition_scope.rb +74 -0
- data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/competition_odds.rb +82 -0
- data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/current_observation_state.rb +53 -0
- data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/line_acquisition_scope.rb +30 -0
- data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/monotonic_observation.rb +113 -0
- data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/observation_values.rb +53 -0
- data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/outcome_candidates.rb +38 -0
- data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/projection.rb +87 -0
- data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/scoreboard.rb +115 -0
- data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/synthetic_observation.rb +32 -0
- data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/timeline_observation.rb +119 -0
- data/app/deserializers/jumbotron/deserializers/clients/espn/competitor.rb +14 -1
- data/app/deserializers/jumbotron/deserializers/clients/espn/competitor_record.rb +27 -0
- data/app/deserializers/jumbotron/deserializers/clients/espn/venue.rb +10 -1
- data/app/deserializers/jumbotron/deserializers/clients/espn/venue_address.rb +27 -0
- data/app/deserializers/jumbotron/deserializers/public/register_sandbox_projection.rb +42 -0
- data/app/deserializers/jumbotron/deserializers/public/reset_sandbox_projection.rb +51 -0
- data/app/errors/jumbotron/error.rb +7 -0
- data/app/errors/jumbotron/errors/sandbox.rb +67 -0
- data/app/errors/jumbotron/sandbox_reset_rejected_error.rb +6 -0
- data/app/models/jumbotron/canonical.rb +20 -5
- data/app/models/jumbotron/game.rb +11 -0
- data/app/models/jumbotron/public/translate_outcome.rb +13 -7
- data/app/models/jumbotron/public.rb +31 -6
- data/app/models/jumbotron/sandbox_game_mapping.rb +24 -0
- data/app/models/jumbotron/sandbox_game_plan.rb +23 -0
- data/app/models/jumbotron/sandbox_projection.rb +30 -0
- data/app/models/jumbotron/sandbox_projection_data.rb +32 -0
- data/app/models/jumbotron/season.rb +10 -0
- data/app/providers/jumbotron/providers/sandbox/resources/competition_odds.rb +24 -0
- data/app/providers/jumbotron/providers/sandbox/resources/projection.rb +23 -0
- data/app/providers/jumbotron/providers/sandbox/resources/scoreboard.rb +24 -0
- data/app/services/jumbotron/services/adapters/execute_line_acquisition_scope.rb +2 -1
- data/app/services/jumbotron/services/adapters/resolve_game_update_policy.rb +1 -0
- data/app/services/jumbotron/services/adapters/select_eligible_games.rb +1 -1
- data/app/services/jumbotron/services/canonical/upsert_game_graph.rb +126 -0
- data/app/services/jumbotron/services/canonical/upsert_game_participant.rb +114 -6
- data/app/services/jumbotron/services/canonical/upsert_team.rb +24 -0
- data/app/services/jumbotron/services/canonical/upsert_venue.rb +33 -1
- data/app/services/jumbotron/services/public/assemble_public_game.rb +20 -2
- data/app/services/jumbotron/services/public/assemble_sandbox_projection.rb +30 -0
- data/app/services/jumbotron/services/public/assemble_sandbox_projection_reset.rb +49 -0
- data/app/services/jumbotron/services/sandbox/build_source_snapshot.rb +224 -0
- data/app/services/jumbotron/services/sandbox/project_game.rb +73 -0
- data/app/services/jumbotron/services/sandbox/register_projection.rb +76 -0
- data/app/services/jumbotron/services/sandbox/remove_empty_projection_geometry.rb +46 -0
- data/app/services/jumbotron/services/sandbox/remove_mapped_game.rb +29 -0
- data/app/services/jumbotron/services/sandbox/resolve_game_observation.rb +106 -0
- data/app/services/jumbotron/services/sandbox/seed_projected_game.rb +95 -0
- data/app/services/jumbotron/services/sandbox/select_line_basis.rb +69 -0
- data/app/services/jumbotron/services/sandbox/validate_projected_season.rb +77 -0
- data/app/workflows/jumbotron/workflows/public_read.rb +2 -1
- data/app/workflows/jumbotron/workflows/sandbox/register_projection_workflow.rb +33 -0
- data/app/workflows/jumbotron/workflows/sandbox/reset_projection_workflow.rb +158 -0
- data/app/workflows/jumbotron/workflows/synchronize_adapter_workflow.rb +3 -51
- data/db/migrate/20260828220001_add_game_presentation_contract_fields.rb +19 -0
- data/db/migrate/20260908000001_create_jumbotron_sandbox_game_plans.rb +16 -0
- data/db/migrate/20260908000002_create_jumbotron_sandbox_projections.rb +39 -0
- data/docs/upgrades/0.2.0.md +74 -0
- data/lib/jumbotron/client.rb +14 -0
- data/lib/jumbotron/version.rb +1 -1
- data/spec/factories/jumbotron/sandbox_game_mapping.rb +24 -0
- data/spec/factories/jumbotron/sandbox_game_plan.rb +10 -0
- data/spec/factories/jumbotron/sandbox_projection.rb +18 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|