Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/mkmccarty/TokenTimeBoostBot/src/boost"
"github.com/mkmccarty/TokenTimeBoostBot/src/bottools"
"github.com/mkmccarty/TokenTimeBoostBot/src/config"
"github.com/mkmccarty/TokenTimeBoostBot/src/ei"
"github.com/mkmccarty/TokenTimeBoostBot/src/events"
"github.com/mkmccarty/TokenTimeBoostBot/src/farmerstate"
"github.com/mkmccarty/TokenTimeBoostBot/src/menno"
Expand Down Expand Up @@ -1082,6 +1083,12 @@ func main() {
log.Println(err.Error())
}
}
if event.Name == ei.MissionConfigPath {
log.Println("modified file:", event.Name)
if !ei.ReloadMissionConfig() {
log.Println("Failed to reload mission config")
}
}
}
case err, ok := <-watcher.Errors:
if !ok {
Expand All @@ -1096,6 +1103,10 @@ func main() {
if err != nil {
log.Fatal(err)
}
err = watcher.Add(ei.MissionConfigPath)
if err != nil {
log.Fatal(err)
}
Comment on lines +1106 to +1109
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

watcher.Add(ei.MissionConfigPath) calls log.Fatal on error. Since ei_missions.go explicitly falls back to embedded missionJSON when the file doesn’t exist, treating a missing optional config file as fatal will prevent the app from starting on fresh installs. Consider checking os.IsNotExist and skipping the watch (or watching the directory) until the file is created.

Copilot uses AI. Check for mistakes.

stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP)
Expand Down
182 changes: 180 additions & 2 deletions src/ei/ei_missions.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ package ei

import (
"encoding/json"
"log"
"os"
"sort"
"strconv"
"time"
)

const missionJSON = `{"ships":[
Expand All @@ -18,21 +23,194 @@ const missionJSON = `{"ships":[
{"name": "Atreggies Henliner","art":"atreggies","duration":["2d","3d","4d"]}
]}`

// MissionConfigPath is the on-disk config used for mission and artifact data.
const MissionConfigPath = "ttbb-data/ei-afx-config.json"

// ShipData holds data for each mission ship
type ShipData struct {
Name string `json:"Name"`
Art string `json:"Art"`
ArtDev string `json:"ArtDev"`
Duration []string `json:"Duration"`
}

type missionData struct {
Ships []ShipData `json:"ships"`
}

type missionConfig struct {
MissionParameters []missionParameter `json:"missionParameters"`
ArtifactParameters []artifactParameter `json:"artifactParameters"`
CraftingLevelInfos []craftingLevelInfo `json:"craftingLevelInfos"`
}

type craftingLevelInfo struct {
XpRequired int `json:"xpRequired"`
RarityMult float64 `json:"rarityMult"`
}

type artifactParameter struct {
Spec artifactSpec `json:"spec"`
BaseQuality float64 `json:"baseQuality"`
Value float64 `json:"value"`
OddsMultiplier float64 `json:"oddsMultiplier"`
CraftingPrice float64 `json:"craftingPrice"`
CraftingPriceLow float64 `json:"craftingPriceLow"`
CraftingPriceDomain float64 `json:"craftingPriceDomain"`
CraftingPriceCurve float64 `json:"craftingPriceCurve"`
CraftingXp float64 `json:"craftingXp"`
}

type artifactSpec struct {
Name string `json:"name"`
Level string `json:"level"`
Rarity string `json:"rarity"`
}

type missionParameter struct {
Ship string `json:"ship"`
Durations []missionDuration `json:"durations"`
}

type missionDuration struct {
DurationType string `json:"durationType"`
Seconds int `json:"seconds"`
Quality float64 `json:"quality"`
MinQuality float64 `json:"minQuality"`
MaxQuality float64 `json:"maxQuality"`
Capacity int `json:"capacity"`
}

// MissionArt holds the mission art and durations loaded from JSON
var MissionArt missionData

// ArtifactParameters holds artifact parameters loaded from JSON
var ArtifactParameters []artifactParameter

// CraftingLevelInfos holds crafting level info loaded from JSON
var CraftingLevelInfos []craftingLevelInfo
Comment on lines +86 to +90
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ArtifactParameters and CraftingLevelInfos are exported but their element types (artifactParameter/craftingLevelInfo) are unexported. Other packages can read the vars but can’t reference the types, which makes the API awkward to consume. Either export the types (ArtifactParameter/CraftingLevelInfo) or keep the vars unexported and provide exported getters that return exported types.

Copilot uses AI. Check for mistakes.

var missionShipInfo = map[string]ShipData{
"CHICKEN_ONE": {Name: "Chicken One", Art: "chicken1"},
"CHICKEN_NINE": {Name: "Chicken Nine", Art: "chicken9"},
"CHICKEN_HEAVY": {Name: "Chicken Heavy", Art: "chickenheavy"},
"BCR": {Name: "BCR", Art: "bcr"},
"MILLENIUM_CHICKEN": {Name: "Quintillion Chicken", Art: "milleniumchicken"},
"CORELLIHEN_CORVETTE": {Name: "Cornish-Hen Corvette", Art: "corellihencorvette"},
"GALEGGTICA": {Name: "Galeggtica", Art: "galeggtica"},
"CHICKFIANT": {Name: "Defihent", Art: "defihent"},
"VOYEGGER": {Name: "Voyegger", Art: "voyegger"},
"HENERPRISE": {Name: "Henerprise", Art: "henerprise"},
"ATREGGIES": {Name: "Atreggies Henliner", Art: "atreggies"},
}

func init() {
_ = json.Unmarshal([]byte(missionJSON), &MissionArt)
if !loadMissionDataFromConfig(MissionConfigPath) {
_ = json.Unmarshal([]byte(missionJSON), &MissionArt)
}
}

// ReloadMissionConfig reloads mission, artifact, and crafting data from disk.
func ReloadMissionConfig() bool {
return loadMissionDataFromConfig(MissionConfigPath)
}
Comment on lines +112 to +115
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReloadMissionConfig can be invoked from the fsnotify goroutine while other goroutines read MissionArt (and potentially ArtifactParameters/CraftingLevelInfos). Updating these package-level vars without synchronization can introduce data races and inconsistent reads. Consider guarding updates/reads with a RWMutex or storing the config in an atomic.Value and exposing accessor functions.

Copilot uses AI. Check for mistakes.

func loadMissionDataFromConfig(path string) bool {
data, err := os.ReadFile(path)
if err != nil {
log.Printf("Mission config read failed: %v", err)
return false
}

var cfg missionConfig
if err := json.Unmarshal(data, &cfg); err != nil {
log.Printf("Mission config parse failed: %v", err)
return false
}

ArtifactParameters = cfg.ArtifactParameters
CraftingLevelInfos = cfg.CraftingLevelInfos

Comment on lines +130 to +132
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These globals are assigned before verifying the mission ship data is valid. If loadMissionDataFromConfig later returns false, callers keep the previous MissionArt but artifact/crafting globals may have been partially overwritten, leaving an inconsistent state. Consider staging parsed values in locals and only committing to package-level vars after all validation passes.

Copilot uses AI. Check for mistakes.
var md missionData
for _, param := range cfg.MissionParameters {
info, ok := missionShipInfo[param.Ship]
if !ok {
continue
}
info.Duration = pickMissionDurations(param.Durations)
md.Ships = append(md.Ships, info)
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MissionArt.Ships is treated elsewhere as a shipTypeID-indexed slice (e.g., MissionArt.Ships[shipID] and len(MissionArt.Ships)-selectedShip-1). Appending ships based on config order can change ordering/length and lead to wrong ship lookups or panics. Consider constructing a fixed-length slice (IDs 0..10) and placing each ship at its numeric index, or otherwise enforcing deterministic ordering that matches shipType IDs.

Suggested change
md.Ships = append(md.Ships, info)
// Place each ship at its shipTypeID index rather than appending in config order.
shipID := int(param.Ship)
if shipID < 0 {
continue
}
// Grow the slice as needed to accommodate this ship ID, preserving existing entries.
if shipID >= len(md.Ships) {
newShips := make([]missionShip, shipID+1)
copy(newShips, md.Ships)
md.Ships = newShips
}
md.Ships[shipID] = info

Copilot uses AI. Check for mistakes.
}

if len(md.Ships) == 0 {
return false
}

MissionArt = md
return true
}

func pickMissionDurations(durations []missionDuration) []string {
preferred := []string{"SHORT", "LONG", "EPIC"}
Comment on lines +151 to +152
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding unit tests for pickMissionDurations/formatMissionDuration since this new logic affects user-facing output and depends on durationType selection and formatting edge cases (e.g., 90m => 1h30m, day+hour combos, fallback behavior when preferred types are missing).

Copilot uses AI. Check for mistakes.
byType := make(map[string]int, len(durations))
for _, d := range durations {
if d.Seconds <= 0 {
continue
}
byType[d.DurationType] = d.Seconds
}

var result []string
for _, key := range preferred {
if seconds, ok := byType[key]; ok {
result = append(result, formatMissionDuration(seconds))
}
}

if len(result) > 0 {
return result
}

sort.Slice(durations, func(i, j int) bool {
return durations[i].Seconds < durations[j].Seconds
})
for _, d := range durations {
if d.Seconds <= 0 {
continue
}
result = append(result, formatMissionDuration(d.Seconds))
if len(result) == 3 {
break
}
}

return result
}

func formatMissionDuration(seconds int) string {
d := time.Duration(seconds) * time.Second
if d <= 0 {
return "0m"
}

days := d / (24 * time.Hour)
d -= days * 24 * time.Hour
hours := d / time.Hour
d -= hours * time.Hour
minutes := d / time.Minute

parts := ""
if days > 0 {
parts += formatDurationPart(int(days), "d")
}
if hours > 0 {
parts += formatDurationPart(int(hours), "h")
}
if minutes > 0 || parts == "" {
parts += formatDurationPart(int(minutes), "m")
}

return parts
}

func formatDurationPart(value int, unit string) string {
return strconv.Itoa(value) + unit
}
Loading