Skip to content

Commit

Permalink
Pricing overhaul, race splitting
Browse files Browse the repository at this point in the history
* Add splitting to races

Adds a Split button to races. This button will split if there is at
least one segment left in the run, or create a segment then split if
not. It will also create a run if one isn't yet attached to the race
entry. These two features together can be used to build splits adhoc
during a blind or semi-blind races.

The API allows you to choose whether to enable this smart behavior by
passing `more=1`. When not included, the API will only split in the
classical sense, completing the run if you're on the last segment.

Includes several other race improvements:

- Adds timelines to races for each runner splitting
- Timelines (both in & out of races) now have smarter segment widths for
  in-progress runs (they were previously all equivalent widths)
- Adds a "Syncing" spinner to the race page that shows whenever you've
  performed an action but it hasn't reflected on the page yet due to
  the worker and/or ActionCable taking an extra bit
- The race page is now more aggressive about hiding itself (and showing
  a spinner) while loading / while navigating away from the page, rather
  than showing outdated information cached by Turbolinks
- The Create Race button no longer shows an error symbol after being
  clicked & transitioning to the new race page, even though there was no
  error
- Can no longer start editing a race after it has started

* CR comments

* Rubocop

* Pricing overhaul

* Clean up an unused ad file, fix some copy

* Fix highlight button showing for non-owners

* Fix inconsistent button styling on game page

* CR comments

* Fix split button

* Hide Pricing link if already subscribed

* Fix advanced charts

* Fix a logged-out 500

* Fix subscriptions section on Settings page

* Fix another logged-out 500
  • Loading branch information
glacials authored Nov 26, 2019
1 parent 57f2e1e commit 1dd1337
Show file tree
Hide file tree
Showing 61 changed files with 1,101 additions and 694 deletions.
9 changes: 9 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ AllCops:
Style/Documentation:
Enabled: false

Style/TrailingCommaInArguments:
EnforcedStyleForMultiline: comma

Style/TrailingCommaInArrayLiteral:
EnforcedStyleForMultiline: comma

Style/TrailingCommaInHashLiteral:
EnforcedStyleForMultiline: comma

Layout/SpaceInsideHashLiteralBraces:
EnforcedStyle: no_space

Expand Down
4 changes: 3 additions & 1 deletion app/assets/stylesheets/tabs.sass
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
ul.nav.nav-tabs > li.active > a
.nav .active
color: var(--secondary)
cursor: pointer
border-bottom: 1px solid var(--secondary)
9 changes: 9 additions & 0 deletions app/assets/stylesheets/tips.sass
Original file line number Diff line number Diff line change
@@ -1,2 +1,11 @@
.tipsy code
letter-spacing: -.05em

.info
border-bottom: 1px dashed
cursor: default
display: inline
font-size: 0.7em

.info:after
content: '?'
25 changes: 15 additions & 10 deletions app/controllers/api/v4/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ class Api::V4::ApplicationController < ActionController::Base
skip_before_action :touch_auth_session
before_action :read_only_mode, if: -> { ENV['READ_ONLY_MODE'] == '1' }

rescue_from ActionController::ParameterMissing do |e|
render status: :bad_request, json: {status: 400, message: e.message}
end

def options
headers['Allow'] = 'POST, PUT, DELETE, GET, OPTIONS'
end
Expand All @@ -28,23 +32,24 @@ def build_link_headers(links)
end.join(', ')
end

def not_found(collection_name, message: nil)
def not_found(collection, message: nil)
{
status: :not_found,
json: {
status: 404,
error: message || "No #{collection_name} with ID #{params[collection_name] || params["#{collection_name}_id"] || params[:id]} found."
}
error: message ||
"No #{collection} with ID #{params[collection] || params["#{collection}_id"] || params[:id]} found.",
},
}
end

# Add response body to unauthorized requests
def doorkeeper_unauthorized_render_options(error: nil)
def doorkeeper_unauthorized_render_options(*)
{
json: {
status: 401,
error: 'Not authorized'
}
error: 'Not authorized',
},
}
end

Expand Down Expand Up @@ -92,7 +97,7 @@ def set_user
rescue ActiveRecord::RecordNotFound
render status: :unauthorized, json: {
status: 401,
error: 'No user found for this token'
error: 'No user found for this token',
}
end

