package config import ( "fmt" "sort" "strings" "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" ) const ( partyCharactersFamilySource = "party.characters" characterIDToken = "{character_id}" ) var memberVariableSelectors = map[string]func(PartyCharacter) string{ "character_id": func(character PartyCharacter) string { return character.ID }, "player.name": func(character PartyCharacter) string { return character.Player.Name }, "character.name": func(character PartyCharacter) string { return character.Character.Name }, "character.class_summary": func(character PartyCharacter) string { return character.ClassSummary() }, "character.alias_summary": func(character PartyCharacter) string { return character.AliasSummary() }, } func retainArtifactFamilyDeclarations(cfg *PipelineConfig) { if cfg == nil || cfg.Scriptorium == nil || cfg.resolution == nil { return } cfg.resolution.artifactFamilies = cloneArtifactFamilyDefinitions(cfg.Scriptorium.ArtifactFamilies) } func expandPipelineArtifactFamilies(cfg *PipelineConfig, party ResolvedParty) error { if cfg == nil || cfg.Scriptorium == nil || cfg.resolution == nil || cfg.resolution.artifactFamiliesExpanded { return nil } families := cfg.resolution.artifactFamilies if len(families) == 0 { families = cloneArtifactFamilyDefinitions(cfg.Scriptorium.ArtifactFamilies) } if len(families) == 0 { return nil } if party.Mode != PartyModeCanonical || party.Canonical == nil { return fmt.Errorf("pipeline.scriptorium.artifact_families requires a canonical campaign party") } expanded := cloneArtifactDefinitions(cfg.Scriptorium.Artifacts) familyKeys := sortedFamilyKeys(families) for _, familyKey := range familyKeys { if !artifactpolicy.IsConfiguredKey(familyKey) { return fmt.Errorf("pipeline.scriptorium.artifact_families keys must match ^[a-z][a-z0-9_]*$") } if _, exists := expanded[familyKey]; exists { return fmt.Errorf("pipeline.scriptorium.artifact_families.%s collides with configured artifact key %q", familyKey, familyKey) } } characters := append([]PartyCharacter(nil), party.Canonical.Characters...) sort.Slice(characters, func(left, right int) bool { return characters[left].ID < characters[right].ID }) catalog := ArtifactFamilyCatalog{Families: make(map[string]ArtifactFamilyOrigin, len(familyKeys)), Members: map[string]ArtifactFamilyMemberOrigin{}} outputOwners := make(map[string]string, len(expanded)) for key, artifact := range expanded { if output := strings.TrimSpace(artifact.OutputPath); output != "" { outputOwners[output] = key } } for _, familyKey := range familyKeys { family := families[familyKey] prefix := "pipeline.scriptorium.artifact_families." + familyKey if strings.TrimSpace(family.ForEach) != partyCharactersFamilySource { return fmt.Errorf("%s.for_each must be %q", prefix, partyCharactersFamilySource) } if err := validateFamilyOutputPattern(prefix+".output_path_pattern", family.OutputPathPattern); err != nil { return err } familyOrigin := ArtifactFamilyOrigin{ Members: make([]string, 0, len(characters)), MemberDependencies: append([]string(nil), family.MemberDependencies...), Publish: cloneArtifactFamilyPublish(family.Publish), Source: artifactFamilySource(cfg, familyKey), } for _, character := range characters { key := familyKey + "_" + character.ID if !artifactpolicy.IsConfiguredKey(key) { return fmt.Errorf("%s generates invalid artifact key %q", prefix, key) } if _, exists := expanded[key]; exists { return fmt.Errorf("%s generates artifact key %q that collides with another artifact", prefix, key) } outputPath := strings.ReplaceAll(family.OutputPathPattern, characterIDToken, character.ID) if prior, exists := outputOwners[outputPath]; exists { return fmt.Errorf("%s.output_path_pattern generates output path %q already used by artifact %q", prefix, outputPath, prior) } vars, err := resolveFamilyVars(prefix, family, character) if err != nil { return err } expanded[key] = ScriptoriumArtifactConfig{ Enabled: family.Enabled, DependsOn: append([]string(nil), family.DependsOn...), RenderDebug: family.RenderDebug, PromptID: family.PromptID, ProfileID: family.ProfileID, OutputPath: outputPath, Timeout: family.Timeout, Inputs: cloneArtifactInputs(family.Inputs), Vars: vars, } outputOwners[outputPath] = key familyOrigin.Members = append(familyOrigin.Members, key) catalog.Members[key] = ArtifactFamilyMemberOrigin{Family: familyKey, CharacterID: character.ID, Source: familyOrigin.Source} } catalog.Families[familyKey] = familyOrigin } cfg.Scriptorium.Artifacts = expanded cfg.Scriptorium.ArtifactFamilies = nil cfg.familyCatalog = catalog cfg.resolution.artifactFamiliesExpanded = true if err := validateScriptorium(cfg.Scriptorium, cfg.Notarius); err != nil { return err } if err := recomputePipelineEffectiveDigest(cfg); err != nil { return fmt.Errorf("refresh expanded pipeline digest: %w", err) } return nil } func validateFamilyOutputPattern(field, pattern string) error { if strings.Count(pattern, characterIDToken) != 1 { return fmt.Errorf("%s must contain exactly one %s token", field, characterIDToken) } remainder := strings.ReplaceAll(pattern, characterIDToken, "") if strings.ContainsAny(remainder, "{}") { return fmt.Errorf("%s contains unsupported brace syntax", field) } return nil } func resolveFamilyVars(prefix string, family ScriptoriumArtifactFamilyConfig, character PartyCharacter) (map[string]any, error) { vars := make(map[string]any, len(family.Vars)+len(family.MemberVars)) for name, value := range family.Vars { vars[name] = value } for name, selector := range family.MemberVars { if strings.TrimSpace(name) == "" { return nil, fmt.Errorf("%s.member_vars keys must be non-empty", prefix) } if _, exists := vars[name]; exists { return nil, fmt.Errorf("%s.member_vars.%s conflicts with static vars", prefix, name) } resolve, ok := memberVariableSelectors[strings.TrimSpace(selector)] if !ok { return nil, fmt.Errorf("%s.member_vars.%s selector %q is unsupported", prefix, name, selector) } vars[name] = resolve(character) } return vars, nil } func sortedFamilyKeys(families map[string]ScriptoriumArtifactFamilyConfig) []string { keys := make([]string, 0, len(families)) for key := range families { keys = append(keys, key) } sort.Strings(keys) return keys } func artifactFamilySource(cfg *PipelineConfig, family string) string { if cfg == nil || cfg.resolution == nil { return "" } prefix := "scriptorium.artifact_families." + family + "." for _, ownership := range cfg.resolution.ownership { if strings.HasPrefix(ownership.path, prefix) && len(ownership.sources) > 0 { return ownership.sources[0] } } return pipelineDefaultOwnershipSource } func cloneArtifactDefinitions(in map[string]ScriptoriumArtifactConfig) map[string]ScriptoriumArtifactConfig { if len(in) == 0 { return map[string]ScriptoriumArtifactConfig{} } out := make(map[string]ScriptoriumArtifactConfig, len(in)) for key, artifact := range in { artifact.DependsOn = append([]string(nil), artifact.DependsOn...) artifact.Inputs = cloneArtifactInputs(artifact.Inputs) artifact.Vars = cloneArtifactVars(artifact.Vars) out[key] = artifact } return out } func cloneArtifactInputs(in map[string]ScriptoriumInputConfig) map[string]ScriptoriumInputConfig { if len(in) == 0 { return nil } out := make(map[string]ScriptoriumInputConfig, len(in)) for key, input := range in { out[key] = input } return out } func cloneArtifactVars(in map[string]any) map[string]any { if len(in) == 0 { return nil } out := make(map[string]any, len(in)) for key, value := range in { out[key] = value } return out } func cloneArtifactFamilyDefinitions(in map[string]ScriptoriumArtifactFamilyConfig) map[string]ScriptoriumArtifactFamilyConfig { if len(in) == 0 { return nil } out := make(map[string]ScriptoriumArtifactFamilyConfig, len(in)) for key, family := range in { family.DependsOn = append([]string(nil), family.DependsOn...) family.MemberDependencies = append([]string(nil), family.MemberDependencies...) family.Inputs = cloneArtifactInputs(family.Inputs) family.Vars = cloneArtifactVars(family.Vars) if len(family.MemberVars) > 0 { memberVars := make(map[string]string, len(family.MemberVars)) for name, selector := range family.MemberVars { memberVars[name] = selector } family.MemberVars = memberVars } family.Publish = cloneArtifactFamilyPublish(family.Publish) out[key] = family } return out } func cloneArtifactFamilyPublish(in *ScriptoriumArtifactFamilyPublishConfig) *ScriptoriumArtifactFamilyPublishConfig { if in == nil { return nil } copy := *in return © } func cloneArtifactFamilyCatalog(in ArtifactFamilyCatalog) ArtifactFamilyCatalog { out := ArtifactFamilyCatalog{Families: make(map[string]ArtifactFamilyOrigin, len(in.Families)), Members: make(map[string]ArtifactFamilyMemberOrigin, len(in.Members))} for key, family := range in.Families { family.Members = append([]string(nil), family.Members...) family.MemberDependencies = append([]string(nil), family.MemberDependencies...) family.Publish = cloneArtifactFamilyPublish(family.Publish) out.Families[key] = family } for key, member := range in.Members { out.Members[key] = member } return out }