diff options
Diffstat (limited to 'internal/behavior')
| -rw-r--r-- | internal/behavior/desc.go | 69 | ||||
| -rw-r--r-- | internal/behavior/desc_test.go | 86 | ||||
| -rw-r--r-- | internal/behavior/store.go | 34 |
3 files changed, 189 insertions, 0 deletions
diff --git a/internal/behavior/desc.go b/internal/behavior/desc.go index f6dbaf3..744b97e 100644 --- a/internal/behavior/desc.go +++ b/internal/behavior/desc.go @@ -1,8 +1,77 @@ package behavior +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + +// DescVariant is one alternative description, optionally gated by a condition. type DescVariant struct { Text string `yaml:"text"` Condition *Condition `yaml:"condition,omitempty"` } +// DescList is an ordered list of description variants. The first variant whose +// condition matches is shown. +// +// Content may author the common single, unconditional description two ways: +// as a bare scalar (`description: "A dark room."`) or as a list of variants +// (`description: [{text: "A dark room."}]`). MarshalYAML collapses a single +// unconditional variant back to the scalar form so machine-written room files +// stay close to what a human would type. type DescList []DescVariant + +func (d *DescList) UnmarshalYAML(value *yaml.Node) error { + switch value.Kind { + case yaml.ScalarNode: + var text string + if err := value.Decode(&text); err != nil { + return err + } + if text == "" { + *d = nil + return nil + } + *d = DescList{{Text: text}} + return nil + case yaml.SequenceNode: + out := make(DescList, 0, len(value.Content)) + for _, item := range value.Content { + // Accept a bare string element as shorthand for a variant. + if item.Kind == yaml.ScalarNode { + var text string + if err := item.Decode(&text); err != nil { + return err + } + out = append(out, DescVariant{Text: text}) + continue + } + var variant DescVariant + if err := item.Decode(&variant); err != nil { + return err + } + out = append(out, variant) + } + *d = out + return nil + case yaml.AliasNode: + return d.UnmarshalYAML(value.Alias) + case 0: + // An omitted/zero node leaves the list empty. + *d = nil + return nil + default: + return fmt.Errorf("description must be a string or a list of variants") + } +} + +func (d DescList) MarshalYAML() (any, error) { + if len(d) == 0 { + return []DescVariant{}, nil + } + if len(d) == 1 && d[0].Condition == nil { + return d[0].Text, nil + } + return []DescVariant(d), nil +} diff --git a/internal/behavior/desc_test.go b/internal/behavior/desc_test.go new file mode 100644 index 0000000..d506530 --- /dev/null +++ b/internal/behavior/desc_test.go @@ -0,0 +1,86 @@ +package behavior + +import ( + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestDescListScalarForm(t *testing.T) { + var wrap struct { + Description DescList `yaml:"description"` + } + if err := yaml.Unmarshal([]byte("description: \"A dark room.\"\n"), &wrap); err != nil { + t.Fatalf("scalar description failed to decode: %v", err) + } + if len(wrap.Description) != 1 || wrap.Description[0].Text != "A dark room." { + t.Fatalf("scalar decode = %#v", wrap.Description) + } +} + +func TestDescListSequenceForm(t *testing.T) { + src := "description:\n - text: \"Default\"\n - text: \"Lit\"\n condition:\n player_flag: lamp_lit\n" + var wrap struct { + Description DescList `yaml:"description"` + } + if err := yaml.Unmarshal([]byte(src), &wrap); err != nil { + t.Fatalf("sequence description failed to decode: %v", err) + } + if len(wrap.Description) != 2 { + t.Fatalf("got %d variants, want 2", len(wrap.Description)) + } + if wrap.Description[1].Condition == nil { + t.Fatal("conditional variant lost its condition") + } +} + +func TestDescListRoundTripPreservesCondition(t *testing.T) { + original := DescList{ + {Text: "Default"}, + {Text: "Lit", Condition: &Condition{PlayerFlag: "lamp_lit"}}, + } + data, err := yaml.Marshal(struct { + Description DescList `yaml:"description"` + }{Description: original}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + // A conditional list must not collapse to the scalar shorthand. + if strings.Contains(string(data), "description: Default") { + t.Fatalf("conditional list collapsed to scalar:\n%s", data) + } + var loaded struct { + Description DescList `yaml:"description"` + } + if err := yaml.Unmarshal(data, &loaded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(loaded.Description) != 2 || loaded.Description[1].Condition == nil { + t.Fatalf("round trip lost structure: %#v", loaded.Description) + } +} + +func TestDescListSingleVariantMarshalsAsScalar(t *testing.T) { + data, err := yaml.Marshal(struct { + Description DescList `yaml:"description"` + }{Description: DescList{{Text: "A dark room."}}}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(data), "description: A dark room.") { + t.Fatalf("single unconditional variant should use the scalar form:\n%s", data) + } +} + +func TestDescListEmptyStillMarshals(t *testing.T) { + data, err := yaml.Marshal(struct { + Description DescList `yaml:"description"` + }{}) + if err != nil { + t.Fatalf("marshal empty: %v", err) + } + if !strings.Contains(string(data), "description:") { + t.Fatalf("empty description should still emit a key:\n%s", data) + } +} diff --git a/internal/behavior/store.go b/internal/behavior/store.go index 4051836..f2e9350 100644 --- a/internal/behavior/store.go +++ b/internal/behavior/store.go @@ -12,12 +12,32 @@ import ( "gopkg.in/yaml.v3" ) +// excludedContentPath reports whether path lies under a directory or file +// whose name starts with "_", relative to root. That is the project's +// convention for work-in-progress content that must not be loaded, validated, +// indexed, or published while a builder is still authoring it. +func excludedContentPath(root, path string) bool { + rel, err := filepath.Rel(root, path) + if err != nil { + return false + } + for _, part := range strings.Split(rel, string(os.PathSeparator)) { + if strings.HasPrefix(part, "_") { + return true + } + } + return false +} + func BuildPathIndex(dir string) map[string]string { index := make(map[string]string) filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") { return nil } + if excludedContentPath(dir, path) { + return nil + } id := strings.TrimSuffix(d.Name(), ".yaml") index[id] = path return nil @@ -31,6 +51,9 @@ func BuildRoomIndex(dir string) map[int]string { if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") { return nil } + if excludedContentPath(dir, path) { + return nil + } id := strings.TrimSuffix(d.Name(), ".yaml") n, convErr := strconv.Atoi(id) if convErr != nil { @@ -39,6 +62,11 @@ func BuildRoomIndex(dir string) map[int]string { // skip it here and let validation flag it. return nil } + // Room IDs are positive; every other code path treats <= 0 as "no + // room". A 0.yaml/-1.yaml must not become a real room. + if n <= 0 { + return nil + } index[n] = path return nil }) @@ -59,6 +87,9 @@ func WalkYAMLDir(dir string, fn func(path, id string, data []byte) error) error if d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") { return nil } + if excludedContentPath(dir, path) { + return nil + } data, readErr := os.ReadFile(path) if readErr != nil { if os.IsNotExist(readErr) { @@ -82,6 +113,9 @@ func CheckDuplicateIDs(dir string) []Duplicate { if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") { return nil } + if excludedContentPath(dir, path) { + return nil + } id := strings.TrimSuffix(d.Name(), ".yaml") seen[id] = append(seen[id], path) return nil |
