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
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Jumbotron
6
+ module Adapters
7
+ module Sandbox
8
+ module NflHelpers
9
+ module Projection
10
+ module_function
11
+
12
+ def call(payload, observed_at:)
13
+ data = payload.symbolize_keys
14
+ Canonical::SyncInput.new(
15
+ observed_at: observed_at,
16
+ games: [game_input(data)],
17
+ teams: []
18
+ )
19
+ rescue ArgumentError, KeyError, TypeError => e
20
+ raise TransformError, "invalid sandbox projection: #{e.message}"
21
+ end
22
+
23
+ def game_input(data) # rubocop:disable Metrics/MethodLength
24
+ Canonical::GameInput.new(
25
+ provider_identities: game_identities(data),
26
+ scheduled_at: Time.iso8601(data.fetch(:scheduled_at).to_s),
27
+ lifecycle: "scheduled",
28
+ neutral_site: data.fetch(:neutral_site, false),
29
+ season_year: data.fetch(:season),
30
+ season_phase_key: data.fetch(:season_phase),
31
+ schedule_group: schedule_group(data),
32
+ venue: nil,
33
+ participants: %i[home away].map { |role| participant(data.fetch(role), role) },
34
+ progress: nil
35
+ )
36
+ end
37
+ private_class_method :game_input
38
+
39
+ def game_identities(data)
40
+ event_id = data.fetch(:event_id).to_s
41
+ competition_id = data.fetch(:competition_id, event_id).to_s
42
+ [
43
+ Canonical::ProviderIdentityRef.new(provider: "sandbox", namespace: "event", id: event_id),
44
+ Canonical::ProviderIdentityRef.new(
45
+ provider: "sandbox",
46
+ namespace: "competition",
47
+ id: competition_id
48
+ )
49
+ ].uniq
50
+ end
51
+ private_class_method :game_identities
52
+
53
+ def schedule_group(data)
54
+ number = Integer(data.fetch(:week_number))
55
+ Canonical::ScheduleGroupInput.new(
56
+ kind: "week",
57
+ number: number,
58
+ name: data.fetch(:week_name).to_s
59
+ )
60
+ end
61
+ private_class_method :schedule_group
62
+
63
+ def participant(raw, role) # rubocop:disable Metrics/MethodLength
64
+ data = raw.symbolize_keys
65
+ Canonical::ParticipantInput.new(
66
+ provider_identities: [
67
+ Canonical::ProviderIdentityRef.new(
68
+ provider: "espn",
69
+ namespace: "team",
70
+ id: data.fetch(:provider_id).to_s
71
+ )
72
+ ],
73
+ team_name: data.fetch(:name).to_s,
74
+ team_nickname: data[:nickname].presence,
75
+ team_abbreviation: data[:abbreviation].presence,
76
+ role: role.to_s,
77
+ score: nil,
78
+ result: nil,
79
+ record_summary: nil
80
+ )
81
+ end
82
+ private_class_method :participant
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -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
@@ -13,6 +13,7 @@ module Jumbotron
13
13
  field :winner, type: Support.types.boolean, nullable: true
14
14
  field :order, type: Support.types.integer, nullable: true
15
15
  field :team, type: Team::Result, required: true
16
+ field :records, type: Support.types.array(CompetitorRecord::Result), nullable: true
16
17
  end
17
18
 
18
19
  def self.call(payload)
@@ -25,10 +26,22 @@ module Jumbotron
25
26
  score: coerce_score(Support.payload.fetch(payload, "score")),
26
27
  winner: Support.payload.fetch(payload, "winner"),
27
28
  order: Support.payload.fetch(payload, "order"),
28
- team: Support.nest("team", Support.payload.fetch(payload, "team")) { |raw| Team.call(raw) }
29
+ team: Support.nest("team", Support.payload.fetch(payload, "team")) { |raw| Team.call(raw) },
30
+ records: map_records(Support.payload.fetch(payload, "records"))
29
31
  )
30
32
  end
31
33
 
34
+ def self.map_records(raw)
35
+ return nil if raw.equal?(Support.missing) || raw.nil?
36
+
37
+ Array(raw).each_with_index.map do |item, index|
38
+ CompetitorRecord.call(item)
39
+ rescue CommandTower::Clients::Errors::DeserializationError => e
40
+ raise CommandTower::Deserializers::Clients::Errors.prefix(e, "records[#{index}]")
41
+ end
42
+ end
43
+ private_class_method :map_records
44
+
32
45
  def self.coerce_id(raw)
33
46
  return raw if raw.equal?(Support.missing) || raw.nil? || raw.is_a?(String)
34
47
 
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Deserializers
5
+ module Clients
6
+ module Espn
7
+ class CompetitorRecord
8
+ class Result < CommandTower::Deserializers::Clients::Result
9
+ field :type, type: Support.types.string, required: true
10
+ field :summary, type: Support.types.string, required: true
11
+ field :name, type: Support.types.string, nullable: true
12
+ end
13
+
14
+ def self.call(payload)
15
+ Support.ensure_hash!(payload, label: "competitor.record")
16
+
17
+ Result.build!(
18
+ type: Support.payload.fetch(payload, "type"),
19
+ summary: Support.payload.fetch(payload, "summary"),
20
+ name: Support.payload.fetch(payload, "name")
21
+ )
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -9,6 +9,7 @@ module Jumbotron
9
9
  field :id, type: Support.types.string, required: true
10
10
  field :full_name, type: Support.types.string, required: true
11
11
  field :indoor, type: Support.types.boolean, nullable: true
12
+ field :address, type: VenueAddress::Result, nullable: true
12
13
  end
13
14
 
14
15
  def self.call(payload)
@@ -17,10 +18,18 @@ module Jumbotron
17
18
  Result.build!(
18
19
  id: coerce_id(Support.payload.fetch(payload, "id")),
19
20
  full_name: Support.payload.fetch(payload, "fullName"),
20
- indoor: Support.payload.fetch(payload, "indoor")
21
+ indoor: Support.payload.fetch(payload, "indoor"),
22
+ address: map_address(Support.payload.fetch(payload, "address"))
21
23
  )
22
24
  end
23
25
 
26
+ def self.map_address(raw)
27
+ return nil if raw.equal?(Support.missing) || raw.nil?
28
+
29
+ VenueAddress.call(raw)
30
+ end
31
+ private_class_method :map_address
32
+
24
33
  def self.coerce_id(raw)
25
34
  return raw if raw.equal?(Support.missing) || raw.nil? || raw.is_a?(String)
26
35
 
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Deserializers
5
+ module Clients
6
+ module Espn
7
+ class VenueAddress
8
+ class Result < CommandTower::Deserializers::Clients::Result
9
+ field :city, type: Support.types.string, nullable: true
10
+ field :state, type: Support.types.string, nullable: true
11
+ field :country, type: Support.types.string, nullable: true
12
+ end
13
+
14
+ def self.call(payload)
15
+ Support.ensure_hash!(payload, label: "venue.address")
16
+
17
+ Result.build!(
18
+ city: Support.payload.fetch(payload, "city"),
19
+ state: Support.payload.fetch(payload, "state"),
20
+ country: Support.payload.fetch(payload, "country")
21
+ )
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ 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
@@ -2,5 +2,12 @@
2
2
 
3
3
  module Jumbotron
4
4
  class Error < StandardError
5
+ attr_reader :code, :details
6
+
7
+ def initialize(message = nil, code: "internal_error", details: {})
8
+ @code = code.to_s
9
+ @details = details || {}
10
+ super(message)
11
+ end
5
12
  end
6
13
  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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ class SandboxResetRejectedError < Error
5
+ end
6
+ end