Initial commit

Signed-off-by: jerryaldrichiii <jerryaldrichiii@gmail.com>
main
Jerry Aldrich 5 years ago
commit 766411ba16
  1. 21
      Dockerfile
  2. 26
      README.md
  3. 3
      go.mod
  4. 72
      main.go
  5. 169
      pkg/logparser/logparser.go
  6. 127
      pkg/logparser/logparser_test.go
  7. 71
      templates/index.gohtml

@ -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"]

@ -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
```

@ -0,0 +1,3 @@
module nethack-high-scores
go 1.14

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

@ -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]
}

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

@ -0,0 +1,71 @@
<!DOCTYPE HTML>
<html>
<head>
<title>{{.ServerName}}</title>
<meta charset="utf-8">
<meta name="Description" content="{{.ServerName}}">
<style>
body {
background: black;
background-color: black;
color: #00FF00;
font: calc(1vmax) monospace;
text-align: center;
}
table {
display: inline-block;
}
td, th, tr {
text-align: left;
padding-left: 1em;
padding-right: 1em;
}
tr:hover {
background-color: #101010;
}
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
h1 {
font-size: 175%;
}
p {
font-size: 100%;
}
.text-nowrap {
white-space: nowrap;
}
</style>
</head>
<body>
<h1>HIGH SCORES</h1>
<table>
<tr>
<th class="text-center">Rank</th>
<th>Score</th>
<th>Name</th>
<th class="text-center">Character</th>
<th class="text-center">Level</th>
<th class="text-center">Death Location</th>
<th>Death Reason</th>
<th class="text-center text-nowrap">Date</th>
</tr>
{{range $i, $e := .Records}}
<tr>
<td class="text-right">{{add $i 1}}</td>
<td>{{$e.Points}}</td>
<td>{{.Name}}</td>
<td class="text-center">{{$e.Role}} {{$e.Race}}<br>{{$e.Gender}} {{$e.Alignment}}</td>
<td class="text-center">{{$e.MaxLevel}}</td>
<td class="text-center">{{$e.DeathLocation}}<br>(Level {{$e.DeathDungeonLevel}})</td>
<td>{{$e.DeathReason}}</td>
<td class="text-center text-nowrap">{{$e.EndDate}}</td>
</tr>
{{end}}
</table>
<p>Join the party via: <b>ssh nethack@{{.ServerName}}</b></p>
<body>
</html>
Loading…
Cancel
Save