jumbotron 0.1.7 → 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/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/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/game.rb +11 -0
- data/app/models/jumbotron/public/translate_outcome.rb +13 -7
- data/app/models/jumbotron/public.rb +18 -1
- 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/canonical/upsert_game_graph.rb +126 -0
- data/app/services/jumbotron/services/canonical/upsert_game_participant.rb +23 -3
- 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/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 +59 -2
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Jumbotron
|
|
6
|
+
module Adapters
|
|
7
|
+
module Sandbox
|
|
8
|
+
module NflHelpers
|
|
9
|
+
class Scoreboard
|
|
10
|
+
def self.call(payload, observed_at:)
|
|
11
|
+
data = payload.symbolize_keys
|
|
12
|
+
resolved = resolve_observation(data.fetch(:game_id), observed_at)
|
|
13
|
+
Canonical::SyncInput.new(
|
|
14
|
+
observed_at: observed_at,
|
|
15
|
+
games: [game_input(data, resolved)],
|
|
16
|
+
teams: []
|
|
17
|
+
)
|
|
18
|
+
rescue ArgumentError, KeyError, TypeError => e
|
|
19
|
+
raise TransformError, "invalid sandbox scoreboard observation: #{e.message}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.resolve_observation(game_id, observed_at)
|
|
23
|
+
resolved = Jumbotron::Services::Sandbox::ResolveGameObservation.call(
|
|
24
|
+
game_id: game_id,
|
|
25
|
+
observed_at: observed_at
|
|
26
|
+
)
|
|
27
|
+
return resolved.data if resolved.success?
|
|
28
|
+
|
|
29
|
+
error = Array(resolved.errors).first
|
|
30
|
+
raise TransformError, error.respond_to?(:message) ? error.message : "sandbox observation failed"
|
|
31
|
+
end
|
|
32
|
+
private_class_method :resolve_observation
|
|
33
|
+
|
|
34
|
+
def self.game_input(data, resolved)
|
|
35
|
+
event_id = data.fetch(:event_id).to_s
|
|
36
|
+
competition_id = data[:competition_id].presence || event_id
|
|
37
|
+
Canonical::GameInput.new(**game_attributes(data, event_id, competition_id, resolved))
|
|
38
|
+
end
|
|
39
|
+
private_class_method :game_input
|
|
40
|
+
|
|
41
|
+
def self.game_attributes(data, event_id, competition_id, resolved) # rubocop:disable Metrics/MethodLength
|
|
42
|
+
observation = resolved.fetch(:observation)
|
|
43
|
+
{
|
|
44
|
+
provider_identities: game_identities(event_id, competition_id),
|
|
45
|
+
scheduled_at: resolved.fetch(:scheduled_at),
|
|
46
|
+
lifecycle: observation.lifecycle,
|
|
47
|
+
neutral_site: false,
|
|
48
|
+
season_year: season_identity(data.fetch(:season_year)),
|
|
49
|
+
season_phase_key: data.fetch(:season_phase_key).to_s,
|
|
50
|
+
schedule_group: schedule_group(data.fetch(:week_number)),
|
|
51
|
+
venue: nil,
|
|
52
|
+
participants: %i[home away].map do |role|
|
|
53
|
+
participant(data.fetch(role), role, observation)
|
|
54
|
+
end,
|
|
55
|
+
progress: observation.progress
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
private_class_method :game_attributes
|
|
59
|
+
|
|
60
|
+
def self.season_identity(value)
|
|
61
|
+
Integer(value, exception: false) || value.to_s
|
|
62
|
+
end
|
|
63
|
+
private_class_method :season_identity
|
|
64
|
+
|
|
65
|
+
def self.game_identities(event_id, competition_id)
|
|
66
|
+
[
|
|
67
|
+
Canonical::ProviderIdentityRef.new(provider: "sandbox", namespace: "event", id: event_id),
|
|
68
|
+
Canonical::ProviderIdentityRef.new(
|
|
69
|
+
provider: "sandbox",
|
|
70
|
+
namespace: "competition",
|
|
71
|
+
id: competition_id.to_s
|
|
72
|
+
)
|
|
73
|
+
].uniq
|
|
74
|
+
end
|
|
75
|
+
private_class_method :game_identities
|
|
76
|
+
|
|
77
|
+
def self.schedule_group(number)
|
|
78
|
+
week = Integer(number)
|
|
79
|
+
Canonical::ScheduleGroupInput.new(kind: "week", number: week, name: "Week #{week}")
|
|
80
|
+
end
|
|
81
|
+
private_class_method :schedule_group
|
|
82
|
+
|
|
83
|
+
def self.participant(raw, role, observation)
|
|
84
|
+
data = raw.symbolize_keys
|
|
85
|
+
Canonical::ParticipantInput.new(**participant_attributes(data, role, observation))
|
|
86
|
+
end
|
|
87
|
+
private_class_method :participant
|
|
88
|
+
|
|
89
|
+
def self.participant_attributes(data, role, observation)
|
|
90
|
+
{
|
|
91
|
+
provider_identities: [team_identity(data.fetch(:provider_id))],
|
|
92
|
+
team_name: data.fetch(:name).to_s,
|
|
93
|
+
team_nickname: data[:nickname].presence,
|
|
94
|
+
team_abbreviation: data[:abbreviation].presence,
|
|
95
|
+
role: role.to_s,
|
|
96
|
+
score: observation.public_send("#{role}_score"),
|
|
97
|
+
result: observation.public_send("#{role}_result"),
|
|
98
|
+
record_summary: nil
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
private_class_method :participant_attributes
|
|
102
|
+
|
|
103
|
+
def self.team_identity(provider_id)
|
|
104
|
+
Canonical::ProviderIdentityRef.new(
|
|
105
|
+
provider: "espn",
|
|
106
|
+
namespace: "team",
|
|
107
|
+
id: provider_id.to_s
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
private_class_method :team_identity
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module Adapters
|
|
5
|
+
module Sandbox
|
|
6
|
+
module NflHelpers
|
|
7
|
+
module SyntheticObservation
|
|
8
|
+
Current = Data.define(:lifecycle, :progress, :home_score, :away_score)
|
|
9
|
+
Observation = Data.define(
|
|
10
|
+
:lifecycle,
|
|
11
|
+
:progress,
|
|
12
|
+
:home_score,
|
|
13
|
+
:away_score,
|
|
14
|
+
:home_result,
|
|
15
|
+
:away_result
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
QUARTER_SECONDS = TimelineObservation::QUARTER_SECONDS
|
|
19
|
+
HALFTIME_SECONDS = TimelineObservation::HALFTIME_SECONDS
|
|
20
|
+
TOTAL_SECONDS = TimelineObservation::TOTAL_SECONDS
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def call(now:, kickoff:, plan:, current:)
|
|
25
|
+
proposed = TimelineObservation.call(now: now, kickoff: kickoff, plan: plan)
|
|
26
|
+
MonotonicObservation.call(proposed: proposed, current: current, plan: plan)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module Adapters
|
|
5
|
+
module Sandbox
|
|
6
|
+
module NflHelpers
|
|
7
|
+
module TimelineObservation
|
|
8
|
+
QUARTER_SECONDS = 27.5.minutes.to_i
|
|
9
|
+
HALFTIME_SECONDS = 10.minutes.to_i
|
|
10
|
+
ACTIVE_SECONDS = QUARTER_SECONDS * 4
|
|
11
|
+
TOTAL_SECONDS = ACTIVE_SECONDS + HALFTIME_SECONDS
|
|
12
|
+
GAME_CLOCK_SECONDS = 15.minutes.to_i
|
|
13
|
+
STAGES = [
|
|
14
|
+
[QUARTER_SECONDS, :q1],
|
|
15
|
+
[QUARTER_SECONDS * 2, :q2],
|
|
16
|
+
[(QUARTER_SECONDS * 2) + HALFTIME_SECONDS, :halftime],
|
|
17
|
+
[(QUARTER_SECONDS * 3) + HALFTIME_SECONDS, :q3],
|
|
18
|
+
[TOTAL_SECONDS, :q4]
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def call(now:, kickoff:, plan:)
|
|
24
|
+
elapsed = elapsed_seconds(now, kickoff)
|
|
25
|
+
return scheduled_observation if elapsed.negative?
|
|
26
|
+
return completed_observation(plan) if elapsed >= TOTAL_SECONDS
|
|
27
|
+
|
|
28
|
+
stage_observation(stage_for(elapsed), elapsed, plan)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def elapsed_seconds(now, kickoff)
|
|
32
|
+
now.to_time.to_f - kickoff.to_time.to_f
|
|
33
|
+
rescue ArgumentError, NoMethodError, TypeError
|
|
34
|
+
raise ArgumentError, "sandbox observation requires valid now and kickoff times"
|
|
35
|
+
end
|
|
36
|
+
private_class_method :elapsed_seconds
|
|
37
|
+
|
|
38
|
+
def stage_for(elapsed)
|
|
39
|
+
STAGES.find { |ending, _stage| elapsed < ending }.last
|
|
40
|
+
end
|
|
41
|
+
private_class_method :stage_for
|
|
42
|
+
|
|
43
|
+
def stage_observation(stage, elapsed, plan)
|
|
44
|
+
return halftime_observation(plan) if stage == :halftime
|
|
45
|
+
|
|
46
|
+
quarter = { q1: 1, q2: 2, q3: 3, q4: 4 }.fetch(stage)
|
|
47
|
+
local = local_elapsed(stage, elapsed)
|
|
48
|
+
active_observation(quarter, local, active_elapsed(stage, local), plan)
|
|
49
|
+
end
|
|
50
|
+
private_class_method :stage_observation
|
|
51
|
+
|
|
52
|
+
def local_elapsed(stage, elapsed)
|
|
53
|
+
offsets = {
|
|
54
|
+
q1: 0,
|
|
55
|
+
q2: QUARTER_SECONDS,
|
|
56
|
+
q3: (QUARTER_SECONDS * 2) + HALFTIME_SECONDS,
|
|
57
|
+
q4: (QUARTER_SECONDS * 3) + HALFTIME_SECONDS
|
|
58
|
+
}
|
|
59
|
+
elapsed - offsets.fetch(stage)
|
|
60
|
+
end
|
|
61
|
+
private_class_method :local_elapsed
|
|
62
|
+
|
|
63
|
+
def active_elapsed(stage, local)
|
|
64
|
+
completed_quarters = { q1: 0, q2: 1, q3: 2, q4: 3 }.fetch(stage)
|
|
65
|
+
(completed_quarters * QUARTER_SECONDS) + local
|
|
66
|
+
end
|
|
67
|
+
private_class_method :active_elapsed
|
|
68
|
+
|
|
69
|
+
def scheduled_observation
|
|
70
|
+
ObservationValues.observation(lifecycle: "scheduled")
|
|
71
|
+
end
|
|
72
|
+
private_class_method :scheduled_observation
|
|
73
|
+
|
|
74
|
+
def active_observation(quarter, local, active_elapsed, plan)
|
|
75
|
+
fraction = active_elapsed / ACTIVE_SECONDS.to_f
|
|
76
|
+
ObservationValues.observation(
|
|
77
|
+
lifecycle: "in_progress",
|
|
78
|
+
progress: ObservationValues.progress(state: "active", quarter: quarter, clock: clock(local)),
|
|
79
|
+
home_score: ObservationValues.score_at(plan.home_score, fraction),
|
|
80
|
+
away_score: ObservationValues.score_at(plan.away_score, fraction)
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
private_class_method :active_observation
|
|
84
|
+
|
|
85
|
+
def halftime_observation(plan)
|
|
86
|
+
ObservationValues.observation(
|
|
87
|
+
lifecycle: "in_progress",
|
|
88
|
+
progress: ObservationValues.progress(state: "intermission", quarter: 2),
|
|
89
|
+
home_score: ObservationValues.score_at(plan.home_score, 0.5),
|
|
90
|
+
away_score: ObservationValues.score_at(plan.away_score, 0.5)
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
private_class_method :halftime_observation
|
|
94
|
+
|
|
95
|
+
def completed_observation(plan)
|
|
96
|
+
home_result, away_result = ObservationValues.results(plan.home_score, plan.away_score)
|
|
97
|
+
ObservationValues.observation(
|
|
98
|
+
lifecycle: "completed",
|
|
99
|
+
home_score: plan.home_score,
|
|
100
|
+
away_score: plan.away_score,
|
|
101
|
+
home_result: home_result,
|
|
102
|
+
away_result: away_result
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
private_class_method :completed_observation
|
|
106
|
+
|
|
107
|
+
def clock(local_seconds)
|
|
108
|
+
ObservationValues.clock(
|
|
109
|
+
local_seconds: local_seconds,
|
|
110
|
+
quarter_seconds: QUARTER_SECONDS,
|
|
111
|
+
game_clock_seconds: GAME_CLOCK_SECONDS
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
private_class_method :clock
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module Deserializers
|
|
5
|
+
module Public
|
|
6
|
+
class RegisterSandboxProjection < CommandTower::Deserializers::ApplicationDeserializer
|
|
7
|
+
FIELDS = %i[sport league season season_phase].freeze
|
|
8
|
+
|
|
9
|
+
def call(params)
|
|
10
|
+
name = unwrap(require_string(unwrap(fetch_param(params, :name)), field: "name"))
|
|
11
|
+
return name if deserializer_result?(name)
|
|
12
|
+
|
|
13
|
+
source = unwrap(fetch_param(params, :source))
|
|
14
|
+
return source if deserializer_result?(source)
|
|
15
|
+
return invalid("source must be a hash") unless source.is_a?(Hash)
|
|
16
|
+
|
|
17
|
+
selector = parse_selector(source)
|
|
18
|
+
return selector if deserializer_result?(selector)
|
|
19
|
+
|
|
20
|
+
success(Jumbotron::Public::RegisterSandboxProjectionRequest.new(name: name, source: selector))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def parse_selector(source)
|
|
26
|
+
values = FIELDS.to_h do |field|
|
|
27
|
+
value = source[field] || source[field.to_s]
|
|
28
|
+
parsed = unwrap(require_string(value, field: "source.#{field}"))
|
|
29
|
+
return parsed if deserializer_result?(parsed)
|
|
30
|
+
|
|
31
|
+
[field, parsed]
|
|
32
|
+
end
|
|
33
|
+
Jumbotron::Public::SandboxScheduleSelector.new(**values)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def invalid(message)
|
|
37
|
+
failure(errors: [{ code: "invalid_request", message: message }])
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Jumbotron
|
|
6
|
+
module Deserializers
|
|
7
|
+
module Public
|
|
8
|
+
class ResetSandboxProjection < CommandTower::Deserializers::ApplicationDeserializer
|
|
9
|
+
def call(params)
|
|
10
|
+
sandbox = unwrap(require_string(unwrap(fetch_param(params, :sandbox)), field: "sandbox"))
|
|
11
|
+
return sandbox if deserializer_result?(sandbox)
|
|
12
|
+
|
|
13
|
+
start_week = positive_integer(params, :start_week)
|
|
14
|
+
return start_week if deserializer_result?(start_week)
|
|
15
|
+
|
|
16
|
+
anchor_day = positive_integer(params, :anchor_day)
|
|
17
|
+
return anchor_day if deserializer_result?(anchor_day)
|
|
18
|
+
|
|
19
|
+
now = parse_time(unwrap(fetch_param(params, :now)))
|
|
20
|
+
return now if deserializer_result?(now)
|
|
21
|
+
|
|
22
|
+
success(
|
|
23
|
+
Jumbotron::Public::ResetSandboxProjectionRequest.new(
|
|
24
|
+
sandbox: sandbox,
|
|
25
|
+
start_week: start_week,
|
|
26
|
+
anchor_day: anchor_day,
|
|
27
|
+
now: now
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def positive_integer(params, field)
|
|
35
|
+
raw = unwrap(fetch_param(params, field))
|
|
36
|
+
return raw if deserializer_result?(raw)
|
|
37
|
+
|
|
38
|
+
unwrap(require_integer(raw, field: field.to_s, min: 1))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def parse_time(raw)
|
|
42
|
+
return raw if raw.is_a?(Time) || raw.is_a?(ActiveSupport::TimeWithZone)
|
|
43
|
+
|
|
44
|
+
Time.iso8601(raw.to_s)
|
|
45
|
+
rescue ArgumentError, TypeError
|
|
46
|
+
failure(errors: [{ code: "invalid_request", message: "now must be an ISO8601 time" }])
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module Errors
|
|
5
|
+
module Sandbox
|
|
6
|
+
class InvalidGameError < CommandTower::Errors::ApplicationError
|
|
7
|
+
def code
|
|
8
|
+
"invalid_sandbox_game"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def message
|
|
12
|
+
"game is not an eligible sandbox game"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class OutcomeLineUnavailableError < CommandTower::Errors::ApplicationError
|
|
17
|
+
def code
|
|
18
|
+
"sandbox_outcome_line_unavailable"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def message
|
|
22
|
+
"sandbox outcome requires current home spread and over total lines"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class InvalidProgressionStateError < CommandTower::Errors::ApplicationError
|
|
27
|
+
def code
|
|
28
|
+
"invalid_sandbox_progression_state"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def message
|
|
32
|
+
details.is_a?(Hash) && details[:message].present? ? details[:message].to_s : "sandbox state cannot progress"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class ProjectionNotFoundError < CommandTower::Errors::ApplicationError
|
|
37
|
+
def code
|
|
38
|
+
"sandbox_not_found"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def message
|
|
42
|
+
"sandbox projection was not found"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class ProjectionConflictError < CommandTower::Errors::ApplicationError
|
|
47
|
+
def code
|
|
48
|
+
"sandbox_projection_conflict"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def message
|
|
52
|
+
"sandbox projection conflicts with its registered source"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
class ProjectionRejectedError < CommandTower::Errors::ApplicationError
|
|
57
|
+
def code
|
|
58
|
+
details.is_a?(Hash) && details[:code].present? ? details[:code].to_s : "sandbox_projection_incomplete"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def message
|
|
62
|
+
details.is_a?(Hash) && details[:message].present? ? details[:message].to_s : "sandbox projection was rejected"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -34,6 +34,17 @@ module Jumbotron
|
|
|
34
34
|
has_many :teams, through: :game_participants
|
|
35
35
|
has_many :provider_identities, as: :target, inverse_of: :target, dependent: :restrict_with_exception
|
|
36
36
|
has_many :line_observations, inverse_of: :game, dependent: :restrict_with_exception
|
|
37
|
+
has_one :sandbox_game_plan, inverse_of: :game, dependent: :restrict_with_exception
|
|
38
|
+
has_many :source_sandbox_game_mappings,
|
|
39
|
+
class_name: "Jumbotron::SandboxGameMapping",
|
|
40
|
+
foreign_key: :source_game_id,
|
|
41
|
+
inverse_of: :source_game,
|
|
42
|
+
dependent: :restrict_with_exception
|
|
43
|
+
has_one :sandbox_game_mapping,
|
|
44
|
+
class_name: "Jumbotron::SandboxGameMapping",
|
|
45
|
+
foreign_key: :sandbox_game_id,
|
|
46
|
+
inverse_of: :sandbox_game,
|
|
47
|
+
dependent: :restrict_with_exception
|
|
37
48
|
|
|
38
49
|
attribute :lifecycle, :string, default: "scheduled"
|
|
39
50
|
attribute :neutral_site, :boolean, default: false
|
|
@@ -10,6 +10,7 @@ module Jumbotron
|
|
|
10
10
|
season_not_found
|
|
11
11
|
season_phase_not_found
|
|
12
12
|
schedule_group_not_found
|
|
13
|
+
sandbox_not_found
|
|
13
14
|
].freeze
|
|
14
15
|
|
|
15
16
|
module_function
|
|
@@ -25,29 +26,34 @@ module Jumbotron
|
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
def public_error_for(result)
|
|
28
|
-
code, message =
|
|
29
|
+
code, message, details = error_code_message_and_details(result)
|
|
29
30
|
klass = exception_class_for(code)
|
|
30
|
-
klass.new(message)
|
|
31
|
+
klass.new(message, code: code, details: details)
|
|
31
32
|
end
|
|
32
33
|
module_function :public_error_for
|
|
33
34
|
|
|
34
|
-
def
|
|
35
|
+
def error_code_message_and_details(result) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
35
36
|
error = Array(result.errors).first
|
|
36
37
|
case error
|
|
37
38
|
when CommandTower::Errors::ApplicationError
|
|
38
|
-
[error.code.to_s, error.message]
|
|
39
|
+
[error.code.to_s, error.message, error.details || {}]
|
|
39
40
|
when Hash
|
|
40
|
-
[
|
|
41
|
+
[
|
|
42
|
+
(error[:code] || error["code"]).to_s,
|
|
43
|
+
(error[:message] || error["message"]).to_s,
|
|
44
|
+
error[:details] || error["details"] || {}
|
|
45
|
+
]
|
|
41
46
|
else
|
|
42
|
-
["internal_error", error.to_s]
|
|
47
|
+
["internal_error", error.to_s, {}]
|
|
43
48
|
end
|
|
44
49
|
end
|
|
45
|
-
module_function :
|
|
50
|
+
module_function :error_code_message_and_details
|
|
46
51
|
|
|
47
52
|
def exception_class_for(code)
|
|
48
53
|
return Jumbotron::NotFoundError if NOT_FOUND_CODES.include?(code)
|
|
49
54
|
return Jumbotron::InvalidRequestError if code == "invalid_request"
|
|
50
55
|
return Jumbotron::UnsupportedCapabilityError if code == "unsupported"
|
|
56
|
+
return Jumbotron::SandboxResetRejectedError if code.start_with?("sandbox_", "source_", "anchor_")
|
|
51
57
|
|
|
52
58
|
Jumbotron::Error
|
|
53
59
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Jumbotron
|
|
4
|
-
module Public
|
|
4
|
+
module Public # rubocop:disable Metrics/ModuleLength
|
|
5
5
|
ALLOWED_INCLUDES = %i[consensus current_lines consensus_constituents].freeze
|
|
6
6
|
|
|
7
7
|
Sport = Data.define(:id, :name)
|
|
@@ -10,6 +10,23 @@ module Jumbotron
|
|
|
10
10
|
SeasonPhase = Data.define(:id, :name)
|
|
11
11
|
ScheduleGroup = Data.define(:id, :kind, :number, :name)
|
|
12
12
|
ScheduleGroupEnumeration = Data.define(:groups, :completeness)
|
|
13
|
+
SandboxScheduleSelector = Data.define(:sport, :league, :season, :season_phase)
|
|
14
|
+
RegisterSandboxProjectionRequest = Data.define(:name, :source)
|
|
15
|
+
ResetSandboxProjectionRequest = Data.define(:sandbox, :start_week, :anchor_day, :now)
|
|
16
|
+
SandboxProjection = Data.define(:name, :source, :sandbox)
|
|
17
|
+
SandboxProjectionReset = Data.define(
|
|
18
|
+
:projection,
|
|
19
|
+
:source_anchor_date,
|
|
20
|
+
:target_anchor_date,
|
|
21
|
+
:date_shift_days,
|
|
22
|
+
:projected_game_count,
|
|
23
|
+
:added_game_count,
|
|
24
|
+
:updated_game_count,
|
|
25
|
+
:removed_game_count,
|
|
26
|
+
:start_week_game_count,
|
|
27
|
+
:naturally_locked_game_count,
|
|
28
|
+
:reset_at
|
|
29
|
+
)
|
|
13
30
|
ScheduleGroupsRequest = Data.define(
|
|
14
31
|
:league_id,
|
|
15
32
|
:sport,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
class SandboxGameMapping < ApplicationRecord
|
|
5
|
+
belongs_to :sandbox_projection, inverse_of: :sandbox_game_mappings
|
|
6
|
+
belongs_to :source_game, class_name: "Jumbotron::Game", inverse_of: :source_sandbox_game_mappings
|
|
7
|
+
belongs_to :sandbox_game, class_name: "Jumbotron::Game", inverse_of: :sandbox_game_mapping
|
|
8
|
+
|
|
9
|
+
validates :source_game_id, uniqueness: { scope: :sandbox_projection_id }
|
|
10
|
+
validates :sandbox_game_id, uniqueness: true
|
|
11
|
+
validates :home_spread, :total, numericality: true
|
|
12
|
+
validates :line_fingerprint, presence: true
|
|
13
|
+
validate :games_are_in_expected_worlds
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def games_are_in_expected_worlds
|
|
18
|
+
return if source_game.nil? || sandbox_game.nil?
|
|
19
|
+
return if source_game.league.name != "nfl-sandbox" && sandbox_game.league.name == "nfl-sandbox"
|
|
20
|
+
|
|
21
|
+
errors.add(:base, "mapping requires live source and sandbox target games")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
class SandboxGamePlan < ApplicationRecord
|
|
5
|
+
IMMUTABLE_ATTRIBUTES = %w[game_id home_score away_score selected_at].freeze
|
|
6
|
+
|
|
7
|
+
belongs_to :game, inverse_of: :sandbox_game_plan
|
|
8
|
+
|
|
9
|
+
validates :game_id, uniqueness: true
|
|
10
|
+
validates :home_score, :away_score,
|
|
11
|
+
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
|
12
|
+
validates :selected_at, presence: true
|
|
13
|
+
validate :selected_outcome_is_immutable, on: :update
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def selected_outcome_is_immutable
|
|
18
|
+
return unless IMMUTABLE_ATTRIBUTES.any? { |attribute| will_save_change_to_attribute?(attribute) }
|
|
19
|
+
|
|
20
|
+
errors.add(:base, "selected sandbox outcome is immutable")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
class SandboxProjection < ApplicationRecord
|
|
5
|
+
belongs_to :source_season, class_name: "Jumbotron::Season", inverse_of: :source_sandbox_projections
|
|
6
|
+
belongs_to :source_anchor_phase, class_name: "Jumbotron::SeasonPhase"
|
|
7
|
+
belongs_to :sandbox_season, class_name: "Jumbotron::Season", inverse_of: :sandbox_projection
|
|
8
|
+
has_many :sandbox_game_mappings, inverse_of: :sandbox_projection, dependent: :restrict_with_exception
|
|
9
|
+
|
|
10
|
+
validates :name, presence: true, uniqueness: true, format: { with: /\A[a-z0-9][a-z0-9-]*\z/ }
|
|
11
|
+
validates :adapter_id, inclusion: { in: %w[sandbox_nfl] }
|
|
12
|
+
validates :calendar_time_zone, inclusion: { in: ActiveSupport::TimeZone.all.map(&:tzinfo).map(&:name) }
|
|
13
|
+
validate :anchor_phase_belongs_to_source
|
|
14
|
+
validate :source_and_sandbox_are_distinct
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def anchor_phase_belongs_to_source
|
|
19
|
+
return if source_anchor_phase.nil? || source_anchor_phase.season_id == source_season_id
|
|
20
|
+
|
|
21
|
+
errors.add(:source_anchor_phase, "must belong to source season")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def source_and_sandbox_are_distinct
|
|
25
|
+
return unless source_season_id == sandbox_season_id
|
|
26
|
+
|
|
27
|
+
errors.add(:sandbox_season, "must differ from source season")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module SandboxProjectionData
|
|
5
|
+
Team = Data.define(:provider_id, :name, :nickname, :abbreviation)
|
|
6
|
+
Game = Data.define(
|
|
7
|
+
:source_game_id,
|
|
8
|
+
:event_id,
|
|
9
|
+
:competition_id,
|
|
10
|
+
:scheduled_at,
|
|
11
|
+
:season,
|
|
12
|
+
:season_phase,
|
|
13
|
+
:week_number,
|
|
14
|
+
:week_name,
|
|
15
|
+
:neutral_site,
|
|
16
|
+
:home,
|
|
17
|
+
:away,
|
|
18
|
+
:home_spread,
|
|
19
|
+
:total,
|
|
20
|
+
:line_fingerprint,
|
|
21
|
+
:line_source
|
|
22
|
+
)
|
|
23
|
+
Snapshot = Data.define(
|
|
24
|
+
:games,
|
|
25
|
+
:source_anchor_date,
|
|
26
|
+
:target_anchor_date,
|
|
27
|
+
:date_shift_days,
|
|
28
|
+
:fingerprint,
|
|
29
|
+
:start_week
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -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
|