summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.pik/build.sh2
-rw-r--r--.pik/run.sh6
-rw-r--r--README.md12
-rw-r--r--api/cards.go12
-rw-r--r--api/endpoints.go35
-rw-r--r--api/filter.go9
-rw-r--r--api/noapi.go7
-rw-r--r--go.mod38
-rw-r--r--go.sum73
-rw-r--r--ingest/run.go74
-rw-r--r--main.go58
-rw-r--r--model/act.go9
-rw-r--r--model/run.go24
-rw-r--r--model/save.go106
-rw-r--r--model/versions.go3
-rw-r--r--paths.go7
-rw-r--r--pprof.go14
-rw-r--r--spool/spool.go39
-rw-r--r--stats/acts.go33
-rw-r--r--stats/ancients.go50
-rw-r--r--stats/cards.go37
-rw-r--r--stats/count.go33
-rw-r--r--stats/db.go44
-rw-r--r--stats/enrich.go71
-rw-r--r--stats/option.go11
-rw-r--r--stats/versions_enrich.go23
-rw-r--r--storage/cgo.go10
-rw-r--r--storage/duckdb.go9
-rw-r--r--storage/storage.go98
-rw-r--r--storage/wasm.go10
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
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..7e35d71
--- /dev/null
+++ b/go.mod
@@ -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
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..32a6d42
--- /dev/null
+++ b/go.sum
@@ -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
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..777a067
--- /dev/null
+++ b/main.go
@@ -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"