diff options
| author | ewy <ewy0@protonmail.com> | 2026-05-22 16:54:49 +0200 |
|---|---|---|
| committer | ewy <ewy0@protonmail.com> | 2026-05-22 16:54:49 +0200 |
| commit | 007e2de369f9fc26da3237646de14f2af5052ee8 (patch) | |
| tree | f81557385628fc93f1ef9616b8bc75a304f9d740 | |
initial commit
| -rw-r--r-- | .pik/build.sh | 2 | ||||
| -rw-r--r-- | .pik/run.sh | 6 | ||||
| -rw-r--r-- | README.md | 12 | ||||
| -rw-r--r-- | api/cards.go | 12 | ||||
| -rw-r--r-- | api/endpoints.go | 35 | ||||
| -rw-r--r-- | api/filter.go | 9 | ||||
| -rw-r--r-- | api/noapi.go | 7 | ||||
| -rw-r--r-- | go.mod | 38 | ||||
| -rw-r--r-- | go.sum | 73 | ||||
| -rw-r--r-- | ingest/run.go | 74 | ||||
| -rw-r--r-- | main.go | 58 | ||||
| -rw-r--r-- | model/act.go | 9 | ||||
| -rw-r--r-- | model/run.go | 24 | ||||
| -rw-r--r-- | model/save.go | 106 | ||||
| -rw-r--r-- | model/versions.go | 3 | ||||
| -rw-r--r-- | paths.go | 7 | ||||
| -rw-r--r-- | pprof.go | 14 | ||||
| -rw-r--r-- | spool/spool.go | 39 | ||||
| -rw-r--r-- | stats/acts.go | 33 | ||||
| -rw-r--r-- | stats/ancients.go | 50 | ||||
| -rw-r--r-- | stats/cards.go | 37 | ||||
| -rw-r--r-- | stats/count.go | 33 | ||||
| -rw-r--r-- | stats/db.go | 44 | ||||
| -rw-r--r-- | stats/enrich.go | 71 | ||||
| -rw-r--r-- | stats/option.go | 11 | ||||
| -rw-r--r-- | stats/versions_enrich.go | 23 | ||||
| -rw-r--r-- | storage/cgo.go | 10 | ||||
| -rw-r--r-- | storage/duckdb.go | 9 | ||||
| -rw-r--r-- | storage/storage.go | 98 | ||||
| -rw-r--r-- | storage/wasm.go | 10 |
30 files changed, 957 insertions, 0 deletions
diff --git a/.pik/build.sh b/.pik/build.sh new file mode 100644 index 0000000..63a9c0c --- /dev/null +++ b/.pik/build.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +go build "$@" .
\ No newline at end of file diff --git a/.pik/run.sh b/.pik/run.sh new file mode 100644 index 0000000..2031c02 --- /dev/null +++ b/.pik/run.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +TMP="$(mktemp -d)" +go build -tags duckdb "$@" -o "$TMP/sts2s" . +cd "$LOC" +"$TMP/sts2s" --database "$TMP/db"
\ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..24005b3 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# sts2stats + +this project requires go 1.23 + +if you have it, build the package (`pik build` or `bash .pik/build.sh`) + +sts2stats needs to be executed in your profile folder: +* `cp ./sts2stats /home/$USER/.local/share/SlayTheSpire2/steam/$STEAMID/profile1/` + * substitute your own variables! i don't know your steam id +* `./sts2stats` + +a browser window with the duckdb ui should open. diff --git a/api/cards.go b/api/cards.go new file mode 100644 index 0000000..79aa94f --- /dev/null +++ b/api/cards.go @@ -0,0 +1,12 @@ +//go:build api + +package api + +import ( + "sts2stats/stats" + "sts2stats/storage" +) + +func CardChoices() (any, error) { + return storage.Entities[*stats.CardChoice]("SELECT * FROM CardChoice") +} diff --git a/api/endpoints.go b/api/endpoints.go new file mode 100644 index 0000000..8eed009 --- /dev/null +++ b/api/endpoints.go @@ -0,0 +1,35 @@ +//go:build api + +package api + +import ( + "encoding/json" + "net/http" +) + +func Init() { + http.HandleFunc("/cards", ToJson(CardChoices)) + http.ListenAndServe(":6060", nil) +} + +type HttpHandler = func() (any, error) + +func ToJson(handler HttpHandler) http.HandlerFunc { + f := func(w http.ResponseWriter, r *http.Request) { + res, err := handler() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + data, err := json.Marshal(res) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + _, err = w.Write(data) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } + return f +} diff --git a/api/filter.go b/api/filter.go new file mode 100644 index 0000000..1130c7c --- /dev/null +++ b/api/filter.go @@ -0,0 +1,9 @@ +//go:build api + +package api + +type Filter struct { + Win *bool + ActName *string + ActIndex *int +} diff --git a/api/noapi.go b/api/noapi.go new file mode 100644 index 0000000..18745a1 --- /dev/null +++ b/api/noapi.go @@ -0,0 +1,7 @@ +//go:build !api + +package api + +func Init() { + //noop +} @@ -0,0 +1,38 @@ +module sts2stats + +go 1.26.2 + +require ( + github.com/adrg/xdg v0.5.3 // indirect + github.com/apache/arrow-go/v18 v18.5.1 // indirect + github.com/duckdb/duckdb-go-bindings v0.10503.0 // indirect + github.com/duckdb/duckdb-go-bindings/lib/darwin-amd64 v0.10503.0 // indirect + github.com/duckdb/duckdb-go-bindings/lib/darwin-arm64 v0.10503.0 // indirect + github.com/duckdb/duckdb-go-bindings/lib/linux-amd64 v0.10503.0 // indirect + github.com/duckdb/duckdb-go-bindings/lib/linux-arm64 v0.10503.0 // indirect + github.com/duckdb/duckdb-go-bindings/lib/windows-amd64 v0.10503.0 // indirect + github.com/duckdb/duckdb-go/v2 v2.10503.0 // indirect + github.com/go-gorp/gorp v2.2.0+incompatible // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/google/flatbuffers v25.12.19+incompatible // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.3 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect + github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + github.com/mattn/go-sqlite3 v1.14.44 // indirect + github.com/ncruces/go-sqlite3 v0.34.2 // indirect + github.com/ncruces/go-sqlite3-wasm/v2 v2.4.35301 // indirect + github.com/ncruces/julianday v1.0.0 // indirect + github.com/pierrec/lz4/v4 v4.1.25 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect + golang.org/x/tools v0.43.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect +) @@ -0,0 +1,73 @@ +github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= +github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/Masterminds/structable v0.0.0-20160207173539-880b1df16ad7 h1:FqyzNKwWdwET1t+HpVnhIidEpNgE4Snn9vcZFd2wSe0= +github.com/Masterminds/structable v0.0.0-20160207173539-880b1df16ad7/go.mod h1:CBK/3s101oxmHZ6XJtdD3yKaeG6aRNi7TVVzJiWpMIY= +github.com/Masterminds/structable v0.0.0-20170407152004-a1a302ef78ec h1:zchF0NAt+UR7iOTb+htQIauAUh3tv7xuHBWqrj6VlBk= +github.com/Masterminds/structable v0.0.0-20170407152004-a1a302ef78ec/go.mod h1:CBK/3s101oxmHZ6XJtdD3yKaeG6aRNi7TVVzJiWpMIY= +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= +github.com/apache/arrow-go/v18 v18.5.1 h1:yaQ6zxMGgf9YCYw4/oaeOU3AULySDlAYDOcnr4LdHdI= +github.com/apache/arrow-go/v18 v18.5.1/go.mod h1:OCCJsmdq8AsRm8FkBSSmYTwL/s4zHW9CqxeBxEytkNE= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/duckdb/duckdb-go-bindings v0.10503.0 h1:nBms3habub5GB3N7zV7l1BwFP2mdRGrUepa+s5feZ1E= +github.com/duckdb/duckdb-go-bindings v0.10503.0/go.mod h1:USV+K6f/1gJBElmujc08nbJLSqPde1tqGrYuw/xR518= +github.com/duckdb/duckdb-go-bindings/lib/darwin-amd64 v0.10503.0 h1:O/yDkGeEZhWcRQwXGCy4SOioPqg8UNJEU/r4p6tMsgg= +github.com/duckdb/duckdb-go-bindings/lib/darwin-amd64 v0.10503.0/go.mod h1:EnAvZh1kNJHp5yF+M1ZHNEvapnmt6anq1xXHVrAGqMo= +github.com/duckdb/duckdb-go-bindings/lib/darwin-arm64 v0.10503.0 h1:gTYLRJZd9DcNtT3UkT+5NmMU1BgDZ22eP1vBGMjV2Z0= +github.com/duckdb/duckdb-go-bindings/lib/darwin-arm64 v0.10503.0/go.mod h1:IGLSeEcFhNeZF16aVjQCULD7TsFZKG5G7SyKJAXKp5c= +github.com/duckdb/duckdb-go-bindings/lib/linux-amd64 v0.10503.0 h1:XzD2yzX9trLosZJYrCAgf/WDek6ANJHCQOFaoH7hfzg= +github.com/duckdb/duckdb-go-bindings/lib/linux-amd64 v0.10503.0/go.mod h1:KAIynZ0GHCS7X5fRyuFnQMg/SZBPK/bS9OCOVojClxw= +github.com/duckdb/duckdb-go-bindings/lib/linux-arm64 v0.10503.0 h1:zYkiCNaQAmneI98Hfjj31TLR9QnPA+8IuablmWPthog= +github.com/duckdb/duckdb-go-bindings/lib/linux-arm64 v0.10503.0/go.mod h1:81SGOYoEUs8qaAfSk1wRfM5oobrIJ5KI7AzYhK6/bvQ= +github.com/duckdb/duckdb-go-bindings/lib/windows-amd64 v0.10503.0 h1:InTPj+kxhdsFmG8iip6FAqJoH8Oerx0tW4dzfxqkAEk= +github.com/duckdb/duckdb-go-bindings/lib/windows-amd64 v0.10503.0/go.mod h1:K25pJL26ARblGDeuAkrdblFvUen92+CwksLtPEHRqqQ= +github.com/duckdb/duckdb-go/v2 v2.10503.0 h1:cl8hbxl8Wn2rBuBWVyyDyJe/ZfCji7wrMPZ/EdFzN1Y= +github.com/duckdb/duckdb-go/v2 v2.10503.0/go.mod h1:3trgI3LZo7TTgJYAP9+xCDJYdAF+E+TKE41JWKeWE5I= +github.com/go-gorp/gorp v2.2.0+incompatible h1:xAUh4QgEeqPPhK3vxZN+bzrim1z5Av6q837gtjUlshc= +github.com/go-gorp/gorp v2.2.0+incompatible/go.mod h1:7IfkAQnO7jfT/9IQ3R9wL1dFhukN6aQxzKTHnkxzA/E= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs= +github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= +github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= +github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/ncruces/go-sqlite3 v0.34.2 h1:+B50kRdn2BfMTSoRbkgnNaIolxIq1qS6lhcXyvNe230= +github.com/ncruces/go-sqlite3 v0.34.2/go.mod h1:ZUqB9w9k4ACD7X5YeISBY05glvkgTur3dwhoDFGASK4= +github.com/ncruces/go-sqlite3-wasm/v2 v2.4.35301 h1:xGFgiIf1SS4yTqyuW3cSR6hd9KRlUFzVloJ873AyrxU= +github.com/ncruces/go-sqlite3-wasm/v2 v2.4.35301/go.mod h1:7dV8P4xml/vrgb/zKfJaZ5aas5el3VyBR28XkpBq5NM= +github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= +github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= +github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= +github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= diff --git a/ingest/run.go b/ingest/run.go new file mode 100644 index 0000000..87b86b1 --- /dev/null +++ b/ingest/run.go @@ -0,0 +1,74 @@ +package ingest + +import ( + "io/fs" + "os" + "path/filepath" + "slices" + "sts2stats/model" + "sts2stats/spool" + "sts2stats/stats" + "sts2stats/storage" + "sync" +) + +type Profile struct { + RunSaves []*model.Run +} + +var SkippedExtensions = []string{".backup"} + +// AddProfile adds a profile by reading from a folder. +// the path should be something like ~/.local/share/SlayTheSpire2/steam/<steamid>/profile1 +func AddProfile(f fs.FS, index int) error { + existingRows, err := storage.Query("SELECT RunId FROM RunStat WHERE InProgress = 0") + if err != nil { + return err + } + var existingRunIds []string + for existingRows.Next() { + var id string + err = existingRows.Scan(&id) + if err != nil { + return err + } + existingRunIds = append(existingRunIds, id) + } + + var runs []*model.Run + wg := sync.WaitGroup{} + err = fs.WalkDir(f, "saves/history", func(path string, d fs.DirEntry, err error) error { + if d == nil || d.IsDir() { + return nil + } + ext := filepath.Ext(path) + if ext != ".run" { + if !slices.Contains(SkippedExtensions, ext) { + spool.Warn("skipping file with unexpected file extension: %s\n", path) + } + return nil + } + content, err := os.ReadFile(path) + save, err := model.NewRun(content) + if slices.Contains(existingRunIds, save.RunId) { + return nil + } + if err != nil { + return err + } + wg.Go(func() { + err := stats.Enrich(save) + if err != nil { + spool.Warn("%v\n", err) + } + }) + runs = append(runs, &save) + return nil + }) + if err != nil { + return err + } + wg.Wait() + spool.Warn("indexed all runs") + return nil +} @@ -0,0 +1,58 @@ +package main + +import ( + "github.com/spf13/pflag" + _ "net/http/pprof" + "os" + "os/exec" + "os/signal" + "sts2stats/api" + "sts2stats/ingest" + "sts2stats/spool" + "sts2stats/stats" + "sts2stats/storage" +) + +var Types = []any{ + stats.Act{}, + stats.GameVersion{}, + stats.AncientChoice{}, + stats.CardChoice{}, + stats.RunStat{}, +} + +var Loaders = map[string]stats.LoadFunc{ + //"card": stats.LoadCards, +} + +func main() { + pflag.Parse() + go api.Init() + err := storage.Init(Types...) + for i, l := range Loaders { + err := l() + if err != nil { + spool.Panic("during %v: %v\n", i, err) + } + } + defer storage.Close() + if err != nil { + spool.Panic("%v\n", err) + } + root := must(os.OpenRoot(".")) + defer root.Close() + go func() { must[any](nil, ingest.AddProfile(root.FS(), 0)) }() + must[any](nil, storage.UI()) + exec.Command("xdg-open", "http://localhost:4213/").Run() + + c := make(chan os.Signal, 1) + go signal.Notify(c, os.Interrupt, os.Kill) + <-c +} + +func must[O any](out O, err error) O { + if err != nil { + spool.Panic("%v\n", err) + } + return out +} diff --git a/model/act.go b/model/act.go new file mode 100644 index 0000000..3a95939 --- /dev/null +++ b/model/act.go @@ -0,0 +1,9 @@ +package model + +type Act struct { + Floor int + Label string + Key string +} + +var Acts = make(map[string]*Act) diff --git a/model/run.go b/model/run.go new file mode 100644 index 0000000..9329708 --- /dev/null +++ b/model/run.go @@ -0,0 +1,24 @@ +package model + +import ( + "crypto/md5" + "encoding/hex" + "encoding/json" +) + +type Run struct { + RunSave + RunId string +} + +func NewRun(fileContent []byte) (Run, error) { + var save RunSave + err := json.Unmarshal(fileContent, &save) + sum := md5.Sum(fileContent) + r := Run{RunId: hex.EncodeToString(sum[:]), RunSave: save} + if err != nil { + return r, err + } + + return r, nil +} diff --git a/model/save.go b/model/save.go new file mode 100644 index 0000000..4127828 --- /dev/null +++ b/model/save.go @@ -0,0 +1,106 @@ +package model + +type PlayerStat struct { + CurrentGold int `json:"current_gold"` + CurrentHp int `json:"current_hp"` + DamageTaken int `json:"damage_taken"` + GoldGained int `json:"gold_gained"` + GoldLost int `json:"gold_lost"` + GoldSpent int `json:"gold_spent"` + GoldStolen int `json:"gold_stolen"` + HpHealed int `json:"hp_healed"` + MaxHp int `json:"max_hp"` + MaxHpGained int `json:"max_hp_gained"` + MaxHpLost int `json:"max_hp_lost"` + PlayerID int `json:"player_id"` +} + +type RunSave struct { + Acts []string `json:"acts"` + Ascension int `json:"ascension"` + BuildID string `json:"build_id"` + GameMode string `json:"game_mode"` + KilledByEncounter string `json:"killed_by_encounter"` + KilledByEvent string `json:"killed_by_event"` + MapPointHistory [][]struct { + MapPointType string `json:"map_point_type"` + PlayerStats []struct { + PlayerStat + CardChoices []struct { + Card struct { + Id string `json:"id"` + CurrentUpgradeLevel int `json:"current_upgrade_level"` + } + WasPicked bool `json:"was_picked"` + } `json:"card_choices"` + AncientChoice []struct { + TextKey string `json:"TextKey"` + Title struct { + Key string `json:"key"` + Table string `json:"table"` + } `json:"title"` + WasChosen bool `json:"was_chosen"` + } `json:"ancient_choice"` + CardsTransformed []struct { + FinalCard struct { + FloorAddedToDeck int `json:"floor_added_to_deck"` + ID string `json:"id"` + } `json:"final_card"` + OriginalCard struct { + FloorAddedToDeck int `json:"floor_added_to_deck"` + ID string `json:"id"` + } `json:"original_card"` + } `json:"cards_transformed"` + EventChoices []struct { + Title struct { + Key string `json:"key"` + Table string `json:"table"` + } `json:"title"` + } `json:"event_choices"` + GoldGained int `json:"gold_gained"` + GoldLost int `json:"gold_lost"` + GoldSpent int `json:"gold_spent"` + GoldStolen int `json:"gold_stolen"` + HpHealed int `json:"hp_healed"` + MaxHp int `json:"max_hp"` + MaxHpGained int `json:"max_hp_gained"` + MaxHpLost int `json:"max_hp_lost"` + PlayerID int `json:"player_id"` + RelicChoices []struct { + Choice string `json:"choice"` + WasPicked bool `json:"was_picked"` + } `json:"relic_choices"` + } `json:"player_stats"` + Rooms []struct { + ModelID string `json:"model_id"` + RoomType string `json:"room_type"` + TurnsTaken int `json:"turns_taken"` + } `json:"rooms"` + } `json:"map_point_history"` + Modifiers []interface{} `json:"modifiers"` + PlatformType string `json:"platform_type"` + Players []struct { + Character string `json:"character"` + Deck []struct { + FloorAddedToDeck int `json:"floor_added_to_deck"` + ID string `json:"id"` + Enchantment struct { + Amount int `json:"amount"` + ID string `json:"id"` + } `json:"enchantment,omitempty"` + } `json:"deck"` + ID int `json:"id"` + MaxPotionSlotCount int `json:"max_potion_slot_count"` + Potions []interface{} `json:"potions"` + Relics []struct { + FloorAddedToDeck int `json:"floor_added_to_deck"` + ID string `json:"id"` + } `json:"relics"` + } `json:"players"` + RunTime int `json:"run_time"` + SchemaVersion int `json:"schema_version"` + Seed string `json:"seed"` + StartTime int `json:"start_time"` + WasAbandoned bool `json:"was_abandoned"` + Win bool `json:"win"` +} diff --git a/model/versions.go b/model/versions.go new file mode 100644 index 0000000..f07a3b1 --- /dev/null +++ b/model/versions.go @@ -0,0 +1,3 @@ +package model + +var Versions []string diff --git a/paths.go b/paths.go new file mode 100644 index 0000000..7bd6e1c --- /dev/null +++ b/paths.go @@ -0,0 +1,7 @@ +package main + +import ( + "github.com/spf13/pflag" +) + +var Profile = pflag.StringP("profile", "p", ".", "slay the spire 2 profile folder") diff --git a/pprof.go b/pprof.go new file mode 100644 index 0000000..bc710e0 --- /dev/null +++ b/pprof.go @@ -0,0 +1,14 @@ +//go:build.sh pprof + +package main + +import ( + "net/http" + "runtime" +) + +func init() { + runtime.SetBlockProfileRate(1) + runtime.SetMutexProfileFraction(1) + go http.ListenAndServe("localhost:6060", nil) +} diff --git a/spool/spool.go b/spool/spool.go new file mode 100644 index 0000000..dcaf0e8 --- /dev/null +++ b/spool/spool.go @@ -0,0 +1,39 @@ +package spool + +import ( + "fmt" + "github.com/spf13/pflag" + "os" + "runtime/debug" + "time" +) + +var ( + DebugFlag = pflag.BoolP("debug", "d", false, "show debug output") +) + +func Debug(format string, args ...any) { + if !*DebugFlag { + return + } + _, _ = fmt.Fprintf(os.Stderr, format, args...) +} + +func Info(format string, args ...any) { + _, _ = fmt.Fprintf(os.Stdout, timestamp(format), args...) +} + +func Warn(format string, args ...any) { + _, _ = fmt.Fprintf(os.Stderr, timestamp(format), args...) +} + +func Panic(format string, args ...any) { + _, _ = fmt.Fprintf(os.Stderr, timestamp(format), args...) + debug.PrintStack() + os.Exit(1) +} + +func timestamp(in string) string { + n := time.Now() + return fmt.Sprintf("[%v] %v", n.Format(time.TimeOnly), in) +} diff --git a/stats/acts.go b/stats/acts.go new file mode 100644 index 0000000..19c4219 --- /dev/null +++ b/stats/acts.go @@ -0,0 +1,33 @@ +package stats + +import ( + "slices" + "strings" + "sts2stats/model" +) + +type Act struct { + Index int + Label string + Key string `db:"Key,primarykey"` +} + +var actKeys []string + +func EnrichActs(run model.Run, stat RunStat) (result []any, err error) { + for i, a := range run.Acts { + if slices.Contains(actKeys, a) { + continue + } + actKeys = append(actKeys, a) + + act := Act{ + Index: i, + Key: a, + Label: strings.SplitN(a, ".", 2)[0], + } + + result = append(result, &act) + } + return result, nil +} diff --git a/stats/ancients.go b/stats/ancients.go new file mode 100644 index 0000000..16ea207 --- /dev/null +++ b/stats/ancients.go @@ -0,0 +1,50 @@ +package stats + +import ( + "sts2stats/model" +) + +const ( + AncientKey = "ancient" +) + +type AncientChoice struct { + RunStat + AncientOption + ActIndex int + ActName string + Chosen bool +} + +type AncientOption struct { + Key string + Type string +} + +func EnrichAncients(run model.Run, st RunStat) (opts []any, err error) { + for actIndex, act := range run.MapPointHistory { + for _, floor := range act { + if floor.MapPointType != AncientKey { + continue + } + + for _, stat := range floor.PlayerStats { + for _, choice := range stat.AncientChoice { + + opts = append(opts, &AncientChoice{ + AncientOption: AncientOption{ + Key: choice.TextKey, + Type: choice.Title.Table, + }, + RunStat: st, + ActIndex: actIndex, + ActName: run.Acts[actIndex], + Chosen: choice.WasChosen, + }) + } + } + + } + } + return opts, nil +} diff --git a/stats/cards.go b/stats/cards.go new file mode 100644 index 0000000..d4049f4 --- /dev/null +++ b/stats/cards.go @@ -0,0 +1,37 @@ +package stats + +import ( + "sts2stats/model" +) + +type CardChoice struct { + RunStat + model.PlayerStat + RoomStat + Card string + Upgrade int + Picked bool +} + +func EnrichCardChoice(run model.Run, st RunStat) (result []any, err error) { + for ia, act := range run.MapPointHistory { + for i, floor := range act { + for _, stat := range floor.PlayerStats { + for _, choice := range stat.CardChoices { + result = append(result, &CardChoice{ + RunStat: st, + Card: choice.Card.Id, + Upgrade: choice.Card.CurrentUpgradeLevel, + PlayerStat: stat.PlayerStat, + RoomStat: RoomStat{ + Floor: i, + Act: ia + 1, + }, + Picked: false, + }) + } + } + } + } + return +} diff --git a/stats/count.go b/stats/count.go new file mode 100644 index 0000000..e9c87f5 --- /dev/null +++ b/stats/count.go @@ -0,0 +1,33 @@ +package stats + +import "sts2stats/model" + +type Counter interface { + Count(run model.Run) error +} + +var Counters = []Counter{} + +type CountFunc func(run model.Run) error + +type CountFuncWrapper struct { + f CountFunc +} + +func (c CountFuncWrapper) Count(run model.Run) error { + return c.f(run) +} + +func Func(f CountFunc) Counter { + return CountFuncWrapper{f: f} +} + +func Count(run model.Run) error { + for _, c := range Counters { + err := c.Count(run) + if err != nil { + return err + } + } + return nil +} diff --git a/stats/db.go b/stats/db.go new file mode 100644 index 0000000..327373f --- /dev/null +++ b/stats/db.go @@ -0,0 +1,44 @@ +package stats + +import ( + "sts2stats/model" + "time" +) + +type RunStat struct { + RunId string + StartTime time.Time + Ascension int + Version string + Win bool + FloorsClimbed int + Abandoned bool + InProgress bool +} + +type RoomStat struct { + Act int + Floor int +} + +func NewRunStat(run model.Run) RunStat { + st := RunStat{ + Version: run.BuildID, + StartTime: time.Unix(int64(run.StartTime), 0), + Ascension: run.Ascension, + Win: run.Win, + RunId: run.RunId, + Abandoned: run.WasAbandoned, + FloorsClimbed: runLen(run), + InProgress: run.KilledByEncounter != "" || run.KilledByEvent != "" || run.Win != true, + } + return st +} + +func runLen(run model.Run) int { + var res int + for _, a := range run.MapPointHistory { + res += len(a) + } + return res +} diff --git a/stats/enrich.go b/stats/enrich.go new file mode 100644 index 0000000..40e91ec --- /dev/null +++ b/stats/enrich.go @@ -0,0 +1,71 @@ +package stats + +import ( + "sts2stats/model" + "sts2stats/spool" + "sts2stats/storage" + "sync" + "time" +) + +type LoadFunc = func() error + +// Enricher reads data from the RunSave and fills dictionaries and data structures +type Enricher interface { + Enrich(run model.Run, stat RunStat) ([]any, error) +} + +type EnrichFunc func(run model.Run, stat RunStat) ([]any, error) + +type EnrichFuncWrapper struct { + f EnrichFunc +} + +func EnrichWrap(f EnrichFunc) Enricher { + return EnrichFuncWrapper{f: f} +} + +func (e EnrichFuncWrapper) Enrich(run model.Run, stat RunStat) ([]any, error) { + return e.f(run, stat) +} + +var Enrichers = map[string]Enricher{ + "act": EnrichWrap(EnrichActs), + "version": EnrichWrap(EnrichGameVersion), + "ancient choice": EnrichWrap(EnrichAncients), + "card choice": EnrichWrap(EnrichCardChoice), +} + +func Enrich(run model.Run) error { + startTime := time.Now() + id := run.RunId[:4] + wg := sync.WaitGroup{} + st := NewRunStat(run) + for k, e := range Enrichers { + wg.Go(func() { + spool.Debug("[%v] Starting %v enrichment\n", id, k) + res, err := e.Enrich(run, st) + if err != nil { + spool.Panic("%v\n", err) + } + + if len(res) == 0 { + spool.Debug("[%v] Finished %v enrichment\n", id, k) + return + } + + spool.Debug("[%v] Collected %v entities (%v)\n", id, k, len(res)) + err = storage.SaveNow(res...) + if err != nil { + spool.Panic("during %v: %v\n", k, err) + } + spool.Debug("[%v] Saved %v entities (%v)\n", id, k, len(res)) + }) + } + wg.Wait() + + endTime := time.Now() + spool.Info("[%v] digested run\n", id) + spool.Debug("[%v] took %.2fs", endTime.Sub(startTime).Seconds()) + return nil +} diff --git a/stats/option.go b/stats/option.go new file mode 100644 index 0000000..e8d729d --- /dev/null +++ b/stats/option.go @@ -0,0 +1,11 @@ +package stats + +type ChoiceStat struct { + Amount int + Wins int +} + +type Choice struct { + Taken *ChoiceStat + Ignored *ChoiceStat +} diff --git a/stats/versions_enrich.go b/stats/versions_enrich.go new file mode 100644 index 0000000..416c469 --- /dev/null +++ b/stats/versions_enrich.go @@ -0,0 +1,23 @@ +package stats + +import ( + "slices" + "sts2stats/model" +) + +type GameVersion struct { + Version string `db:"Version,primarykey"` +} + +var versions []string + +func EnrichGameVersion(run model.Run, stat RunStat) (result []any, err error) { + if !slices.Contains(versions, run.BuildID) { + versions = append(versions, run.BuildID) + v := GameVersion{ + Version: run.BuildID, + } + result = append(result, &v) + } + return result, nil +} diff --git a/storage/cgo.go b/storage/cgo.go new file mode 100644 index 0000000..3549bc5 --- /dev/null +++ b/storage/cgo.go @@ -0,0 +1,10 @@ +//go:build sqlite + +package storage + +import ( + // cgo driver + _ "github.com/mattn/go-sqlite3" +) + +var driver = "sqlite3" diff --git a/storage/duckdb.go b/storage/duckdb.go new file mode 100644 index 0000000..e145384 --- /dev/null +++ b/storage/duckdb.go @@ -0,0 +1,9 @@ +//go:build !sqlite && !sqlite_wasm + +package storage + +import ( + _ "github.com/duckdb/duckdb-go/v2" +) + +var driver = "duckdb" diff --git a/storage/storage.go b/storage/storage.go new file mode 100644 index 0000000..6351ba3 --- /dev/null +++ b/storage/storage.go @@ -0,0 +1,98 @@ +package storage + +import ( + "database/sql" + "github.com/adrg/xdg" + "github.com/go-gorp/gorp" + "github.com/spf13/pflag" + "os" + "path/filepath" + "sts2stats/spool" + "sync" +) + +var ( + Cache, CacheErr = xdg.CacheFile("sts2stats/db." + driver) + Db = pflag.String("database", Cache, "file path for database") + Reset = pflag.BoolP("reindex", "r", true, "reindex all runs") +) + +var conn *sql.DB +var dbmap gorp.DbMap +var lock = &sync.Mutex{} + +func Init(items ...any) error { + if *Reset { + spool.Warn("reindex expected, removing database\n") + err := os.Remove(*Db) + if err != nil { + return err + } + } + + if CacheErr != nil { + return CacheErr + } + spool.Info("opening %v database in %s\n", driver, *Db) + dir, _ := filepath.Split(Cache) + _ = os.MkdirAll(dir, 0600) + db, err := sql.Open(driver, *Db) + if err != nil { + return err + } + conn = db + + dbmap = gorp.DbMap{ + Db: conn, + Dialect: gorp.SqliteDialect{}, + } + + err = register(items...) + if err != nil { + return err + } + + return dbmap.CreateTablesIfNotExists() +} + +func register(item ...any) error { + for _, t := range item { + dbmap.AddTable(t) + } + return dbmap.CreateTablesIfNotExists() +} + +func SaveNow(item ...any) error { + lock.Lock() + err := dbmap.Insert(item...) + lock.Unlock() + return err +} + +func Close() { + if conn != nil { + err := conn.Close() + if err != nil { + spool.Warn("%v\n", err) + } + } +} + +func UI() error { + _, err := dbmap.Db.Exec("SET ui_local_port = 4213; CALL start_ui_server();") + return err +} + +func Query(query string, args ...any) (*sql.Rows, error) { + return dbmap.Query(query, args...) +} + +func Entities[T any](query string, args ...any) ([]T, error) { + thing := new(T) + res, err := dbmap.Select(thing, query, args...) + var result []T + for _, r := range res { + result = append(result, r.(T)) + } + return result, err +} diff --git a/storage/wasm.go b/storage/wasm.go new file mode 100644 index 0000000..dd0c5df --- /dev/null +++ b/storage/wasm.go @@ -0,0 +1,10 @@ +//go:build sqlite_wasm + +package storage + +import ( + // wasm driver + _ "github.com/ncruces/go-sqlite3/driver" +) + +var driver = "sqlite3" |
