Files
sow-tools/internal/topdata/portraits_native.go
T
2026-04-10 15:26:27 +02:00

258 lines
6.5 KiB
Go

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)
}
}