From 766411ba16b4970087ab070937e537b3a5660f70 Mon Sep 17 00:00:00 2001 From: Jerry Aldrich Date: Tue, 16 Jun 2020 02:03:21 -0700 Subject: [PATCH] Initial commit Signed-off-by: jerryaldrichiii --- Dockerfile | 21 ++++ README.md | 26 +++++ go.mod | 3 + main.go | 72 ++++++++++++++ pkg/logparser/logparser.go | 169 ++++++++++++++++++++++++++++++++ pkg/logparser/logparser_test.go | 127 ++++++++++++++++++++++++ templates/index.gohtml | 71 ++++++++++++++ 7 files changed, 489 insertions(+) create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 go.mod create mode 100644 main.go create mode 100644 pkg/logparser/logparser.go create mode 100644 pkg/logparser/logparser_test.go create mode 100644 templates/index.gohtml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f703f53 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM golang:alpine AS builder + +WORKDIR $GOPATH/src/nethack-high-scores +COPY . . + +RUN mkdir -p /dist + +# TODO: Ship with binary? +COPY templates/ /dist/templates +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /dist/nhs + +FROM scratch + +COPY --from=builder /dist /dist + +EXPOSE 8080 +VOLUME "/logfile" + +WORKDIR /dist + +CMD ["./nhs", "/nethack/logfile"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..1842672 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# NetHack High Scores + +This is simple go app that renders NetHack high scores and serves them via a +Golang based web server. + +You can view it live at +[nethack.jerryaldrichiii.com](nethack.jerryaldrichiii.com) + +## Running + +Locally: + +```text +go run main.go /path/to/nethack/logfile +``` + +Docker: + +```text +docker build . -t yourorigin/nethack-high-scores:latest + +docker run -it --rm \ + -v /path/to/nethack/logfile:/nethack/logfile \ + -p 8080:8080 \ + yourorigin/nethack-high-scores:latest +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4f26fa8 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module nethack-high-scores + +go 1.14 diff --git a/main.go b/main.go new file mode 100644 index 0000000..0e3316b --- /dev/null +++ b/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "fmt" + "html/template" + "log" + "net/http" + "nethack-high-scores/pkg/logparser" + "os" +) + +type siteData struct { + ServerName string + Records []logparser.Record +} + +func main() { + HTTP_PORT := 8080 + + if len(os.Args) != 2 { + fmt.Printf("USAGE: %v NETHACK_LOG_FILE\n", os.Args[0]) + os.Exit(1) + } + + logFile := os.Args[1] + if _, err := os.Open(logFile); err != nil { + panic(err) + } + + http.HandleFunc("/", servePage) + + log.Printf("Listening on :%v...", HTTP_PORT) + addr := fmt.Sprintf(":%v", HTTP_PORT) + log.Fatal(http.ListenAndServe(addr, nil)) +} + +func servePage(w http.ResponseWriter, r *http.Request) { + log.Printf( + "%s %s %s %s", + r.RemoteAddr, r.Method, r.URL, r.Header["User-Agent"], + ) + + // Error already handled in main() + f, _ := os.Open(os.Args[1]) + + records, errs := logparser.ParseLog(f) + if len(errs) > 0 { + panic(errs) + } + + records = logparser.TopX(records, 10) + tmpl := template.New("index.gohtml") + tmpl = tmpl.Funcs(template.FuncMap{ + "add": func(i int, x int) int { + return i + x + }, + }) + tmpl, err := tmpl.ParseFiles("./templates/index.gohtml") + if err != nil { + log.Fatal(err) + } + + log.Printf("%#v", tmpl) + data := siteData{ + ServerName: "nethack.jerryaldrichiii.com", + Records: records, + } + err = tmpl.Execute(w, data) + if err != nil { + log.Fatal(err) + } +} diff --git a/pkg/logparser/logparser.go b/pkg/logparser/logparser.go new file mode 100644 index 0000000..8d5a04b --- /dev/null +++ b/pkg/logparser/logparser.go @@ -0,0 +1,169 @@ +package logparser + +import ( + "bufio" + "fmt" + "math" + "os" + "sort" + "strconv" + "strings" + "time" +) + +type Record struct { + Version string + Points int + DeathLocation string + DeathDungeonLevel int + MaxLevel int + HP int + MaxHP int + NumDeaths int + EndDate string + StartDate string + UserID int + Role string + Race string + Gender string + Alignment string + Name string + DeathReason string +} + +// ParseLog parses a NetHack logfile. +// If any errors are encountered, an empty []Record is returned with all the +// errors. +// See: https://nethackwiki.com/wiki/Logfile +func ParseLog(log *os.File) ([]Record, []error) { + scanner := bufio.NewScanner(log) + records := []Record{} + + var err error + errors := []error{} + + for scanner.Scan() { + record := Record{} + fields := strings.Fields(scanner.Text()) + + dungeonNum, _ := strconv.Atoi(fields[2]) + dungeonLvl, _ := strconv.Atoi(fields[3]) + + record.DeathDungeonLevel = dungeonLvl + record.DeathLocation, err = ParseDungeon(dungeonNum, dungeonLvl) + if err != nil { + errors = append(errors, err) + } + record.Version = fields[0] + record.UserID, err = strconv.Atoi(fields[10]) + if err != nil { + errors = append(errors, err) + } + record.Role = fields[11] + record.Race = fields[12] + record.Gender = fields[13] + record.Alignment = fields[14] + record.Name = strings.Split(fields[15], ",")[0] + + remainingFields := strings.Join(fields[15:], " ") + record.DeathReason = strings.Split(remainingFields, record.Name+",")[1] + + record.Points, err = strconv.Atoi(fields[1]) + if err != nil { + errors = append(errors, err) + } + record.MaxLevel, err = strconv.Atoi(fields[4]) + if err != nil { + errors = append(errors, err) + } + record.HP, err = strconv.Atoi(fields[5]) + if err != nil { + errors = append(errors, err) + } + record.MaxHP, err = strconv.Atoi(fields[6]) + if err != nil { + errors = append(errors, err) + } + record.NumDeaths, err = strconv.Atoi(fields[7]) + if err != nil { + errors = append(errors, err) + } + + startDate, err := time.Parse("20060102", fields[9]) + if err != nil { + errors = append(errors, err) + } + record.StartDate = startDate.Format("2006-01-02") + + endDate, err := time.Parse("20060102", fields[8]) + if err != nil { + errors = append(errors, err) + } + record.EndDate = endDate.Format("2006-01-02") + + records = append(records, record) + } + + if len(errors) > 0 { + return []Record{}, errors + } + + return records, nil +} + +// ParseDungeon takes a dungeon number and a dungeon level returns the correct +// location. +// See: https://nethackwiki.com/wiki/Logfile +func ParseDungeon(dNum int, dLvl int) (string, error) { + if dNum < 0 || dNum > 7 { + return "", fmt.Errorf("Invalid dungeon number '%v'", dNum) + } + + if dNum == 7 && (dLvl > -1 || dLvl < -5) { + return "", fmt.Errorf( + "Invalid plane '%v': Must be > -1 or < -5 if dungeon number is 7", dLvl, + ) + } + + if dNum != 7 && (dLvl < 0) { + return "", fmt.Errorf( + "Invalid dungeon level '%v': Must be > 0 if dungeon number != 7", dLvl, + ) + } + + // Dungeon Level is negative if on one of the planes + planes := []string{ + "Plane of Earth", + "Plane of Air", + "Plane of Fire", + "Plane of Water", + "Astral Plane", + } + if dNum == 7 { + // Convert to positive float to get abosoulte then convert to int and + // subtract one for correct index + return planes[int(math.Abs(float64(dLvl)))-1], nil + } + + dungeons := []string{ + "The Dungeons of Doom", + "Gehennom", + "The Gnomish Mines", + "The Quest", + "Sokoban", + "Fort Ludios", + "Vlad's Tower", + } + return dungeons[dNum], nil +} + +func TopX(records []Record, top int) []Record { + sorted := records + sort.Slice(sorted, func(i, j int) bool { + return records[i].Points > records[j].Points + }) + if top > len(sorted) { + top = len(sorted) + } + return sorted[0:top] +} diff --git a/pkg/logparser/logparser_test.go b/pkg/logparser/logparser_test.go new file mode 100644 index 0000000..94d097e --- /dev/null +++ b/pkg/logparser/logparser_test.go @@ -0,0 +1,127 @@ +package logparser + +import ( + "reflect" + "strings" + "testing" +) + +func TestParseDungeon(t *testing.T) { + t.Parallel() + + t.Run("InvalidDungeon", func(t *testing.T) { + t.Parallel() + badDungeons := []int{8, -1} + for _, d := range badDungeons { + result, err := ParseDungeon(d, 1) + if result != "" { + t.Errorf("Expected empty result, got: %v", result) + } + expectedText := "Invalid dungeon" + expectedError := strings.Contains(err.Error(), expectedText) + if !expectedError { + t.Errorf("Error must contain '%v', got: %v", expectedText, err) + } + } + }) + + t.Run("InvalidDungeonPlaneCombo", func(t *testing.T) { + t.Parallel() + result, err := ParseDungeon(3, -1) + if result != "" { + t.Errorf("Expected empty result, got: %v", result) + } + expectedText := "Must be > 0 if dungeon number != 7" + expectedError := strings.Contains(err.Error(), expectedText) + if !expectedError { + t.Errorf("Error must contain '%v', got: %v", expectedText, err) + } + }) + + t.Run("InvalidPlane", func(t *testing.T) { + t.Parallel() + badPlanes := []int{1, -6} + for _, p := range badPlanes { + result, err := ParseDungeon(7, p) + if result != "" { + t.Errorf("Expected empty result, got: %v", result) + } + expectedText := "Must be > -1 or < -5 if dungeon number is 7" + expectedError := strings.Contains(err.Error(), expectedText) + if !expectedError { + t.Errorf("Error must contain '%v', got: %v", expectedText, err) + } + } + }) + + tests := []struct { + dNum int + dLvl int + expectedResult string + }{ + {0, 1, "The Dungeons of Doom"}, + {0, 2, "The Dungeons of Doom"}, + {1, 3, "Gehennom"}, + {2, 4, "The Gnomish Mines"}, + {3, 5, "The Quest"}, + {4, 6, "Sokoban"}, + {5, 7, "Fort Ludios"}, + {6, 55, "Vlad's Tower"}, + {7, -1, "Plane of Earth"}, + {7, -2, "Plane of Air"}, + {7, -3, "Plane of Fire"}, + {7, -4, "Plane of Water"}, + {7, -5, "Astral Plane"}, + } + for _, test := range tests { + t.Run(test.expectedResult, func(t *testing.T) { + result, err := ParseDungeon(test.dNum, test.dLvl) + if err != nil { + t.Error(err) + } + if result != test.expectedResult { + t.Errorf("Expected '%v', but got '%v'", test.expectedResult, result) + } + }) + } +} + +func TestTopX(t *testing.T) { + t.Parallel() + + records := []Record{ + {Points: 50, Name: "Test50"}, + {Points: 20, Name: "Test20"}, + {Points: 30, Name: "Test30"}, + {Points: 40, Name: "Test40"}, + {Points: 10, Name: "Test10"}, + } + + sortedTop3 := TopX(records, 3) + expectedTop3 := []Record{ + {Points: 50, Name: "Test50"}, + {Points: 40, Name: "Test40"}, + {Points: 30, Name: "Test30"}, + } + if !reflect.DeepEqual(sortedTop3, expectedTop3) { + t.Errorf( + "Expected the following two []Record to be equal\n%v\n%v", + sortedTop3, expectedTop3, + ) + } + + sortedTooMany := TopX(records, 100) + expectedTooMany := []Record{ + {Points: 50, Name: "Test50"}, + {Points: 40, Name: "Test40"}, + {Points: 30, Name: "Test30"}, + {Points: 20, Name: "Test20"}, + {Points: 10, Name: "Test10"}, + } + if !reflect.DeepEqual(sortedTooMany, expectedTooMany) { + t.Errorf( + "Expected the following two []Record to be equal\n%v\n%v", + sortedTooMany, expectedTooMany, + ) + } +} diff --git a/templates/index.gohtml b/templates/index.gohtml new file mode 100644 index 0000000..63eed3f --- /dev/null +++ b/templates/index.gohtml @@ -0,0 +1,71 @@ + + + + {{.ServerName}} + + + + + +

HIGH SCORES

+ + + + + + + + + + + + {{range $i, $e := .Records}} + + + + + + + + + + + {{end}} +
RankScoreNameCharacterLevelDeath LocationDeath ReasonDate
{{add $i 1}}{{$e.Points}}{{.Name}}{{$e.Role}} {{$e.Race}}
{{$e.Gender}} {{$e.Alignment}}
{{$e.MaxLevel}}{{$e.DeathLocation}}
(Level {{$e.DeathDungeonLevel}})
{{$e.DeathReason}}{{$e.EndDate}}
+

Join the party via: ssh nethack@{{.ServerName}}

+ +