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) } }