package config import ( "bytes" "fmt" "io" "sort" "strconv" "strings" "unicode" "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" "gopkg.in/yaml.v3" ) const ( // PartySchemaVersion identifies the canonical campaign party document. PartySchemaVersion = "narratio.party.v1" // PlayersSchemaVersion identifies the derived players-only document. PlayersSchemaVersion = "narratio.players.v1" ) // PartyMode records whether a source document uses the canonical party // contract or the bounded compatibility path for older opaque files. type PartyMode string const ( PartyModeCanonical PartyMode = "canonical" PartyModeLegacy PartyMode = "legacy" ) // PartyDocument classifies a party input. Canonical is populated only for a // versioned canonical document; legacy contents intentionally remain opaque. type PartyDocument struct { Mode PartyMode Canonical *CanonicalParty } // IsCanonical reports whether the document has validated canonical data. func (d *PartyDocument) IsCanonical() bool { return d != nil && d.Mode == PartyModeCanonical && d.Canonical != nil } // PlayersYAML renders the derived players projection for a canonical document. func (d *PartyDocument) PlayersYAML() ([]byte, error) { if !d.IsCanonical() { return nil, fmt.Errorf("canonical party is required") } return d.Canonical.PlayersYAML() } // CanonicalParty is the normalized domain value for narratio.party.v1. Raw is // retained independently so later preparation can copy the authored party file // byte-for-byte rather than serializing normalized values. type CanonicalParty struct { Raw []byte Characters []PartyCharacter } // PartyCharacter is one ordered character declaration from a canonical party. type PartyCharacter struct { ID string Player PartyPlayer Character PartyCharacterDetails } // PartyPlayer identifies the player controlling a character. type PartyPlayer struct { Name string } // PartyCharacterDetails contains the player-facing character details. type PartyCharacterDetails struct { Name string Aliases []string Classes []PartyClass } // PartyClass represents one declared class. A missing Level is distinct from // an explicitly supplied level. type PartyClass struct { Name string Level *int } // ParseParty classifies and, when selected by schema_version, strictly parses // a party source. An unversioned source takes the deliberately opaque legacy // compatibility path; see party_legacy.go. func ParseParty(data []byte) (*PartyDocument, error) { root, decoder, err := parsePartyFirstDocument(data) if err != nil { return nil, err } if !partyDocumentSelectsCanonical(root) { return classifyLegacyPartyDocument(), nil } if err := requireNoTrailingPartyDocuments(decoder); err != nil { return nil, err } party, err := parseCanonicalParty(root, data) if err != nil { return nil, err } return &PartyDocument{Mode: PartyModeCanonical, Canonical: party}, 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 partyDocumentSelectsCanonical(root *yaml.Node) bool { if root == nil || root.Kind != yaml.MappingNode { return false } for index := 0; index+1 < len(root.Content); index += 2 { key := root.Content[index] if key.Kind == yaml.ScalarNode && key.Tag == "!!str" && key.Value == "schema_version" { return true } } return false } func requireNoTrailingPartyDocuments(decoder *yaml.Decoder) error { var trailing yaml.Node if err := decoder.Decode(&trailing); err == nil { return fmt.Errorf("canonical party must contain exactly one YAML document") } else if err != io.EOF { return fmt.Errorf("decode trailing canonical party YAML: %w", err) } return nil } func parseCanonicalParty(root *yaml.Node, raw []byte) (*CanonicalParty, error) { fields, err := partyMappingFields(root, "party") if err != nil { return nil, err } if err := partyKnownFields(fields, "party", "schema_version", "characters"); err != nil { return nil, err } version, err := partyRequiredString(fields, "schema_version", "party") if err != nil { return nil, err } if version != PartySchemaVersion { return nil, fmt.Errorf("party.schema_version %q is unsupported", version) } 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 } 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) } 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 } aliases = append(aliases, alias) } return aliases, 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 if value == "" || strings.TrimSpace(value) != value { return "", fmt.Errorf("%s must be non-empty and trimmed", path) } for _, runeValue := range value { if unicode.IsControl(runeValue) { return "", fmt.Errorf("%s must not contain control characters", path) } } return value, nil } func partyNameAmbiguous(name string, existing []string) bool { for _, candidate := range existing { if strings.EqualFold(name, candidate) { return true } } 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) } 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) } 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 } 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 } } if !known { return fmt.Errorf("%s has unknown field %q", path, name) } } 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 false } // ClassSummary preserves the declaration order required by the party contract. func (c PartyCharacter) ClassSummary() string { parts := make([]string, 0, len(c.Character.Classes)) for _, class := range c.Character.Classes { entry := class.Name if class.Level != nil { entry += fmt.Sprintf(" %d", *class.Level) } parts = append(parts, entry) } return strings.Join(parts, " / ") } // AliasSummary preserves the declaration order required by the party contract. func (c PartyCharacter) AliasSummary() string { return strings.Join(c.Character.Aliases, ", ") } // PlayersYAML renders the deterministic players-only projection of a // canonical party. It returns one final newline, as produced by yaml.Marshal. func (p *CanonicalParty) PlayersYAML() ([]byte, error) { if p == nil { return nil, fmt.Errorf("canonical party is required") } characters := append([]PartyCharacter(nil), p.Characters...) sort.Slice(characters, func(left, right int) bool { return characters[left].ID < characters[right].ID }) type projectionCharacter struct { ID string `yaml:"id"` Name string `yaml:"name"` Aliases []string `yaml:"alias,omitempty"` } type projectionPlayer struct { Name string `yaml:"name"` Character projectionCharacter `yaml:"character"` } projection := struct { SchemaVersion string `yaml:"schema_version"` Players []projectionPlayer `yaml:"players"` }{ 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, Aliases: append([]string(nil), character.Character.Aliases...), }, }) } encoded, err := yaml.Marshal(projection) if err != nil { return nil, fmt.Errorf("render players projection: %w", err) } return encoded, nil }