random-stuff/it-was-inevitable/game.go

268 lines
5.4 KiB
Go
Executable File

package main
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"strings"
"syscall"
"time"
"github.com/hpcloud/tail"
"github.com/kr/pty"
)
func logError(err error, message string) {
if err != nil {
log.Println(message, err)
}
}
func logErrorD(f func() error, message string) {
logError(f(), message)
}
type dataBuffer struct {
queue []string
threshold int
recent [minLinesBeforeDuplicate]string
fuzzy [fuzzyDuplicateWindow][]string
running bool
}
func dwarfFortress(ctx context.Context, buffer *dataBuffer, ch chan<- string) {
addch := make(chan string, 100)
debugch := make(chan struct{})
go watchLog(ctx, addch)
go watchDebug(ctx, debugch)
for {
select {
case <-ctx.Done():
return
default:
}
runGame(ctx, buffer, ch, addch, debugch)
}
}
func runGame(ctx context.Context, buffer *dataBuffer, ch chan<- string, addch <-chan string, debugch <-chan struct{}) {
cmd := exec.CommandContext(ctx, "/df_linux/dfhack")
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
cmd.Dir = "/df_linux"
f, err := pty.Start(cmd)
if err != nil {
panic(err)
}
defer logErrorD(f.Close, "Closing Pseudo-TTY:")
exited := make(chan error, 1)
go func() {
exited <- cmd.Wait()
}()
defer logErrorD(clearSaves, "Removing saved worlds:")
signal := func(s os.Signal) {
logError(cmd.Process.Signal(s), "Sending "+s.String()+":")
}
buffer.running = true
first := true
for {
var nextLine string
out := ch
if len(buffer.queue) == 0 {
out = nil
} else {
nextLine = buffer.queue[0]
}
select {
case <-time.After(time.Minute * 30):
if buffer.running {
log.Println("30 minutes without any log output. Assuming DF is stuck. Resetting.")
signal(syscall.SIGTERM)
select {
case <-time.After(time.Minute):
log.Println("Process has not exited. Killing.")
signal(syscall.SIGKILL)
case <-exited:
return
}
}
case out <- nextLine:
buffer.queue = buffer.queue[1:]
checkQueueLength(buffer, signal)
case line := <-addch:
if isDuplicate(buffer, line) {
log.Println("Duplicate detected:", line)
continue
}
if first || len(buffer.queue) == 0 {
log.Println("First toot queued:", line)
first = false
}
buffer.queue = append(buffer.queue, line)
checkQueueLength(buffer, signal)
case err := <-exited:
log.Println("Dwarf Fortress process exited:", err)
return
case <-debugch:
signal(syscall.SIGKILL)
if err := os.Remove("/df_linux/df-ai-debug.log"); err != nil {
log.Println("Removing debug log:", err)
}
return
}
}
}
func isDuplicate(buffer *dataBuffer, line string) bool {
if minLinesBeforeDuplicate == 0 && fuzzyDuplicateWindow == 0 {
return false
}
firstLine := line[:strings.IndexByte(line, '\n')]
for _, dup := range buffer.recent {
if dup == firstLine {
return true
}
}
firstLineWords := strings.Fields(firstLine)
fuzzy := 0
for _, dup := range buffer.fuzzy {
if isFuzzyDuplicate(dup, firstLineWords) {
fuzzy++
}
}
if fuzzy > maxFuzzyDuplicates {
return true
}
if len(buffer.recent) != 0 {
copy(buffer.recent[:], buffer.recent[1:])
buffer.recent[len(buffer.recent)-1] = firstLine
}
if len(buffer.fuzzy) != 0 {
copy(buffer.fuzzy[:], buffer.fuzzy[1:])
buffer.fuzzy[len(buffer.fuzzy)-1] = firstLineWords
}
return false
}
func isFuzzyDuplicate(dup, words []string) bool {
// Only allow changes, not additions or deletions, to simplify this.
if len(dup) != len(words) {
return false
}
different := 0
for i := range dup {
if dup[i] != words[i] {
different++
}
}
return different <= maxFuzzyDifferentWords
}
func checkQueueLength(buffer *dataBuffer, signal func(os.Signal)) {
if l := len(buffer.queue); l%100 == 0 && (buffer.threshold >= l+100 || buffer.threshold <= l-100) {
buffer.threshold = l
log.Println(l, "lines are buffered.")
}
if len(buffer.queue) < minQueuedLines {
if !buffer.running {
log.Println("Unsuspending Dwarf Fortress")
buffer.running = true
}
signal(syscall.SIGCONT)
} else if len(buffer.queue) > maxQueuedLines {
if buffer.running {
log.Println("Suspending Dwarf Fortress")
buffer.running = false
}
signal(syscall.SIGSTOP)
}
}
func watchDebug(ctx context.Context, ch chan<- struct{}) {
for {
// Wait at least a minute between kills to make sure there's time to clean up.
select {
case <-ctx.Done():
return
case <-time.After(time.Minute):
}
f, err := tail.TailFile("/df_linux/df-ai-debug.log", tail.Config{
Follow: true,
})
if err != nil {
panic(err)
}
select {
case <-ctx.Done():
return
case first := <-f.Lines:
log.Println("df-ai crashed! debug log follows:")
go func() {
f.Kill(f.StopAtEOF())
f.Cleanup()
}()
fmt.Println(first.Text)
for line := range f.Lines {
fmt.Println(line.Text)
}
ch <- struct{}{}
}
}
}
func watchLog(ctx context.Context, ch chan<- string) {
f, err := tail.TailFile("/df_linux/gamelog.txt", tail.Config{
ReOpen: true,
Follow: true,
})
if err != nil {
panic(err)
}
defer f.Cleanup()
go func() {
<-ctx.Done()
f.Kill(ctx.Err())
}()
for line := range f.Lines {
if line.Err != nil {
log.Println(line.Err)
continue
}
text := mapCP437(strings.TrimSpace(line.Text))
// Chatter messages are formatted as "Urist McName, Occupation: It was inevitable."
if i, j := strings.Index(text, ", "), strings.Index(text, ": "); i > 0 && j > i {
ch <- text[j+2:] + "\n\u2014 " + text[:j]
}
}
}