package topdata import ( "encoding/json" "fmt" "os" "path/filepath" "slices" "strconv" "strings" ) const ( itempropsRegistryDirName = "itemprops/registry" itempropsRegistryLock = "lock.json" ) var itempropsAvailabilityColumns = []string{ "0_Melee", "1_Ranged", "2_Thrown", "3_Staves", "4_Rods", "5_Ammo", "6_Arm_Shld", "7_Helm", "8_Potions", "9_Scrolls", "10_Wands", "11_Thieves", "12_TrapKits", "13_Hide", "14_Claw", "15_Misc_Uneq", "16_Misc", "17_No_Props", "18_Containers", "19_HealerKit", "20_Torch", "21_Glove", } var itempropsAvailabilityAliases = map[string]string{ "0_melee": "melee", "1_ranged": "ranged", "2_thrown": "thrown", "3_staves": "staves", "4_rods": "rods", "5_ammo": "ammo", "6_arm_shld": "arm_shld", "7_helm": "helm", "8_potions": "potions", "9_scrolls": "scrolls", "10_wands": "wands", "11_thieves": "thieves", "12_trapkits": "trapkits", "13_hide": "hide", "14_claw": "claw", "15_misc_uneq": "misc_uneq", "16_misc": "misc", "17_no_props": "no_props", "18_containers": "containers", "19_healerkit": "healerkit", "20_torch": "torch", "21_glove": "glove", } var itempropsAvailabilityToColumn = map[string]string{ "melee": "0_Melee", "ranged": "1_Ranged", "thrown": "2_Thrown", "staves": "3_Staves", "rods": "4_Rods", "ammo": "5_Ammo", "arm_shld": "6_Arm_Shld", "helm": "7_Helm", "potions": "8_Potions", "scrolls": "9_Scrolls", "wands": "10_Wands", "thieves": "11_Thieves", "trapkits": "12_TrapKits", "hide": "13_Hide", "claw": "14_Claw", "misc_uneq": "15_Misc_Uneq", "misc": "16_Misc", "no_props": "17_No_Props", "containers": "18_Containers", "healerkit": "19_HealerKit", "torch": "20_Torch", "glove": "21_Glove", } type itempropsRegistry struct { RootPath string LockPath string LockData map[string]int Properties []map[string]any CostModels []map[string]any ParamModels []map[string]any SubtypeModels []map[string]any } func loadItempropsRegistry(dataDir string) (*itempropsRegistry, error) { root := filepath.Join(dataDir, filepath.FromSlash(itempropsRegistryDirName)) info, err := os.Stat(root) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } if !info.IsDir() { return nil, fmt.Errorf("itemprops registry root must be a directory: %s", root) } lockData, err := loadLockfile(filepath.Join(root, itempropsRegistryLock)) if err != nil { return nil, err } properties, err := loadRegistryRows(filepath.Join(root, "properties.json")) if err != nil { return nil, err } costModels, err := loadRegistryRows(filepath.Join(root, "cost_models.json")) if err != nil { return nil, err } paramModels, err := loadRegistryRows(filepath.Join(root, "param_models.json")) if err != nil { return nil, err } subtypeModels, err := loadRegistryRows(filepath.Join(root, "subtype_models.json")) if err != nil { return nil, err } return &itempropsRegistry{ RootPath: root, LockPath: filepath.Join(root, itempropsRegistryLock), LockData: lockData, Properties: properties, CostModels: costModels, ParamModels: paramModels, SubtypeModels: subtypeModels, }, nil } func loadRegistryRows(path string) ([]map[string]any, error) { obj, err := loadJSONObject(path) if err != nil { return nil, err } rawRows, ok := obj["rows"].([]any) if !ok { return nil, fmt.Errorf("%s: rows must be an array", path) } rows := make([]map[string]any, 0, len(rawRows)) for index, raw := range rawRows { row, ok := raw.(map[string]any) if !ok { return nil, fmt.Errorf("%s: row %d must be an object", path, index) } rows = append(rows, row) } return rows, nil } func collectItempropsRegistryDatasets(dataDir string, persistLocks bool) ([]nativeCollectedDataset, error) { registry, err := loadItempropsRegistry(dataDir) if err != nil { return nil, err } if registry == nil { return nil, nil } propertyIDs, lockModified, err := assignRegistryIDs(registry.Properties, registry.LockData, "itemprop:") if err != nil { return nil, err } costIDs, costModified, err := assignRegistryIDs(registry.CostModels, registry.LockData, "cost_models:") if err != nil { return nil, err } paramIDs, paramModified, err := assignRegistryIDs(registry.ParamModels, registry.LockData, "param_models:") if err != nil { return nil, err } lockModified = lockModified || costModified || paramModified if lockModified && persistLocks { if err := saveLockfile(registry.LockPath, registry.LockData); err != nil { return nil, err } } subtypeByKey := map[string]map[string]any{} for _, row := range registry.SubtypeModels { key := stringField(row, "key") if key != "" { subtypeByKey[key] = row } } costByKey := map[string]map[string]any{} for _, row := range registry.CostModels { key := stringField(row, "key") if key != "" { costByKey[key] = row } } paramByKey := map[string]map[string]any{} for _, row := range registry.ParamModels { key := stringField(row, "key") if key != "" { paramByKey[key] = row } } datasets := make([]nativeCollectedDataset, 0) itempropsRows := make([]map[string]any, 0, len(registry.Properties)) itempropdefRows := make([]map[string]any, 0, len(registry.Properties)) propertyRowsSorted := slices.Clone(registry.Properties) slices.SortFunc(propertyRowsSorted, func(a, b map[string]any) int { return propertyIDs[stringField(a, "key")] - propertyIDs[stringField(b, "key")] }) for _, property := range propertyRowsSorted { key := stringField(property, "key") rowID := propertyIDs[key] itempropsRow := map[string]any{ "id": rowID, "key": "itemprops:" + strings.TrimPrefix(key, "itemprop:"), "StringRef": itempropsSetName(property), "Label": itempropsSetLabel(property), } for _, column := range itempropsAvailabilityColumns { itempropsRow[column] = nullValue } availability, err := canonicalAvailability(property) if err != nil { return nil, fmt.Errorf("%s: %w", key, err) } for _, group := range availability { column := itempropsAvailabilityToColumn[group] itempropsRow[column] = "1" } itempropsRows = append(itempropsRows, itempropsRow) subtypeResRef := nullValue if subtypeKey, ok := refField(property["subtype"]); ok { subtype, ok := subtypeByKey[subtypeKey] if !ok { return nil, fmt.Errorf("%s: unknown subtype ref %s", key, subtypeKey) } subtypeResRef = stringField(subtype, "table_resref") if subtypeResRef == "" { return nil, fmt.Errorf("%s: subtype model %s is missing table_resref", key, subtypeKey) } } costValue := nullValue costRefID := nullValue if rawCost, ok := property["cost"].(map[string]any); ok { if value, ok := rawCost["value"]; ok { costValue = formatRegistryScalar(value) } costRefKey, ok := refField(rawCost) if ok { id, ok := costIDs[costRefKey] if !ok { return nil, fmt.Errorf("%s: unknown cost ref %s", key, costRefKey) } costRefID = strconv.Itoa(id) } } paramRefID := nullValue if paramKey, ok := refField(property["param"]); ok { id, ok := paramIDs[paramKey] if !ok { return nil, fmt.Errorf("%s: unknown param ref %s", key, paramKey) } paramRefID = strconv.Itoa(id) } itempropdefRows = append(itempropdefRows, map[string]any{ "id": rowID, "key": "itempropdef:" + strings.TrimPrefix(key, "itemprop:"), "Name": deepCopyValue(property["name"]), "Label": itempropsDefLabel(property), "SubTypeResRef": subtypeResRef, "Cost": costValue, "CostTableResRef": costRefID, "Param1ResRef": paramRefID, "GameStrRef": deepCopyValue(property["property_text"]), "Description": nullableRegistryField(property, "description"), }) } costRows := make([]map[string]any, 0, len(registry.CostModels)) costRowsSorted := slices.Clone(registry.CostModels) slices.SortFunc(costRowsSorted, func(a, b map[string]any) int { return costIDs[stringField(a, "key")] - costIDs[stringField(b, "key")] }) for _, costModel := range costRowsSorted { key := stringField(costModel, "key") costRows = append(costRows, map[string]any{ "id": costIDs[key], "key": key, "Name": stringField(costModel, "index_name"), "Label": stringField(costModel, "label"), "ClientLoad": formatRegistryScalar(costModel["client_load"]), }) } paramRows := make([]map[string]any, 0, len(registry.ParamModels)) paramRowsSorted := slices.Clone(registry.ParamModels) slices.SortFunc(paramRowsSorted, func(a, b map[string]any) int { return paramIDs[stringField(a, "key")] - paramIDs[stringField(b, "key")] }) for _, paramModel := range paramRowsSorted { key := stringField(paramModel, "key") table, ok := paramModel["table"].(map[string]any) if !ok { return nil, fmt.Errorf("%s: param model missing table", key) } paramRows = append(paramRows, map[string]any{ "id": paramIDs[key], "key": key, "Name": nullableRegistryField(paramModel, "name"), "Lable": stringField(paramModel, "label"), "TableResRef": stringField(table, "resref"), }) } datasets = append(datasets, newGeneratedDataset("itemprops/registry/itemprops", "itemprops.2da", itempropsAvailabilityColumnsWithMeta(), itempropsRows), newGeneratedDataset("itemprops/registry/itempropdef", "itempropdef.2da", []string{"Name", "Label", "SubTypeResRef", "Cost", "CostTableResRef", "Param1ResRef", "GameStrRef", "Description"}, itempropdefRows), newGeneratedDataset("itemprops/registry/costtable_index", "iprp_costtable.2da", []string{"Name", "Label", "ClientLoad"}, costRows), newGeneratedDataset("itemprops/registry/paramtable_index", "iprp_paramtable.2da", []string{"Name", "Lable", "TableResRef"}, paramRows), ) auxDatasets, err := collectItempropsAuxiliaryDatasets(registry.CostModels, "cost_models", "itemprops/registry/cost_models") if err != nil { return nil, err } datasets = append(datasets, auxDatasets...) auxDatasets, err = collectItempropsAuxiliaryDatasets(registry.ParamModels, "param_models", "itemprops/registry/param_models") if err != nil { return nil, err } datasets = append(datasets, auxDatasets...) auxDatasets, err = collectItempropsAuxiliaryDatasets(registry.SubtypeModels, "subtype_models", "itemprops/registry/subtype_models") if err != nil { return nil, err } datasets = append(datasets, auxDatasets...) return datasets, nil } func newGeneratedDataset(name, outputName string, columns []string, rows []map[string]any) nativeCollectedDataset { return nativeCollectedDataset{ Dataset: nativeDataset{ Kind: nativeDatasetPlain, Name: name, OutputName: outputName, Spec: specForDataset(name), }, Columns: columns, Rows: rows, LockData: map[string]int{}, } } func itempropsAvailabilityColumnsWithMeta() []string { columns := append([]string{}, itempropsAvailabilityColumns...) columns = append(columns, "StringRef", "Label") return columns } func collectItempropsAuxiliaryDatasets(models []map[string]any, keyPrefix, namePrefix string) ([]nativeCollectedDataset, error) { out := make([]nativeCollectedDataset, 0) for _, model := range models { key := stringField(model, "key") table, ok := model["table"].(map[string]any) if !ok { continue } if !strings.EqualFold(stringField(table, "ownership"), "owned") { continue } outputName := stringField(table, "output") if outputName == "" { outputName = stringField(table, "resref") + ".2da" } rawColumns, ok := table["columns"].([]any) if !ok { return nil, fmt.Errorf("%s: owned table is missing columns", key) } columns := make([]string, 0, len(rawColumns)) for _, rawColumn := range rawColumns { column, ok := rawColumn.(string) if !ok { return nil, fmt.Errorf("%s: owned table columns must be strings", key) } columns = append(columns, column) } rawRows, ok := table["rows"].([]any) if !ok { return nil, fmt.Errorf("%s: owned table rows must be an array", key) } rows := make([]map[string]any, 0, len(rawRows)) for index, rawRow := range rawRows { row, ok := rawRow.(map[string]any) if !ok { return nil, fmt.Errorf("%s: owned table row %d must be an object", key, index) } canonical, err := canonicalizeBaseRow(nativeDataset{Name: keyPrefix + "/" + strings.TrimPrefix(key, keyPrefix+":"), Spec: specForDataset("itemprops")}, columns, row, index) if err != nil { return nil, err } rows = append(rows, canonical) } out = append(out, newGeneratedDataset(namePrefix+"/"+strings.TrimPrefix(key, keyPrefix+":"), outputName, columns, rows)) } return out, nil } func assignRegistryIDs(rows []map[string]any, lockData map[string]int, requiredPrefix string) (map[string]int, bool, error) { ids := map[string]int{} used := map[int]struct{}{} for key, id := range lockData { if strings.HasPrefix(key, requiredPrefix) { used[id] = struct{}{} } } modified := false for _, row := range rows { key := stringField(row, "key") if key == "" { return nil, false, fmt.Errorf("registry row is missing key") } if !strings.HasPrefix(key, requiredPrefix) { return nil, false, fmt.Errorf("registry key %q must use prefix %q", key, requiredPrefix) } if id, ok := lockData[key]; ok { ids[key] = id used[id] = struct{}{} continue } } nextID := nextAvailableID(used) for _, row := range rows { key := stringField(row, "key") if _, ok := ids[key]; ok { continue } lockData[key] = nextID ids[key] = nextID used[nextID] = struct{}{} nextID = nextAvailableID(used) modified = true } return ids, modified, nil } func canonicalAvailability(row map[string]any) ([]string, error) { rawAvailability, ok := row["availability"] if !ok { return nil, nil } rawList, ok := rawAvailability.([]any) if !ok { return nil, fmt.Errorf("availability must be an array") } out := make([]string, 0, len(rawList)) seen := map[string]struct{}{} for _, raw := range rawList { text, ok := raw.(string) if !ok { return nil, fmt.Errorf("availability values must be strings") } normalized := normalizeAvailabilityName(text) if _, ok := itempropsAvailabilityToColumn[normalized]; !ok { return nil, fmt.Errorf("unknown availability group %q", text) } if _, ok := seen[normalized]; ok { continue } seen[normalized] = struct{}{} out = append(out, normalized) } slices.Sort(out) return out, nil } func normalizeAvailabilityName(name string) string { normalized := strings.TrimSpace(strings.ToLower(name)) normalized = strings.ReplaceAll(normalized, " ", "_") normalized = strings.ReplaceAll(normalized, "-", "_") if alias, ok := itempropsAvailabilityAliases[normalized]; ok { return alias } return normalized } func refField(value any) (string, bool) { if value == nil { return "", false } refMap, ok := value.(map[string]any) if !ok { return "", false } rawRef, ok := refMap["ref"] if !ok { return "", false } text, ok := rawRef.(string) return text, ok && strings.TrimSpace(text) != "" } func stringField(obj map[string]any, field string) string { raw, ok := obj[field] if !ok || raw == nil { return "" } switch typed := raw.(type) { case string: return typed case float64: if typed == float64(int64(typed)) { return strconv.FormatInt(int64(typed), 10) } return strconv.FormatFloat(typed, 'f', -1, 64) default: return "" } } func nullableRegistryField(obj map[string]any, field string) any { value, ok := obj[field] if !ok || value == nil { return nullValue } if text, ok := value.(string); ok && strings.TrimSpace(text) == "" { return nullValue } return deepCopyValue(value) } func itempropsDefLabel(obj map[string]any) string { return stringField(obj, "label") } func itempropsSetName(obj map[string]any) any { if value, ok := obj["set_name"]; ok { return deepCopyValue(value) } return deepCopyValue(obj["name"]) } func itempropsSetLabel(obj map[string]any) string { if label := stringField(obj, "set_label"); label != "" { return label } return itempropsDefLabel(obj) } func formatRegistryScalar(value any) string { if value == nil { return nullValue } switch typed := value.(type) { case string: if strings.TrimSpace(typed) == "" { return nullValue } return typed case float64: if typed == float64(int64(typed)) { return strconv.FormatInt(int64(typed), 10) } return strconv.FormatFloat(typed, 'f', -1, 64) case int: return strconv.Itoa(typed) default: return fmt.Sprint(typed) } } func deepCopyValue(value any) any { raw, err := json.Marshal(value) if err != nil { return value } var out any if err := json.Unmarshal(raw, &out); err != nil { return value } return out } func validateItempropsRegistryGraph(dataDir string, report *ValidationReport) { registry, err := loadItempropsRegistry(dataDir) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(dataDir, filepath.FromSlash(itempropsRegistryDirName)), Message: err.Error(), }) return } if registry == nil { return } seenPropertyKeys := map[string]struct{}{} seenDefLabels := map[string]string{} seenSetLabels := map[string]string{} seenCostKeys := map[string]struct{}{} seenParamKeys := map[string]struct{}{} seenSubtypeKeys := map[string]string{} ownedOutputs := map[string]string{} for _, row := range registry.CostModels { key := stringField(row, "key") if key == "" { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "cost_models.json"), Message: "cost model row is missing key", }) continue } if _, ok := seenCostKeys[key]; ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "cost_models.json"), Message: fmt.Sprintf("duplicate cost model key %q", key), }) } seenCostKeys[key] = struct{}{} validateOwnedRegistryTable(filepath.Join(registry.RootPath, "cost_models.json"), key, row, ownedOutputs, report) } for _, row := range registry.ParamModels { key := stringField(row, "key") if key == "" { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "param_models.json"), Message: "param model row is missing key", }) continue } if _, ok := seenParamKeys[key]; ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "param_models.json"), Message: fmt.Sprintf("duplicate param model key %q", key), }) } seenParamKeys[key] = struct{}{} validateOwnedRegistryTable(filepath.Join(registry.RootPath, "param_models.json"), key, row, ownedOutputs, report) } for _, row := range registry.SubtypeModels { key := stringField(row, "key") if key == "" { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "subtype_models.json"), Message: "subtype model row is missing key", }) continue } resref := stringField(row, "table_resref") if existing, ok := seenSubtypeKeys[key]; ok { if !strings.EqualFold(existing, resref) { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "subtype_models.json"), Message: fmt.Sprintf("duplicate subtype model key %q", key), }) } continue } seenSubtypeKeys[key] = resref validateOwnedRegistryTable(filepath.Join(registry.RootPath, "subtype_models.json"), key, row, ownedOutputs, report) } for _, row := range registry.Properties { key := stringField(row, "key") if key == "" { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "properties.json"), Message: "property row is missing key", }) continue } if _, ok := seenPropertyKeys[key]; ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "properties.json"), Message: fmt.Sprintf("duplicate property key %q", key), }) } seenPropertyKeys[key] = struct{}{} defLabel := itempropsDefLabel(row) if defLabel == "" { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "properties.json"), Message: fmt.Sprintf("%s is missing label", key), }) } else if owner, ok := seenDefLabels[strings.ToLower(defLabel)]; ok && owner != key { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "properties.json"), Message: fmt.Sprintf("generated itempropdef label collision between %q and %q", owner, key), }) } else { seenDefLabels[strings.ToLower(defLabel)] = key } setLabel := itempropsSetLabel(row) if setLabel == "" { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "properties.json"), Message: fmt.Sprintf("%s is missing itemprops set label", key), }) } else if owner, ok := seenSetLabels[strings.ToLower(setLabel)]; ok && owner != key { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "properties.json"), Message: fmt.Sprintf("generated itemprops set label collision between %q and %q", owner, key), }) } else { seenSetLabels[strings.ToLower(setLabel)] = key } if _, err := canonicalAvailability(row); err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "properties.json"), Message: fmt.Sprintf("%s: %v", key, err), }) } if subtypeKey, ok := refField(row["subtype"]); ok { if _, exists := seenSubtypeKeys[subtypeKey]; !exists { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "properties.json"), Message: fmt.Sprintf("%s: unknown subtype ref %s", key, subtypeKey), }) } } if rawCost, ok := row["cost"].(map[string]any); ok { if costKey, ok := refField(rawCost); ok { if _, exists := seenCostKeys[costKey]; !exists { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "properties.json"), Message: fmt.Sprintf("%s: unknown cost ref %s", key, costKey), }) } } } if paramKey, ok := refField(row["param"]); ok { if _, exists := seenParamKeys[paramKey]; !exists { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "properties.json"), Message: fmt.Sprintf("%s: unknown param ref %s", key, paramKey), }) } } } } func validateOwnedRegistryTable(path, key string, row map[string]any, ownedOutputs map[string]string, report *ValidationReport) { table, ok := row["table"].(map[string]any) if !ok { return } if !strings.EqualFold(stringField(table, "ownership"), "owned") { return } resref := stringField(table, "resref") if resref == "" { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s: owned table is missing resref", key), }) return } outputName := stringField(table, "output") if outputName == "" { outputName = resref + ".2da" } if owner, ok := ownedOutputs[strings.ToLower(outputName)]; ok && owner != key { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("owned table output collision between %q and %q", owner, key), }) } else { ownedOutputs[strings.ToLower(outputName)] = key } if _, ok := table["columns"].([]any); !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s: owned table columns must be an array", key), }) } if _, ok := table["rows"].([]any); !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s: owned table rows must be an array", key), }) } }