terraria-companion/server.go
Felipe M. 0915d43e56
Some checks failed
ci/woodpecker/tag/release Pipeline failed
ci/woodpecker/push/ci Pipeline failed
refactor: move icons from public/icons to data/icons and support embedding
Icons are now stored in data/icons alongside other data files. When built
with embed_data tag, icons are embedded into the binary via embed.FS.
Server serves icons from disk first (for hot-reload), falling back to
embedded FS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 00:28:30 +01:00

460 lines
13 KiB
Go

package main
import (
"encoding/json"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// ── Data types ──────────────────────────────────────────────────
type Item struct {
ItemID string `json:"itemid"`
Name string `json:"name"`
InternalName string `json:"internalname"`
ImageFile string `json:"imagefile"`
AutoSwing string `json:"autoswing"`
Stack string `json:"stack"`
Consumable string `json:"consumable"`
HardMode string `json:"hardmode"`
Type string `json:"type"`
ListCat string `json:"listcat"`
Tag string `json:"tag"`
Damage string `json:"damage"`
DamageType string `json:"damagetype"`
Defense string `json:"defense"`
Velocity string `json:"velocity"`
Knockback string `json:"knockback"`
Rare string `json:"rare"`
Buy string `json:"buy"`
Sell string `json:"sell"`
UseTime string `json:"usetime"`
Critical string `json:"critical"`
Tooltip string `json:"tooltip"`
Pick string `json:"pick"`
Axe string `json:"axe"`
Hammer string `json:"hammer"`
Fishing string `json:"fishing"`
Bait string `json:"bait"`
Mana string `json:"mana"`
Placeable string `json:"placeable"`
BodySlot string `json:"bodyslot"`
Buffs string `json:"buffs"`
Debuffs string `json:"debuffs"`
Unobtainable string `json:"unobtainable"`
}
type Recipe struct {
Result string `json:"result"`
ResultID string `json:"resultid"`
ResultImage string `json:"resultimage"`
ResultText string `json:"resulttext"`
Amount string `json:"amount"`
Station string `json:"station"`
Ingredients string `json:"ingredients"`
Ings string `json:"ings"`
Legacy string `json:"legacy"`
}
type Drop struct {
NameRaw string `json:"nameraw"`
Item string `json:"item"`
Quantity string `json:"quantity"`
Rate string `json:"rate"`
IsFromNPC string `json:"isfromnpc"`
Normal string `json:"normal"`
Expert string `json:"expert"`
Master string `json:"master"`
}
type FetchState struct {
Phase string `json:"phase"`
ItemsOffset int `json:"itemsOffset"`
ItemsTotal *int `json:"itemsTotal"`
RecipesOffset int `json:"recipesOffset"`
RecipesTotal *int `json:"recipesTotal"`
DropsOffset int `json:"dropsOffset"`
DropsTotal *int `json:"dropsTotal"`
IconsDownloaded int `json:"iconsDownloaded"`
IconsTotal *int `json:"iconsTotal"`
IconsFailed []string `json:"iconsFailed"`
}
type StatusResponse struct {
Items int `json:"items"`
Recipes int `json:"recipes"`
Drops int `json:"drops"`
Icons int `json:"icons"`
FetchState *FetchState `json:"fetchState"`
}
// ── DataStore ───────────────────────────────────────────────────
type DataStore struct {
mu sync.RWMutex
items []Item
itemsByName map[string]Item
recipesByResult map[string][]Recipe
dropsByItem map[string][]Drop
splashes []string
}
func sanitizeImagefile(name string) string {
if idx := strings.Index(name, " / "); idx != -1 {
return strings.TrimSpace(name[:idx])
}
return name
}
func (ds *DataStore) loadFromBytes(itemsData, recipesData, dropsData, splashesData []byte) {
ds.mu.Lock()
defer ds.mu.Unlock()
var items []Item
if err := json.Unmarshal(itemsData, &items); err == nil {
ds.items = items
ds.itemsByName = make(map[string]Item, len(items))
for i := range ds.items {
ds.items[i].ImageFile = sanitizeImagefile(ds.items[i].ImageFile)
if ds.items[i].Name != "" {
ds.itemsByName[strings.ToLower(ds.items[i].Name)] = ds.items[i]
}
}
log.Printf("[data] Loaded %d items", len(ds.items))
}
var recipes []Recipe
if err := json.Unmarshal(recipesData, &recipes); err == nil {
ds.recipesByResult = make(map[string][]Recipe)
for _, r := range recipes {
key := strings.ToLower(r.Result)
ds.recipesByResult[key] = append(ds.recipesByResult[key], r)
}
log.Printf("[data] Loaded %d recipes", len(recipes))
}
var drops []Drop
if err := json.Unmarshal(dropsData, &drops); err == nil {
ds.dropsByItem = make(map[string][]Drop)
for _, d := range drops {
key := strings.ToLower(d.Item)
ds.dropsByItem[key] = append(ds.dropsByItem[key], d)
}
log.Printf("[data] Loaded %d drops", len(drops))
}
var splashes []string
if err := json.Unmarshal(splashesData, &splashes); err == nil {
ds.splashes = splashes
log.Printf("[data] Loaded %d splashes", len(ds.splashes))
}
}
// ── JSON helper ─────────────────────────────────────────────────
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
_ = enc.Encode(v)
}
// ── Server ──────────────────────────────────────────────────────
func runServer() {
port := os.Getenv("PORT")
if port == "" {
port = "3000"
}
store := &DataStore{}
// Load embedded data on startup
store.loadFromBytes(embeddedItemsJSON, embeddedRecipesJSON, embeddedDropsJSON, embeddedSplashesJSON)
// Background goroutine: hot-reload from disk every 5s
dataDir := "data"
itemsFile := filepath.Join(dataDir, "items.json")
recipesFile := filepath.Join(dataDir, "recipes.json")
dropsFile := filepath.Join(dataDir, "drops.json")
splashesFile := filepath.Join(dataDir, "splashes.json")
var lastItemsMtime time.Time
var lastRecipesMtime time.Time
var lastDropsMtime time.Time
var lastSplashesMtime time.Time
go func() {
for {
time.Sleep(5 * time.Second)
itemsChanged := false
recipesChanged := false
dropsChanged := false
splashesChanged := false
if info, err := os.Stat(itemsFile); err == nil && info.ModTime() != lastItemsMtime {
itemsChanged = true
}
if info, err := os.Stat(recipesFile); err == nil && info.ModTime() != lastRecipesMtime {
recipesChanged = true
}
if info, err := os.Stat(dropsFile); err == nil && info.ModTime() != lastDropsMtime {
dropsChanged = true
}
if info, err := os.Stat(splashesFile); err == nil && info.ModTime() != lastSplashesMtime {
splashesChanged = true
}
if !itemsChanged && !recipesChanged && !dropsChanged && !splashesChanged {
continue
}
// Read all files from disk (fall back to embedded)
itemsData, err := os.ReadFile(itemsFile)
if err != nil {
itemsData = embeddedItemsJSON
} else {
if info, err := os.Stat(itemsFile); err == nil {
lastItemsMtime = info.ModTime()
}
}
recipesData, err := os.ReadFile(recipesFile)
if err != nil {
recipesData = embeddedRecipesJSON
} else {
if info, err := os.Stat(recipesFile); err == nil {
lastRecipesMtime = info.ModTime()
}
}
dropsData, err := os.ReadFile(dropsFile)
if err != nil {
dropsData = embeddedDropsJSON
} else {
if info, err := os.Stat(dropsFile); err == nil {
lastDropsMtime = info.ModTime()
}
}
splashesData, err := os.ReadFile(splashesFile)
if err != nil {
splashesData = embeddedSplashesJSON
} else {
if info, err := os.Stat(splashesFile); err == nil {
lastSplashesMtime = info.ModTime()
}
}
store.loadFromBytes(itemsData, recipesData, dropsData, splashesData)
}
}()
mux := http.NewServeMux()
// API handlers
mux.HandleFunc("GET /api/search", func(w http.ResponseWriter, r *http.Request) {
q := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q")))
if len(q) < 2 {
writeJSON(w, []Item{})
return
}
store.mu.RLock()
defer store.mu.RUnlock()
var exact, prefix, contains []Item
for _, item := range store.items {
name := strings.ToLower(item.Name)
if name == q {
exact = append(exact, item)
} else if strings.HasPrefix(name, q) {
prefix = append(prefix, item)
} else if strings.Contains(name, q) {
contains = append(contains, item)
}
}
sort.Slice(prefix, func(i, j int) bool { return prefix[i].Name < prefix[j].Name })
sort.Slice(contains, func(i, j int) bool { return contains[i].Name < contains[j].Name })
results := make([]Item, 0, len(exact)+len(prefix)+len(contains))
results = append(results, exact...)
results = append(results, prefix...)
results = append(results, contains...)
if len(results) > 30 {
results = results[:30]
}
writeJSON(w, results)
})
mux.HandleFunc("GET /api/item", func(w http.ResponseWriter, r *http.Request) {
name := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("name")))
store.mu.RLock()
item, ok := store.itemsByName[name]
store.mu.RUnlock()
if !ok {
writeJSON(w, nil)
return
}
writeJSON(w, item)
})
mux.HandleFunc("GET /api/recipes", func(w http.ResponseWriter, r *http.Request) {
name := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("item")))
store.mu.RLock()
recipes := store.recipesByResult[name]
store.mu.RUnlock()
// Deduplicate by station (first wins)
seen := make(map[string]bool)
unique := make([]Recipe, 0)
for _, r := range recipes {
if seen[r.Station] {
continue
}
seen[r.Station] = true
unique = append(unique, r)
}
writeJSON(w, unique)
})
mux.HandleFunc("GET /api/drops", func(w http.ResponseWriter, r *http.Request) {
name := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("item")))
store.mu.RLock()
drops := store.dropsByItem[name]
store.mu.RUnlock()
if drops == nil {
drops = []Drop{}
}
writeJSON(w, drops)
})
mux.HandleFunc("GET /api/splashes", func(w http.ResponseWriter, r *http.Request) {
store.mu.RLock()
splashes := store.splashes
store.mu.RUnlock()
if splashes == nil {
splashes = []string{}
}
writeJSON(w, splashes)
})
mux.HandleFunc("GET /api/status", func(w http.ResponseWriter, r *http.Request) {
store.mu.RLock()
itemCount := len(store.items)
recipeCount := len(store.recipesByResult)
dropCount := len(store.dropsByItem)
store.mu.RUnlock()
// Read fetch state from disk
var fetchState *FetchState
if data, err := os.ReadFile(filepath.Join(dataDir, ".fetch-state.json")); err == nil {
var fs FetchState
if json.Unmarshal(data, &fs) == nil {
fetchState = &fs
}
}
// Count icons (excluding dotfiles) — disk first, then embedded
iconCount := 0
iconsDirPath := filepath.Join("data", "icons")
if entries, err := os.ReadDir(iconsDirPath); err == nil {
for _, e := range entries {
if !e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
iconCount++
}
}
}
if iconCount == 0 {
if entries, err := fs.ReadDir(iconsFS, "data/icons"); err == nil {
for _, e := range entries {
if !e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
iconCount++
}
}
}
}
writeJSON(w, StatusResponse{
Items: itemCount,
Recipes: recipeCount,
Drops: dropCount,
Icons: iconCount,
FetchState: fetchState,
})
})
// Icon serving: disk first (for hot-reload), then embedded
iconsDiskDir := filepath.Join("data", "icons")
iconsSub, _ := fs.Sub(iconsFS, "data/icons")
mux.HandleFunc("/icons/", func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/icons/")
if name == "" {
http.NotFound(w, r)
return
}
diskPath := filepath.Join(iconsDiskDir, name)
if _, err := os.Stat(diskPath); err == nil {
http.ServeFile(w, r, diskPath)
return
}
http.ServeFileFS(w, r, iconsSub, name)
})
if os.Getenv("DEV") != "" {
log.Println("[dev] Serving frontend from disk (live edits)")
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Try to serve the file from disk; fall back to index.html for SPA routes
path := filepath.Join("public", filepath.Clean(r.URL.Path))
if info, err := os.Stat(path); err == nil && !info.IsDir() {
http.ServeFile(w, r, path)
return
}
http.ServeFile(w, r, filepath.Join("public", "index.html"))
})
} else {
sub, err := fs.Sub(publicFS, "public")
if err != nil {
log.Fatalf("Failed to create sub filesystem: %v", err)
}
embeddedSub := sub
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Try embedded FS; fall back to index.html for SPA routes
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "index.html"
}
if _, err := fs.Stat(embeddedSub, path); err == nil {
http.FileServerFS(embeddedSub).ServeHTTP(w, r)
return
}
// SPA fallback: serve index.html
data, _ := fs.ReadFile(embeddedSub, "index.html")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(data)
})
}
log.Printf("Terraria Companion running at http://localhost:%s", port)
if err := http.ListenAndServe(":"+port, mux); err != nil {
log.Fatal(err)
}
}