package topdata import ( "fmt" "slices" "strings" ) var portraitColumns = []string{"BaseResRef", "Sex", "Race", "InanimateType", "Plot", "LowGore"} var portraitMetadataInjectionDatasets = map[string]struct{}{ "appearance": {}, "genericdoors": {}, "placeables": {}, } func parseRowMetadata(raw any) (map[string]any, error) { if raw == nil { return nil, nil } obj, ok := raw.(map[string]any) if !ok { return nil, fmt.Errorf("meta must be an object") } if len(obj) == 0 { return map[string]any{}, nil } meta := make(map[string]any, len(obj)) for key, value := range obj { switch normalizeMetadataKey(key) { case "family": family, err := parseFamilyMetadata(value) if err != nil { return nil, fmt.Errorf("meta.family: %w", err) } meta["family"] = family case "wiki": wiki, err := parseWikiMetadata(value) if err != nil { return nil, fmt.Errorf("meta.wiki: %w", err) } if len(wiki) > 0 { meta["wiki"] = wiki } case "portrait": portrait, err := parsePortraitMetadata(value) if err != nil { return nil, fmt.Errorf("meta.portrait: %w", err) } meta["portrait"] = portrait default: return nil, fmt.Errorf("unknown metadata key %q", key) } } return meta, nil } func parsePortraitMetadata(raw any) (map[string]any, error) { obj, ok := raw.(map[string]any) if !ok { return nil, fmt.Errorf("must be an object") } portrait := map[string]any{} for key, value := range obj { switch normalizeMetadataKey(key) { case "resref": text, ok := value.(string) if !ok || strings.TrimSpace(text) == "" { return nil, fmt.Errorf("resref must be a non-empty string") } portrait["resref"] = text case "sex", "race", "inanimate_type", "plot", "low_gore": portrait[normalizeMetadataKey(key)] = value default: return nil, fmt.Errorf("unknown field %q", key) } } if _, ok := portrait["resref"]; !ok { return nil, fmt.Errorf("resref is required") } return portrait, nil } func normalizeMetadataKey(key string) string { replacer := strings.NewReplacer("-", "_", " ", "_") return strings.ToLower(replacer.Replace(strings.TrimSpace(key))) } func portraitMetaFromRow(row map[string]any) (map[string]any, bool) { rawMeta, ok := lookupField(row, "meta") if !ok { return nil, false } meta, ok := rawMeta.(map[string]any) if !ok { return nil, false } rawPortrait, ok := meta["portrait"] if !ok { return nil, false } portrait, ok := rawPortrait.(map[string]any) if !ok { return nil, false } return portrait, true } func rowHasPortraitMetadata(row map[string]any) bool { _, ok := portraitMetaFromRow(row) return ok } func portraitMetadataMayInject(datasetName string, row map[string]any) bool { if _, ok := portraitMetadataInjectionDatasets[datasetName]; !ok { return false } optIn, _ := row[internalPortraitModuleAuthoredKey].(bool) return optIn && rowHasPortraitMetadata(row) } func mergePortraitMetadata(collected []nativeCollectedDataset) ([]nativeCollectedDataset, error) { portraitIndex := -1 for i, dataset := range collected { if dataset.Dataset.OutputName == "portraits.2da" || dataset.Dataset.Name == "portraits" { portraitIndex = i break } } if portraitIndex < 0 { return collected, nil } portraits := collected[portraitIndex] if len(portraits.Columns) == 0 { portraits.Columns = append([]string(nil), portraitColumns...) } rowByResref := map[string]map[string]any{} usedIDs := map[int]struct{}{} for _, row := range portraits.Rows { rowID, ok := row["id"].(int) if ok { usedIDs[rowID] = struct{}{} } resref, _ := lookupField(row, "BaseResRef") if text, ok := resref.(string); ok { rowByResref[text] = row } } for _, rowID := range portraits.LockData { usedIDs[rowID] = struct{}{} } nextID := nextAvailableID(usedIDs) lockModified := false activeAutoLockKeys := map[string]struct{}{} for _, dataset := range collected { if _, ok := portraitMetadataInjectionDatasets[dataset.Dataset.Name]; !ok { continue } for _, row := range dataset.Rows { if !portraitMetadataMayInject(dataset.Dataset.Name, row) { continue } portrait, _ := portraitMetaFromRow(row) resref := portrait["resref"].(string) target, ok := rowByResref[resref] if !ok { lockKey := "portraits:auto:" + resref activeAutoLockKeys[lockKey] = struct{}{} rowID, found := portraits.LockData[lockKey] if !found { rowID = nextID portraits.LockData[lockKey] = rowID lockModified = true usedIDs[rowID] = struct{}{} nextID = nextAvailableID(usedIDs) } target = map[string]any{ "id": rowID, "key": lockKey, } for _, column := range portraits.Columns { target[column] = nullValue } target["BaseResRef"] = resref portraits.Rows = append(portraits.Rows, target) rowByResref[resref] = target } if err := applyPortraitContribution(target, portrait, rowIdentity(dataset.Dataset.Name, row)); err != nil { return nil, err } } } for lockKey := range portraits.LockData { if !strings.HasPrefix(lockKey, "portraits:auto:") { continue } if _, ok := activeAutoLockKeys[lockKey]; ok { continue } delete(portraits.LockData, lockKey) lockModified = true } if lockModified && portraits.Dataset.LockPath != "" { if err := saveLockfile(portraits.Dataset.LockPath, portraits.LockData); err != nil { return nil, fmt.Errorf("dataset %s: %w", portraits.Dataset.Name, err) } } slices.SortFunc(portraits.Rows, func(a, b map[string]any) int { return a["id"].(int) - b["id"].(int) }) collected[portraitIndex] = portraits return collected, nil } func applyPortraitContribution(target map[string]any, portrait map[string]any, source string) error { mapping := map[string]string{ "resref": "BaseResRef", "sex": "Sex", "race": "Race", "inanimate_type": "InanimateType", "plot": "Plot", "low_gore": "LowGore", } for key, value := range portrait { column, ok := mapping[normalizeMetadataKey(key)] if !ok { continue } current, _ := lookupField(target, column) if current == nil || current == nullValue { target[column] = value continue } if normalizeComparableValue(current) == normalizeComparableValue(value) { continue } return fmt.Errorf("portrait %q conflicts for column %s between existing value %v and contributor %s value %v", portrait["resref"], column, current, source, value) } return nil } func normalizeComparableValue(value any) string { switch typed := value.(type) { case nil: return nullValue case string: return typed default: return format2DAValue(typed) } }