moth/cmd/mothd/server.go

291 lines
8.0 KiB
Go

package main
import (
"fmt"
"io"
"strconv"
"time"
"github.com/dirtbags/moth/pkg/award"
)
// Category represents a puzzle category.
type Category struct {
Name string
Puzzles []int
}
// ReadSeekCloser defines a struct that can read, seek, and close.
type ReadSeekCloser interface {
io.Reader
io.Seeker
io.Closer
}
// Configuration stores information about server configuration.
type Configuration struct {
Devel bool
}
// StateExport is given to clients requesting the current state.
type StateExport struct {
Config Configuration
Messages string
TeamNames map[string]string
PointsLog award.List
Puzzles map[string][]int
}
// PuzzleProvider defines what's required to provide puzzles.
type PuzzleProvider interface {
Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error)
Inventory() []Category
CheckAnswer(cat string, points int, answer string) (bool, error)
Mothball(cat string, w io.Writer) error
Maintainer
}
// ThemeProvider defines what's required to provide a theme.
type ThemeProvider interface {
Open(path string) (ReadSeekCloser, time.Time, error)
Maintainer
}
// StateProvider defines what's required to provide MOTH state.
type StateProvider interface {
Messages() string
PointsLog() award.List
TeamName(teamID string) (string, error)
SetTeamName(teamID, teamName string) error
ParticipantTeam(participantID string) (string, error)
AssignParticipant(participantID string, teamID string) error
AwardPoints(participantID string, cat string, points int) error
LogEvent(event, participantID, teamID, cat string, points int, extra ...string)
Maintainer
}
// 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)
}
// MothServer gathers together the providers that make up a MOTH server.
type MothServer struct {
PuzzleProviders []PuzzleProvider
Theme ThemeProvider
State StateProvider
Config Configuration
}
// NewMothServer returns a new MothServer.
func NewMothServer(config Configuration, theme ThemeProvider, state StateProvider, puzzleProviders ...PuzzleProvider) *MothServer {
return &MothServer{
Config: config,
PuzzleProviders: puzzleProviders,
Theme: theme,
State: state,
}
}
// NewHandler returns a new http.RequestHandler for the provided teamID.
func (s *MothServer) NewHandler(participantID, teamID string) MothRequestHandler {
return MothRequestHandler{
MothServer: s,
participantID: participantID,
teamID: teamID,
}
}
// MothRequestHandler provides http.RequestHandler for a MothServer.
type MothRequestHandler struct {
*MothServer
participantID string
teamID string
}
// PuzzlesOpen opens a file associated with a puzzle.
// 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) {
export := mh.exportStateIfRegistered(true)
found := false
for _, p := range export.Puzzles[cat] {
if p == points {
found = true
}
}
if !found {
return nil, time.Time{}, fmt.Errorf("puzzle does not exist or is locked")
}
// 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
}
}
// Log puzzle.json loads
if path == "puzzle.json" {
mh.State.LogEvent("load", mh.participantID, mh.teamID, cat, points)
}
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
teamID, err := mh.State.ParticipantTeam(mh.participantID)
if err != nil {
return fmt.Errorf("invalid participant ID")
}
if _, err := mh.State.TeamName(teamID); err != nil {
return fmt.Errorf("invalid team ID")
}
for _, provider := range mh.PuzzleProviders {
if ok, err := provider.CheckAnswer(cat, points, answer); err != nil {
return err
} else if ok {
correct = true
}
}
if !correct {
mh.State.LogEvent("wrong", mh.participantID, teamID, cat, points)
return fmt.Errorf("incorrect answer")
}
mh.State.LogEvent("correct", mh.participantID, teamID, cat, points)
if err := mh.State.AwardPoints(mh.participantID, cat, points); err != nil {
return fmt.Errorf("error awarding points: %s", err)
}
return nil
}
// ThemeOpen opens a file from a theme.
func (mh *MothRequestHandler) ThemeOpen(path string) (ReadSeekCloser, time.Time, error) {
return mh.Theme.Open(path)
}
// Register associates a team name with a team ID.
func (mh *MothRequestHandler) Register(teamName string) error {
// BUG(neale): Register returns an error if a team is already registered; it may make more sense to return success
if teamName == "" {
return fmt.Errorf("empty team name")
}
mh.State.LogEvent("register", mh.participantID, mh.teamID, "", 0)
return mh.State.SetTeamName(mh.teamID, teamName)
}
// AssignParticipant associates a participant with a team
func (mh *MothRequestHandler) AssignParticipant() error {
if mh.participantID == "" {
return fmt.Errorf("empty participant ID")
}
if mh.teamID == "" {
return fmt.Errorf("empty team ID")
}
mh.State.LogEvent("assign", mh.participantID, mh.teamID, "", 0)
return mh.State.AssignParticipant(mh.participantID, mh.teamID)
}
// 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 {
return mh.exportStateIfRegistered(false)
}
func (mh *MothRequestHandler) exportStateIfRegistered(override bool) *StateExport {
export := StateExport{}
export.Config = mh.Config
teamName, err := mh.State.TeamName(mh.teamID)
registered := override || mh.Config.Devel || (err == nil)
export.Messages = mh.State.Messages()
export.TeamNames = make(map[string]string)
// Anonymize team IDs in points log, and write out team names
pointsLog := mh.State.PointsLog()
exportIDs := make(map[string]string)
maxSolved := make(map[string]int)
export.PointsLog = make(award.List, len(pointsLog))
if registered {
export.TeamNames["self"] = teamName
exportIDs[mh.teamID] = "self"
}
for logno, awd := range pointsLog {
if id, ok := exportIDs[awd.TeamID]; ok {
awd.TeamID = id
} else {
exportID := strconv.Itoa(logno)
name, _ := mh.State.TeamName(awd.TeamID)
exportIDs[awd.TeamID] = exportID
awd.TeamID = exportID
export.TeamNames[exportID] = name
}
export.PointsLog[logno] = awd
// Record the highest-value unlocked puzzle in each category
if awd.Points > maxSolved[awd.Category] {
maxSolved[awd.Category] = awd.Points
}
}
export.Puzzles = make(map[string][]int)
if registered {
// 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.
for _, provider := range mh.PuzzleProviders {
for _, category := range provider.Inventory() {
// Append sentry (end of puzzles)
allPuzzles := append(category.Puzzles, 0)
max := maxSolved[category.Name]
puzzles := make([]int, 0, len(allPuzzles))
for i, val := range allPuzzles {
puzzles = allPuzzles[:i+1]
if !mh.Config.Devel && (val > max) {
break
}
}
export.Puzzles[category.Name] = puzzles
}
}
}
return &export
}
// Mothball generates a mothball for the given category.
func (mh *MothRequestHandler) Mothball(cat string, w io.Writer) error {
var err error
if !mh.Config.Devel {
return fmt.Errorf("cannot mothball in production mode")
}
for _, provider := range mh.PuzzleProviders {
if err = provider.Mothball(cat, w); err == nil {
return nil
}
}
return err
}