From e7480716b3f32229cde927e1be00f0b44eef2388 Mon Sep 17 00:00:00 2001 From: diamondburned Date: Thu, 21 Mar 2024 23:33:16 -0700 Subject: [PATCH] Add scoring v2, set Day 5 to be worth 200 pts/part --- config.json | 17 +++-- internal/config/config.go | 9 +-- main.go | 12 +--- server/frontend/pages/problem.html | 11 +++ server/frontend/pages/problem_result.html | 3 +- server/frontend/pages/problems.html | 5 +- server/problem/problem.go | 54 +++++++++++++-- server/problem/scoring.go | 82 +++++++++++++++++++---- server/r_problems.go | 36 ++++++---- 9 files changed, 173 insertions(+), 56 deletions(-) diff --git a/config.json b/config.json index ac46a75..bc1f143 100644 --- a/config.json +++ b/config.json @@ -9,23 +9,28 @@ "modules": [ { "cmd": "python3 -m problems.booting-up", - "readme": "./problems/booting-up/README.md" + "readme": "./problems/booting-up/README.md", + "scoring_version": 1 }, { "cmd": "python3 -m problems.empty-spaces", - "readme": "./problems/empty-spaces/README.md" + "readme": "./problems/empty-spaces/README.md", + "scoring_version": 1 }, { "cmd": "python3 -m problems.crafting", - "readme": "./problems/crafting/README.md" + "readme": "./problems/crafting/README.md", + "scoring_version": 1 }, { "cmd": "python3 -m problems.internet-connection", - "readme": "./problems/internet-connection/README.md" + "readme": "./problems/internet-connection/README.md", + "scoring_version": 1 }, { - "cmd": "false", - "readme": "./problems/_placeholder/README.md" + "cmd": "python3 -m problems.intruder-alert", + "readme": "./problems/intruder-alert/README.md", + "points_per_part": 200 } ], "schedule": { diff --git a/internal/config/config.go b/internal/config/config.go index b56170d..81267ee 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,8 @@ import ( "fmt" "os" "time" + + "dev.acmcsuf.com/march-madness-2024/server/problem" ) type Config struct { @@ -20,7 +22,7 @@ type Config struct { } type ProblemsConfig struct { - Modules []ProblemModule `json:"modules"` + Modules []problem.ModuleConfig `json:"modules"` Schedule struct { Start time.Time `json:"start"` Every Duration `json:"every"` @@ -28,11 +30,6 @@ type ProblemsConfig struct { Cooldown Duration `json:"cooldown"` } -type ProblemModule struct { - Command string `json:"cmd"` - README string `json:"readme"` -} - type HackathonConfig struct { StartTime time.Time `json:"start_time"` Duration Duration `json:"duration"` diff --git a/main.go b/main.go index 237a4ed..0451664 100644 --- a/main.go +++ b/main.go @@ -71,17 +71,11 @@ func run(ctx context.Context) error { problems := make([]problem.Problem, len(config.Problems.Modules)) for i, module := range config.Problems.Modules { - description, err := problem.ParseProblemDescriptionFile(module.README) + p, err := problem.NewProblemFromModule(module, logger) if err != nil { - return fmt.Errorf("failed to parse README file at %q: %w", module.README, err) + return fmt.Errorf("failed to create problem from module %q: %w", module.README, err) } - - runner, err := problem.NewCommandRunner(logger.With("component", "runner"), module.Command) - if err != nil { - return fmt.Errorf("failed to create command runner %q: %w", module.Command, err) - } - - problems[i] = problem.NewProblem(module.README, description, runner) + problems[i] = p } problem.CacheAllProblems(problems, logger.With("component", "problem_cache")) diff --git a/server/frontend/pages/problem.html b/server/frontend/pages/problem.html index a4aa9f2..f949015 100644 --- a/server/frontend/pages/problem.html +++ b/server/frontend/pages/problem.html @@ -11,6 +11,17 @@

Day {{ .Day }}

{{ .Problem.Description.Title }}

+ {{ if not .PPPIsDefault }} +
+

+ Heads up! + Solving this problem will net you a total of + {{ .PointsPerPart }} points for each part! +

+
+ {{ end }} + +
{{ md .Problem.Description.Part1 }}
diff --git a/server/frontend/pages/problem_result.html b/server/frontend/pages/problem_result.html index d6efa1d..900aa42 100644 --- a/server/frontend/pages/problem_result.html +++ b/server/frontend/pages/problem_result.html @@ -25,7 +25,8 @@

Problem Submission

{{ else }} {{ if .Correct }}

- Congratulations! Your answer is correct! + Congratulations, your answer is correct! Solving this problem nets you a + total of {{ .PointsAwarded | floor }} points. Go back to the problem here, or check out the leaderboard.

diff --git a/server/frontend/pages/problems.html b/server/frontend/pages/problems.html index 79fd78b..89e8bda 100644 --- a/server/frontend/pages/problems.html +++ b/server/frontend/pages/problems.html @@ -19,8 +19,9 @@

Week of Code

A new coding problem every day!

- You may get more points for solving problems as soon as they are released, so keep an - eye out for the next problem! + For each problem, you can earn up to {{ .PointsPerPart }} points for each parts you + solve. You may get more points for solving problems as soon as they are released, so + keep an eye out for the next problem!

diff --git a/server/problem/problem.go b/server/problem/problem.go index 7de357d..1ce1bfb 100644 --- a/server/problem/problem.go +++ b/server/problem/problem.go @@ -15,6 +15,22 @@ import ( "github.com/puzpuzpuz/xsync/v3" ) +// ModuleConfig is a module that points to a problem. +type ModuleConfig struct { + Command string `json:"cmd"` + README string `json:"readme"` + ProblemConfig +} + +// ProblemConfig contains optional configuration for a problem. +type ProblemConfig struct { + // PointsPerPart is the number of points awarded for each part of the problem. + // It overrides the default [PointsPerPart]. + PointsPerPart float64 `json:"points_per_part,omitempty"` + // ScoringVersion is the version of the scoring function. + ScoringVersion ScoringVersion `json:"scoring_version,omitempty"` +} + // Problem is a problem that can be solved. type Problem struct { // ID returns the unique ID of the problem. @@ -24,17 +40,42 @@ type Problem struct { Description ProblemDescription Runner + ProblemConfig } // NewProblem creates a new problem. -func NewProblem(id string, desc ProblemDescription, runner Runner) Problem { +func NewProblem(id string, desc ProblemDescription, runner Runner, cfg ProblemConfig) Problem { + if cfg.PointsPerPart == 0 { + cfg.PointsPerPart = PointsPerPart + } + if cfg.ScoringVersion == 0 { + cfg.ScoringVersion = latestScoreScalingVersion + } return Problem{ - ID: id, - Description: desc, - Runner: runner, + ID: id, + Description: desc, + Runner: runner, + ProblemConfig: cfg, } } +// NewProblemFromModule creates a new problem from a problem module. +func NewProblemFromModule(module ModuleConfig, logger *slog.Logger) (Problem, error) { + var z Problem + + description, err := ParseProblemDescriptionFile(module.README) + if err != nil { + return z, fmt.Errorf("failed to parse README file at %q: %w", module.README, err) + } + + runner, err := NewCommandRunner(logger.With("component", "runner"), module.Command) + if err != nil { + return z, fmt.Errorf("failed to create command runner %q: %w", module.Command, err) + } + + return NewProblem(module.README, description, runner, module.ProblemConfig), nil +} + // Runner is a problem runner. type Runner interface { // Input generates the input for the problem. @@ -84,7 +125,7 @@ func (p *CommandRunner) Part2Solution(ctx context.Context, seed int) (int64, err } func (p *CommandRunner) run(ctx context.Context, seed int, args string) (string, error) { - command := p.command + " " + args + command := fmt.Sprintf("%s --seed %d %s", p.command, seed, args) logger := p.logger.With( "seed", seed, "command", command) @@ -169,14 +210,13 @@ func (c *CachedRunner) Part2Solution(ctx context.Context, seed int) (int64, erro return getCache(ctx, c, seed, part2CacheKey, c.runner.Part2Solution) } -var errCacheMiss = errors.New("cache miss") - func getCache[T any]( ctx context.Context, c *CachedRunner, seed int, pkey problemCacheKey, fn func(context.Context, int) (T, error), ) (T, error) { key := runnerCacheKey{c.problemID, seed, pkey} + logger := c.logger.With( "seed", seed, "key.id", key.id, diff --git a/server/problem/scoring.go b/server/problem/scoring.go index 1dc1e52..e6a3471 100644 --- a/server/problem/scoring.go +++ b/server/problem/scoring.go @@ -26,25 +26,81 @@ func CalculateCooldownEnd(totalAttempts int, lastSubmitted, now time.Time) time. cooldownMax)) } -const ( - // PointsPerPart is the number of points awarded for solving a part of a - // problem. - PointsPerPart = 100 - // MaxHour is the maximum hour before people get the lowest points. - MaxHour = 24 -) +// PointsPerPart is the number of points awarded for solving a part of a +// problem. +const PointsPerPart = 100 // ScalePoints scales the points for a problem's part based on the time the // problem was started and the time the part was solved. -func ScalePoints(t, startedAt time.Time) float64 { - h := t.Sub(startedAt).Hours() - return scoreScalingFn(clamp(h/MaxHour, 0, 1)) * PointsPerPart +// +// Optional parameters: +// +// - If maxPoints is 0, it is set to PointsPerPart. +// - If version is 0, the latest scoring function is used. +func ScalePoints(t, startedAt time.Time, maxPoints float64, version ScoringVersion) float64 { + if maxPoints == 0 { + maxPoints = PointsPerPart + } + return version.fn()(t, startedAt) * maxPoints +} + +// ScoringVersion is the version of the scoring function. +type ScoringVersion int + +const ( + _ ScoringVersion = iota + V1ScoreScaling + V2ScoreScaling + + maxScoreScalingVersion // latest = maxScoreScalingVersion - 1 +) + +const latestScoreScalingVersion = maxScoreScalingVersion - 1 + +func (v ScoringVersion) IsValid() bool { + return 0 < v && v < maxScoreScalingVersion } -func scoreScalingFn(x float64) float64 { +func (v ScoringVersion) fn() scoringFn { + switch v { + case 0: + return latestScoreScalingVersion.fn() + case V1ScoreScaling: + return scoreScalingV1 + case V2ScoreScaling: + return scoreScalingV2 + default: + panic("invalid scoring version") + } +} + +type scoringFn func(t, startedAt time.Time) float64 + +var ( + _ scoringFn = scoreScalingV1 + _ scoringFn = scoreScalingV2 +) + +func scoreScalingV1(t, startedAt time.Time) float64 { + const maxHour = 24 // https://www.desmos.com/calculator/22el44ng3r - f := func(x float64) float64 { return (math.Atan(-math.Pi*x+math.Pi/2) / 4) + 0.75 } - g := func(x float64) float64 { return f(x) + (1 - f(0)) } + f1 := func(x float64) float64 { return (math.Atan(-math.Pi*x+math.Pi/2) / 4) + 0.75 } + f2 := func(x float64) float64 { return f1(x) + (1 - f1(0)) } + g := func(x float64) float64 { return clamp(f2(x), 0, 1) } + x := t.Sub(startedAt).Hours() / maxHour + return g(x) +} + +func scoreScalingV2(t, startedAt time.Time) float64 { + // https://www.desmos.com/calculator/adpqv3xqzr + const maxHour = 12 + const intensity = 6.7 + const phase = 1.1 + const m = 3.4 + f1 := func(x float64) float64 { return math.Atan(-m*x+phase) / intensity } + f2 := func(x float64) float64 { return f1(x) + (1 - f1(0)) } + g := func(x float64) float64 { return clamp(f2(x), 0, 1) } + x := t.Sub(startedAt).Hours() / maxHour return g(x) } diff --git a/server/r_problems.go b/server/r_problems.go index bd65761..d7646d3 100644 --- a/server/r_problems.go +++ b/server/r_problems.go @@ -27,7 +27,8 @@ func (s *Server) routeProblems(r chi.Router) { type problemsPageData struct { frontend.ComponentContext - Problems *problem.ProblemSet + Problems *problem.ProblemSet + PointsPerPart float64 } func (s *Server) listProblems(w http.ResponseWriter, r *http.Request) { @@ -37,7 +38,8 @@ func (s *Server) listProblems(w http.ResponseWriter, r *http.Request) { TeamName: u.TeamName, Username: u.Username, }, - Problems: s.problems, + Problems: s.problems, + PointsPerPart: problem.PointsPerPart, }) } @@ -46,6 +48,7 @@ type problemPageData struct { Problem *problem.Problem Day problemDay PointsPerPart float64 + PPPIsDefault bool SolvedPart1 bool SolvedPart2 bool } @@ -79,7 +82,8 @@ func (s *Server) viewProblem(w http.ResponseWriter, r *http.Request) { }, Problem: p, Day: day, - PointsPerPart: problem.PointsPerPart, + PointsPerPart: p.PointsPerPart, + PPPIsDefault: p.PointsPerPart == problem.PointsPerPart, SolvedPart1: p1solves > 0, SolvedPart2: p2solves > 0, }) @@ -107,10 +111,11 @@ func (s *Server) viewProblemInput(w http.ResponseWriter, r *http.Request) { type problemResultPageData struct { frontend.ComponentContext - Day problemDay - Cooldown time.Duration - CooldownTime time.Time - Correct bool + Day problemDay + Cooldown time.Duration + CooldownTime time.Time + Correct bool + PointsAwarded float64 } func (s *Server) submitProblem(w http.ResponseWriter, r *http.Request) { @@ -184,6 +189,7 @@ func (s *Server) submitProblem(w http.ResponseWriter, r *http.Request) { cooldown := max(0, cooldownTime.Sub(now)) var correct bool + var points float64 if cooldown == 0 { seed := problem.StringToSeed(u.TeamName) @@ -203,6 +209,11 @@ func (s *Server) submitProblem(w http.ResponseWriter, r *http.Request) { } correct = answer == data.Answer + if correct { + points = problem.ScalePoints( + now, s.problems.ProblemStartTime(day.index()), + p.PointsPerPart, p.ScoringVersion) + } err = s.database.Tx(func(q *db.Queries) error { _, err := s.database.RecordSubmission(ctx, db.RecordSubmissionParams{ @@ -221,7 +232,7 @@ func (s *Server) submitProblem(w http.ResponseWriter, r *http.Request) { if correct { _, err = s.database.AddPoints(ctx, db.AddPointsParams{ TeamName: u.TeamName, - Points: problem.ScalePoints(now, s.problems.ProblemStartTime(day.index())), + Points: points, Reason: "week of code", }) if err != nil { @@ -242,10 +253,11 @@ func (s *Server) submitProblem(w http.ResponseWriter, r *http.Request) { TeamName: u.TeamName, Username: u.Username, }, - Day: day, - Correct: correct, - Cooldown: cooldown, - CooldownTime: cooldownTime, + Day: day, + Correct: correct, + Cooldown: cooldown, + CooldownTime: cooldownTime, + PointsAwarded: points, }) }