Finalizing scoring system; lots of other fixes
authorBrendan Hansen <brendan.f.hansen@gmail.com>
Wed, 25 Sep 2019 22:50:01 +0000 (17:50 -0500)
committerBrendan Hansen <brendan.f.hansen@gmail.com>
Wed, 25 Sep 2019 22:50:01 +0000 (17:50 -0500)
18 files changed:
codebox/app/app.moon
codebox/controllers/account/register.moon
codebox/controllers/admin/competition/add_problem.moon
codebox/controllers/admin/competition/delete_problem.moon
codebox/controllers/admin/competition/edit.moon
codebox/controllers/admin/problem/edit.moon
codebox/controllers/admin/utils/Tupfile [new file with mode: 0644]
codebox/controllers/admin/utils/score.moon [new file with mode: 0644]
codebox/controllers/executer/status_update.moon
codebox/middleware/competition_started.moon
codebox/middleware/during_competition.moon
codebox/migrations.moon
codebox/models/competitions.moon
codebox/services/queries.moon
codebox/services/scoring.moon
codebox/utils/table.moon [new file with mode: 0644]
codebox/views/admin/competition/edit.moon
codebox/views/admin/problem/edit.moon

index 45949c0f27031ac6c7c1e3b25d018ce3e91ce85e..f1eeb51bfb470f4d004b5f3915653a94485a1475 100644 (file)
@@ -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!
index 9c2959661fbdbb7f9e664b57c8a62a8472a0684d..4262eb843e6957381b423269ab3743f891aad56e 100644 (file)
@@ -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'
 
index b87551f3492f272d847670308402d4fd60decc44..d15daa734bcd01d63d26a99553cafab2c1d288e6 100644 (file)
@@ -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 }
index b2ba0e20a0d9d8e8073bf11ab970f5fe8885db4b..544377a1a75481fe5feb699e2daf16e3614aefed 100644 (file)
@@ -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 }
index 8ec29d01933d093de7b8535f70e2724d1b1aad13..2aacac8c3d934e1757708cd0e61002dac37a3eb5 100644 (file)
@@ -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!
 
index f39ca466ef55c1e7945ffd0a0253744392375404..9b66dc9e794404adfde0eb5d93c4270086d35190 100644 (file)
@@ -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 (file)
index 0000000..f0fe651
--- /dev/null
@@ -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 (file)
index 0000000..9b359e0
--- /dev/null
@@ -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'
index a5d231c8fa595d21e5451f525157b2a79291635f..9949072547825ff1e9e8f82dee196e2d72b2b842 100644 (file)
@@ -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' }
index bee3a2f72bce3b9d6c9f56a2a7e7b52795a3ee81..e109ae4b69b542935632eb6e36f8f1b0185bd445 100644 (file)
@@ -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'
index 51b4e48a0badeb2f78050ce3fd5b0427c062c006..0358be1d6c61a8370c8dd73301c4c668906672a4 100644 (file)
@@ -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'
index 73334fd52a714a52a4e2cebb2a95076f53c25278..0e23230ddfc55b4b21ef217b5e5d976b238560a4 100644 (file)
@@ -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)
 }
index 36c6df4286c086361c3cd890a032a2b70a974932..046884f08417d560114251d4cf3ed06493573bf4 100644 (file)
@@ -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
+        }
+    }
index 22a3a1617ad40200e1264bbc6759337f955bca4c..214eef6f94729bbcff9c657df77d64dea8688ee2 100644 (file)
@@ -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
 }
index c3ba9ab09e961414c9e4eea5ef4d65625231b330..25eaf51c8c221cf3ed1f908d20842fea35b7a185 100644 (file)
 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 (file)
index 0000000..da3af30
--- /dev/null
@@ -0,0 +1,4 @@
+table.contains = (t, v) ->
+    for a in *t
+        return true if a == v
+    return false
index 98425eca805fbafe83164286dd7b32dc2b312261..af684a11985723a0099818faf12c438f0f42825f 100644 (file)
@@ -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"
 
index 794090841b4e3ef13b42e2a864b7f024bd035273..45d99c5bded7adc802f7ac2089b73b83fb924540 100644 (file)
@@ -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