Add canonical party domain and players projection
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -599,7 +599,7 @@ as a cache key.
|
||||
|
||||
## Stage 10 — Canonical Party Domain And Players Projection
|
||||
|
||||
**Status: Pending**
|
||||
**Status: Completed**
|
||||
|
||||
### Goal
|
||||
|
||||
|
||||
494
internal/config/party.go
Normal file
494
internal/config/party.go
Normal 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
|
||||
}
|
||||
8
internal/config/party_legacy.go
Normal file
8
internal/config/party_legacy.go
Normal 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}
|
||||
}
|
||||
286
internal/config/party_test.go
Normal file
286
internal/config/party_test.go
Normal 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: [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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user