Files
sow-tools/internal/topdata/itemprops_registry.go
T
archvillainette e69564dff2
build-binaries / build-binaries (pull_request) Successful in 2m5s
test-image / build-image (pull_request) Successful in 45s
test / test (pull_request) Successful in 1m22s
Crucible: Fix Formatting Churn
2026-06-17 09:52:45 +02:00

1215 lines
37 KiB
Go

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 importLegacyItempropsRegistry(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
legacyDataDir := filepath.Join(referenceBuilderDir, "data")
registryDir := filepath.Join(dataDir, filepath.FromSlash(itempropsRegistryDirName))
if _, err := os.Stat(filepath.Join(registryDir, "properties.json")); err == nil {
if refs, err := countLegacyTLKRefsInRegistry(registryDir); err == nil && refs == 0 {
return 0, nil
}
}
if _, err := os.Stat(filepath.Join(legacyDataDir, "itemprops")); err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
defs, err := collectBaseDataset(nativeDataset{
Name: "itemprops/defs",
BasePath: filepath.Join(legacyDataDir, "itemprops", "defs", "base.json"),
LockPath: filepath.Join(legacyDataDir, "itemprops", "defs", "lock.json"),
ModulesDir: filepath.Join(legacyDataDir, "itemprops", "defs", "modules"),
Spec: specForDataset("itemprops"),
})
if err != nil {
return 0, err
}
sets, err := collectBaseDataset(nativeDataset{
Name: "itemprops/sets",
BasePath: filepath.Join(legacyDataDir, "itemprops", "sets", "base.json"),
LockPath: filepath.Join(legacyDataDir, "itemprops", "sets", "lock.json"),
ModulesDir: filepath.Join(legacyDataDir, "itemprops", "sets", "modules"),
Spec: specForDataset("itemprops"),
})
if err != nil {
return 0, err
}
costIndex, err := collectBaseDataset(nativeDataset{
Name: "itemprops/costtables/index",
BasePath: filepath.Join(legacyDataDir, "itemprops", "costtables", "index", "base.json"),
LockPath: filepath.Join(legacyDataDir, "itemprops", "costtables", "index", "lock.json"),
ModulesDir: filepath.Join(legacyDataDir, "itemprops", "costtables", "index", "modules"),
Spec: specForDataset("itemprops"),
})
if err != nil {
return 0, err
}
paramIndex, err := collectBaseDataset(nativeDataset{
Name: "itemprops/paramtables/index",
BasePath: filepath.Join(legacyDataDir, "itemprops", "paramtables", "index", "base.json"),
LockPath: filepath.Join(legacyDataDir, "itemprops", "paramtables", "index", "lock.json"),
ModulesDir: filepath.Join(legacyDataDir, "itemprops", "paramtables", "index", "modules"),
Spec: specForDataset("itemprops"),
})
if err != nil {
return 0, err
}
ownedCostTables, err := collectLegacyOwnedItempropTables(filepath.Join(legacyDataDir, "itemprops", "costtables"), "index")
if err != nil {
return 0, err
}
ownedParamTables, err := collectLegacyOwnedItempropTables(filepath.Join(legacyDataDir, "itemprops", "paramtables"), "index")
if err != nil {
return 0, err
}
ownedSubtypeTables, err := collectLegacyOwnedItempropTables(filepath.Join(legacyDataDir, "itemprops", "subtypes"), "")
if err != nil && !os.IsNotExist(err) {
return 0, err
}
setsByID := map[int]map[string]any{}
for _, row := range sets.Rows {
setsByID[row["id"].(int)] = row
}
costByID := map[int]map[string]any{}
for _, row := range costIndex.Rows {
costByID[row["id"].(int)] = row
}
paramByID := map[int]map[string]any{}
for _, row := range paramIndex.Rows {
paramByID[row["id"].(int)] = row
}
registryLock := map[string]int{}
legacyKeyByID := map[string]string{}
if legacyTLK != nil {
for key, id := range legacyTLK.Lock {
legacyKeyByID[strconv.Itoa(id)] = key
}
}
properties := make([]map[string]any, 0, len(defs.Rows))
subtypeRegistryByKey := map[string]map[string]any{}
for _, row := range defs.Rows {
rowID := row["id"].(int)
key := canonicalItempropPropertyKey(row)
registryLock[key] = rowID
property := map[string]any{
"key": key,
"label": row["Label"],
"name": deepCopyValue(row["Name"]),
"property_text": deepCopyValue(row["GameStrRef"]),
}
if description := nullableRegistryField(row, "Description"); description != nullValue {
property["description"] = description
}
if setRow, ok := setsByID[rowID]; ok {
property["availability"] = importLegacyAvailability(setRow)
if setName := nullableRegistryField(setRow, "StringRef"); setName != nullValue && setName != stringField(row, "Name") {
property["set_name"] = setName
}
if setLabel := stringField(setRow, "Label"); setLabel != "" && setLabel != stringField(row, "Label") {
property["set_label"] = setLabel
}
} else {
property["availability"] = []any{}
}
if subtypeResRef := stringField(row, "SubTypeResRef"); subtypeResRef != "" && subtypeResRef != nullValue {
subtypeKey := canonicalModelKey("subtype_models", subtypeResRef)
property["subtype"] = map[string]any{"ref": subtypeKey}
if _, ok := subtypeRegistryByKey[subtypeKey]; !ok {
subtypeRegistryByKey[subtypeKey] = buildLegacySubtypeModel(subtypeKey, subtypeResRef, ownedSubtypeTables)
}
}
costValue := stringField(row, "Cost")
costID := stringField(row, "CostTableResRef")
if costValue != "" && costValue != nullValue || costID != "" && costID != nullValue {
cost := map[string]any{}
if costValue != "" && costValue != nullValue {
cost["value"] = costValue
}
if costID != "" && costID != nullValue {
id, err := strconv.Atoi(costID)
if err != nil {
return 0, err
}
costRow, ok := costByID[id]
if !ok {
return 0, fmt.Errorf("%s references missing cost table index %s", key, costID)
}
cost["ref"] = canonicalModelKey("cost_models", stringField(costRow, "Name"))
}
property["cost"] = cost
}
if paramID := stringField(row, "Param1ResRef"); paramID != "" && paramID != nullValue {
id, err := strconv.Atoi(paramID)
if err != nil {
return 0, err
}
paramRow, ok := paramByID[id]
if !ok {
return 0, fmt.Errorf("%s references missing param table index %s", key, paramID)
}
property["param"] = map[string]any{"ref": canonicalModelKey("param_models", stringField(paramRow, "TableResRef"))}
}
if legacyTLK != nil {
property["name"] = importLegacyStrrefValue(property["name"], key+".name", legacyTLK, legacyKeyByID)
property["property_text"] = importLegacyStrrefValue(property["property_text"], key+".property_text", legacyTLK, legacyKeyByID)
if description, ok := property["description"]; ok {
property["description"] = importLegacyStrrefValue(description, key+".description", legacyTLK, legacyKeyByID)
}
if updated, _, err := inlineLegacyTLKValue(property, legacyTLK); err != nil {
return 0, err
} else if updated {
// property updated in place
}
}
properties = append(properties, property)
}
costModels := make([]map[string]any, 0, len(costIndex.Rows))
for _, row := range costIndex.Rows {
key := canonicalModelKey("cost_models", stringField(row, "Name"))
registryLock[key] = row["id"].(int)
model := map[string]any{
"key": key,
"index_name": row["Name"],
"label": row["Label"],
"client_load": row["ClientLoad"],
"table": buildLegacyOwnedModelTable(stringField(row, "Name"), ownedCostTables),
}
costModels = append(costModels, model)
}
paramModels := make([]map[string]any, 0, len(paramIndex.Rows))
for _, row := range paramIndex.Rows {
key := canonicalModelKey("param_models", stringField(row, "TableResRef"))
registryLock[key] = row["id"].(int)
model := map[string]any{
"key": key,
"name": deepCopyValue(row["Name"]),
"label": row["Lable"],
"table": buildLegacyOwnedModelTable(stringField(row, "TableResRef"), ownedParamTables),
}
paramModels = append(paramModels, model)
}
subtypeModels := make([]map[string]any, 0, len(subtypeRegistryByKey))
for _, key := range sortedKeysAny(subtypeRegistryByKey) {
subtypeModels = append(subtypeModels, subtypeRegistryByKey[key])
}
writes := []struct {
path string
obj map[string]any
}{
{filepath.Join(registryDir, "properties.json"), map[string]any{"rows": properties}},
{filepath.Join(registryDir, "cost_models.json"), map[string]any{"rows": costModels}},
{filepath.Join(registryDir, "param_models.json"), map[string]any{"rows": paramModels}},
{filepath.Join(registryDir, "subtype_models.json"), map[string]any{"rows": subtypeModels}},
{filepath.Join(registryDir, itempropsRegistryLock), anyMapInt(registryLock)},
}
if err := os.MkdirAll(registryDir, 0o755); err != nil {
return 0, err
}
for _, write := range writes {
raw, err := json.MarshalIndent(write.obj, "", " ")
if err != nil {
return 0, err
}
raw = append(raw, '\n')
if err := os.WriteFile(write.path, raw, 0o644); err != nil {
return 0, err
}
}
return len(writes), nil
}
func countLegacyTLKRefsInRegistry(registryDir string) (int, error) {
paths, err := collectDataJSONPaths(registryDir)
if err != nil {
return 0, err
}
refs := 0
for _, path := range paths {
obj, err := loadJSONObject(path)
if err != nil {
return 0, err
}
refs += countLegacyTLKRefsInValue(obj)
}
return refs, nil
}
func collectLegacyOwnedItempropTables(root, skipDir string) (map[string]map[string]any, error) {
result := map[string]map[string]any{}
entries, err := os.ReadDir(root)
if err != nil {
return nil, err
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
if skipDir != "" && entry.Name() == skipDir {
continue
}
dir := filepath.Join(root, entry.Name())
basePath := filepath.Join(dir, "base.json")
if _, err := os.Stat(basePath); err != nil {
continue
}
collected, err := collectBaseDataset(nativeDataset{
Name: filepath.ToSlash(filepath.Join("itemprops", filepath.Base(root), entry.Name())),
BasePath: basePath,
LockPath: filepath.Join(dir, "lock.json"),
ModulesDir: filepath.Join(dir, "modules"),
Spec: specForDataset("itemprops"),
})
if err != nil {
return nil, err
}
result[strings.ToLower(outputStem(collected.Dataset.OutputName))] = map[string]any{
"ownership": "owned",
"resref": outputStem(collected.Dataset.OutputName),
"output": collected.Dataset.OutputName,
"columns": stringSliceToAny(collected.Columns),
"rows": rowsToAny(collected.Rows),
}
}
return result, nil
}
func buildLegacySubtypeModel(key, resref string, owned map[string]map[string]any) map[string]any {
return map[string]any{
"key": key,
"table_resref": resref,
"table": buildLegacyOwnedModelTable(resref, owned),
}
}
func buildLegacyOwnedModelTable(resref string, owned map[string]map[string]any) map[string]any {
if table, ok := owned[strings.ToLower(resref)]; ok {
return table
}
return map[string]any{
"ownership": "external",
"resref": resref,
}
}
func canonicalItempropPropertyKey(row map[string]any) string {
if key := stringField(row, "key"); key != "" {
return "itemprop:" + strings.TrimPrefix(key, "itempropdef:")
}
return canonicalModelKey("itemprop", stringField(row, "Label"))
}
func canonicalModelKey(prefix, source string) string {
normalized := strings.ToLower(strings.TrimSpace(source))
normalized = strings.ReplaceAll(normalized, " ", "")
normalized = strings.ReplaceAll(normalized, "-", "")
normalized = strings.ReplaceAll(normalized, "_", "")
return prefix + ":" + normalized
}
func importLegacyStrrefValue(value any, fallbackKey string, legacy *legacyTLKData, keyByID map[string]string) any {
if legacy == nil {
return value
}
if _, ok, _ := parseTLKPayload(value, true); ok {
return value
}
_ = keyByID
key := fallbackKey
entry, ok := legacy.Entries[key]
if !ok {
return value
}
payload := map[string]any{
"key": key,
"text": entry.Text,
}
if entry.SoundResRef != "" {
payload["sound_resref"] = entry.SoundResRef
}
if entry.SoundLength != 0 {
payload["sound_length"] = entry.SoundLength
}
return map[string]any{"tlk": payload}
}
func importLegacyAvailability(row map[string]any) []any {
out := make([]any, 0)
for _, column := range itempropsAvailabilityColumns {
value, ok := row[column]
if !ok {
continue
}
text := formatRegistryScalar(value)
if text != "1" {
continue
}
out = append(out, normalizeAvailabilityName(column))
}
return out
}
func anyMapInt(values map[string]int) map[string]any {
out := make(map[string]any, len(values))
for key, value := range values {
out[key] = value
}
return out
}
func stringSliceToAny(values []string) []any {
out := make([]any, 0, len(values))
for _, value := range values {
out = append(out, value)
}
return out
}
func rowsToAny(rows []map[string]any) []any {
out := make([]any, 0, len(rows))
for _, row := range rows {
out = append(out, deepCopyValue(row))
}
return out
}
func sortedKeysAny(values map[string]map[string]any) []string {
out := make([]string, 0, len(values))
for key := range values {
out = append(out, key)
}
slices.Sort(out)
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),
})
}
}