Add canonical party domain and players projection

This commit is contained in:
2026-08-30 13:51:12 +00:00
parent 4e991fa21d
commit 7e4ceb3d48
5 changed files with 806 additions and 1 deletions

View File

@@ -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 a temporary downloaded session file therefore cannot invalidate the resolved
pipeline or campaign context. 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 ## Diagnostics And Runtime Metadata
Syntax, duplicate-key, composition, conflict, and schema failures include the 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 single-read boundary by changing the pipeline file after its initial load and
confirming local session resolution retains the original pipeline. Other confirming local session resolution retains the original pipeline. Other
configuration tests continue to protect defaults and validation after assembly. 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.

View File

@@ -599,7 +599,7 @@ as a cache key.
## Stage 10 — Canonical Party Domain And Players Projection ## Stage 10 — Canonical Party Domain And Players Projection
**Status: Pending** **Status: Completed**
### Goal ### Goal

494
internal/config/party.go Normal file
View File

@@ -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
}

View File

@@ -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}
}

View File

@@ -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: [night]
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")
}
}