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,
})
}