aboutsummaryrefslogtreecommitdiff
path: root/internal/behavior
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-19 03:15:59 -0400
committerhistoria <[not public]>2026-09-19 03:15:59 -0400
commit0e60567a7a0addb9421769ee7be7853bfa8788cb (patch)
tree7645112db0e4eae95b8a83c1e4f9aab38bede655 /internal/behavior
parentcc33072eced3d5460512c09c42ec4531c807d7af (diff)
downloadthehouseoficarus-0e60567a7a0addb9421769ee7be7853bfa8788cb.tar.gz
refactor: simplifcation, change how rooms are stored in player yaml, remove legacy code
Diffstat (limited to 'internal/behavior')
-rw-r--r--internal/behavior/desc.go69
-rw-r--r--internal/behavior/desc_test.go86
-rw-r--r--internal/behavior/store.go34
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