package topdata import ( "encoding/json" "fmt" "os" "path/filepath" "regexp" "slices" "strconv" "strings" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" ) // supportedPartCategories defines the part types that should be auto-discovered // from the sow-assets repository. var supportedPartCategories = []string{ "belt", "bicep", "chest", "foot", "forearm", "leg", "neck", "pelvis", "robe", "shin", "shoulder", } var partDatasetToAssetCategory = map[string]string{ "belt": "belt", "bicep": "bicep", "chest": "chest", "foot": "foot", "forearm": "forearm", "legs": "leg", "neck": "neck", "pelvis": "pelvis", "robe": "robe", "shin": "shin", "shoulder": "shoulder", } // trailingNumberRegex matches trailing digits at the end of a string var trailingNumberRegex = regexp.MustCompile(`(\d+)$`) // isPartsDataset checks if a dataset name corresponds to a parts dataset func isPartsDataset(datasetName string) bool { // Check if the dataset is under parts/ directory if strings.HasPrefix(datasetName, "parts/") { return true } // Check if it's exactly a parts file (e.g., "parts/belt") parts := strings.Split(datasetName, "/") if len(parts) == 2 && parts[0] == "parts" { return true } return false } func isAutogenEligiblePartsDataset(dataset nativeCollectedDataset) bool { if !isPartsDataset(dataset.Dataset.Name) { return false } for _, row := range dataset.Rows { key, ok := row["key"].(string) if ok && strings.TrimSpace(key) != "" { return false } } return true } // extractPartCategory extracts the part category from a dataset name // e.g., "parts/belt" -> "belt", "parts/bicep" -> "bicep" func extractPartCategory(datasetName string) string { parts := strings.Split(datasetName, "/") if len(parts) >= 2 { return parts[len(parts)-1] } return "" } func partAssetCategoryForDataset(datasetName string) string { category := extractPartCategory(datasetName) if mapped, ok := partDatasetToAssetCategory[category]; ok { return mapped } return category } // isSupportedPartCategory checks if the given category is in the supported list func isSupportedPartCategory(category string) bool { for _, c := range supportedPartCategories { if c == category { return true } } return false } // extractTrailingNumericSuffix extracts the trailing numeric suffix from a filename stem // e.g., "pfa0_belt018" -> 18, "pfa0_belt171" -> 171 // Returns 0 and false if no trailing numeric suffix is found func extractTrailingNumericSuffix(filenameStem string) (int, bool) { matches := trailingNumberRegex.FindStringSubmatch(filenameStem) if len(matches) < 2 { return 0, false } num, err := strconv.Atoi(matches[1]) if err != nil { return 0, false } return num, true } // DiscoveredPartModel represents a discovered model file with its row ID type DiscoveredPartModel struct { RowID int Filename string } // DiscoverPartModels scans the assets/part directory for .mdl files // and returns a map of rowID -> default row data for the given category. // The assetsDir should be the root assets directory (containing the part/ subdirectory). func DiscoverPartModels(assetsDir string, category string) (map[int]*DiscoveredPartModel, error) { result := make(map[int]*DiscoveredPartModel) if !isSupportedPartCategory(category) { return result, nil } partRoot, err := resolvePartsRoot(assetsDir) if err != nil { return nil, err } if partRoot == "" { return result, nil } // Construct the path to the part category directory partDir := filepath.Join(partRoot, category) // Check if the directory exists info, err := os.Stat(partDir) if err != nil { if os.IsNotExist(err) { return result, nil } return nil, fmt.Errorf("failed to access part directory %s: %w", partDir, err) } if !info.IsDir() { return nil, fmt.Errorf("part directory %s is not a directory", partDir) } // Walk the directory and find .mdl files err = filepath.Walk(partDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } // Only process .mdl files if !strings.HasSuffix(strings.ToLower(info.Name()), ".mdl") { return nil } // Extract the filename stem (without extension) filenameStem := strings.TrimSuffix(info.Name(), filepath.Ext(info.Name())) // Extract trailing numeric suffix rowID, hasSuffix := extractTrailingNumericSuffix(filenameStem) if !hasSuffix { return nil } // Store the discovered model if existing, ok := result[rowID]; ok { // If we already have this row ID, keep the first one found (alphabetically) if path < existing.Filename { result[rowID] = &DiscoveredPartModel{ RowID: rowID, Filename: path, } } } else { result[rowID] = &DiscoveredPartModel{ RowID: rowID, Filename: path, } } return nil }) if err != nil { return nil, fmt.Errorf("failed to scan part directory %s: %w", partDir, err) } return result, nil } // CreateDefaultPartRow creates a default row for a discovered part model. // Base defaults are COSTMODIFIER=0 and ACBONUS=0.00. Any robe HIDE* columns // present in the dataset columns default to 0 as well. func CreateDefaultPartRow(rowID int) map[string]any { return CreateDefaultPartRowForColumns(rowID, nil) } func CreateDefaultPartRowForColumns(rowID int, columns []string) map[string]any { return createDefaultPartRowForDataset(rowID, "", columns, project.PartsRowsConfig{}) } func createDefaultPartRowForDataset(rowID int, datasetName string, columns []string, cfg project.PartsRowsConfig) map[string]any { row := map[string]any{ "id": rowID, "COSTMODIFIER": "0", "ACBONUS": "0.00", } for field, value := range cfg.RowDefaults { if strings.TrimSpace(field) == "" { continue } row[field] = value } if value, ok := configuredPartsRowsACBonusValue(rowID, datasetName, cfg); ok { row["ACBONUS"] = value } for _, column := range columns { if strings.HasPrefix(column, "HIDE") { row[column] = "0" } } return row } func isUnsetPartValue(value any) bool { switch typed := value.(type) { case nil: return true case string: return typed == "" || typed == "****" } return false } func applyDiscoveredPartDefaults(row map[string]any, columns []string) { applyDiscoveredPartDefaultsWithConfig(row, "", columns, project.PartsRowsConfig{}) } func applyDiscoveredPartDefaultsWithConfig(row map[string]any, datasetName string, columns []string, cfg project.PartsRowsConfig) { if isUnsetPartValue(row["COSTMODIFIER"]) { row["COSTMODIFIER"] = "0" } for field, value := range cfg.RowDefaults { if strings.TrimSpace(field) == "" { continue } if isUnsetPartValue(row[field]) { row[field] = value } } if isUnsetPartValue(row["ACBONUS"]) { row["ACBONUS"] = "0.00" } if value, ok := configuredPartsRowsACBonusValue(rowIDFromPartRow(row), datasetName, cfg); ok { row["ACBONUS"] = value } for _, column := range columns { if !strings.HasPrefix(column, "HIDE") { continue } if isUnsetPartValue(row[column]) { row[column] = "0" } } } func rowIDFromPartRow(row map[string]any) int { rowID, _ := row["id"].(int) return rowID } func resolvePartsRoot(root string) (string, error) { if strings.TrimSpace(root) == "" { return "", nil } candidates := []string{ filepath.Join(root, "assets", "parts"), filepath.Join(root, "assets", "part"), filepath.Join(root, "parts"), filepath.Join(root, "part"), } for _, candidate := range candidates { info, err := os.Stat(candidate) if err == nil && info.IsDir() { return candidate, nil } if err != nil && !os.IsNotExist(err) { return "", fmt.Errorf("failed to access parts root %s: %w", candidate, err) } } return "", nil } // scanAutogeneratedParts scans the assets/part directory for all supported part categories // and returns a map of category -> set of row IDs that should be auto-generated. // The assetsDir parameter should be the path to the assets directory that contains the part/ subdirectory. func scanAutogeneratedParts(assetsDir string) (map[string]map[int]struct{}, error) { if assetsDir == "" { return nil, nil } result := make(map[string]map[int]struct{}) for _, category := range supportedPartCategories { discovered, err := DiscoverPartModels(assetsDir, category) if err != nil { return nil, err } // Always include the category, even if no IDs were discovered ids := make(map[int]struct{}, len(discovered)) for rowID := range discovered { ids[rowID] = struct{}{} } result[category] = ids } return result, nil } func resolveAutogeneratedPartsInventory(p *project.Project, progress func(string)) (map[string]map[int]struct{}, error) { overrideDir, err := resolvePartsAssetsOverrideDir(p) if err != nil { return nil, err } if overrideDir != "" { if progress != nil { progress(fmt.Sprintf("Scanning part models from local topdata.assets override %s...", overrideDir)) } return scanAutogeneratedParts(overrideDir) } return resolveReleasedPartsManifest(p, progress) } func hasCollectedPartsDatasets(collected []nativeCollectedDataset) bool { for _, dataset := range collected { if isPartsDataset(dataset.Dataset.Name) { return true } } return false } func loadPartOverrides(path string) ([]map[string]any, error) { if _, err := os.Stat(path); err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } obj, err := loadJSONObject(path) if err != nil { return nil, err } rawOverrides, ok := obj["overrides"] if !ok { return nil, nil } overrideList, ok := rawOverrides.([]any) if !ok { return nil, fmt.Errorf("%s: overrides must be an array", path) } overrides := make([]map[string]any, 0, len(overrideList)) for index, rawOverride := range overrideList { override, ok := rawOverride.(map[string]any) if !ok { return nil, fmt.Errorf("%s: override %d must be an object", path, index) } overrides = append(overrides, override) } return overrides, nil } func applyPartOverrides(sourceDir string, collected []nativeCollectedDataset) ([]nativeCollectedDataset, error) { return applyPartOverridesWithConfig(sourceDir, collected, project.PartsRowsConfig{}) } func applyPartOverridesWithConfig(sourceDir string, collected []nativeCollectedDataset, cfg project.PartsRowsConfig) ([]nativeCollectedDataset, error) { result := make([]nativeCollectedDataset, len(collected)) copy(result, collected) for i, dataset := range result { if !strings.HasPrefix(dataset.Dataset.Name, "parts/") { continue } category := extractPartCategory(dataset.Dataset.Name) if category == "" { continue } overrides, err := loadPartOverridesForCategory(sourceDir, category) if err != nil { return nil, err } if len(overrides) == 0 { continue } rows := make([]map[string]any, len(dataset.Rows)) rowByID := make(map[int]map[string]any, len(dataset.Rows)) for index, row := range dataset.Rows { cloned := cloneRowMap(row) rows[index] = cloned if rowID, ok := cloned["id"].(int); ok { rowByID[rowID] = cloned } } for index, override := range overrides { rawID, ok := override["id"] if !ok { return nil, fmt.Errorf("parts/%s override %d is missing id", category, index) } rowID, err := asInt(rawID) if err != nil { return nil, fmt.Errorf("parts/%s override %d id is not numeric", category, index) } row, ok := rowByID[rowID] if !ok { row = createDefaultPartRowForDataset(rowID, dataset.Dataset.Name, dataset.Columns, cfg) rows = append(rows, row) rowByID[rowID] = row } for field, value := range override { if field == "id" { continue } row[field] = normalizePartOverrideValue(value) } } slices.SortFunc(rows, func(a, b map[string]any) int { idA := a["id"].(int) idB := b["id"].(int) return idA - idB }) result[i].Rows = rows } return result, nil } func normalizePartsRowsACBonus(collected []nativeCollectedDataset, cfg project.PartsRowsConfig) ([]nativeCollectedDataset, error) { if !partsRowsACBonusConfigured(cfg) && len(cfg.RowDefaults) == 0 { return collected, nil } result := make([]nativeCollectedDataset, len(collected)) copy(result, collected) for i, dataset := range result { if !strings.HasPrefix(dataset.Dataset.Name, "parts/") { continue } rows := make([]map[string]any, len(dataset.Rows)) for index, row := range dataset.Rows { cloned := cloneRowMap(row) for field, value := range cfg.RowDefaults { if strings.TrimSpace(field) == "" { continue } if isUnsetPartValue(cloned[field]) { cloned[field] = value } } rowID, ok := cloned["id"].(int) if ok { if value, configured := configuredPartsRowsACBonusValue(rowID, dataset.Dataset.Name, cfg); configured { cloned["ACBONUS"] = value } } rows[index] = cloned } result[i].Rows = rows } return result, nil } func partsRowsACBonusConfigured(cfg project.PartsRowsConfig) bool { if strings.TrimSpace(cfg.ACBonus.Default.Strategy) != "" { return true } if len(cfg.ACBonus.Datasets) > 0 { return true } for _, dataset := range cfg.Datasets { if strings.TrimSpace(dataset.ACBonus.Strategy) != "" { return true } } return false } func configuredPartsRowsACBonusValue(rowID int, datasetName string, cfg project.PartsRowsConfig) (string, bool) { policy, ok := partsRowsACBonusPolicyForDataset(datasetName, cfg) if !ok { return "", false } switch strings.TrimSpace(policy.Strategy) { case "descending_row_id_sort_key": format := strings.TrimSpace(policy.Format) if format == "" { format = "%.2f" } return fmt.Sprintf(format, float64(policy.MaxRowID-rowID)/float64(policy.Divisor)), true case "fixed": return policy.Value, true default: return "", false } } func partsRowsACBonusPolicyForDataset(datasetName string, cfg project.PartsRowsConfig) (project.PartsRowsACBonusPolicy, bool) { category := extractPartCategory(datasetName) if policy, ok := cfg.ACBonus.Datasets[category]; ok && strings.TrimSpace(policy.Strategy) != "" { return policy, true } if dataset, ok := cfg.Datasets[category]; ok && strings.TrimSpace(dataset.ACBonus.Strategy) != "" { return dataset.ACBonus, true } if strings.TrimSpace(cfg.ACBonus.Default.Strategy) != "" { return cfg.ACBonus.Default, true } return project.PartsRowsACBonusPolicy{}, false } func loadPartOverridesForCategory(sourceDir, category string) ([]map[string]any, error) { overrides := []map[string]any{} legacyPath := filepath.Join(sourceDir, "data", "parts", "overrides", category+".json") legacyOverrides, err := loadPartOverrides(legacyPath) if err != nil { return nil, err } overrides = append(overrides, legacyOverrides...) moduleDir := filepath.Join(sourceDir, "data", "parts", "modules") modulePaths, err := partModuleOverridePaths(moduleDir, category) if err != nil { return nil, err } for _, path := range modulePaths { moduleOverrides, err := loadPartOverrides(path) if err != nil { return nil, err } overrides = append(overrides, moduleOverrides...) } return overrides, nil } func partModuleOverridePaths(moduleDir, category string) ([]string, error) { info, err := os.Stat(moduleDir) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } if !info.IsDir() { return nil, fmt.Errorf("parts module override path %s is not a directory", moduleDir) } var paths []string err = filepath.WalkDir(moduleDir, func(path string, d os.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } if strings.ToLower(filepath.Ext(path)) != ".json" { return nil } if partModuleOverrideMatchesCategory(path, category) { paths = append(paths, path) } return nil }) if err != nil { return nil, err } slices.Sort(paths) return paths, nil } func partModuleOverrideMatchesCategory(path, category string) bool { stem := strings.TrimSuffix(strings.ToLower(filepath.Base(path)), strings.ToLower(filepath.Ext(path))) category = strings.ToLower(strings.TrimSpace(category)) return stem == category || strings.HasPrefix(stem, category+"_") || strings.HasSuffix(stem, "_"+category) || strings.Contains(stem, "_"+category+"_") } func normalizePartOverrideValue(value any) any { switch typed := value.(type) { case float64: if typed == float64(int(typed)) { return strconv.Itoa(int(typed)) } case int: return strconv.Itoa(typed) case json.Number: return typed.String() } return value } // augmentWithAutogeneratedParts applies repository-backed discovery defaults. // Missing discovered IDs are added with default values. Existing discovered rows // retain authored values unless they still use placeholder values, in which case // discovery activates them with engine-visible defaults. func augmentWithAutogeneratedParts(collected []nativeCollectedDataset, autogenerated map[string]map[int]struct{}) []nativeCollectedDataset { return augmentWithAutogeneratedPartsWithConfig(collected, autogenerated, project.PartsRowsConfig{}) } func augmentWithAutogeneratedPartsWithConfig(collected []nativeCollectedDataset, autogenerated map[string]map[int]struct{}, cfg project.PartsRowsConfig) []nativeCollectedDataset { if len(autogenerated) == 0 { return collected } result := make([]nativeCollectedDataset, len(collected)) copy(result, collected) for i, dataset := range collected { if !isAutogenEligiblePartsDataset(dataset) { continue } category := partAssetCategoryForDataset(dataset.Dataset.Name) if !isSupportedPartCategory(category) { continue } ids, ok := autogenerated[category] if !ok || len(ids) == 0 { continue } // Build a map of existing row IDs. existingRows := make(map[int]map[string]any, len(dataset.Rows)) for _, row := range dataset.Rows { if id, ok := row["id"].(int); ok { existingRows[id] = row } } // Add rows for discovered IDs that don't already exist newRows := make([]map[string]any, 0, len(dataset.Rows)) newRows = append(newRows, dataset.Rows...) for rowID := range ids { if row, exists := existingRows[rowID]; exists { applyDiscoveredPartDefaultsWithConfig(row, dataset.Dataset.Name, dataset.Columns, cfg) continue } if _, exists := existingRows[rowID]; !exists { newRow := createDefaultPartRowForDataset(rowID, dataset.Dataset.Name, dataset.Columns, cfg) newRows = append(newRows, newRow) } } // Sort rows by ID slices.SortFunc(newRows, func(a, b map[string]any) int { idA := a["id"].(int) idB := b["id"].(int) return idA - idB }) result[i].Rows = newRows } return result }