"first_null_row" configuration option
This commit is contained in:
@@ -243,6 +243,9 @@ topdata:
|
|||||||
min: 0
|
min: 0
|
||||||
max: 175
|
max: 175
|
||||||
hex_width: 2
|
hex_width: 2
|
||||||
|
row_generation:
|
||||||
|
- namespace: portraits
|
||||||
|
mode: first_null_row
|
||||||
class_feat_injections:
|
class_feat_injections:
|
||||||
global_feats:
|
global_feats:
|
||||||
- feat: feat:literate
|
- feat: feat:literate
|
||||||
@@ -295,6 +298,12 @@ topdata:
|
|||||||
value: 0
|
value: 0
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`topdata.row_generation` controls where newly generated rows are allocated for
|
||||||
|
specific datasets. The default `after_base` mode preserves existing behavior by
|
||||||
|
allocating after the final imported base row. `first_null_row` fills base rows
|
||||||
|
whose canonical key is empty and whose emitted 2DA columns are all null-like
|
||||||
|
(`null`, empty string, or `****`) before extending the table.
|
||||||
|
|
||||||
`topdata.class_feat_injections` controls generated rows for every
|
`topdata.class_feat_injections` controls generated rows for every
|
||||||
`classes/feats/*.json` table. `global_feats` injects concrete `feat:*`
|
`classes/feats/*.json` table. `global_feats` injects concrete `feat:*`
|
||||||
references unless the class already authors that feat; `require_present` and
|
references unless the class already authors that feat; `require_present` and
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ type EffectiveTopDataConfig struct {
|
|||||||
PackageTLK string `json:"package_tlk" yaml:"package_tlk"`
|
PackageTLK string `json:"package_tlk" yaml:"package_tlk"`
|
||||||
ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"`
|
ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"`
|
||||||
ValueDefaults []TopDataValueDefaultConfig `json:"value_defaults" yaml:"value_defaults"`
|
ValueDefaults []TopDataValueDefaultConfig `json:"value_defaults" yaml:"value_defaults"`
|
||||||
|
RowGeneration []TopDataRowGenerationConfig `json:"row_generation" yaml:"row_generation"`
|
||||||
ClassFeatInjections TopDataClassFeatInjectionConfig `json:"class_feat_injections" yaml:"class_feat_injections"`
|
ClassFeatInjections TopDataClassFeatInjectionConfig `json:"class_feat_injections" yaml:"class_feat_injections"`
|
||||||
Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"`
|
Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"`
|
||||||
}
|
}
|
||||||
@@ -242,6 +243,7 @@ func (p *Project) EffectiveConfig() EffectiveConfig {
|
|||||||
PackageTLK: defaultString(p.Config.TopData.PackageTLK, DefaultTopDataPackageTLK),
|
PackageTLK: defaultString(p.Config.TopData.PackageTLK, DefaultTopDataPackageTLK),
|
||||||
ValueEncodings: cloneTopDataValueEncodings(p.Config.TopData.ValueEncodings),
|
ValueEncodings: cloneTopDataValueEncodings(p.Config.TopData.ValueEncodings),
|
||||||
ValueDefaults: cloneTopDataValueDefaults(p.Config.TopData.ValueDefaults),
|
ValueDefaults: cloneTopDataValueDefaults(p.Config.TopData.ValueDefaults),
|
||||||
|
RowGeneration: cloneTopDataRowGeneration(p.Config.TopData.RowGeneration),
|
||||||
ClassFeatInjections: cloneTopDataClassFeatInjections(p.Config.TopData.ClassFeatInjections),
|
ClassFeatInjections: cloneTopDataClassFeatInjections(p.Config.TopData.ClassFeatInjections),
|
||||||
Wiki: TopDataWikiConfig{
|
Wiki: TopDataWikiConfig{
|
||||||
OutputRoot: defaultString(p.Config.TopData.Wiki.OutputRoot, DefaultTopDataWikiOutputRoot),
|
OutputRoot: defaultString(p.Config.TopData.Wiki.OutputRoot, DefaultTopDataWikiOutputRoot),
|
||||||
@@ -431,6 +433,29 @@ func cloneTopDataValueDefaults(values []TopDataValueDefaultConfig) []TopDataValu
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cloneTopDataRowGeneration(values []TopDataRowGenerationConfig) []TopDataRowGenerationConfig {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]TopDataRowGenerationConfig, len(values))
|
||||||
|
copy(out, values)
|
||||||
|
for index := range out {
|
||||||
|
if strings.TrimSpace(out[index].Dataset) == "" {
|
||||||
|
out[index].Dataset = strings.TrimSpace(out[index].Namespace)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(out[index].Namespace) == "" {
|
||||||
|
out[index].Namespace = strings.TrimSpace(out[index].Dataset)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(out[index].Mode) == "" {
|
||||||
|
out[index].Mode = "after_base"
|
||||||
|
}
|
||||||
|
out[index].Dataset = filepath.ToSlash(strings.TrimSpace(out[index].Dataset))
|
||||||
|
out[index].Namespace = filepath.ToSlash(strings.TrimSpace(out[index].Namespace))
|
||||||
|
out[index].Mode = strings.TrimSpace(out[index].Mode)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func cloneTopDataClassFeatInjections(value TopDataClassFeatInjectionConfig) TopDataClassFeatInjectionConfig {
|
func cloneTopDataClassFeatInjections(value TopDataClassFeatInjectionConfig) TopDataClassFeatInjectionConfig {
|
||||||
out := TopDataClassFeatInjectionConfig{
|
out := TopDataClassFeatInjectionConfig{
|
||||||
GlobalFeats: slices.Clone(value.GlobalFeats),
|
GlobalFeats: slices.Clone(value.GlobalFeats),
|
||||||
|
|||||||
@@ -217,10 +217,17 @@ type TopDataConfig struct {
|
|||||||
PackageTLK string `json:"package_tlk" yaml:"package_tlk"`
|
PackageTLK string `json:"package_tlk" yaml:"package_tlk"`
|
||||||
ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"`
|
ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"`
|
||||||
ValueDefaults []TopDataValueDefaultConfig `json:"value_defaults" yaml:"value_defaults"`
|
ValueDefaults []TopDataValueDefaultConfig `json:"value_defaults" yaml:"value_defaults"`
|
||||||
|
RowGeneration []TopDataRowGenerationConfig `json:"row_generation" yaml:"row_generation"`
|
||||||
ClassFeatInjections TopDataClassFeatInjectionConfig `json:"class_feat_injections" yaml:"class_feat_injections"`
|
ClassFeatInjections TopDataClassFeatInjectionConfig `json:"class_feat_injections" yaml:"class_feat_injections"`
|
||||||
Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"`
|
Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TopDataRowGenerationConfig struct {
|
||||||
|
Dataset string `json:"dataset,omitempty" yaml:"dataset,omitempty"`
|
||||||
|
Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"`
|
||||||
|
Mode string `json:"mode" yaml:"mode"`
|
||||||
|
}
|
||||||
|
|
||||||
type TopDataClassFeatInjectionConfig struct {
|
type TopDataClassFeatInjectionConfig struct {
|
||||||
GlobalFeats []TopDataClassFeatGlobalRule `json:"global_feats" yaml:"global_feats"`
|
GlobalFeats []TopDataClassFeatGlobalRule `json:"global_feats" yaml:"global_feats"`
|
||||||
ClassSkillMasterfeats []TopDataClassFeatMasterfeatRule `json:"class_skill_masterfeats" yaml:"class_skill_masterfeats"`
|
ClassSkillMasterfeats []TopDataClassFeatMasterfeatRule `json:"class_skill_masterfeats" yaml:"class_skill_masterfeats"`
|
||||||
@@ -606,6 +613,7 @@ func (p *Project) ValidateLayout() error {
|
|||||||
failures = append(failures, validateGeneratedConfig(effective.Generated)...)
|
failures = append(failures, validateGeneratedConfig(effective.Generated)...)
|
||||||
failures = append(failures, validateTopDataValueEncodings(effective.TopData.ValueEncodings)...)
|
failures = append(failures, validateTopDataValueEncodings(effective.TopData.ValueEncodings)...)
|
||||||
failures = append(failures, validateTopDataValueDefaults(effective.TopData.ValueDefaults)...)
|
failures = append(failures, validateTopDataValueDefaults(effective.TopData.ValueDefaults)...)
|
||||||
|
failures = append(failures, validateTopDataRowGeneration(effective.TopData.RowGeneration)...)
|
||||||
failures = append(failures, validateTopDataClassFeatInjections(effective.TopData.ClassFeatInjections)...)
|
failures = append(failures, validateTopDataClassFeatInjections(effective.TopData.ClassFeatInjections)...)
|
||||||
failures = append(failures, validateRelativePath("scripts.cache", effective.Scripts.Cache)...)
|
failures = append(failures, validateRelativePath("scripts.cache", effective.Scripts.Cache)...)
|
||||||
failures = append(failures, validateRelativePath("scripts.source_dir", effective.Scripts.SourceDir)...)
|
failures = append(failures, validateRelativePath("scripts.source_dir", effective.Scripts.SourceDir)...)
|
||||||
@@ -805,6 +813,33 @@ func validateTopDataValueDefaults(defaults []TopDataValueDefaultConfig) []error
|
|||||||
return failures
|
return failures
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateTopDataRowGeneration(rules []TopDataRowGenerationConfig) []error {
|
||||||
|
failures := []error{}
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for index, rule := range rules {
|
||||||
|
prefix := fmt.Sprintf("topdata.row_generation[%d]", index)
|
||||||
|
dataset := strings.TrimSpace(rule.Dataset)
|
||||||
|
namespace := strings.TrimSpace(rule.Namespace)
|
||||||
|
if dataset == "" {
|
||||||
|
dataset = namespace
|
||||||
|
}
|
||||||
|
if dataset == "" {
|
||||||
|
failures = append(failures, fmt.Errorf("%s.dataset is required", prefix))
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(rule.Mode) {
|
||||||
|
case "", "after_base", "first_null_row":
|
||||||
|
default:
|
||||||
|
failures = append(failures, fmt.Errorf("%s.mode %q is not supported", prefix, rule.Mode))
|
||||||
|
}
|
||||||
|
key := filepath.ToSlash(dataset)
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
failures = append(failures, fmt.Errorf("%s duplicates an earlier dataset row generation rule", prefix))
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
}
|
||||||
|
return failures
|
||||||
|
}
|
||||||
|
|
||||||
func validateTopDataClassFeatInjections(config TopDataClassFeatInjectionConfig) []error {
|
func validateTopDataClassFeatInjections(config TopDataClassFeatInjectionConfig) []error {
|
||||||
failures := []error{}
|
failures := []error{}
|
||||||
for index, rule := range config.GlobalFeats {
|
for index, rule := range config.GlobalFeats {
|
||||||
|
|||||||
@@ -191,6 +191,34 @@ topdata:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEffectiveConfigIncludesTopDataRowGeneration(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeProjectFile(t, filepath.Join(root, ConfigFile), `
|
||||||
|
module:
|
||||||
|
name: Test Module
|
||||||
|
resref: testmod
|
||||||
|
topdata:
|
||||||
|
source: topdata
|
||||||
|
row_generation:
|
||||||
|
- namespace: portraits
|
||||||
|
mode: first_null_row
|
||||||
|
`)
|
||||||
|
|
||||||
|
proj, err := Load(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rules := proj.EffectiveConfig().TopData.RowGeneration
|
||||||
|
if len(rules) != 1 {
|
||||||
|
t.Fatalf("expected one topdata row generation rule, got %#v", rules)
|
||||||
|
}
|
||||||
|
got := rules[0]
|
||||||
|
if got.Dataset != "portraits" || got.Namespace != "portraits" || got.Mode != "first_null_row" {
|
||||||
|
t.Fatalf("unexpected topdata row generation rule: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEffectiveConfigIncludesTopDataClassFeatInjections(t *testing.T) {
|
func TestEffectiveConfigIncludesTopDataClassFeatInjections(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeProjectFile(t, filepath.Join(root, ConfigFile), `
|
writeProjectFile(t, filepath.Join(root, ConfigFile), `
|
||||||
@@ -252,6 +280,12 @@ func TestValidateLayoutRejectsInvalidTopDataValueEncodings(t *testing.T) {
|
|||||||
{Dataset: "racialtypes/core", Column: "ECL", Value: 1},
|
{Dataset: "racialtypes/core", Column: "ECL", Value: 1},
|
||||||
{Dataset: "", Column: "", Value: 0},
|
{Dataset: "", Column: "", Value: 0},
|
||||||
},
|
},
|
||||||
|
RowGeneration: []TopDataRowGenerationConfig{
|
||||||
|
{Dataset: "portraits", Mode: "first_null_row"},
|
||||||
|
{Dataset: "portraits", Mode: "after_base"},
|
||||||
|
{Namespace: "placeabletypes", Mode: "unsupported"},
|
||||||
|
{Mode: "first_null_row"},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -271,6 +305,9 @@ func TestValidateLayoutRejectsInvalidTopDataValueEncodings(t *testing.T) {
|
|||||||
"topdata.value_defaults[1] duplicates an earlier dataset/column default",
|
"topdata.value_defaults[1] duplicates an earlier dataset/column default",
|
||||||
"topdata.value_defaults[2].dataset is required",
|
"topdata.value_defaults[2].dataset is required",
|
||||||
"topdata.value_defaults[2].column is required",
|
"topdata.value_defaults[2].column is required",
|
||||||
|
"topdata.row_generation[1] duplicates an earlier dataset row generation rule",
|
||||||
|
"topdata.row_generation[2].mode \"unsupported\" is not supported",
|
||||||
|
"topdata.row_generation[3].dataset is required",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(text, want) {
|
if !strings.Contains(text, want) {
|
||||||
t.Fatalf("expected validation error %q, got %v", want, err)
|
t.Fatalf("expected validation error %q, got %v", want, err)
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ func mergeExpansionData(collected []nativeCollectedDataset) ([]nativeCollectedDa
|
|||||||
if existingID, exists := targetDS.LockData[key]; exists {
|
if existingID, exists := targetDS.LockData[key]; exists {
|
||||||
rowID = existingID
|
rowID = existingID
|
||||||
hasID = true
|
hasID = true
|
||||||
} else if existingID, exists := originalLockData[key]; exists {
|
} else if existingID, exists := originalLockData[key]; exists && targetDS.Dataset.RowGeneration != "first_null_row" {
|
||||||
rowID = existingID
|
rowID = existingID
|
||||||
hasID = true
|
hasID = true
|
||||||
targetDS.LockData[key] = existingID
|
targetDS.LockData[key] = existingID
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type nativeDataset struct {
|
|||||||
Columns []string
|
Columns []string
|
||||||
ValueEncodings map[string]project.TopDataValueEncodingConfig
|
ValueEncodings map[string]project.TopDataValueEncodingConfig
|
||||||
ValueDefaults map[string]any
|
ValueDefaults map[string]any
|
||||||
|
RowGeneration string
|
||||||
CompareReference bool
|
CompareReference bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +241,7 @@ func buildNativeUnchecked(p *project.Project, opts NativeBuildOptions, progress
|
|||||||
}
|
}
|
||||||
datasets = applyTopDataValueEncodings(datasets, p.Config.TopData.ValueEncodings)
|
datasets = applyTopDataValueEncodings(datasets, p.Config.TopData.ValueEncodings)
|
||||||
datasets = applyTopDataValueDefaults(datasets, p.Config.TopData.ValueDefaults)
|
datasets = applyTopDataValueDefaults(datasets, p.Config.TopData.ValueDefaults)
|
||||||
|
datasets = applyTopDataRowGeneration(datasets, p.Config.TopData.RowGeneration)
|
||||||
registryDatasets, err := collectGeneratedRegistryDatasets(dataDir)
|
registryDatasets, err := collectGeneratedRegistryDatasets(dataDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return BuildResult{}, err
|
return BuildResult{}, err
|
||||||
@@ -448,6 +450,30 @@ func applyTopDataValueDefaults(datasets []nativeDataset, defaults []project.TopD
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyTopDataRowGeneration(datasets []nativeDataset, rules []project.TopDataRowGenerationConfig) []nativeDataset {
|
||||||
|
if len(rules) == 0 {
|
||||||
|
return datasets
|
||||||
|
}
|
||||||
|
out := append([]nativeDataset(nil), datasets...)
|
||||||
|
for index := range out {
|
||||||
|
for _, rule := range rules {
|
||||||
|
datasetName := strings.TrimSpace(rule.Dataset)
|
||||||
|
if datasetName == "" {
|
||||||
|
datasetName = strings.TrimSpace(rule.Namespace)
|
||||||
|
}
|
||||||
|
if filepath.ToSlash(datasetName) != out[index].Name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mode := strings.TrimSpace(rule.Mode)
|
||||||
|
if mode == "" {
|
||||||
|
mode = "after_base"
|
||||||
|
}
|
||||||
|
out[index].RowGeneration = mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func nativeCompileGroup(datasetName string) string {
|
func nativeCompileGroup(datasetName string) string {
|
||||||
head, _, ok := strings.Cut(datasetName, "/")
|
head, _, ok := strings.Cut(datasetName, "/")
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -785,6 +811,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
|
|||||||
rowByID := map[int]map[string]any{}
|
rowByID := map[int]map[string]any{}
|
||||||
rowByKey := map[string]map[string]any{}
|
rowByKey := map[string]map[string]any{}
|
||||||
usedIDs := map[int]struct{}{}
|
usedIDs := map[int]struct{}{}
|
||||||
|
firstNullRowMode := dataset.RowGeneration == "first_null_row"
|
||||||
lockModified := false
|
lockModified := false
|
||||||
lockAdded := 0
|
lockAdded := 0
|
||||||
lockPruned := 0
|
lockPruned := 0
|
||||||
@@ -798,8 +825,28 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if firstNullRowMode {
|
||||||
for key, rowID := range lockData {
|
for key, rowID := range lockData {
|
||||||
if rowID <= baseBoundaryID {
|
if rowID <= baseBoundaryID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := baseRowKeys[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if explicitID, ok := explicitModuleIDs[key]; ok && explicitID == rowID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delete(lockData, key)
|
||||||
|
lockModified = true
|
||||||
|
lockPruned++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, rowID := range lockData {
|
||||||
|
if rowID <= baseBoundaryID {
|
||||||
|
if firstNullRowMode {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if _, ok := baseRowKeys[key]; ok {
|
if _, ok := baseRowKeys[key]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -823,8 +870,11 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: %w", dataset.Name, err)
|
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: %w", dataset.Name, err)
|
||||||
}
|
}
|
||||||
rows = append(rows, row)
|
|
||||||
rowID := row["id"].(int)
|
rowID := row["id"].(int)
|
||||||
|
if firstNullRowMode && isNativeAllocationNullRow(row, columns) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rows = append(rows, row)
|
||||||
if key, ok := row["key"].(string); ok && key != "" {
|
if key, ok := row["key"].(string); ok && key != "" {
|
||||||
if baseRowKeyCounts[key] > 1 {
|
if baseRowKeyCounts[key] > 1 {
|
||||||
if lockedID, ok := lockData[key]; !ok || lockedID != rowID {
|
if lockedID, ok := lockData[key]; !ok || lockedID != rowID {
|
||||||
@@ -843,9 +893,11 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
|
|||||||
}
|
}
|
||||||
usedIDs[rowID] = struct{}{}
|
usedIDs[rowID] = struct{}{}
|
||||||
}
|
}
|
||||||
|
if !firstNullRowMode {
|
||||||
for rowID := 0; rowID <= baseBoundaryID; rowID++ {
|
for rowID := 0; rowID <= baseBoundaryID; rowID++ {
|
||||||
usedIDs[rowID] = struct{}{}
|
usedIDs[rowID] = struct{}{}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
for _, rowID := range explicitModuleIDs {
|
for _, rowID := range explicitModuleIDs {
|
||||||
usedIDs[rowID] = struct{}{}
|
usedIDs[rowID] = struct{}{}
|
||||||
}
|
}
|
||||||
@@ -4457,6 +4509,18 @@ func ensureRowsExposeColumns(rows []map[string]any, columns []string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isNativeAllocationNullRow(row map[string]any, columns []string) bool {
|
||||||
|
if key, ok := row["key"].(string); ok && strings.TrimSpace(key) != "" && strings.TrimSpace(key) != nullValue {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, column := range columns {
|
||||||
|
if !isNullLikeValue(row[column]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func canonicalizeBaseRow(dataset nativeDataset, columns []string, raw map[string]any, index int) (map[string]any, error) {
|
func canonicalizeBaseRow(dataset nativeDataset, columns []string, raw map[string]any, index int) (map[string]any, error) {
|
||||||
rowID := index
|
rowID := index
|
||||||
if value, ok := raw["id"]; ok {
|
if value, ok := raw["id"]; ok {
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ func ValidateProject(p *project.Project) ValidationReport {
|
|||||||
validateTopPackageAssets(sourceDir, dataDir, &report)
|
validateTopPackageAssets(sourceDir, dataDir, &report)
|
||||||
validateNativeOutputCatalog(dataDir, &report)
|
validateNativeOutputCatalog(dataDir, &report)
|
||||||
validateNativeEntryKeyUniqueness(dataDir, &report)
|
validateNativeEntryKeyUniqueness(dataDir, &report)
|
||||||
validateNativeLockAllocation(dataDir, &report)
|
validateNativeLockAllocation(dataDir, p.EffectiveConfig().TopData.RowGeneration, &report)
|
||||||
validateGeneratedFeatFamilies(dataDir, &report)
|
validateGeneratedFeatFamilies(dataDir, &report)
|
||||||
validateItempropsRegistryGraph(dataDir, &report)
|
validateItempropsRegistryGraph(dataDir, &report)
|
||||||
if _, err := os.Stat(statePath); err == nil {
|
if _, err := os.Stat(statePath); err == nil {
|
||||||
@@ -273,7 +273,7 @@ func ValidateProject(p *project.Project) ValidationReport {
|
|||||||
_ = validateJSONFile(statePath, "state", &report)
|
_ = validateJSONFile(statePath, "state", &report)
|
||||||
}
|
}
|
||||||
if !report.HasErrors() {
|
if !report.HasErrors() {
|
||||||
validateNativeAuthoringWarnings(dataDir, &report)
|
validateNativeAuthoringWarnings(dataDir, p.EffectiveConfig().TopData.RowGeneration, &report)
|
||||||
validateNativeBuildability(p, &report)
|
validateNativeBuildability(p, &report)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1089,7 +1089,7 @@ func validateNativeEntryKeyUniqueness(dataDir string, report *ValidationReport)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateNativeLockAllocation(dataDir string, report *ValidationReport) {
|
func validateNativeLockAllocation(dataDir string, rowGeneration []project.TopDataRowGenerationConfig, report *ValidationReport) {
|
||||||
datasets, err := discoverNativeDatasets(dataDir)
|
datasets, err := discoverNativeDatasets(dataDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
report.Diagnostics = append(report.Diagnostics, Diagnostic{
|
report.Diagnostics = append(report.Diagnostics, Diagnostic{
|
||||||
@@ -1099,6 +1099,7 @@ func validateNativeLockAllocation(dataDir string, report *ValidationReport) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
datasets = applyTopDataRowGeneration(datasets, rowGeneration)
|
||||||
for _, dataset := range datasets {
|
for _, dataset := range datasets {
|
||||||
if dataset.Kind != nativeDatasetBase {
|
if dataset.Kind != nativeDatasetBase {
|
||||||
continue
|
continue
|
||||||
@@ -1118,6 +1119,8 @@ func validateNativeLockAllocation(dataDir string, report *ValidationReport) {
|
|||||||
}
|
}
|
||||||
baseBoundaryID := len(rawRows) - 1
|
baseBoundaryID := len(rawRows) - 1
|
||||||
baseKeys := map[string]struct{}{}
|
baseKeys := map[string]struct{}{}
|
||||||
|
nullBaseIDs := map[int]struct{}{}
|
||||||
|
columns, _ := parseColumns(baseObj, dataset.Name)
|
||||||
for index, raw := range rawRows {
|
for index, raw := range rawRows {
|
||||||
row, ok := raw.(map[string]any)
|
row, ok := raw.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -1133,6 +1136,12 @@ func validateNativeLockAllocation(dataDir string, report *ValidationReport) {
|
|||||||
if key, ok := row["key"].(string); ok && key != "" {
|
if key, ok := row["key"].(string); ok && key != "" {
|
||||||
baseKeys[key] = struct{}{}
|
baseKeys[key] = struct{}{}
|
||||||
}
|
}
|
||||||
|
if len(columns) > 0 {
|
||||||
|
canonical, err := canonicalizeBaseRow(dataset, columns, row, index)
|
||||||
|
if err == nil && isNativeAllocationNullRow(canonical, columns) {
|
||||||
|
nullBaseIDs[rowID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
explicitModuleIDs := map[string]int{}
|
explicitModuleIDs := map[string]int{}
|
||||||
for _, dir := range []string{dataset.ModulesDir, dataset.GeneratedDir} {
|
for _, dir := range []string{dataset.ModulesDir, dataset.GeneratedDir} {
|
||||||
@@ -1208,6 +1217,11 @@ func validateNativeLockAllocation(dataDir string, report *ValidationReport) {
|
|||||||
if rowID > baseBoundaryID {
|
if rowID > baseBoundaryID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if dataset.RowGeneration == "first_null_row" {
|
||||||
|
if _, ok := nullBaseIDs[rowID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
if _, ok := baseKeys[key]; ok {
|
if _, ok := baseKeys[key]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1226,11 +1240,12 @@ func validateNativeLockAllocation(dataDir string, report *ValidationReport) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateNativeAuthoringWarnings(dataDir string, report *ValidationReport) {
|
func validateNativeAuthoringWarnings(dataDir string, rowGeneration []project.TopDataRowGenerationConfig, report *ValidationReport) {
|
||||||
datasets, err := discoverNativeDatasets(dataDir)
|
datasets, err := discoverNativeDatasets(dataDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
datasets = applyTopDataRowGeneration(datasets, rowGeneration)
|
||||||
for _, dataset := range datasets {
|
for _, dataset := range datasets {
|
||||||
if dataset.Kind != nativeDatasetBase {
|
if dataset.Kind != nativeDatasetBase {
|
||||||
continue
|
continue
|
||||||
@@ -1256,6 +1271,7 @@ func validateDatasetOverrideTargetWarnings(dataset nativeDataset, report *Valida
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
columns, _ := parseColumns(baseObj, dataset.Name)
|
||||||
rowIDToKey := map[int]string{}
|
rowIDToKey := map[int]string{}
|
||||||
keyToID := map[string]int{}
|
keyToID := map[string]int{}
|
||||||
usedIDs := map[int]struct{}{}
|
usedIDs := map[int]struct{}{}
|
||||||
@@ -1270,6 +1286,12 @@ func validateDatasetOverrideTargetWarnings(dataset nativeDataset, report *Valida
|
|||||||
rowID = parsed
|
rowID = parsed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if dataset.RowGeneration == "first_null_row" && len(columns) > 0 {
|
||||||
|
canonical, err := canonicalizeBaseRow(dataset, columns, row, index)
|
||||||
|
if err == nil && isNativeAllocationNullRow(canonical, columns) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
usedIDs[rowID] = struct{}{}
|
usedIDs[rowID] = struct{}{}
|
||||||
if key, ok := row["key"].(string); ok && key != "" {
|
if key, ok := row["key"].(string); ok && key != "" {
|
||||||
rowIDToKey[rowID] = key
|
rowIDToKey[rowID] = key
|
||||||
|
|||||||
@@ -646,6 +646,120 @@ func TestSaveLockfilePreservesExistingOrderAndAppendsNewKeys(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildNativeAllocatesConfiguredDatasetsIntoFirstNullBaseRows(t *testing.T) {
|
||||||
|
root := testProjectRoot(t)
|
||||||
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits", "modules"))
|
||||||
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
||||||
|
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "base.json"), `{
|
||||||
|
"output": "portraits.2da",
|
||||||
|
"columns": ["BaseResRef", "Sex"],
|
||||||
|
"rows": [
|
||||||
|
{"id": 0, "key": "portraits:none", "BaseResRef": "****", "Sex": 4},
|
||||||
|
{"id": 1, "BaseResRef": "****", "Sex": "****"},
|
||||||
|
{"id": 2, "BaseResRef": null, "Sex": null},
|
||||||
|
{"id": 3, "key": "portraits:existing", "BaseResRef": "existing_", "Sex": 1}
|
||||||
|
]
|
||||||
|
}`+"\n")
|
||||||
|
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "lock.json"), `{
|
||||||
|
"portraits:none": 0,
|
||||||
|
"portraits:existing": 3,
|
||||||
|
"portraits:first": 4,
|
||||||
|
"portraits:second": 5
|
||||||
|
}`+"\n")
|
||||||
|
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "modules", "custom.json"), `{
|
||||||
|
"entries": {
|
||||||
|
"portraits:first": {"BaseResRef": "first_", "Sex": 4},
|
||||||
|
"portraits:second": {"BaseResRef": "second_", "Sex": 4}
|
||||||
|
}
|
||||||
|
}`+"\n")
|
||||||
|
|
||||||
|
proj := testProject(root)
|
||||||
|
proj.Config.TopData.RowGeneration = []project.TopDataRowGenerationConfig{
|
||||||
|
{Dataset: "portraits", Mode: "first_null_row"},
|
||||||
|
}
|
||||||
|
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildNativeWithOptions failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "portraits", "lock.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read lockfile: %v", err)
|
||||||
|
}
|
||||||
|
lockText := string(lockRaw)
|
||||||
|
if !strings.Contains(lockText, `"portraits:first": 1`) || !strings.Contains(lockText, `"portraits:second": 2`) {
|
||||||
|
t.Fatalf("expected new portrait locks to use first null base rows, got:\n%s", lockText)
|
||||||
|
}
|
||||||
|
outputRaw, err := os.ReadFile(filepath.Join(result.Output2DADir, "portraits.2da"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read portraits.2da: %v", err)
|
||||||
|
}
|
||||||
|
output := string(outputRaw)
|
||||||
|
for _, want := range []string{
|
||||||
|
"1\tfirst_\t4",
|
||||||
|
"2\tsecond_\t4",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(output, want) {
|
||||||
|
t.Fatalf("expected generated portraits row %q, got:\n%s", want, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(output, "4\tfirst_") || strings.Contains(output, "5\tsecond_") {
|
||||||
|
t.Fatalf("expected portraits not to allocate after the base boundary, got:\n%s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeExpansionDataAllocatesConfiguredDatasetsIntoFirstNullRows(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
lockPath := filepath.Join(root, "portraits-lock.json")
|
||||||
|
writeFile(t, lockPath, `{
|
||||||
|
"portraits:existing": 0,
|
||||||
|
"portraits:expanded": 5
|
||||||
|
}`+"\n")
|
||||||
|
|
||||||
|
collected, err := mergeExpansionData([]nativeCollectedDataset{
|
||||||
|
{
|
||||||
|
Dataset: nativeDataset{
|
||||||
|
Kind: nativeDatasetBase,
|
||||||
|
Name: "appearance",
|
||||||
|
OutputName: "appearance.2da",
|
||||||
|
RowGeneration: "after_base",
|
||||||
|
},
|
||||||
|
Columns: []string{"LABEL", "PORTRAIT"},
|
||||||
|
Rows: []map[string]any{{"id": 0, "key": "appearance:source", "PORTRAIT": map[string]any{"value": "po_expanded_", "data": map[string]any{"portraits": map[string]any{"key": "portraits:expanded", "BaseResRef": "expanded_", "Sex": 4}}}}},
|
||||||
|
LockData: map[string]int{"appearance:source": 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Dataset: nativeDataset{
|
||||||
|
Kind: nativeDatasetBase,
|
||||||
|
Name: "portraits",
|
||||||
|
LockPath: lockPath,
|
||||||
|
OutputName: "portraits.2da",
|
||||||
|
RowGeneration: "first_null_row",
|
||||||
|
},
|
||||||
|
Columns: []string{"BaseResRef", "Sex"},
|
||||||
|
Rows: []map[string]any{{"id": 0, "key": "portraits:existing", "BaseResRef": "existing_", "Sex": 4}},
|
||||||
|
LockData: map[string]int{"portraits:existing": 0},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mergeExpansionData failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
portraits := collected[1]
|
||||||
|
if got := portraits.LockData["portraits:expanded"]; got != 1 {
|
||||||
|
t.Fatalf("expected expansion lock to use first null row 1, got %d in %#v", got, portraits.LockData)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, row := range portraits.Rows {
|
||||||
|
if row["key"] == "portraits:expanded" && row["id"] == 1 {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("expected expanded row at id 1, got %#v", portraits.Rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolvedTableRegistryRejectsDuplicateKeys(t *testing.T) {
|
func TestResolvedTableRegistryRejectsDuplicateKeys(t *testing.T) {
|
||||||
_, err := newResolvedTableRegistry([]nativeCollectedDataset{
|
_, err := newResolvedTableRegistry([]nativeCollectedDataset{
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user