Migrate remaining configuration documents to ConfigKit

This commit is contained in:
2026-09-07 04:02:21 +00:00
parent 9e40c729e1
commit f0225e937c
13 changed files with 297 additions and 398 deletions

View File

@@ -3,12 +3,12 @@ package config
import (
"bytes"
"fmt"
"io"
"math"
"sort"
"strconv"
"strings"
"unicode"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gopkg.in/yaml.v3"
)
@@ -54,264 +54,141 @@ type PartyClass struct {
Level *int
}
// ParseParty strictly parses one supported party document.
type partyDocument struct {
SchemaVersion string `yaml:"schema_version"`
Characters map[string]partyCharacter `yaml:"characters"`
}
type partyCharacter struct {
Player partyPlayer `yaml:"player"`
Character partyCharacterDetails `yaml:"character"`
}
type partyPlayer struct {
Name string `yaml:"name"`
}
type partyCharacterDetails struct {
Name string `yaml:"name"`
Aliases []string `yaml:"alias"`
Classes []partyClass `yaml:"classes"`
}
type partyClass struct {
Name string `yaml:"name"`
Level *int `yaml:"level"`
}
type partyDomainError struct {
path configkit.Path
message string
}
// ParseParty strictly parses one supported party document through ConfigKit.
func ParseParty(data []byte) (*CanonicalParty, error) {
root, decoder, err := parsePartyFirstDocument(data)
if int64(len(data)) > maxConfigDocumentBytes {
return nil, fmt.Errorf("parse party config: party document exceeds %d-byte limit", maxConfigDocumentBytes)
}
definition := configkit.Definition[partyDocument]{
Version: &configkit.VersionRule{
Path: configkit.Keys("schema_version"),
Check: validatePartyVersion,
},
Validate: validatePartyDocument,
}
result, err := configkit.Load(configkit.Bytes("party.yml", data), configkit.Options[partyDocument]{Definition: definition})
if err != nil {
return nil, err
return nil, describeConfigKitLoadError("party", err)
}
if err := requireNoTrailingPartyDocuments(decoder); err != nil {
return nil, err
}
party, err := parseCanonicalParty(root, data)
if err != nil {
return nil, err
}
return party, nil
return canonicalPartyFromResult(result, data), nil
}
func parsePartyFirstDocument(data []byte) (*yaml.Node, *yaml.Decoder, error) {
decoder := yaml.NewDecoder(bytes.NewReader(data))
var document yaml.Node
if err := decoder.Decode(&document); err != nil {
if err == io.EOF {
return nil, nil, fmt.Errorf("party YAML document is empty")
}
return nil, nil, fmt.Errorf("decode party YAML: %w", err)
}
if document.Kind != yaml.DocumentNode || len(document.Content) != 1 {
return nil, nil, fmt.Errorf("party must contain one YAML document")
}
return document.Content[0], decoder, nil
}
func requireNoTrailingPartyDocuments(decoder *yaml.Decoder) error {
var trailing yaml.Node
if err := decoder.Decode(&trailing); err == nil {
return fmt.Errorf("party must contain exactly one YAML document")
} else if err != io.EOF {
return fmt.Errorf("decode trailing party YAML: %w", err)
func validatePartyVersion(version configkit.Version, validation *configkit.Validation) error {
path := configkit.Keys("schema_version")
if !version.Present || version.Null || version.Text == "" {
validation.Error(path, fmt.Sprintf("schema_version is required; supported schema_version is %q", PartySchemaVersion))
} else if version.Text != PartySchemaVersion {
validation.Error(path, fmt.Sprintf("schema_version is unsupported; supported schema_version is %q", PartySchemaVersion))
}
return nil
}
func parseCanonicalParty(root *yaml.Node, raw []byte) (*CanonicalParty, error) {
fields, err := partyMappingFields(root, "party")
if err != nil {
return nil, err
func validatePartyDocument(document *partyDocument, validation *configkit.Validation) {
if problem := firstPartyDomainError(document); problem != nil {
validation.Error(problem.path, problem.message)
}
}
version, ok := fields["schema_version"]
if !ok {
return nil, fmt.Errorf("party.schema_version is required; supported schema_version is %q", PartySchemaVersion)
func firstPartyDomainError(document *partyDocument) *partyDomainError {
charactersPath := configkit.Keys("characters")
if document == nil || len(document.Characters) == 0 {
return &partyDomainError{path: charactersPath, message: "characters must be non-empty"}
}
schemaVersion, err := partyRequiredString(map[string]*yaml.Node{"schema_version": version}, "schema_version", "party")
if err != nil {
return nil, fmt.Errorf("party.schema_version must be a non-empty string; supported schema_version is %q", PartySchemaVersion)
ids := make([]string, 0, len(document.Characters))
for id := range document.Characters {
ids = append(ids, id)
}
if schemaVersion != PartySchemaVersion {
return nil, fmt.Errorf("party.schema_version %q is unsupported; supported schema_version is %q", schemaVersion, PartySchemaVersion)
}
if err := partyKnownFields(fields, "party", "schema_version", "characters"); err != nil {
return nil, err
}
charactersNode, ok := fields["characters"]
if !ok {
return nil, fmt.Errorf("party.characters is required")
}
charactersFields, err := partyMappingFields(charactersNode, "party.characters")
if err != nil {
return nil, err
}
if len(charactersFields) == 0 {
return nil, fmt.Errorf("party.characters must be non-empty")
}
party := &CanonicalParty{Raw: append([]byte(nil), raw...)}
seenNames := make([]string, 0, len(charactersFields))
for _, id := range partyMappingOrder(charactersNode) {
entry, err := parsePartyCharacter(id, charactersFields[id], seenNames)
if err != nil {
return nil, err
sort.Strings(ids)
seenNames := make([]string, 0, len(ids)*2)
for _, id := range ids {
path := charactersPath.Key(id)
character := document.Characters[id]
if id != strings.TrimSpace(id) || !artifactpolicy.IsConfiguredKey(id) {
return &partyDomainError{path: path, message: "invalid character id"}
}
party.Characters = append(party.Characters, entry)
seenNames = append(seenNames, entry.Character.Name)
seenNames = append(seenNames, entry.Character.Aliases...)
}
return party, nil
}
func parsePartyCharacter(id string, node *yaml.Node, seenNames []string) (PartyCharacter, error) {
path := "party.characters." + id
if id != strings.TrimSpace(id) || !artifactpolicy.IsConfiguredKey(id) {
return PartyCharacter{}, fmt.Errorf("%s has invalid character id %q", path, id)
}
fields, err := partyMappingFields(node, path)
if err != nil {
return PartyCharacter{}, err
}
if err := partyKnownFields(fields, path, "player", "character"); err != nil {
return PartyCharacter{}, err
}
player, err := parsePartyPlayer(fields["player"], path+".player")
if err != nil {
return PartyCharacter{}, err
}
character, err := parsePartyCharacterDetails(fields["character"], path+".character")
if err != nil {
return PartyCharacter{}, err
}
if partyNameAmbiguous(character.Name, seenNames) {
return PartyCharacter{}, fmt.Errorf("%s name or alias %q is ambiguous", path, character.Name)
}
visibleNames := append(append([]string(nil), seenNames...), character.Name)
for _, name := range character.Aliases {
if partyNameAmbiguous(name, visibleNames) {
return PartyCharacter{}, fmt.Errorf("%s name or alias %q is ambiguous", path, name)
if err := validatePartyDisplayName(character.Player.Name); err != "" {
return &partyDomainError{path: path.Key("player").Key("name"), message: err}
}
visibleNames = append(visibleNames, name)
}
return PartyCharacter{ID: id, Player: player, Character: character}, nil
}
func parsePartyPlayer(node *yaml.Node, path string) (PartyPlayer, error) {
fields, err := partyMappingFields(node, path)
if err != nil {
return PartyPlayer{}, err
}
if err := partyKnownFields(fields, path, "name"); err != nil {
return PartyPlayer{}, err
}
name, err := partyRequiredDisplayString(fields, "name", path)
if err != nil {
return PartyPlayer{}, err
}
return PartyPlayer{Name: name}, nil
}
func parsePartyCharacterDetails(node *yaml.Node, path string) (PartyCharacterDetails, error) {
fields, err := partyMappingFields(node, path)
if err != nil {
return PartyCharacterDetails{}, err
}
if err := partyKnownFields(fields, path, "name", "alias", "classes"); err != nil {
return PartyCharacterDetails{}, err
}
name, err := partyRequiredDisplayString(fields, "name", path)
if err != nil {
return PartyCharacterDetails{}, err
}
aliases, err := partyAliases(fields["alias"], path+".alias")
if err != nil {
return PartyCharacterDetails{}, err
}
classes, err := partyClasses(fields["classes"], path+".classes")
if err != nil {
return PartyCharacterDetails{}, err
}
return PartyCharacterDetails{Name: name, Aliases: aliases, Classes: classes}, nil
}
func partyAliases(node *yaml.Node, path string) ([]string, error) {
if node == nil {
return nil, nil
}
if node.Kind != yaml.SequenceNode {
return nil, fmt.Errorf("%s must be a list", path)
}
aliases := make([]string, 0, len(node.Content))
for index, item := range node.Content {
alias, err := partyDisplayString(item, fmt.Sprintf("%s[%d]", path, index))
if err != nil {
return nil, err
if err := validatePartyDisplayName(character.Character.Name); err != "" {
return &partyDomainError{path: path.Key("character").Key("name"), message: err}
}
aliases = append(aliases, alias)
if partyNameAmbiguous(character.Character.Name, seenNames) {
return &partyDomainError{path: path.Key("character").Key("name"), message: "name or alias is ambiguous"}
}
visibleNames := append(append([]string(nil), seenNames...), character.Character.Name)
for index, alias := range character.Character.Aliases {
aliasPath := path.Key("character").Key("alias").Index(index)
if err := validatePartyDisplayName(alias); err != "" {
return &partyDomainError{path: aliasPath, message: err}
}
if partyNameAmbiguous(alias, visibleNames) {
return &partyDomainError{path: aliasPath, message: "name or alias is ambiguous"}
}
visibleNames = append(visibleNames, alias)
}
classesPath := path.Key("character").Key("classes")
if len(character.Character.Classes) == 0 {
return &partyDomainError{path: classesPath, message: "classes must be a non-empty list"}
}
classNames := make([]string, 0, len(character.Character.Classes))
for index, class := range character.Character.Classes {
classPath := classesPath.Index(index)
if err := validatePartyDisplayName(class.Name); err != "" {
return &partyDomainError{path: classPath.Key("name"), message: err}
}
if partyNameAmbiguous(class.Name, classNames) {
return &partyDomainError{path: classPath.Key("name"), message: "duplicate class"}
}
classNames = append(classNames, class.Name)
if class.Level != nil && *class.Level <= 0 {
return &partyDomainError{path: classPath.Key("level"), message: "level must be a positive integer"}
}
}
seenNames = visibleNames
}
return aliases, nil
return nil
}
func partyClasses(node *yaml.Node, path string) ([]PartyClass, error) {
if node == nil {
return nil, fmt.Errorf("%s is required", path)
}
if node.Kind != yaml.SequenceNode || len(node.Content) == 0 {
return nil, fmt.Errorf("%s must be a non-empty list", path)
}
classes := make([]PartyClass, 0, len(node.Content))
classNames := make([]string, 0, len(node.Content))
for index, item := range node.Content {
classPath := fmt.Sprintf("%s[%d]", path, index)
fields, err := partyMappingFields(item, classPath)
if err != nil {
return nil, err
}
if err := partyKnownFields(fields, classPath, "name", "level"); err != nil {
return nil, err
}
name, err := partyRequiredDisplayString(fields, "name", classPath)
if err != nil {
return nil, err
}
if partyNameAmbiguous(name, classNames) {
return nil, fmt.Errorf("%s has duplicate class %q", path, name)
}
classNames = append(classNames, name)
level, err := partyLevel(fields["level"], classPath+".level")
if err != nil {
return nil, err
}
classes = append(classes, PartyClass{Name: name, Level: level})
}
return classes, nil
}
func partyLevel(node *yaml.Node, path string) (*int, error) {
if node == nil {
return nil, nil
}
if node.Kind != yaml.ScalarNode || node.Tag != "!!int" {
return nil, fmt.Errorf("%s must be a positive integer", path)
}
level, err := strconv.Atoi(node.Value)
if err != nil || level <= 0 {
return nil, fmt.Errorf("%s must be a positive integer", path)
}
return &level, nil
}
func partyRequiredDisplayString(fields map[string]*yaml.Node, key, path string) (string, error) {
node, ok := fields[key]
if !ok {
return "", fmt.Errorf("%s.%s is required", path, key)
}
return partyDisplayString(node, path+"."+key)
}
func partyRequiredString(fields map[string]*yaml.Node, key, path string) (string, error) {
node, ok := fields[key]
if !ok {
return "", fmt.Errorf("%s.%s is required", path, key)
}
if node.Kind != yaml.ScalarNode || node.Tag != "!!str" || node.Value == "" {
return "", fmt.Errorf("%s.%s must be a non-empty string", path, key)
}
return node.Value, nil
}
func partyDisplayString(node *yaml.Node, path string) (string, error) {
if node == nil || node.Kind != yaml.ScalarNode || node.Tag != "!!str" {
return "", fmt.Errorf("%s must be a display string", path)
}
value := node.Value
func validatePartyDisplayName(value string) string {
if value == "" || strings.TrimSpace(value) != value {
return "", fmt.Errorf("%s must be non-empty and trimmed", path)
return "display name must be non-empty and trimmed"
}
for _, runeValue := range value {
if unicode.IsControl(runeValue) {
return "", fmt.Errorf("%s must not contain control characters", path)
return "display name must not contain control characters"
}
}
return value, nil
return ""
}
func partyNameAmbiguous(name string, existing []string) bool {
@@ -323,70 +200,45 @@ func partyNameAmbiguous(name string, existing []string) bool {
return false
}
func partyMappingFields(node *yaml.Node, path string) (map[string]*yaml.Node, error) {
if node == nil || node.Kind != yaml.MappingNode {
return nil, fmt.Errorf("%s must be a mapping", path)
func canonicalPartyFromResult(result *configkit.Result[partyDocument], raw []byte) *CanonicalParty {
type orderedCharacter struct {
id string
location configkit.Location
}
if len(node.Content)%2 != 0 {
return nil, fmt.Errorf("%s has an incomplete mapping", path)
}
fields := make(map[string]*yaml.Node, len(node.Content)/2)
for index := 0; index < len(node.Content); index += 2 {
key, value := node.Content[index], node.Content[index+1]
if key.Kind != yaml.ScalarNode || key.Tag != "!!str" {
return nil, fmt.Errorf("%s has a non-string field name", path)
metadata := result.Metadata()
ordered := make([]orderedCharacter, 0, len(result.Config.Characters))
for id := range result.Config.Characters {
entry := orderedCharacter{id: id, location: configkit.Location{Line: math.MaxInt}}
if info, ok := metadata.Lookup(configkit.Keys("characters", id)); ok && len(info.Origin.Locations) != 0 {
entry.location = info.Origin.Locations[0]
}
if key.Value == "" {
return nil, fmt.Errorf("%s has an empty field name", path)
}
if _, duplicate := fields[key.Value]; duplicate {
return nil, fmt.Errorf("%s has duplicate field %q", path, key.Value)
}
if partyContainsAlias(value) {
return nil, fmt.Errorf("%s.%s must not use YAML aliases", path, key.Value)
}
fields[key.Value] = value
ordered = append(ordered, entry)
}
return fields, nil
}
func partyMappingOrder(node *yaml.Node) []string {
keys := make([]string, 0, len(node.Content)/2)
for index := 0; index < len(node.Content); index += 2 {
keys = append(keys, node.Content[index].Value)
}
return keys
}
func partyKnownFields(fields map[string]*yaml.Node, path string, allowed ...string) error {
for name := range fields {
known := false
for _, candidate := range allowed {
if name == candidate {
known = true
break
}
sort.Slice(ordered, func(left, right int) bool {
if ordered[left].location.Line != ordered[right].location.Line {
return ordered[left].location.Line < ordered[right].location.Line
}
if !known {
return fmt.Errorf("%s has unknown field %q", path, name)
if ordered[left].location.Column != ordered[right].location.Column {
return ordered[left].location.Column < ordered[right].location.Column
}
}
return nil
}
func partyContainsAlias(node *yaml.Node) bool {
if node == nil {
return false
}
if node.Kind == yaml.AliasNode {
return true
}
for _, child := range node.Content {
if partyContainsAlias(child) {
return true
return ordered[left].id < ordered[right].id
})
party := &CanonicalParty{Raw: bytes.Clone(raw), Characters: make([]PartyCharacter, 0, len(ordered))}
for _, orderedCharacter := range ordered {
input := result.Config.Characters[orderedCharacter.id]
classes := make([]PartyClass, len(input.Character.Classes))
for index, class := range input.Character.Classes {
classes[index] = PartyClass{Name: class.Name, Level: class.Level}
}
party.Characters = append(party.Characters, PartyCharacter{
ID: orderedCharacter.id,
Player: PartyPlayer{Name: input.Player.Name},
Character: PartyCharacterDetails{
Name: input.Character.Name, Aliases: append([]string(nil), input.Character.Aliases...), Classes: classes,
},
})
}
return false
return party
}
// ClassSummary preserves the declaration order required by the party contract.
@@ -429,16 +281,12 @@ func (p *CanonicalParty) PlayersYAML() ([]byte, error) {
projection := struct {
SchemaVersion string `yaml:"schema_version"`
Players []projectionPlayer `yaml:"players"`
}{
SchemaVersion: PlayersSchemaVersion,
Players: make([]projectionPlayer, 0, len(characters)),
}
}{SchemaVersion: PlayersSchemaVersion, Players: make([]projectionPlayer, 0, len(characters))}
for _, character := range characters {
projection.Players = append(projection.Players, projectionPlayer{
Name: character.Player.Name,
Character: projectionCharacter{
ID: character.ID,
Name: character.Character.Name,
ID: character.ID, Name: character.Character.Name,
Aliases: append([]string(nil), character.Character.Aliases...),
},
})