Expand All @@ -101,17 +106,17 @@ def validate_user

render status: :unauthorized, json: {
status: 401,
error: 'A user is required for this action'
error: 'A user is required for this action',
}
end

def set_race(param: :race_id)
def set_race(param: :race_id) # rubocop:disable Naming/AccessorMethodName
@race = Race.find(params[param])
return unless @race.secret_visibility? && !@race.joinable?(user: current_user, token: params[:join_token])

render status: :forbidden, json: {
status: 403,
error: 'Must be invited to see this race'
error: 'Must be invited to see this race',
}
rescue ActiveRecord::RecordNotFound
render not_found(:race)
Expand Down
76 changes: 76 additions & 0 deletions app/controllers/api/v4/races/entries/splits_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
class Api::V4::Races::Entries::SplitsController < Api::V4::Races::EntriesController
before_action :set_time, only: %i[create]
before_action :set_user
before_action :validate_user
before_action :set_race
before_action :set_entry
before_action :set_run
before_action :check_permission

def create
@run.split(
more: params[:more] == '1',
realtime_end_ms: split_params[:realtime_end_ms],
gametime_end_ms: split_params[:gametime_end_ms],
)

# If this was the final split
if @run.completed?(Run::REAL)
run_history = @run.histories.order(attempt_number: :asc).last
run_history.update(split_params.merge(ended_at: run_history.started_at + (split_params[:realtime_end_ms] / 1000)))

# If this was a race run
@run.entry&.update(finished_at: run_history.ended_at)
end

Api::V4::RaceBroadcastJob.perform_later(@race, 'race_entries_updated', 'A user has split')

render json: Api::V4::RunBlueprint.render(@run)
end

private

def set_time
@now = Time.now.utc
end

def split_params
params.require(:split).permit(
:realtime_end_ms,
:gametime_end_ms,
:realtime_gold,
:gametime_gold,
:realtime_skipped,
:gametime_skipped,
)
end

def set_entry
super(param: :entry_id)
end

def set_run
@run = @entry.run

@run ||= @entry.create_run(
user: current_user,
category: @race.category,
game: @race.game,
program: 'exchange',
attempts: 0,
s3_filename: SecureRandom.uuid,
realtime_duration_ms: nil,
gametime_duration_ms: nil,
parsed_at: @now,
default_timing: Run::REAL,
)

return if @run.histories.where(started_at: @entry.race.started_at).any?

@run.histories.create(
started_at: @entry.race.started_at,
attempt_number: @run.histories.order(attempt_number: :asc).last,
pause_duration_ms: 0,
)
end
end
84 changes: 56 additions & 28 deletions app/controllers/api/v4/races/entries_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def show
render status: :ok, json: Api::V4::EntryBlueprint.render(@entry, root: :entry)
end

def create
def create # rubocop:todo Metrics/AbcSize Metrics/CyclomaticComplexity Metrics/MethodLength
entry = @race.entries.new(entry_params)
entry.runner = current_user # if this is a ghost, the validator will correct the runner
entry.creator = current_user
Expand All @@ -23,47 +23,75 @@ def create
else
render status: :bad_request, json: {
status: 400,
error: entry.errors.full_messages.to_sentence
error: entry.errors.full_messages.to_sentence,
}
return
end
rescue ActionController::ParameterMissing
render status: :bad_request, json: {
status: 400,
error: 'Specifying at least one entry param is required, e.g. {"entry": {"readied_at": "now"}}'
error: 'Specifying at least one entry param is required, e.g. {"entry": {"readied_at": "now"}}',
}
end

def update
if @entry.update(entry_params)
render status: :ok, json: Api::V4::EntryBlueprint.render(@entry, root: :entry)
updated = @entry.saved_changes.keys.reject { |k| k == 'updated_at' }.to_sentence
Api::V4::RaceBroadcastJob.perform_later(
@race,
'race_entries_updated',
"An entry's #{updated} has changed"
)
else
unless @entry.update(entry_params)
render status: :bad_request, json: {
status: 400,
error: @entry.errors.full_messages.to_sentence
error: @entry.errors.full_messages.to_sentence,
}
return
end
rescue ActionController::ParameterMissing
render status: :bad_request, json: {
status: 400,
error: 'Missing parameter: "entry"'
}
render status: :ok, json: Api::V4::EntryBlueprint.render(@entry, root: :entry)
updated = @entry.saved_changes.keys.reject { |k| k == 'updated_at' }.to_sentence

