package config import ( "bytes" "fmt" "math" "sort" "strings" "unicode" configkit "gitea.maximumdirect.net/eric/ConfigKit" "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" ) // 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 } 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) { 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, describeConfigKitLoadError("party", err) } return canonicalPartyFromResult(result, data), nil } 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 validatePartyDocument(document *partyDocument, validation *configkit.Validation) { if problem := firstPartyDomainError(document); problem != nil { validation.Error(problem.path, problem.message) } } 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"} } ids := make([]string, 0, len(document.Characters)) for id := range document.Characters { ids = append(ids, id) } 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"} } if err := validatePartyDisplayName(character.Player.Name); err != "" { return &partyDomainError{path: path.Key("player").Key("name"), message: err} } if err := validatePartyDisplayName(character.Character.Name); err != "" { return &partyDomainError{path: path.Key("character").Key("name"), message: err} } 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 nil } func validatePartyDisplayName(value string) string { if value == "" || strings.TrimSpace(value) != value { return "display name must be non-empty and trimmed" } for _, runeValue := range value { if unicode.IsControl(runeValue) { return "display name must not contain control characters" } } return "" } func partyNameAmbiguous(name string, existing []string) bool { for _, candidate := range existing { if strings.EqualFold(name, candidate) { return true } } return false } func canonicalPartyFromResult(result *configkit.Result[partyDocument], raw []byte) *CanonicalParty { type orderedCharacter struct { id string location configkit.Location } 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] } ordered = append(ordered, entry) } 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 ordered[left].location.Column != ordered[right].location.Column { return ordered[left].location.Column < ordered[right].location.Column } 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 party } // 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 }