diff --git a/map-generator/README.md b/map-generator/README.md index 3d9a83eb12..7fa4432052 100644 --- a/map-generator/README.md +++ b/map-generator/README.md @@ -41,6 +41,27 @@ To process a subset of maps, pass a comma-separated list: - `../resources/maps//map16x.bin` - 1/16 scale (quarter dimensions) binary map data used for mini-maps. - `../resources/maps//thumbnail.webp` - WebP image thumbnail of the map. +## Command Line Flags + +- `--maps`: Optional comma-separated list of maps to process. + - ex: `go run . --maps=world,eastasia,big_plains` + +### Logging + +- `--log-level`: Explicitly sets the log level. + - ex: `go run . --log-level=debug` + - values: `ALL`, `DEBUG`, `INFO` (default), `WARN`, `ERROR`. +- `--verbose` or `-v`: Adds additional logging and prefixes logs with the `[mapname]`. Alias of `--log-level=DEBUG`. +- `--debug-performance`: Adds additional logging for performance-based recommendations, sets `--log-level=DEBUG`. +- `--debug-removal`: Adds additional logging of removed island and lake position/size, sets `--log-level=DEBUG`. + +The Generator outputs logs using `slog` with standard log-levels, and an additional ALL level. + +The `--verbose`, `-v`, `--debug-performance`, and `--debug-removal` flags all set the log level to `DEBUG`. +`debug-performance` and `debug-removal` are opt-in on top of the debug log level, as they can produce wordy output. You must pass the specific flag to see the corresponding logs if the `log-level` is set to `DEBUG`. + +Setting `--log-level=ALL` will output all possible logs, including all `DEBUG` tiers, regardless of whether the specific flags are passed. + ## Create image.png The map-generator will process your input file at `assets/maps//image.png` to generate the map diff --git a/map-generator/logger.go b/map-generator/logger.go new file mode 100644 index 0000000000..204158b119 --- /dev/null +++ b/map-generator/logger.go @@ -0,0 +1,213 @@ +// This is the custom logger providing the multi-level and flag-based logging for +// the map-generator. It uses slog. +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "log/slog" + "strings" + "sync" +) + +type LogFlags struct { + logLevel string // The log-level (most -> least wordy): ALL, DEBUG, INFO (default), WARN, ERROR + verbose bool // sets log-level=DEBUG + performance bool // opts-in to performance checks and sets log-level=DEBUG + removal bool // opts-in to island/lake removal logging and sets log-level=DEBUG +} + +// LevelAll is a custom log Level that outputs all messages, regardless of other passed flags +const LevelAll = slog.Level(-8) + +// PerformanceLogTag is a slog attribute used to tag performance-related log messages. +var PerformanceLogTag = slog.String("tag", "performance") + +// RemovalLogTag is a slog attribute used to tag land/water removal-related log messages. +var RemovalLogTag = slog.String("tag", "removal") + +// DetermineLogLevel determines the log level based on the LogFlags +// It prioritizes the log level flag over the default, and switches to debug if performance or removal flags are set. +func DetermineLogLevel( + logFlags LogFlags) slog.Level { + + var level = slog.LevelInfo + if logFlags.verbose { + level = slog.LevelDebug + } + + // switch to debug if any of the optional flags is enabled + if logFlags.performance || logFlags.removal { + level = slog.LevelDebug + } + + // parse the log-level input string to the slog.Level type + if logFlags.logLevel != "" { + switch strings.ToLower(logFlags.logLevel) { + case "all": + level = LevelAll + case "debug": + level = slog.LevelDebug + case "info": + level = slog.LevelInfo + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + default: + fmt.Printf("invalid log level: %s, defaulting to info\n", logFlags.logLevel) + level = slog.LevelInfo + } + } + return level +} + +// GeneratorLogger is a custom slog.Handler that outputs logs based on log level and additional LogFlags. +type GeneratorLogger struct { + opts slog.HandlerOptions + w io.Writer + mu *sync.Mutex + attrs []slog.Attr + prefix string + flags LogFlags +} + +// NewGeneratorLogger creates a new GeneratorLogger. +// It initializes a handler with specific output, options, and flags +func NewGeneratorLogger( + out io.Writer, + opts *slog.HandlerOptions, + flags LogFlags) *GeneratorLogger { + + h := &GeneratorLogger{ + w: out, + mu: &sync.Mutex{}, + flags: flags, + } + if opts != nil { + h.opts = *opts + } + if h.opts.Level == nil { + h.opts.Level = slog.LevelInfo + } + return h +} + +// Enabled checks if a given log level is enabled for this handler. +func (h *GeneratorLogger) Enabled(_ context.Context, level slog.Level) bool { + return level >= h.opts.Level.Level() +} + +// Handle processes a log record. +// It decides whether to output each record based on log level, flags, and if the map is a test map +// On output, it formats the log message with any extra formatting +func (h *GeneratorLogger) Handle(_ context.Context, r slog.Record) error { + isPerformanceLog := false + isRemovalLog := false + isTestMap := false + + var mapName string + + findAttrs := func(a slog.Attr) { + if a.Equal(PerformanceLogTag) { + isPerformanceLog = true + } + if a.Equal(RemovalLogTag) { + isRemovalLog = true + } + if a.Key == "map" { + mapName = a.Value.String() + } + if a.Key == "isTest" { + isTestMap = a.Value.Bool() + } + } + + // Check record attributes for performance tag and map name + r.Attrs(func(a slog.Attr) bool { + findAttrs(a) + return true + }) + + // Check handler's own attributes for performance tag and map name + for _, a := range h.attrs { + findAttrs(a) + } + + // Don't log messages if the flags are not set + // If the log level is set to LevelAll, disregard + if h.opts.Level != LevelAll && isPerformanceLog && !h.flags.performance { + return nil + } + if h.opts.Level != LevelAll && (isRemovalLog && !h.flags.removal) { + return nil + } + + // dont log performance messages for test maps + if isPerformanceLog && isTestMap { + return nil + } + + buf := &bytes.Buffer{} + + // Add map name as a prefix in log Level DEBUG and ALL + if (h.opts.Level == slog.LevelDebug || h.opts.Level == LevelAll) && mapName != "" { + mapName = strings.Trim(mapName, `"`) + fmt.Fprintf(buf, "[%s] ", mapName) + } + + // Add prefix for performance messages + if isPerformanceLog { + fmt.Fprintf(buf, "[PERF] ") + } + + if h.prefix != "" { + fmt.Fprintf(buf, "%s ", h.prefix) + } + + fmt.Fprintln(buf, r.Message) + + h.mu.Lock() + defer h.mu.Unlock() + _, err := h.w.Write(buf.Bytes()) + return err +} + +// WithAttrs returns a new handler with the given attributes added. +func (h *GeneratorLogger) WithAttrs(attrs []slog.Attr) slog.Handler { + newHandler := *h + newHandler.attrs = append(newHandler.attrs, attrs...) + return &newHandler +} + +// WithGroup returns a new handler with the given group name. +// The group name is added as a prefix to subsequent log messages. +func (h *GeneratorLogger) WithGroup(name string) slog.Handler { + if name == "" { + return h + } + newHandler := *h + if newHandler.prefix != "" { + newHandler.prefix += "." + } + newHandler.prefix += name + return &newHandler +} + +type loggerKey struct{} + +// LoggerFromContext retrieves the logger from the context. +// If no logger is found, it returns the default logger. +func LoggerFromContext(ctx context.Context) *slog.Logger { + if logger, ok := ctx.Value(loggerKey{}).(*slog.Logger); ok { + return logger + } + return slog.Default() +} + +// ContextWithLogger returns a new context with the provided logger. +func ContextWithLogger(ctx context.Context, logger *slog.Logger) context.Context { + return context.WithValue(ctx, loggerKey{}, logger) +} diff --git a/map-generator/main.go b/map-generator/main.go index b05070bfda..b6da02593b 100644 --- a/map-generator/main.go +++ b/map-generator/main.go @@ -1,19 +1,18 @@ package main import ( + "context" "encoding/json" "flag" "fmt" "log" + "log/slog" "os" "path/filepath" "strings" "sync" ) -// mapsFlag holds the comma-separated list of map names passed via the --maps command-line argument. -var mapsFlag string - // maps defines the registry of available maps to be processed. // Each entry contains the folder name and a flag indicating if it's a test map. // @@ -75,6 +74,12 @@ var maps = []struct { {Name: "world", IsTest: true}, } +// mapsFlag holds the comma-separated list of map names passed via the --maps command-line argument. +var mapsFlag string + +// logFlags holds all the flags related to configuring the map-generator logging +var logFlags LogFlags + // outputMapDir returns the absolute path to the directory where generated map files should be written. // It distinguishes between test and production output locations. func outputMapDir(isTest bool) (string, error) { @@ -104,7 +109,7 @@ func inputMapDir(isTest bool) (string, error) { // processMap handles the end-to-end generation for a single map. // It reads the source image and JSON, generates the terrain data, and writes the binary outputs and updated manifest. -func processMap(name string, isTest bool) error { +func processMap(ctx context.Context, name string, isTest bool) error { outputMapBaseDir, err := outputMapDir(isTest) if err != nil { return fmt.Errorf("failed to get map directory: %w", err) @@ -135,7 +140,7 @@ func processMap(name string, isTest bool) error { } // Generate maps - result, err := GenerateMap(GeneratorArgs{ + result, err := GenerateMap(ctx, GeneratorArgs{ ImageBuffer: imageBuffer, RemoveSmall: !isTest, // Don't remove small islands for test maps Name: name, @@ -230,7 +235,11 @@ func loadTerrainMaps() error { mapItem := mapItem go func() { defer wg.Done() - if err := processMap(mapItem.Name, mapItem.IsTest); err != nil { + mapLogTag := slog.String("map", mapItem.Name) + testLogTag := slog.Bool("isTest", mapItem.IsTest) + logger := slog.Default().With(mapLogTag).With(testLogTag) + ctx := ContextWithLogger(context.Background(), logger) + if err := processMap(ctx, mapItem.Name, mapItem.IsTest); err != nil { errChan <- err } }() @@ -254,8 +263,23 @@ func loadTerrainMaps() error { // It parses flags and triggers the map generation process. func main() { flag.StringVar(&mapsFlag, "maps", "", "optional comma-separated list of maps to process. ex: --maps=world,eastasia,big_plains") + flag.StringVar(&logFlags.logLevel, "log-level", "", "Explicitly sets the log level to one of: ALL, DEBUG, INFO (default), WARN, ERROR.") + flag.BoolVar(&logFlags.verbose, "verbose", false, "Adds additional logging and prefixes logs with the [mapname]. Alias of log-level=DEBUG.") + flag.BoolVar(&logFlags.verbose, "v", false, "-verbose shorthand") + flag.BoolVar(&logFlags.performance, "log-performance", false, "Adds additional logging for performance-based recommendations, sets log-level=DEBUG") + flag.BoolVar(&logFlags.removal, "log-removal", false, "Adds additional logging of removed island and lake position/size, sets log-level=DEBUG") flag.Parse() + logger := slog.New(NewGeneratorLogger( + os.Stdout, + &slog.HandlerOptions{ + Level: DetermineLogLevel(logFlags), + }, + logFlags, + )) + + slog.SetDefault(logger) + if err := loadTerrainMaps(); err != nil { log.Fatalf("Error generating terrain maps: %v", err) } diff --git a/map-generator/map_generator.go b/map-generator/map_generator.go index f27ea0fdaa..1c0dffdd30 100644 --- a/map-generator/map_generator.go +++ b/map-generator/map_generator.go @@ -2,20 +2,25 @@ package main import ( "bytes" + "context" "fmt" "image" "image/color" "image/png" - "log" "math" "github.com/chai2010/webp" ) -// The smallest a body of land or lake can be, all smaller are removed const ( + // The smallest a body of land or lake can be, all smaller are removed minIslandSize = 30 minLakeSize = 200 + // the recommended max area pixel size for input images + minRecommendedPixelSize = 2000000 + maxRecommendedPixelSize = 3000000 + // the recommended max number of land tiles in the output bin at full size + maxRecommendedLandTileCount = 3000000 ) // Holds raw RGBA image data for the thumbnail @@ -94,7 +99,8 @@ type GeneratorArgs struct { // // Misc Notes // - It normalizes map width/height to multiples of 4 for the mini map downscaling. -func GenerateMap(args GeneratorArgs) (MapResult, error) { +func GenerateMap(ctx context.Context, args GeneratorArgs) (MapResult, error) { + logger := LoggerFromContext(ctx) img, err := png.Decode(bytes.NewReader(args.ImageBuffer)) if err != nil { return MapResult{}, fmt.Errorf("failed to decode PNG: %w", err) @@ -107,7 +113,12 @@ func GenerateMap(args GeneratorArgs) (MapResult, error) { width = width - (width % 4) height = height - (height % 4) - log.Printf("Processing Map: %s, dimensions: %dx%d", args.Name, width, height) + logger.Info(fmt.Sprintf("Processing Map: %s, dimensions: %dx%d", args.Name, width, height)) + + area := width * height + if area < minRecommendedPixelSize || area > maxRecommendedPixelSize { + logger.Debug(fmt.Sprintf("Map area %d pixels is outside recommended range (%d - %d)", area, minRecommendedPixelSize, maxRecommendedPixelSize), PerformanceLogTag) + } // Initialize terrain grid terrain := make([][]Terrain, width) @@ -137,16 +148,16 @@ func GenerateMap(args GeneratorArgs) (MapResult, error) { } } - removeSmallIslands(terrain, args.RemoveSmall) - processWater(terrain, args.RemoveSmall) + removeSmallIslands(ctx, terrain, args.RemoveSmall) + processWater(ctx, terrain, args.RemoveSmall) terrain4x := createMiniMap(terrain) - processWater(terrain4x, false) + processWater(ctx, terrain4x, false) terrain16x := createMiniMap(terrain4x) - processWater(terrain16x, false) + processWater(ctx, terrain16x, false) - thumb := createMapThumbnail(terrain4x, 0.5) + thumb := createMapThumbnail(ctx, terrain4x, 0.5) webp, err := convertToWebP(ThumbData{ Data: thumb.Pix, Width: thumb.Bounds().Dx(), @@ -156,9 +167,20 @@ func GenerateMap(args GeneratorArgs) (MapResult, error) { return MapResult{}, fmt.Errorf("failed to save thumbnail: %w", err) } - mapData, mapNumLandTiles := packTerrain(terrain) - mapData4x, numLandTiles4x := packTerrain(terrain4x) - mapData16x, numLandTiles16x := packTerrain(terrain16x) + mapData, mapNumLandTiles := packTerrain(ctx, terrain) + mapData4x, numLandTiles4x := packTerrain(ctx, terrain4x) + mapData16x, numLandTiles16x := packTerrain(ctx, terrain16x) + + logger.Debug(fmt.Sprintf("Land Tile Count (1x): %d", mapNumLandTiles)) + logger.Debug(fmt.Sprintf("Land Tile Count (4x): %d", numLandTiles4x)) + logger.Debug(fmt.Sprintf("Land Tile Count (16x): %d", numLandTiles16x)) + + if mapNumLandTiles == 0 { + return MapResult{}, fmt.Errorf("Map has 0 land tiles") + } + if mapNumLandTiles > maxRecommendedLandTileCount { + logger.Debug(fmt.Sprintf("Map has more land tiles (%d) than recommended maximum (%d)", mapNumLandTiles, maxRecommendedLandTileCount), PerformanceLogTag) + } return MapResult{ Map: MapInfo{ @@ -242,8 +264,9 @@ func createMiniMap(tm [][]Terrain) [][]Terrain { // It marks Land tiles as shoreline if they neighbor Water, and Water tiles as // shoreline if they neighbor Land. // Returns a list of coordinates for all shoreline Water tiles found. -func processShore(terrain [][]Terrain) []Coord { - log.Println("Identifying shorelines") +func processShore(ctx context.Context, terrain [][]Terrain) []Coord { + logger := LoggerFromContext(ctx) + logger.Info("Identifying shorelines") var shorelineWaters []Coord width := len(terrain) height := len(terrain[0]) @@ -280,8 +303,9 @@ func processShore(terrain [][]Terrain) []Coord { // processDistToLand calculates the distance of water tiles from the nearest land. // It uses a Breadth-First Search (BFS) starting from the shoreline water tiles. // The distance is stored in the Magnitude field of the Water tiles. -func processDistToLand(shorelineWaters []Coord, terrain [][]Terrain) { - log.Println("Setting Water tiles magnitude = Manhattan distance from nearest land") +func processDistToLand(ctx context.Context, shorelineWaters []Coord, terrain [][]Terrain) { + logger := LoggerFromContext(ctx) + logger.Info("Setting Water tiles magnitude = Manhattan distance from nearest land") width := len(terrain) height := len(terrain[0]) @@ -362,8 +386,9 @@ func getNeighborCoords(x, y int, terrain [][]Terrain) []Coord { // It finds all connected water bodies and marks the largest one as Ocean. // If removeSmall is true, lakes smaller than minLakeSize are converted to Land. // Finally, it triggers shoreline identification and distance-to-land calculations. -func processWater(terrain [][]Terrain, removeSmall bool) { - log.Println("Processing water bodies") +func processWater(ctx context.Context, terrain [][]Terrain, removeSmall bool) { + logger := LoggerFromContext(ctx) + logger.Info("Processing water bodies") visited := make(map[string]bool) type waterBody struct { @@ -408,13 +433,14 @@ func processWater(terrain [][]Terrain, removeSmall bool) { for _, coord := range largestWaterBody.coords { terrain[coord.X][coord.Y].Ocean = true } - log.Printf("Identified ocean with %d water tiles", largestWaterBody.size) + logger.Info(fmt.Sprintf("Identified ocean with %d water tiles", largestWaterBody.size)) if removeSmall { // Remove small water bodies - log.Println("Searching for small water bodies for removal") + logger.Info("Searching for small water bodies for removal") for w := 1; w < len(waterBodies); w++ { if waterBodies[w].size < minLakeSize { + logger.Debug(fmt.Sprintf("Removing small lake at %d,%d (size %d)", waterBodies[w].coords[0].X, waterBodies[w].coords[0].Y, waterBodies[w].size), RemovalLogTag) smallLakes++ for _, coord := range waterBodies[w].coords { terrain[coord.X][coord.Y].Type = Land @@ -422,15 +448,14 @@ func processWater(terrain [][]Terrain, removeSmall bool) { } } } - log.Printf("Identified and removed %d bodies of water smaller than %d tiles", - smallLakes, minLakeSize) + logger.Info(fmt.Sprintf("Identified and removed %d bodies of water smaller than %d tiles", smallLakes, minLakeSize)) } // Process shorelines and distances - shorelineWaters := processShore(terrain) - processDistToLand(shorelineWaters, terrain) + shorelineWaters := processShore(ctx, terrain) + processDistToLand(ctx, shorelineWaters, terrain) } else { - log.Println("No water bodies found in the map") + logger.Info("No water bodies found in the map") } } @@ -465,7 +490,8 @@ func getArea(x, y int, terrain [][]Terrain, visited map[string]bool) []Coord { // removeSmallIslands identifies and removes small land masses from the terrain. // If removeSmall is true, any removed bodies are converted to Water. -func removeSmallIslands(terrain [][]Terrain, removeSmall bool) { +func removeSmallIslands(ctx context.Context, terrain [][]Terrain, removeSmall bool) { + logger := LoggerFromContext(ctx) if !removeSmall { return } @@ -501,6 +527,7 @@ func removeSmallIslands(terrain [][]Terrain, removeSmall bool) { for _, body := range landBodies { if body.size < minIslandSize { + logger.Debug(fmt.Sprintf("Removing small island at %d,%d (size %d)", body.coords[0].X, body.coords[0].Y, body.size), RemovalLogTag) smallIslands++ for _, coord := range body.coords { terrain[coord.X][coord.Y].Type = Water @@ -509,8 +536,7 @@ func removeSmallIslands(terrain [][]Terrain, removeSmall bool) { } } - log.Printf("Identified and removed %d islands smaller than %d tiles", - smallIslands, minIslandSize) + logger.Info(fmt.Sprintf("Identified and removed %d islands smaller than %d tiles", smallIslands, minIslandSize)) } // packTerrain serializes the terrain grid into a byte slice. @@ -521,7 +547,7 @@ func removeSmallIslands(terrain [][]Terrain, removeSmall bool) { // - Bits 0-4: Magnitude (0-31). For Water, this is (Distance / 2). // // Returns the packed data and the count of land tiles. -func packTerrain(terrain [][]Terrain) (data []byte, numLandTiles int) { +func packTerrain(ctx context.Context, terrain [][]Terrain) (data []byte, numLandTiles int) { width := len(terrain) height := len(terrain[0]) packedData := make([]byte, width*height) @@ -553,15 +579,16 @@ func packTerrain(terrain [][]Terrain) (data []byte, numLandTiles int) { } } - logBinaryAsBits(packedData, 8) + logBinaryAsBits(ctx, packedData, 8) return packedData, numLandTiles } // createMapThumbnail generates an RGBA image representation of the terrain. // It scales the map dimensions based on the provided quality factor. // Each pixel's color is determined by the terrain type and magnitude via getThumbnailColor. -func createMapThumbnail(terrain [][]Terrain, quality float64) *image.RGBA { - log.Println("Creating thumbnail") +func createMapThumbnail(ctx context.Context, terrain [][]Terrain, quality float64) *image.RGBA { + logger := LoggerFromContext(ctx) + logger.Info("Creating thumbnail") srcWidth := len(terrain) srcHeight := len(terrain[0]) @@ -664,7 +691,8 @@ func getThumbnailColor(t Terrain) RGBA { // logBinaryAsBits logs the binary representation of the first 'length' bytes of data. // It is a helper function for debugging packed terrain data. -func logBinaryAsBits(data []byte, length int) { +func logBinaryAsBits(ctx context.Context, data []byte, length int) { + logger := LoggerFromContext(ctx) if length > len(data) { length = len(data) } @@ -673,7 +701,7 @@ func logBinaryAsBits(data []byte, length int) { for i := 0; i < length; i++ { bits += fmt.Sprintf("%08b ", data[i]) } - log.Printf("Binary data (bits): %s", bits) + logger.Info(fmt.Sprintf("Binary data (bits): %s", bits)) } // createCombinedBinary combines the info JSON, map data, and mini-map data into a single binary buffer.