commit
766411ba16
7 changed files with 489 additions and 0 deletions
@ -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,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…
Reference in new issue