diff --git a/docs/internal/configuration.md b/docs/internal/configuration.md index ca89f82..2c2fc6e 100644 --- a/docs/internal/configuration.md +++ b/docs/internal/configuration.md @@ -52,6 +52,21 @@ download retain that exact pipeline object and its private provenance. Removing a temporary downloaded session file therefore cannot invalidate the resolved pipeline or campaign context. +## Canonical Party Domain + +`ParseParty` is the package-owned boundary for classifying a party source. +When a top-level `schema_version` is present, it strictly validates the +`narratio.party.v1` contract into ordered character domain values. The +canonical value retains a separate exact byte copy of its source so consumers +can materialize the authored party document without reserializing it. Its +`PlayersYAML` method deterministically derives the versioned players-only +projection. + +An unversioned source is classified by the small legacy compatibility boundary +in `party_legacy.go`; it deliberately exposes no parsed roster information. +That boundary exists solely to isolate removable compatibility behavior from +the canonical parser. + ## Diagnostics And Runtime Metadata Syntax, duplicate-key, composition, conflict, and schema failures include the @@ -86,3 +101,5 @@ effective-digest stability. Application configuration-loader tests protect the single-read boundary by changing the pipeline file after its initial load and confirming local session resolution retains the original pipeline. Other configuration tests continue to protect defaults and validation after assembly. +`party_test.go` protects the versioned party schema, domain invariants, and +deterministic players projection without involving campaign or runtime wiring. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 9fb748e..01b7bc6 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -599,7 +599,7 @@ as a cache key. ## Stage 10 — Canonical Party Domain And Players Projection -**Status: Pending** +**Status: Completed** ### Goal diff --git a/internal/config/party.go b/internal/config/party.go new file mode 100644 index 0000000..a23531e --- /dev/null +++ b/internal/config/party.go @@ -0,0 +1,494 @@ +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 +} diff --git a/internal/config/party_legacy.go b/internal/config/party_legacy.go new file mode 100644 index 0000000..598aaab --- /dev/null +++ b/internal/config/party_legacy.go @@ -0,0 +1,8 @@ +package config + +// classifyLegacyPartyDocument is the bounded compatibility path for an +// unversioned party source. It intentionally retains no parsed roster data and +// must be removed once legacy campaign inputs are no longer supported. +func classifyLegacyPartyDocument() *PartyDocument { + return &PartyDocument{Mode: PartyModeLegacy} +} diff --git a/internal/config/party_test.go b/internal/config/party_test.go new file mode 100644 index 0000000..24e835d --- /dev/null +++ b/internal/config/party_test.go @@ -0,0 +1,286 @@ +package config + +import ( + "bytes" + "strings" + "testing" +) + +func TestParsePartyCanonicalContract(t *testing.T) { + data := []byte(`schema_version: narratio.party.v1 +characters: + arannis: + player: + name: Eric + character: + name: Arannis + alias: + - Ari + - The Grey Owl + classes: + - name: wizard + level: 8 + - name: fighter +`) + + document, err := ParseParty(data) + if err != nil { + t.Fatalf("ParseParty() error = %v", err) + } + if !document.IsCanonical() || document.Mode != PartyModeCanonical { + t.Fatalf("document mode = %#v, want canonical", document) + } + if !bytes.Equal(document.Canonical.Raw, data) { + t.Fatal("canonical raw bytes were not preserved") + } + characters := document.Canonical.Characters + if len(characters) != 1 || characters[0].ID != "arannis" { + t.Fatalf("characters = %#v, want ordered arannis character", characters) + } + if got, want := characters[0].ClassSummary(), "wizard 8 / fighter"; got != want { + t.Fatalf("ClassSummary() = %q, want %q", got, want) + } + if got, want := characters[0].AliasSummary(), "Ari, The Grey Owl"; got != want { + t.Fatalf("AliasSummary() = %q, want %q", got, want) + } +} + +func TestParsePartyCanonicalRejectsInvalidDocuments(t *testing.T) { + valid := `schema_version: narratio.party.v1 +characters: + arannis: + player: + name: Eric + character: + name: Arannis + classes: + - name: wizard + level: 8 +` + tests := []struct { + name string + yaml string + want string + }{ + {name: "unsupported version", yaml: strings.Replace(valid, PartySchemaVersion, "narratio.party.v2", 1), want: "unsupported"}, + {name: "non-string version", yaml: strings.Replace(valid, PartySchemaVersion, "1", 1), want: "must be a non-empty string"}, + {name: "unknown top field", yaml: valid + "unknown: true\n", want: "unknown field"}, + {name: "unknown nested field", yaml: strings.Replace(valid, " name: Eric\n", " name: Eric\n role: GM\n", 1), want: "unknown field"}, + {name: "trailing document", yaml: valid + "---\ncharacters: {}\n", want: "exactly one YAML document"}, + {name: "empty characters", yaml: "schema_version: narratio.party.v1\ncharacters: {}\n", want: "must be non-empty"}, + {name: "invalid id", yaml: strings.Replace(valid, " arannis:", " bad-id:", 1), want: "invalid character id"}, + {name: "whitespace id", yaml: strings.Replace(valid, " arannis:", " ' arannis':", 1), want: "invalid character id"}, + {name: "missing player name", yaml: strings.Replace(valid, "player:\n name: Eric", "player: {}", 1), want: "player.name is required"}, + {name: "missing character name", yaml: strings.Replace(valid, " name: Arannis\n", "", 1), want: "character.name is required"}, + {name: "blank player name", yaml: strings.Replace(valid, "name: Eric", "name: ' Eric'", 1), want: "non-empty and trimmed"}, + {name: "control character", yaml: strings.Replace(valid, "name: Arannis", "name: \"Ara\\tnnis\"", 1), want: "control characters"}, + {name: "non-string display", yaml: strings.Replace(valid, "name: Eric", "name: 42", 1), want: "display string"}, + {name: "missing classes", yaml: strings.Replace(valid, " classes:\n - name: wizard\n level: 8\n", "", 1), want: "classes is required"}, + {name: "empty classes", yaml: strings.Replace(valid, " classes:\n - name: wizard\n level: 8\n", " classes: []\n", 1), want: "non-empty list"}, + {name: "zero level", yaml: strings.Replace(valid, "level: 8", "level: 0", 1), want: "positive integer"}, + {name: "negative level", yaml: strings.Replace(valid, "level: 8", "level: -2", 1), want: "positive integer"}, + {name: "non-integer level", yaml: strings.Replace(valid, "level: 8", "level: eight", 1), want: "positive integer"}, + {name: "duplicate class", yaml: strings.Replace(valid, " level: 8\n", " level: 8\n - name: WIZARD\n", 1), want: "duplicate class"}, + {name: "duplicate field", yaml: strings.Replace(valid, " name: Eric\n", " name: Eric\n name: Erin\n", 1), want: "duplicate field"}, + {name: "yaml alias", yaml: strings.Replace(strings.Replace(valid, " name: Eric", " name: &player Eric", 1), " name: Arannis", " name: *player", 1), want: "must not use YAML aliases"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := ParseParty([]byte(test.yaml)) + if err == nil { + t.Fatal("ParseParty() error = nil, want rejection") + } + if !strings.Contains(err.Error(), test.want) { + t.Fatalf("ParseParty() error = %q, want %q", err, test.want) + } + }) + } +} + +func TestParsePartyRejectsGlobalAliasAmbiguityWithUnicodeCaseFolding(t *testing.T) { + tests := []struct { + name string + yaml string + }{ + { + name: "alias equals primary on same character", + yaml: `schema_version: narratio.party.v1 +characters: + arannis: + player: {name: Eric} + character: + name: Arannis + alias: [arannis] + classes: [{name: wizard}] +`, + }, + { + name: "primary collides with prior alias", + yaml: `schema_version: narratio.party.v1 +characters: + arannis: + player: {name: Eric} + character: + name: Arannis + alias: [Ari] + classes: [{name: wizard}] + brenna: + player: {name: Jane} + character: + name: ari + classes: [{name: paladin}] +`, + }, + { + name: "aliases collide on one character", + yaml: `schema_version: narratio.party.v1 +characters: + arannis: + player: {name: Eric} + character: + name: Arannis + alias: [Ari, ari] + classes: [{name: wizard}] +`, + }, + { + name: "unicode simple fold collision", + yaml: `schema_version: narratio.party.v1 +characters: + kelvin: + player: {name: Eric} + character: + name: Kelvin + alias: [Knight] + classes: [{name: wizard}] + knight: + player: {name: Jane} + character: + name: knight + classes: [{name: paladin}] +`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := ParseParty([]byte(test.yaml)) + if err == nil || !strings.Contains(err.Error(), "ambiguous") { + t.Fatalf("ParseParty() error = %v, want ambiguity rejection", err) + } + }) + } +} + +func TestParsePartyAllowsRepeatedPlayersAndEmptyAliasList(t *testing.T) { + document, err := ParseParty([]byte(`schema_version: narratio.party.v1 +characters: + arannis: + player: {name: Eric} + character: + name: Arannis + alias: [] + classes: [{name: wizard}] + brenna: + player: {name: Eric} + character: + name: Brenna + classes: [{name: paladin}] +`)) + if err != nil { + t.Fatalf("ParseParty() error = %v", err) + } + if got := len(document.Canonical.Characters); got != 2 { + t.Fatalf("character count = %d, want 2", got) + } +} + +func TestParsePartyRejectsEmptyAlias(t *testing.T) { + _, err := ParseParty([]byte(`schema_version: narratio.party.v1 +characters: + arannis: + player: {name: Eric} + character: + name: Arannis + alias: [""] + classes: [{name: wizard}] +`)) + if err == nil || !strings.Contains(err.Error(), "non-empty") { + t.Fatalf("ParseParty() error = %v, want empty alias rejection", err) + } +} + +func TestParsePartyClassifiesUnversionedDocumentAsLegacy(t *testing.T) { + document, err := ParseParty([]byte("players:\n - not canonical\n---\nlegacy: remains opaque\n")) + if err != nil { + t.Fatalf("ParseParty() error = %v", err) + } + if document.Mode != PartyModeLegacy || document.Canonical != nil || document.IsCanonical() { + t.Fatalf("document = %#v, want opaque legacy classification", document) + } +} + +func TestCanonicalPartyPlayersYAML(t *testing.T) { + document, err := ParseParty([]byte(`schema_version: narratio.party.v1 +characters: + zeta: + player: {name: Shared Player} + character: + name: Zeta + alias: [Z, The Last] + classes: [{name: wizard, level: 8}] + alpha: + player: {name: Shared Player} + character: + name: Alpha + classes: [{name: ranger}] +`)) + if err != nil { + t.Fatalf("ParseParty() error = %v", err) + } + got, err := document.PlayersYAML() + if err != nil { + t.Fatalf("PlayersYAML() error = %v", err) + } + again, err := document.Canonical.PlayersYAML() + if err != nil { + t.Fatalf("PlayersYAML() repeat error = %v", err) + } + if !bytes.Equal(got, again) { + t.Fatalf("PlayersYAML() was not deterministic:\nfirst:\n%s\nsecond:\n%s", got, again) + } + want := `schema_version: narratio.players.v1 +players: + - name: Shared Player + character: + id: alpha + name: Alpha + - name: Shared Player + character: + id: zeta + name: Zeta + alias: + - Z + - The Last +` + if string(got) != want { + t.Fatalf("PlayersYAML() =\n%s\nwant:\n%s", got, want) + } + if !strings.HasSuffix(string(got), "\n") || strings.Contains(string(got), "classes") { + t.Fatalf("players projection leaked unsupported data: %q", got) + } +} + +func TestCanonicalPartyPlayersYAMLRequiresCanonicalParty(t *testing.T) { + var party *CanonicalParty + if _, err := party.PlayersYAML(); err == nil { + t.Fatal("PlayersYAML() error = nil, want canonical party requirement") + } + legacy, err := ParseParty([]byte("legacy: party\n")) + if err != nil { + t.Fatalf("ParseParty() legacy error = %v", err) + } + if _, err := legacy.PlayersYAML(); err == nil { + t.Fatal("legacy PlayersYAML() error = nil, want canonical party requirement") + } +}