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>
460 lines
13 KiB
Go
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)
|
|
}
|
|
}
|