2020-03-01 16:10:55 -07:00
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
|
|
|
"fmt"
|
2020-08-14 20:26:04 -06:00
|
|
|
"io"
|
2020-03-01 16:10:55 -07:00
|
|
|
"strconv"
|
2020-08-14 20:26:04 -06:00
|
|
|
"time"
|
2020-08-17 17:43:57 -06:00
|
|
|
|
2023-04-11 17:56:59 -06:00
|
|
|
"github.com/dirtbags/moth/v4/pkg/award"
|
2020-03-01 16:10:55 -07:00
|
|
|
)
|
|
|
|
|
2020-08-17 17:43:57 -06:00
|
|
|
// Category represents a puzzle category.
|
2020-03-01 16:10:55 -07:00
|
|
|
type Category struct {
|
|
|
|
Name string
|
|
|
|
Puzzles []int
|
|
|
|
}
|
|
|
|
|
2020-08-17 17:43:57 -06:00
|
|
|
// ReadSeekCloser defines a struct that can read, seek, and close.
|
2020-03-01 16:10:55 -07:00
|
|
|
type ReadSeekCloser interface {
|
|
|
|
io.Reader
|
|
|
|
io.Seeker
|
|
|
|
io.Closer
|
|
|
|
}
|
|
|
|
|
2020-09-08 17:49:02 -06:00
|
|
|
// Configuration stores information about server configuration.
|
|
|
|
type Configuration struct {
|
|
|
|
Devel bool
|
|
|
|
}
|
|
|
|
|
2020-08-17 17:43:57 -06:00
|
|
|
// StateExport is given to clients requesting the current state.
|
2020-03-01 16:10:55 -07:00
|
|
|
type StateExport struct {
|
2020-09-08 17:49:02 -06:00
|
|
|
Config Configuration
|
2020-03-01 16:10:55 -07:00
|
|
|
Messages string
|
|
|
|
TeamNames map[string]string
|
2020-08-17 17:43:57 -06:00
|
|
|
PointsLog award.List
|
2020-08-14 20:26:04 -06:00
|
|
|
Puzzles map[string][]int
|
2020-03-01 16:10:55 -07:00
|
|
|
}
|
|
|
|
|
2020-08-17 17:43:57 -06:00
|
|
|
// PuzzleProvider defines what's required to provide puzzles.
|
2020-03-01 16:10:55 -07:00
|
|
|
type PuzzleProvider interface {
|
|
|
|
Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error)
|
|
|
|
Inventory() []Category
|
2020-09-08 17:49:02 -06:00
|
|
|
CheckAnswer(cat string, points int, answer string) (bool, error)
|
2020-10-12 17:44:44 -06:00
|
|
|
Mothball(cat string, w io.Writer) error
|
2020-08-18 17:04:23 -06:00
|
|
|
Maintainer
|
2020-03-01 16:10:55 -07:00
|
|
|
}
|
|
|
|
|
2020-08-17 17:43:57 -06:00
|
|
|
// ThemeProvider defines what's required to provide a theme.
|
2020-03-01 16:10:55 -07:00
|
|
|
type ThemeProvider interface {
|
|
|
|
Open(path string) (ReadSeekCloser, time.Time, error)
|
2020-08-18 17:04:23 -06:00
|
|
|
Maintainer
|
2020-03-01 16:10:55 -07:00
|
|
|
}
|
|
|
|
|
2020-08-17 17:43:57 -06:00
|
|
|
// StateProvider defines what's required to provide MOTH state.
|
2020-03-01 16:10:55 -07:00
|
|
|
type StateProvider interface {
|
|
|
|
Messages() string
|
2020-08-17 17:43:57 -06:00
|
|
|
PointsLog() award.List
|
|
|
|
TeamName(teamID string) (string, error)
|
|
|
|
SetTeamName(teamID, teamName string) error
|
|
|
|
AwardPoints(teamID string, cat string, points int) error
|
2023-03-23 14:28:11 -06:00
|
|
|
LogEvent(event, teamID, cat string, points int, extra ...string)
|
2020-08-18 17:04:23 -06:00
|
|
|
Maintainer
|
2020-03-01 16:10:55 -07:00
|
|
|
}
|
|
|
|
|
2020-08-18 17:04:23 -06:00
|
|
|
// Maintainer is something that can be maintained.
|
|
|
|
type Maintainer interface {
|
|
|
|
// Maintain is the maintenance loop.
|
|
|
|
// It will only be called once, when execution begins.
|
|
|
|
// It's okay to just exit if there's no maintenance to be done.
|
|
|
|
Maintain(updateInterval time.Duration)
|
2021-10-20 11:29:55 -06:00
|
|
|
|
|
|
|
// refresh is a shortcut used internally for testing
|
|
|
|
refresh()
|
2020-03-01 16:10:55 -07:00
|
|
|
}
|
|
|
|
|
2020-08-17 17:43:57 -06:00
|
|
|
// MothServer gathers together the providers that make up a MOTH server.
|
2020-03-01 16:10:55 -07:00
|
|
|
type MothServer struct {
|
2020-09-08 17:49:02 -06:00
|
|
|
PuzzleProviders []PuzzleProvider
|
|
|
|
Theme ThemeProvider
|
|
|
|
State StateProvider
|
|
|
|
Config Configuration
|
2020-03-01 16:10:55 -07:00
|
|
|
}
|
|
|
|
|
2020-08-17 17:43:57 -06:00
|
|
|
// NewMothServer returns a new MothServer.
|
2020-09-08 17:49:02 -06:00
|
|
|
func NewMothServer(config Configuration, theme ThemeProvider, state StateProvider, puzzleProviders ...PuzzleProvider) *MothServer {
|
2020-03-01 16:10:55 -07:00
|
|
|
return &MothServer{
|
2020-09-08 17:49:02 -06:00
|
|
|
Config: config,
|
|
|
|
PuzzleProviders: puzzleProviders,
|
|
|
|
Theme: theme,
|
|
|
|
State: state,
|
2020-03-01 16:10:55 -07:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-08-17 17:43:57 -06:00
|
|
|
// NewHandler returns a new http.RequestHandler for the provided teamID.
|
2023-03-23 14:28:11 -06:00
|
|
|
func (s *MothServer) NewHandler(teamID string) MothRequestHandler {
|
2020-03-01 16:10:55 -07:00
|
|
|
return MothRequestHandler{
|
2023-03-23 14:28:11 -06:00
|
|
|
MothServer: s,
|
|
|
|
teamID: teamID,
|
2020-03-01 16:10:55 -07:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-08-17 17:43:57 -06:00
|
|
|
// MothRequestHandler provides http.RequestHandler for a MothServer.
|
2020-03-01 16:10:55 -07:00
|
|
|
type MothRequestHandler struct {
|
|
|
|
*MothServer
|
2023-03-23 14:28:11 -06:00
|
|
|
teamID string
|
2020-03-01 16:10:55 -07:00
|
|
|
}
|
|
|
|
|
2020-08-17 17:43:57 -06:00
|
|
|
// PuzzlesOpen opens a file associated with a puzzle.
|
2020-09-08 17:49:02 -06:00
|
|
|
// BUG(neale): Multiple providers with the same category name are not detected or handled well.
|
|
|
|
func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (r ReadSeekCloser, ts time.Time, err error) {
|
2020-10-13 18:33:12 -06:00
|
|
|
export := mh.exportStateIfRegistered(true)
|
2020-09-08 17:49:02 -06:00
|
|
|
found := false
|
2020-03-01 17:01:01 -07:00
|
|
|
for _, p := range export.Puzzles[cat] {
|
|
|
|
if p == points {
|
2020-09-08 17:49:02 -06:00
|
|
|
found = true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if !found {
|
2021-10-13 18:25:27 -06:00
|
|
|
return nil, time.Time{}, fmt.Errorf("puzzle does not exist or is locked")
|
2020-09-08 17:49:02 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
// Try every provider until someone doesn't return an error
|
|
|
|
for _, provider := range mh.PuzzleProviders {
|
|
|
|
r, ts, err = provider.Open(cat, points, path)
|
|
|
|
if err != nil {
|
|
|
|
return r, ts, err
|
2020-03-01 17:01:01 -07:00
|
|
|
}
|
|
|
|
}
|
2020-08-14 20:26:04 -06:00
|
|
|
|
2020-10-14 18:20:49 -06:00
|
|
|
// Log puzzle.json loads
|
|
|
|
if path == "puzzle.json" {
|
2023-03-23 14:28:11 -06:00
|
|
|
mh.State.LogEvent("load", mh.teamID, cat, points)
|
2020-10-14 18:20:49 -06:00
|
|
|
}
|
|
|
|
|
2020-09-08 17:49:02 -06:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// CheckAnswer returns an error if answer is not a correct answer for puzzle points in category cat
|
|
|
|
func (mh *MothRequestHandler) CheckAnswer(cat string, points int, answer string) error {
|
|
|
|
correct := false
|
|
|
|
for _, provider := range mh.PuzzleProviders {
|
|
|
|
if ok, err := provider.CheckAnswer(cat, points, answer); err != nil {
|
|
|
|
return err
|
|
|
|
} else if ok {
|
|
|
|
correct = true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if !correct {
|
2023-03-23 14:28:11 -06:00
|
|
|
mh.State.LogEvent("wrong", mh.teamID, cat, points)
|
2021-10-13 18:25:27 -06:00
|
|
|
return fmt.Errorf("incorrect answer")
|
2020-09-08 17:49:02 -06:00
|
|
|
}
|
|
|
|
|
2023-03-23 14:28:11 -06:00
|
|
|
mh.State.LogEvent("correct", mh.teamID, cat, points)
|
2020-09-08 17:49:02 -06:00
|
|
|
|
2020-12-02 19:31:34 -07:00
|
|
|
if _, err := mh.State.TeamName(mh.teamID); err != nil {
|
2021-10-13 18:25:27 -06:00
|
|
|
return fmt.Errorf("invalid team ID")
|
2020-12-02 19:31:34 -07:00
|
|
|
}
|
2020-09-08 17:49:02 -06:00
|
|
|
if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil {
|
2021-10-13 18:25:27 -06:00
|
|
|
return fmt.Errorf("error awarding points: %s", err)
|
2020-09-08 17:49:02 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
2020-03-01 16:10:55 -07:00
|
|
|
}
|
|
|
|
|
2020-08-17 17:43:57 -06:00
|
|
|
// ThemeOpen opens a file from a theme.
|
2020-03-01 16:10:55 -07:00
|
|
|
func (mh *MothRequestHandler) ThemeOpen(path string) (ReadSeekCloser, time.Time, error) {
|
|
|
|
return mh.Theme.Open(path)
|
|
|
|
}
|
|
|
|
|
2020-08-17 17:43:57 -06:00
|
|
|
// Register associates a team name with a team ID.
|
2020-03-01 16:10:55 -07:00
|
|
|
func (mh *MothRequestHandler) Register(teamName string) error {
|
2020-08-18 17:04:23 -06:00
|
|
|
// BUG(neale): Register returns an error if a team is already registered; it may make more sense to return success
|
2020-03-01 16:10:55 -07:00
|
|
|
if teamName == "" {
|
2021-10-13 18:25:27 -06:00
|
|
|
return fmt.Errorf("empty team name")
|
2020-03-01 16:10:55 -07:00
|
|
|
}
|
2023-03-23 14:28:11 -06:00
|
|
|
mh.State.LogEvent("register", mh.teamID, "", 0)
|
2020-08-17 17:43:57 -06:00
|
|
|
return mh.State.SetTeamName(mh.teamID, teamName)
|
2020-03-01 16:10:55 -07:00
|
|
|
}
|
|
|
|
|
2020-08-17 17:43:57 -06:00
|
|
|
// ExportState anonymizes team IDs and returns StateExport.
|
|
|
|
// If a teamID has been specified for this MothRequestHandler,
|
|
|
|
// the anonymized team name for this teamID has the special value "self".
|
|
|
|
// If not, the puzzles list is empty.
|
|
|
|
func (mh *MothRequestHandler) ExportState() *StateExport {
|
2020-10-13 18:33:12 -06:00
|
|
|
return mh.exportStateIfRegistered(false)
|
|
|
|
}
|
|
|
|
|
2022-05-10 17:47:26 -06:00
|
|
|
// Export state, replacing the team ID with "self" if the team is registered.
|
|
|
|
//
|
|
|
|
// If forceRegistered is true, go ahead and export it anyway
|
|
|
|
func (mh *MothRequestHandler) exportStateIfRegistered(forceRegistered bool) *StateExport {
|
2020-03-01 16:10:55 -07:00
|
|
|
export := StateExport{}
|
2020-09-08 17:49:02 -06:00
|
|
|
export.Config = mh.Config
|
2020-03-01 16:10:55 -07:00
|
|
|
|
2020-10-13 16:41:50 -06:00
|
|
|
teamName, err := mh.State.TeamName(mh.teamID)
|
2022-05-10 17:47:26 -06:00
|
|
|
registered := forceRegistered || mh.Config.Devel || (err == nil)
|
2020-08-14 20:26:04 -06:00
|
|
|
|
2020-03-01 16:10:55 -07:00
|
|
|
export.Messages = mh.State.Messages()
|
2020-10-14 10:05:53 -06:00
|
|
|
export.TeamNames = make(map[string]string)
|
2020-03-01 16:10:55 -07:00
|
|
|
|
|
|
|
// Anonymize team IDs in points log, and write out team names
|
|
|
|
pointsLog := mh.State.PointsLog()
|
2020-10-14 10:04:13 -06:00
|
|
|
exportIDs := make(map[string]string)
|
|
|
|
maxSolved := make(map[string]int)
|
2020-08-17 17:43:57 -06:00
|
|
|
export.PointsLog = make(award.List, len(pointsLog))
|
2020-10-14 10:04:13 -06:00
|
|
|
|
|
|
|
if registered {
|
|
|
|
export.TeamNames["self"] = teamName
|
|
|
|
exportIDs[mh.teamID] = "self"
|
|
|
|
}
|
2020-08-17 17:43:57 -06:00
|
|
|
for logno, awd := range pointsLog {
|
|
|
|
if id, ok := exportIDs[awd.TeamID]; ok {
|
|
|
|
awd.TeamID = id
|
2020-03-01 16:10:55 -07:00
|
|
|
} else {
|
2020-08-17 17:43:57 -06:00
|
|
|
exportID := strconv.Itoa(logno)
|
|
|
|
name, _ := mh.State.TeamName(awd.TeamID)
|
2020-10-14 09:52:19 -06:00
|
|
|
exportIDs[awd.TeamID] = exportID
|
2020-10-14 09:46:51 -06:00
|
|
|
awd.TeamID = exportID
|
2020-08-17 17:43:57 -06:00
|
|
|
export.TeamNames[exportID] = name
|
2020-03-01 16:10:55 -07:00
|
|
|
}
|
2020-08-17 17:43:57 -06:00
|
|
|
export.PointsLog[logno] = awd
|
2020-08-14 20:26:04 -06:00
|
|
|
|
2020-03-01 16:10:55 -07:00
|
|
|
// Record the highest-value unlocked puzzle in each category
|
2020-08-17 17:43:57 -06:00
|
|
|
if awd.Points > maxSolved[awd.Category] {
|
|
|
|
maxSolved[awd.Category] = awd.Points
|
2020-03-01 16:10:55 -07:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export.Puzzles = make(map[string][]int)
|
2020-10-13 16:41:50 -06:00
|
|
|
if registered {
|
2020-08-17 17:43:57 -06:00
|
|
|
// We used to hand this out to everyone,
|
|
|
|
// but then we got a bad reputation on some secretive blacklist,
|
|
|
|
// and now the Navy can't register for events.
|
2020-09-08 17:49:02 -06:00
|
|
|
for _, provider := range mh.PuzzleProviders {
|
|
|
|
for _, category := range provider.Inventory() {
|
|
|
|
// Append sentry (end of puzzles)
|
|
|
|
allPuzzles := append(category.Puzzles, 0)
|
2020-08-17 17:43:57 -06:00
|
|
|
|
2020-09-08 17:49:02 -06:00
|
|
|
max := maxSolved[category.Name]
|
2020-08-17 17:43:57 -06:00
|
|
|
|
2020-09-08 17:49:02 -06:00
|
|
|
puzzles := make([]int, 0, len(allPuzzles))
|
|
|
|
for i, val := range allPuzzles {
|
|
|
|
puzzles = allPuzzles[:i+1]
|
|
|
|
if !mh.Config.Devel && (val > max) {
|
|
|
|
break
|
|
|
|
}
|
2020-08-17 17:43:57 -06:00
|
|
|
}
|
2020-09-08 17:49:02 -06:00
|
|
|
export.Puzzles[category.Name] = puzzles
|
2020-03-01 16:10:55 -07:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return &export
|
2020-03-01 17:01:01 -07:00
|
|
|
}
|
2020-09-15 15:58:21 -06:00
|
|
|
|
|
|
|
// Mothball generates a mothball for the given category.
|
2020-10-12 17:44:44 -06:00
|
|
|
func (mh *MothRequestHandler) Mothball(cat string, w io.Writer) error {
|
|
|
|
var err error
|
|
|
|
|
2020-09-15 15:58:21 -06:00
|
|
|
if !mh.Config.Devel {
|
2021-10-13 18:25:27 -06:00
|
|
|
return fmt.Errorf("cannot mothball in production mode")
|
2020-09-15 15:58:21 -06:00
|
|
|
}
|
|
|
|
for _, provider := range mh.PuzzleProviders {
|
2020-10-12 17:44:44 -06:00
|
|
|
if err = provider.Mothball(cat, w); err == nil {
|
|
|
|
return nil
|
2020-09-15 15:58:21 -06:00
|
|
|
}
|
|
|
|
}
|
2020-10-12 17:44:44 -06:00
|
|
|
return err
|
2020-09-15 15:58:21 -06:00
|
|
|
}
|