moth

Monarch Of The Hill game server
git clone https://git.woozle.org/neale/moth.git

moth / cmd / mothd
Neale Pickett  ·  2023-09-29

server.go

  1package main
  2
  3import (
  4	"fmt"
  5	"io"
  6	"strconv"
  7	"time"
  8
  9	"github.com/dirtbags/moth/v4/pkg/award"
 10)
 11
 12// Category represents a puzzle category.
 13type Category struct {
 14	Name    string
 15	Puzzles []int
 16}
 17
 18// ReadSeekCloser defines a struct that can read, seek, and close.
 19type ReadSeekCloser interface {
 20	io.Reader
 21	io.Seeker
 22	io.Closer
 23}
 24
 25// Configuration stores information about server configuration.
 26type Configuration struct {
 27	Devel bool
 28}
 29
 30// StateExport is given to clients requesting the current state.
 31type StateExport struct {
 32	Config    Configuration
 33	Enabled   bool
 34	TeamNames map[string]string
 35	PointsLog award.List
 36	Puzzles   map[string][]int
 37}
 38
 39// PuzzleProvider defines what's required to provide puzzles.
 40type PuzzleProvider interface {
 41	Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error)
 42	Inventory() []Category
 43	CheckAnswer(cat string, points int, answer string) (bool, error)
 44	Mothball(cat string, w io.Writer) error
 45	Maintainer
 46}
 47
 48// ThemeProvider defines what's required to provide a theme.
 49type ThemeProvider interface {
 50	Open(path string) (ReadSeekCloser, time.Time, error)
 51	Maintainer
 52}
 53
 54// StateProvider defines what's required to provide MOTH state.
 55type StateProvider interface {
 56	Enabled() bool
 57	PointsLog() award.List
 58	TeamName(teamID string) (string, error)
 59	SetTeamName(teamID, teamName string) error
 60	AwardPoints(teamID string, cat string, points int) error
 61	LogEvent(event, teamID, cat string, points int, extra ...string)
 62	Maintainer
 63}
 64
 65// Maintainer is something that can be maintained.
 66type Maintainer interface {
 67	// Maintain is the maintenance loop.
 68	// It will only be called once, when execution begins.
 69	// It's okay to just exit if there's no maintenance to be done.
 70	Maintain(updateInterval time.Duration)
 71
 72	// refresh is a shortcut used internally for testing
 73	refresh()
 74}
 75
 76// MothServer gathers together the providers that make up a MOTH server.
 77type MothServer struct {
 78	PuzzleProviders []PuzzleProvider
 79	Theme           ThemeProvider
 80	State           StateProvider
 81	Config          Configuration
 82}
 83
 84// NewMothServer returns a new MothServer.
 85func NewMothServer(config Configuration, theme ThemeProvider, state StateProvider, puzzleProviders ...PuzzleProvider) *MothServer {
 86	return &MothServer{
 87		Config:          config,
 88		PuzzleProviders: puzzleProviders,
 89		Theme:           theme,
 90		State:           state,
 91	}
 92}
 93
 94// NewHandler returns a new http.RequestHandler for the provided teamID.
 95func (s *MothServer) NewHandler(teamID string) MothRequestHandler {
 96	return MothRequestHandler{
 97		MothServer: s,
 98		teamID:     teamID,
 99	}
100}
101
102// MothRequestHandler provides http.RequestHandler for a MothServer.
103type MothRequestHandler struct {
104	*MothServer
105	teamID string
106}
107
108// PuzzlesOpen opens a file associated with a puzzle.
109// BUG(neale): Multiple providers with the same category name are not detected or handled well.
110func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (r ReadSeekCloser, ts time.Time, err error) {
111	export := mh.exportStateIfRegistered(true)
112	found := false
113	for _, p := range export.Puzzles[cat] {
114		if p == points {
115			found = true
116		}
117	}
118	if !found {
119		return nil, time.Time{}, fmt.Errorf("puzzle does not exist or is locked")
120	}
121
122	// Try every provider until someone doesn't return an error
123	for _, provider := range mh.PuzzleProviders {
124		r, ts, err = provider.Open(cat, points, path)
125		if err != nil {
126			return r, ts, err
127		}
128	}
129
130	// Log puzzle.json loads
131	if path == "puzzle.json" {
132		mh.State.LogEvent("load", mh.teamID, cat, points)
133	}
134
135	return
136}
137
138// CheckAnswer returns an error if answer is not a correct answer for puzzle points in category cat
139func (mh *MothRequestHandler) CheckAnswer(cat string, points int, answer string) error {
140	correct := false
141	for _, provider := range mh.PuzzleProviders {
142		if ok, err := provider.CheckAnswer(cat, points, answer); err != nil {
143			return err
144		} else if ok {
145			correct = true
146		}
147	}
148	if !correct {
149		mh.State.LogEvent("wrong", mh.teamID, cat, points)
150		return fmt.Errorf("incorrect answer")
151	}
152
153	mh.State.LogEvent("correct", mh.teamID, cat, points)
154
155	if _, err := mh.State.TeamName(mh.teamID); err != nil {
156		return fmt.Errorf("invalid team ID")
157	}
158	if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil {
159		return err
160	}
161
162	return nil
163}
164
165// ThemeOpen opens a file from a theme.
166func (mh *MothRequestHandler) ThemeOpen(path string) (ReadSeekCloser, time.Time, error) {
167	return mh.Theme.Open(path)
168}
169
170// Register associates a team name with a team ID.
171func (mh *MothRequestHandler) Register(teamName string) error {
172	if teamName == "" {
173		return fmt.Errorf("empty team name")
174	}
175	mh.State.LogEvent("register", mh.teamID, "", 0)
176	return mh.State.SetTeamName(mh.teamID, teamName)
177}
178
179// ExportState anonymizes team IDs and returns StateExport.
180// If a teamID has been specified for this MothRequestHandler,
181// the anonymized team name for this teamID has the special value "self".
182// If not, the puzzles list is empty.
183func (mh *MothRequestHandler) ExportState() *StateExport {
184	return mh.exportStateIfRegistered(false)
185}
186
187// Export state, replacing the team ID with "self" if the team is registered.
188//
189// If forceRegistered is true, go ahead and export it anyway
190func (mh *MothRequestHandler) exportStateIfRegistered(forceRegistered bool) *StateExport {
191	export := StateExport{}
192	export.Config = mh.Config
193
194	teamName, err := mh.State.TeamName(mh.teamID)
195	registered := forceRegistered || mh.Config.Devel || (err == nil)
196
197	export.Enabled = mh.State.Enabled()
198	export.TeamNames = make(map[string]string)
199
200	// Anonymize team IDs in points log, and write out team names
201	pointsLog := mh.State.PointsLog()
202	exportIDs := make(map[string]string)
203	maxSolved := make(map[string]int)
204	export.PointsLog = make(award.List, len(pointsLog))
205
206	if registered {
207		export.TeamNames["self"] = teamName
208		exportIDs[mh.teamID] = "self"
209	}
210	for logno, awd := range pointsLog {
211		if id, ok := exportIDs[awd.TeamID]; ok {
212			awd.TeamID = id
213		} else {
214			exportID := strconv.Itoa(logno)
215			name, _ := mh.State.TeamName(awd.TeamID)
216			exportIDs[awd.TeamID] = exportID
217			awd.TeamID = exportID
218			export.TeamNames[exportID] = name
219		}
220		export.PointsLog[logno] = awd
221
222		// Record the highest-value unlocked puzzle in each category
223		if awd.Points > maxSolved[awd.Category] {
224			maxSolved[awd.Category] = awd.Points
225		}
226	}
227
228	export.Puzzles = make(map[string][]int)
229	if registered {
230		// We used to hand this out to everyone,
231		// but then we got a bad reputation on some secretive blacklist,
232		// and now the Navy can't register for events.
233		for _, provider := range mh.PuzzleProviders {
234			for _, category := range provider.Inventory() {
235				// Append sentry (end of puzzles)
236				allPuzzles := append(category.Puzzles, 0)
237
238				max := maxSolved[category.Name]
239
240				puzzles := make([]int, 0, len(allPuzzles))
241				for i, val := range allPuzzles {
242					puzzles = allPuzzles[:i+1]
243					if !mh.Config.Devel && (val > max) {
244						break
245					}
246				}
247				export.Puzzles[category.Name] = puzzles
248			}
249		}
250	}
251
252	return &export
253}
254
255// Mothball generates a mothball for the given category.
256func (mh *MothRequestHandler) Mothball(cat string, w io.Writer) error {
257	var err error
258
259	if !mh.Config.Devel {
260		return fmt.Errorf("cannot mothball in production mode")
261	}
262	for _, provider := range mh.PuzzleProviders {
263		if err = provider.Mothball(cat, w); err == nil {
264			return nil
265		}
266	}
267	return err
268}