if @entry.saved_changes.keys.include?('finished_at')
# If this was the last split
if @entry.finished_at && @entry.run&.segments&.where(Run.duration_type(Run::REAL) => nil)&.count == 1
@entry.run.split(
more: false,
realtime_end_ms: (@entry.finished_at - @entry.race.started_at) * 1000,
gametime_end_ms: nil,
)
run_history = @entry.run.histories.order(attempt_number: :asc).last
run_history.update(
realtime_duration_ms: (@entry.finished_at - @entry.race.started_at) * 1000,
gametime_duration_ms: nil,
ended_at: run_history.started_at + (@entry.finished_at - @entry.race.started_at),
)
end
if @entry.finished_at.nil? && @entry.run&.segments&.where(Run.duration_type(Run::REAL) => nil)&.count&.zero?
@entry.run.segments.order(segment_number: :asc).last.update(
realtime_end_ms: nil,
realtime_duration_ms: nil,
realtime_gold: false,
gametime_end_ms: nil,
gametime_duration_ms: nil,
gametime_gold: false,
)
@entry.run.update(
realtime_duration_ms: nil,
gametime_duration_ms: nil,
)
end
end

Api::V4::RaceBroadcastJob.perform_later(
@race,
'race_entries_updated',
"An entry's #{updated} has changed",
)
end

def destroy
if @entry.destroy
render status: :ok, json: {status: 200}
Api::V4::RaceBroadcastJob.perform_later(@race, 'race_entries_updated', 'A user has left the race')
Api::V4::GlobalRaceUpdateJob.perform_later(@race, 'race_entries_updated', 'An user has left a race')
Api::V4::GlobalRaceUpdateJob.perform_later(@race, 'race_entries_updated', 'A user has left a race')
else
render status: :conflict, json: {
status: 409,
error: @entry.errors.full_messages.to_sentence
error: @entry.errors.full_messages.to_sentence,
}
end
end
Expand All @@ -79,27 +107,27 @@ def check_permission

render status: :forbidden, json: {
status: 403,
error: 'Must be invited to this race'
error: 'Must be invited to this race',
}
end

def set_entry
@entry = @race.entries.find(params[:id])
def set_entry(param: :id) # rubocop:disable Naming/AccessorMethodName
@entry = @race.entries.find(params[param])
return if @entry.creator == current_user

render status: :forbidden, json: {
status: 403,
error: 'Entry does not belong to current user'
error: 'Entry does not belong to current user',
}
rescue ActiveRecord::RecordNotFound
render not_found(:entry)
end

def massage_params
params[:entry][:run_id] = params[:entry][:run_id].to_i(36) if params[:entry].try(:[], :run_id).present?
params[:entry][:readied_at] = @now if params[:entry].try(:[], :readied_at) == 'now'
params[:entry][:finished_at] = @now if params[:entry].try(:[], :finished_at) == 'now'
params[:entry][:forfeited_at] = @now if params[:entry].try(:[], :forfeited_at) == 'now'
params[:entry][:run_id] = params[:entry][:run_id].to_i(36) if params.dig(:entry, :run_id).present?
params[:entry]&.each do |k, v|
params[:entry][k] = @now if v == 'now'
end
end

def entry_params
Expand Down
32 changes: 32 additions & 0 deletions app/controllers/api/v4/runs/splits_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
class Api::V4::Runs::SplitsController < Api::V4::ApplicationController
before_action :set_run
before_action only: [:create] do
doorkeeper_authorize! :upload_run
end

def create
if @run.split(
more: params[:more] == '1',
realtime_end_ms: split_params[:realtime_end_ms],
gametime_end_ms: split_params[:gametime_end_ms],
)
render json: Api::V4::RunBlueprint.render(
@run,
root: :run,
historic: params[:historic] == '1',
segment_groups: params[:segment_groups] == '1',
)
else
render status: :bad_request, json: {
status: 400,
message: @run.errors.full_messages.to_sentence,
}
end
end

private

def split_params
params.require(:split).permit(:realtime_end_ms, :gametime_end_ms)
end
end
Loading

0 comments on commit 1dd1337

Please sign in to comment.