258 lines
6.5 KiB
Go
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)
|
|
}
|
|
}
|