300 lines
10 KiB
Go
300 lines
10 KiB
Go
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
|
|
}
|