From 3314d772f5fcf4926c1d8738492452f95cfcb1a6 Mon Sep 17 00:00:00 2001 From: Brendan Hansen Date: Wed, 25 Sep 2019 17:50:01 -0500 Subject: [PATCH] Finalizing scoring system; lots of other fixes --- codebox/app/app.moon | 2 + codebox/controllers/account/register.moon | 3 + .../admin/competition/add_problem.moon | 7 +- .../admin/competition/delete_problem.moon | 13 +- .../controllers/admin/competition/edit.moon | 8 ++ codebox/controllers/admin/problem/edit.moon | 8 +- codebox/controllers/admin/utils/Tupfile | 1 + codebox/controllers/admin/utils/score.moon | 14 ++ .../controllers/executer/status_update.moon | 3 +- codebox/middleware/competition_started.moon | 8 +- codebox/middleware/during_competition.moon | 10 +- codebox/migrations.moon | 7 + codebox/models/competitions.moon | 9 +- codebox/services/queries.moon | 13 ++ codebox/services/scoring.moon | 129 +++++++++++++----- codebox/utils/table.moon | 4 + codebox/views/admin/competition/edit.moon | 43 ++++-- codebox/views/admin/problem/edit.moon | 17 ++- 18 files changed, 233 insertions(+), 66 deletions(-) create mode 100644 codebox/controllers/admin/utils/Tupfile create mode 100644 codebox/controllers/admin/utils/score.moon create mode 100644 codebox/utils/table.moon diff --git a/codebox/app/app.moon b/codebox/app/app.moon index 45949c0..f1eeb51 100644 --- a/codebox/app/app.moon +++ b/codebox/app/app.moon @@ -75,4 +75,6 @@ class extends lapis.Application ['admin.competition.delete_problem': "/admin/competitions/delete_problem"]: controller "admin.competition.delete_problem" ['admin.competition.activate': "/admin/competitions/activate/:competition_id"]: controller "admin.competition.activate" + ['admin.utils.score': "/admin/score"]: controller "admin.utils.score" + "/console": console.make! diff --git a/codebox/controllers/account/register.moon b/codebox/controllers/account/register.moon index 9c29596..4262eb8 100644 --- a/codebox/controllers/account/register.moon +++ b/codebox/controllers/account/register.moon @@ -6,6 +6,7 @@ import capture_errors, yield_error from require 'lapis.application' make_controller inject: crypto: 'crypto' + scoring: 'scoring' middleware: { 'logged_out' } @@ -42,6 +43,8 @@ make_controller table.insert @errors, 'Error creating account' return render: 'account.register' + @scoring\rescore_everything! + @session.user_id = user_id redirect_to: @url_for 'index' diff --git a/codebox/controllers/admin/competition/add_problem.moon b/codebox/controllers/admin/competition/add_problem.moon index b87551f..d15daa7 100644 --- a/codebox/controllers/admin/competition/add_problem.moon +++ b/codebox/controllers/admin/competition/add_problem.moon @@ -4,6 +4,9 @@ import capture_errors, capture_errors_json, yield_error from require 'lapis.appl import Competitions, Problems, CompetitionProblems from require 'models' make_controller + inject: + scoring: 'scoring' + middleware: { 'logged_in', 'admin_required' } post: capture_errors_json => @@ -27,11 +30,13 @@ make_controller if #comp_prob > 0 yield_error 'Problem already in competition' return - + CompetitionProblems\create { competition_id: @params.competition_id problem_id: problem.id letter: @params.letter } + @scoring\rescore_everything! + redirect_to: @url_for 'admin.competition.edit', { competition_id: @params.competition_id } diff --git a/codebox/controllers/admin/competition/delete_problem.moon b/codebox/controllers/admin/competition/delete_problem.moon index b2ba0e2..544377a 100644 --- a/codebox/controllers/admin/competition/delete_problem.moon +++ b/codebox/controllers/admin/competition/delete_problem.moon @@ -1,9 +1,12 @@ import make_controller from require "controllers.controller" import assert_valid from require "lapis.validate" import capture_errors, capture_errors_json, yield_error from require 'lapis.application' -import Competitions, Problems, CompetitionProblems from require 'models' +import Jobs, CompetitionProblems from require 'models' make_controller + inject: + scoring: 'scoring' + middleware: { 'logged_in', 'admin_required' } get: capture_errors_json => @@ -16,4 +19,10 @@ make_controller for cp in *comp_prob cp\delete! - redirect_to: @url_for "admin.competition.edit", { competition_id: @params.competition_id } \ No newline at end of file + jobs = Jobs\select "where competition_id=? and problem_id=?", @params.competition_id, @params.problem_id + for j in *jobs + j\delete! + + @scoring\rescore_everything! + + redirect_to: @url_for "admin.competition.edit", { competition_id: @params.competition_id } diff --git a/codebox/controllers/admin/competition/edit.moon b/codebox/controllers/admin/competition/edit.moon index 8ec29d0..2aacac8 100644 --- a/codebox/controllers/admin/competition/edit.moon +++ b/codebox/controllers/admin/competition/edit.moon @@ -39,6 +39,10 @@ make_controller { "name", exists: true } { "start_time", exists: true } { "end_time", exists: true } + { "time_offset", exists: true } + { "programming_points", exists: true } + { "codegolf_points", exists: true } + { "word_points", exists: true } } @comp = Competitions\find @params.id @@ -49,6 +53,10 @@ make_controller name: @params.name start: @params.start_time end: @params.end_time + time_offset: @params.time_offset + programming_points: @params.programming_points + codegolf_points: @params.codegolf_points + word_points: @params.word_points } @comp\refresh! diff --git a/codebox/controllers/admin/problem/edit.moon b/codebox/controllers/admin/problem/edit.moon index f39ca46..9b66dc9 100644 --- a/codebox/controllers/admin/problem/edit.moon +++ b/codebox/controllers/admin/problem/edit.moon @@ -38,18 +38,20 @@ make_controller { "short_name", exists: true } { "description", exists: true } { "time_limit", exists: true, is_integer: true } + { "kind", exists: true } } - problem = Problems\find @params.problem_id - unless problem + @problem = Problems\find @params.problem_id + unless @problem yield_error "Problem with id '#{@params.problem_id}' not found" return - problem\update { + @problem\update { name: @params.name short_name: @params.short_name description: @params.description time_limit: @params.time_limit + kind: Problems.kinds\for_db @params.kind } redirect_to: (@url_for "admin.problem.edit", problem_name: @params.short_name) diff --git a/codebox/controllers/admin/utils/Tupfile b/codebox/controllers/admin/utils/Tupfile new file mode 100644 index 0000000..f0fe651 --- /dev/null +++ b/codebox/controllers/admin/utils/Tupfile @@ -0,0 +1 @@ +include_rules diff --git a/codebox/controllers/admin/utils/score.moon b/codebox/controllers/admin/utils/score.moon new file mode 100644 index 0000000..9b359e0 --- /dev/null +++ b/codebox/controllers/admin/utils/score.moon @@ -0,0 +1,14 @@ +import make_controller from require "controllers.controller" + +make_controller + inject: + scoring: 'scoring' + + middleware: { 'logged_in', 'admin_required' } + + get: => + @scoring\setup_scoring_tables! + @scoring\score_all! + @scoring\place! + + return json: 'Scored all users' diff --git a/codebox/controllers/executer/status_update.moon b/codebox/controllers/executer/status_update.moon index a5d231c..9949072 100644 --- a/codebox/controllers/executer/status_update.moon +++ b/codebox/controllers/executer/status_update.moon @@ -29,8 +29,7 @@ make_controller } if status.status != Jobs.statuses.running - problem = Problems\find job.problem_id - @scoring\score_problem_for_user job.user_id, problem.short_name + @scoring\score job.user_id, job.problem_id @scoring\place! json: { status: 'success' } diff --git a/codebox/middleware/competition_started.moon b/codebox/middleware/competition_started.moon index bee3a2f..e109ae4 100644 --- a/codebox/middleware/competition_started.moon +++ b/codebox/middleware/competition_started.moon @@ -1,14 +1,12 @@ import Competitions from require 'models' -{:time_to_number} = (require 'utils.time')! - => @competition = Competitions\find active: true unless @competition @write json: 'No active competition' - + current_time = os.time() - start_time = time_to_number @competition.start + start_time = @competition\get_start_time_num! unless start_time <= current_time - @write render: 'competition.not_started' \ No newline at end of file + @write render: 'competition.not_started' diff --git a/codebox/middleware/during_competition.moon b/codebox/middleware/during_competition.moon index 51b4e48..0358be1 100644 --- a/codebox/middleware/during_competition.moon +++ b/codebox/middleware/during_competition.moon @@ -1,17 +1,15 @@ import Competitions from require 'models' -{:time_to_number} = (require 'utils.time')! - => @competition = Competitions\find active: true unless @competition @write json: 'No active competition' - + current_time = os.time() - start_time = time_to_number @competition.start - end_time = time_to_number @competition.end + start_time = @competition\get_start_time_num! + end_time = @competition\get_end_time_num! unless start_time <= current_time @write render: 'competition.not_started' unless current_time <= end_time - @write render: 'competition.finished' \ No newline at end of file + @write render: 'competition.finished' diff --git a/codebox/migrations.moon b/codebox/migrations.moon index 73334fd..0e23230 100644 --- a/codebox/migrations.moon +++ b/codebox/migrations.moon @@ -97,4 +97,11 @@ import insert from require "lapis.db" { "attempts", types.integer default: 0 } { "status", types.enum default: 1 } } + + [10]: => + add_column "competitions", "programming_points", (types.integer default: 1000) + add_column "competitions", "codegolf_points", (types.integer default: 750) + add_column "competitions", "word_points", (types.integer default: 500) + + add_column "competitions", "time_offset", (types.integer default: 0) } diff --git a/codebox/models/competitions.moon b/codebox/models/competitions.moon index 36c6df4..046884f 100644 --- a/codebox/models/competitions.moon +++ b/codebox/models/competitions.moon @@ -1,5 +1,6 @@ import Model from require 'lapis.db.model' db = require 'lapis.db' +time = (require 'utils.time')! class Competitions extends Model @relations: { @@ -22,4 +23,10 @@ class Competitions extends Model { "leaderboard", fetch: => db.select "* from leaderboard_placements where competition_id=? order by place asc", @id } - } + { "start_time_num", fetch: => + (time.time_to_number @start) + @time_offset * 60 + } + { "end_time_num", fetch: => + (time.time_to_number @['end']) + @time_offset * 60 + } + } diff --git a/codebox/services/queries.moon b/codebox/services/queries.moon index 22a3a16..214eef6 100644 --- a/codebox/services/queries.moon +++ b/codebox/services/queries.moon @@ -51,6 +51,17 @@ get_user_score = (user_id, competition_id) -> return 0 if #res == 0 res[1].sum +get_codegolf_leaders = (problem_id, competition_id) -> + db.select "user_id, problem_id, competition_id, status, time_initiated, char_length(code) as bytes + from jobs + where problem_id=? and competition_id=? + order by status asc, bytes asc, time_initiated asc", problem_id, competition_id + +clear_codegolf_scores = (problem_id, competition_id) -> + db.query "update leaderboard_problems + set points=0, status=1 + from leaderboard_placements + where problem_id=? and leaderboard_placements.competition_id=?", problem_id, competition_id -> { :has_correct_submission @@ -60,4 +71,6 @@ get_user_score = (user_id, competition_id) -> :get_first_correct_submission :delete_leaderboard_for_competition :get_user_score + :get_codegolf_leaders + :clear_codegolf_scores } diff --git a/codebox/services/scoring.moon b/codebox/services/scoring.moon index c3ba9ab..25eaf51 100644 --- a/codebox/services/scoring.moon +++ b/codebox/services/scoring.moon @@ -1,101 +1,168 @@ import Injectable from require 'utils.inject' -import Users, Problems, Competitions, LeaderboardProblems, LeaderboardPlacements from require 'models' +import Users, Jobs, Problems, Competitions, LeaderboardProblems, LeaderboardPlacements from require 'models' +require 'utils.table' class Scoring extends Injectable new: => @queries = @make 'queries' @time = @make 'time' + + -- Get the currently active competition @competition = Competitions\find active: true - @competition_start = @time.time_to_number @competition.start - @competition_end = @time.time_to_number @competition.end + -- Load the problems and users that are going to be scored + @comp_problems = @competition\get_competition_problems! + @users = Users\select! + + -- Get the start and end time in a UTC time number + @competition_start = @competition\get_start_time_num! + @competition_end = @competition\get_end_time_num! setup_scoring_tables: => + -- Delete all old leaderboard entries for this competition @queries.delete_leaderboard_for_competition @competition.id - users = Users\select! - problems = @competition\get_competition_problems! + -- Refetch the problems and users in case they have changed + @comp_problems = @competition\get_competition_problems! + @users = Users\select! - for user in *users + for user in *@users + -- Create a placement on the leaderboard for each user placement = LeaderboardPlacements\create competition_id: @competition.id user_id: user.id - for problem in *problems + for problem in *@comp_problems + -- Create a problem on the placement for each problem LeaderboardProblems\create leaderboard_placement_id: placement.id user_id: user.id problem_id: problem.problem_id get_problem_worth: (time_submitted) => + -- Start the linear decent of the problem worth + -- 30 minutes after the competition starts so + -- they have a chance to submit a problem + -- or two before they lose points + start = @competition_start + 30 * 60 return 1 if time_submitted < start + duration = @competition_end - start percent = (time_submitted - start) / duration 1 - 0.5 * percent - score_problem_for_user: (user_id, problem_shortname) => + score_problem_for_user: (user_id, problem) => + -- Get the placement for the user during this competition placement = LeaderboardPlacements\find user_id: user_id, competition_id: @competition.id + status = LeaderboardProblems.statuses.not_attempted points = 0 attempts = 0 - problem = Problems\find short_name: problem_shortname - - -- THIS SHOULD SWITCH ON PROBLEM KIND - - attempts += @queries.count_incorrect_submission user_id, problem_shortname + -- Count the incorrect submissions, and if there are + -- any, set the status of the problem to be wrong + attempts += @queries.count_incorrect_submission user_id, problem.short_name if attempts > 0 status = LeaderboardProblems.statuses.wrong - if @queries.has_correct_submission user_id, problem_shortname + -- If there is a correct submission, get the first one + -- (best for problem worth) and compute the points + -- for this problem: + -- points = programming_points * worth - 50 * wrong_attempts + if @queries.has_correct_submission user_id, problem.short_name job = @queries.get_first_correct_submission user_id, problem.id, @competition.id - points += math.ceil (1000 * @get_problem_worth job.time_initiated) + points += math.ceil (@competition.programming_points * @get_problem_worth job.time_initiated) points -= 50 * attempts status = LeaderboardProblems.statuses.correct attempts += 1 + -- Update the problem in the database lp = LeaderboardProblems\find problem_id: problem.id, leaderboard_placement_id: placement.id lp\update status: status points: points attempts: attempts + score_codegolf: (problem) => + -- Clear the codegolf scores for this problem in this competition + @queries.clear_codegolf_scores problem.id, @competition.id + + -- Get the best code golf submisssions + leaders = @queries.get_codegolf_leaders problem.id, @competition.id + + points = @competition.codegolf_points + third_points = points / 3 + placed_leaders = {} + + -- For each submission, if the user has already attained + -- points, skip that submission + for leader in *leaders + continue if table.contains placed_leaders, leader.user_id + table.insert placed_leaders, leader.user_id + + placement = LeaderboardPlacements\find user_id: leader.user_id, competition_id: @competition.id + lp = LeaderboardProblems\find problem_id: problem.id, leaderboard_placement_id: placement.id + if leader.status == Jobs.statuses.correct + lp\update + status: LeaderboardProblems.statuses.correct + points: points + attempts: leader.bytes + points -= third_points if points > 0 + else + lp\update + status: LeaderboardProblems.statuses.wrong + points: 0 + attempts: 0 + score_user: (user_id) => - problems = @competition\get_competition_problems! - for p in *problems + for p in *@comp_problems problem = p\get_problem! - @score_problem_for_user user_id, problem.short_name + @score_problem_for_user user_id, problem score_problem: (problem_name) => - users = Users\select! - for u in *users - @score_problem_for_user u.id, problem_name + problem = Problems\find short_name: problem_name + for u in *@users + @score_problem_for_user u.id, problem score_all: => - problems = @competition\get_competition_problems! - for p in *problems - problem = p\get_problem! - @score_problem problem.short_name + for p in *@comp_problems + for u in *@users + @score u.id, p.problem_id - place: => - users = Users\select! + score: (user_id, problem_id) => + problem = Problems\find problem_id + switch problem.kind + when Problems.kinds.code then @score_problem_for_user user_id, problem + when Problems.kinds.golf then @score_codegolf problem + when Problems.kinds.word then @score_word_for_user user_id, problem - for u in *users + place: => + for u in *@users u.score = @queries.get_user_score u.id, @competition.id - table.sort users, (a, b) -> - a.score - b.score + table.sort @users, (a, b) -> + a.score > b.score + -- If n users have the same score, they are both place x, and + -- the next person is place x + n. last_score = 1e308 num, act_num = 0, 0 - for u in *users + for u in *@users act_num += 1 if last_score > u.score num = act_num last_score = u.score + lp = LeaderboardPlacements\find user_id: u.id, competition_id: @competition.id lp\update place: num score: u.score + + rescore_everything: => + -- Completely resets everything if a problem is + -- added or removed or if a user registers + @setup_scoring_tables! + @score_all! + @place! diff --git a/codebox/utils/table.moon b/codebox/utils/table.moon new file mode 100644 index 0000000..da3af30 --- /dev/null +++ b/codebox/utils/table.moon @@ -0,0 +1,4 @@ +table.contains = (t, v) -> + for a in *t + return true if a == v + return false diff --git a/codebox/views/admin/competition/edit.moon b/codebox/views/admin/competition/edit.moon index 98425ec..af684a1 100644 --- a/codebox/views/admin/competition/edit.moon +++ b/codebox/views/admin/competition/edit.moon @@ -5,22 +5,45 @@ class AdminCompetitionEdit extends html.Widget h1 "Editing '#{@comp.name}'" div class: 'content', -> - div class: 'split-2', -> + div class: 'split-2-1', -> form method: 'POST', -> input type: 'hidden', name: 'id', value: "#{@comp.id}", "" - label for: 'name', 'Competition name' - input type: 'text', name: 'name', value: "#{@comp.name}", "" + div class: 'split-3-1', -> + div class: 'mar-r-24', -> + label for: 'name', 'Competition name' + input type: 'text', name: 'name', value: "#{@comp.name}", "" + div -> + label for: 'time_offset', 'Time offset (in minutes)' + input type: 'number', name: 'time_offset', value: "#{@comp.time_offset}", "" - label for: 'start_time', 'Start time' - input type: 'datetime-local', name: 'start_time', value: "#{@comp.start}", "" + div class: 'split-2', -> + div -> + label for: 'start_time', 'Start time' + input type: 'datetime-local', name: 'start_time', value: "#{@comp.start}", "" - label for: 'end_time', 'End time' - input type: 'datetime-local', name: 'end_time', value: "#{@comp.end}", "" + div -> + label for: 'end_time', 'End time' + input type: 'datetime-local', name: 'end_time', value: "#{@comp.end}", "" - input type: 'submit', value: 'Save competition' - - div -> + div class: 'mar-t-48', -> text "" + + div class: 'split-3', -> + div class: 'mar-r-12', -> + label for: 'programming_points', 'Programming points' + input type: 'number', name: 'programming_points', value: "#{@comp.programming_points}", "" + + div class: 'mar-r-12 mar-l-12', -> + label for: 'codegolf_points', 'Code golf points' + input type: 'number', name: 'codegolf_points', value: "#{@comp.codegolf_points}", "" + + div class: 'mar-l-12', -> + label for: 'word_points', 'Word points' + input type: 'number', name: 'word_points', value: "#{@comp.word_points}", "" + + input class: 'mar-t-24', type: 'submit', value: 'Save competition' + + div class: 'mar-l-24', -> div class: 'header-line', -> span "Problems" diff --git a/codebox/views/admin/problem/edit.moon b/codebox/views/admin/problem/edit.moon index 7940908..45d99c5 100644 --- a/codebox/views/admin/problem/edit.moon +++ b/codebox/views/admin/problem/edit.moon @@ -1,5 +1,6 @@ html = require "lapis.html" TestCase = require "views.admin.problem.test_case" +import Problems from require 'models' class AdminProblemEdit extends html.Widget content: => @@ -15,22 +16,29 @@ class AdminProblemEdit extends html.Widget label for: 'name', 'Problem name' input type: 'text', name: 'name', placeholder: 'Problem name', value: @problem.name, "" - div class: 'split-2', -> - div -> + div class: 'split-3', -> + div class: 'mar-r-12', -> label for: 'name', 'Short name' input type: 'text', name: 'short_name', placeholder: 'Short URL name', value: @problem.short_name, "" - div -> + div class: 'mar-l-12 mar-r-12', -> label for: 'name', 'Time limit' input type: 'number', value: 500, name: 'time_limit', value: @problem.time_limit, "" + div class: 'mar-l-12', -> + label for: 'kind', 'Problem kind' + element 'select', name: 'kind', -> + option { value: 'code', selected: @problem.kind == Problems.kinds.code }, 'Programming' + option { value: 'golf', selected: @problem.kind == Problems.kinds.golf }, 'Code Golf' + option { value: 'word', selected: @problem.kind == Problems.kinds.word }, 'Word' + div class: 'header-line', -> div 'Problem description' pre { style: 'height: 32rem;', id: 'code-editor', 'data-lang': 'markdown' }, @problem.description input class: 'mar-t-24', type: 'submit', value: 'Update problem info' div class: 'mar-t-24', -> - h2 class: 'pad-l-12', 'Test cases' + h2 class: 'pad-l-12', 'Test cases' div class: 'test-cases', -> for test_case in *@test_cases @@ -38,4 +46,3 @@ class AdminProblemEdit extends html.Widget button { 'data-new-tc': @problem.short_name }, 'New test case' button { 'data-tc-save-all': true }, 'Save all' - \ No newline at end of file -- 2.25.1