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