aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--FIXES.md12
-rw-r--r--internal/admin/api_map.go2
-rw-r--r--internal/admin/api_room_insert_remove.go4
-rw-r--r--internal/admin/server.go37
-rw-r--r--internal/game/cmd_room_insert.go4
-rw-r--r--internal/game/cmd_room_remove.go4
-rw-r--r--internal/game/sys_safespot.go13
-rw-r--r--internal/item/item.go16
-rw-r--r--internal/net/server.go38
-rw-r--r--internal/object/object.go10
-rw-r--r--internal/tlscert/tlscert.go52
-rw-r--r--internal/validate/checks.go63
-rw-r--r--internal/validate/correct_audit_fixes_test.go69
-rw-r--r--internal/world/grid.go23
-rw-r--r--internal/world/grid_test.go2
15 files changed, 247 insertions, 102 deletions
diff --git a/FIXES.md b/FIXES.md
index b258eff..b4c9198 100644
--- a/FIXES.md
+++ b/FIXES.md
@@ -93,17 +93,23 @@ Nothing was staged or committed. The audit artifacts (`AUDIT.md`, `reports/`) ar
- **Refactor**: the `.journal` path is now `persistence.JournalDir(dataDir)` used by both the game and the admin API, so a future rename cannot leave one writer journaling to a directory startup recovery never scans.
- **Test infrastructure**: added a `make test-race` target (`go test -race ./... -timeout 300s`); the shared game harness now stops the Game owner goroutine on cleanup (previously every harness test leaked one); added the first WebSocket transport tests (`TestWebSocketCheckOrigin` unit-tests the origin boundary with a non-loopback peer, `TestWebSocketUpgradeAccepted` drives the `/ws` upgrade end-to-end).
+## Tranche 6 (this pass)
+
+- **Refactor**: `GridConflict.Kind` is now the typed `world.GridConflictKind` (`GridConflictTwist`/`GridConflictOverlap`), replacing the magic strings switched on in five packages.
+- **Refactor**: the duplicated self-signed cert generator is now `tlscert.SelfSigned(commonName)`, shared by the game and admin servers.
+- **Validation**: craft `type`/`subtype` is checked against `item.KnownCraftKeys` — an unknown key left the action set but never advancing (player stuck). Test: `TestValidateCraftBlocksRejectsUnknownType`.
+- **Validation**: the mob/safespot size vocabulary moved to `object.MobSizeRank`; `validateObjects` rejects an unknown `safespot.max_block_size` (silently disables cover) and out-of-range chances, and `validateMobCombatStats` rejects an unknown mob `size`. Test: `TestValidateObjectsRejectsUnknownSafespotSize`.
+
## Still open
- **Explicit undo snapshot-existence flag** (the current fix safely refuses an intentionally empty before-image).
- **Production "how many" batches vs units**.
-- **Craft `type`/`subtype` validator rule** and **safespot validation** (both need their vocabularies moved to a shared package so `validate` can see them without importing `game`).
- **Persistence** `Recover` mid-game misuse.
- **Casino**: craps Fire/ATS settlement deferral.
- **Admin**: symlink handling in the raw file API.
-- **Structural refactors** remaining from `AUDIT.md` §3–4 (`ui.Table` shrink consolidation, self-signed cert, `tableCore` for pai gow, typed `GridConflict.Kind`, `refs` struct in validate, `remapInt`/`remapScalar`).
+- **Structural refactors** remaining from `AUDIT.md` §3–4 (`ui.Table` shrink consolidation, `tableCore` for pai gow, `refs` struct in validate, `remapInt`/`remapScalar`).
- **Test infrastructure**: CI wiring for `test-race`, a master-tick harness option, and coverage for steal/gather/burn/talk.
## Suggested next step
-Tranche 6: the remaining structural refactors (`ui.Table`, cert, `tableCore` for pai gow, typed `GridConflict.Kind`) and the shared vocabularies for craft/safespot validation. Keep `make lint`, `go test ./...`, and `go test -race` green.
+Tranche 7: the remaining structural refactors (`ui.Table`, `tableCore` for pai gow, `refs` struct, `remapInt`/`remapScalar`) and the test-infrastructure work. Keep `make lint`, `go test ./...`, and `go test -race` green.
diff --git a/internal/admin/api_map.go b/internal/admin/api_map.go
index 231f52b..bd71f78 100644
--- a/internal/admin/api_map.go
+++ b/internal/admin/api_map.go
@@ -61,7 +61,7 @@ func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) {
}
return room, true
}, nil, nil, func(gc world.GridConflict) {
- if gc.Kind == "overlap" {
+ if gc.Kind == world.GridConflictOverlap {
log.Printf("map: grid overlap: room %d wants cell %v already held by %d (via %d->%s)",
gc.Target, gc.Want, gc.Occupier, gc.From, gc.Dir)
}
diff --git a/internal/admin/api_room_insert_remove.go b/internal/admin/api_room_insert_remove.go
index 36688dc..bbb4d7e 100644
--- a/internal/admin/api_room_insert_remove.go
+++ b/internal/admin/api_room_insert_remove.go
@@ -401,10 +401,10 @@ func (s *AdminServer) formatGridConflicts(cs []world.GridConflict, mode string)
var parts []string
for _, c := range cs {
switch c.Kind {
- case "twist":
+ case world.GridConflictTwist:
r, _ := s.world.LoadRoom(c.Target)
parts = append(parts, fmt.Sprintf("room %s would have an ambiguous grid position after %s", s.roomLabel(r, c.Target), verb))
- case "overlap":
+ case world.GridConflictOverlap:
tgt, _ := s.world.LoadRoom(c.Target)
occ, _ := s.world.LoadRoom(c.Occupier)
parts = append(parts, fmt.Sprintf("%s would cause a grid collision: room %s cannot be placed because room %s is already there", verb, s.roomLabel(tgt, c.Target), s.roomLabel(occ, c.Occupier)))
diff --git a/internal/admin/server.go b/internal/admin/server.go
index 800cfa1..9819924 100644
--- a/internal/admin/server.go
+++ b/internal/admin/server.go
@@ -7,11 +7,8 @@ import (
"bytes"
"crypto/hmac"
"crypto/rand"
- "crypto/rsa"
"crypto/sha256"
"crypto/tls"
- "crypto/x509"
- "crypto/x509/pkix"
"embed"
"encoding/hex"
"encoding/json"
@@ -21,7 +18,6 @@ import (
"io"
"io/fs"
"log"
- "math/big"
"net"
"net/http"
"os"
@@ -36,6 +32,7 @@ import (
"thehouseoficarus/internal/item"
"thehouseoficarus/internal/object"
"thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/tlscert"
"thehouseoficarus/internal/world"
)
@@ -832,37 +829,7 @@ func loadOrGenerateCert(cfg *config.Config) (tls.Certificate, error) {
}
func generateSelfSignedCert() (tls.Certificate, error) {
- key, err := rsa.GenerateKey(rand.Reader, 2048)
- if err != nil {
- return tls.Certificate{}, fmt.Errorf("rsa key generation: %w", err)
- }
- serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
- if err != nil {
- return tls.Certificate{}, fmt.Errorf("serial number: %w", err)
- }
- now := time.Now()
- tmpl := &x509.Certificate{
- SerialNumber: serial,
- Subject: pkix.Name{
- CommonName: "THOI Admin Self-Signed Certificate",
- },
- NotBefore: now.Add(-1 * time.Hour),
- NotAfter: now.Add(365 * 24 * time.Hour),
- KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
- ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
- BasicConstraintsValid: true,
- IsCA: true,
- DNSNames: []string{"localhost"},
- IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
- }
- certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
- if err != nil {
- return tls.Certificate{}, fmt.Errorf("x509 creation: %w", err)
- }
- return tls.Certificate{
- Certificate: [][]byte{certDER},
- PrivateKey: key,
- }, nil
+ return tlscert.SelfSigned("THOI Admin Self-Signed Certificate")
}
func writeJSON(w http.ResponseWriter, data any) {
diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go
index 09b2e74..8087cca 100644
--- a/internal/game/cmd_room_insert.go
+++ b/internal/game/cmd_room_insert.go
@@ -81,7 +81,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) {
if len(conflicts) > 0 {
for _, c := range conflicts {
switch c.Kind {
- case "twist":
+ case world.GridConflictTwist:
room, _ := g.World.LoadRoom(c.Target)
conflictName := fmt.Sprintf("#%d", c.Target)
if room != nil {
@@ -90,7 +90,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) {
sess.WriteLine(fmt.Sprintf(
"Cannot insert: room %s would have an ambiguous grid position after insertion (reachable via an alternate path from here).",
conflictName))
- case "overlap":
+ case world.GridConflictOverlap:
occRoom, _ := g.World.LoadRoom(c.Occupier)
occName := fmt.Sprintf("#%d", c.Occupier)
if occRoom != nil {
diff --git a/internal/game/cmd_room_remove.go b/internal/game/cmd_room_remove.go
index c84e747..d31f02d 100644
--- a/internal/game/cmd_room_remove.go
+++ b/internal/game/cmd_room_remove.go
@@ -138,7 +138,7 @@ func (g *Game) roomRemove(sess *net.Session, args []string) {
if len(conflicts) > 0 {
for _, c := range conflicts {
switch c.Kind {
- case "twist":
+ case world.GridConflictTwist:
room, _ := g.World.LoadRoom(c.Target)
name := fmt.Sprintf("#%d", c.Target)
if room != nil {
@@ -147,7 +147,7 @@ func (g *Game) roomRemove(sess *net.Session, args []string) {
sess.WriteLine(fmt.Sprintf(
"Cannot remove: pulling the far end would give room %s an ambiguous grid position.",
name))
- case "overlap":
+ case world.GridConflictOverlap:
occRoom, _ := g.World.LoadRoom(c.Occupier)
occName := fmt.Sprintf("#%d", c.Occupier)
if occRoom != nil {
diff --git a/internal/game/sys_safespot.go b/internal/game/sys_safespot.go
index 7876b38..539f69d 100644
--- a/internal/game/sys_safespot.go
+++ b/internal/game/sys_safespot.go
@@ -11,13 +11,6 @@ import (
"thehouseoficarus/internal/world"
)
-var sizeRank = map[string]int{
- "small": 1,
- "medium": 2,
- "large": 3,
- "massive": 4,
-}
-
func (g *Game) isSafespotted(playerName string) bool {
return g.safespot.IsActive(playerName)
}
@@ -34,10 +27,10 @@ func blocksMob(maxBlockSize, mobSize string) bool {
if maxBlockSize == "" {
return true
}
- max := sizeRank[maxBlockSize]
- mob := sizeRank[mobSize]
+ max := object.MobSizeRank[maxBlockSize]
+ mob := object.MobSizeRank[mobSize]
if mob == 0 {
- mob = sizeRank["medium"]
+ mob = object.MobSizeRank["medium"]
}
return mob <= max
}
diff --git a/internal/item/item.go b/internal/item/item.go
index c083d8e..6cb0e42 100644
--- a/internal/item/item.go
+++ b/internal/item/item.go
@@ -218,6 +218,22 @@ func (d *ItemDef) Stats() ItemStats {
type CraftList []CraftDef
+// KnownCraftKeys is the set of craft type/subtype names the production engine
+// understands, plus "combine" (which has its own path). A recipe whose type
+// and subtype are both outside this set produces a non-advancing action, so
+// the validator rejects it.
+var KnownCraftKeys = map[string]bool{
+ "combine": true,
+ "cooking": true,
+ "smelting": true,
+ "smithing": true,
+ "crafting": true,
+ "fletching": true,
+ "pharmacy": true,
+ "cleaning": true,
+ "construction": true,
+}
+
func (d *ItemDef) FirstCraft() *CraftDef {
if len(d.Craft) == 0 {
return nil
diff --git a/internal/net/server.go b/internal/net/server.go
index 8e23a1f..0854426 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -2,14 +2,9 @@ package net
import (
"context"
- "crypto/rand"
- "crypto/rsa"
"crypto/tls"
- "crypto/x509"
- "crypto/x509/pkix"
"fmt"
"log"
- "math/big"
"net"
"net/http"
"sort"
@@ -20,6 +15,7 @@ import (
"thehouseoficarus/internal/color"
"thehouseoficarus/internal/config"
"thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/tlscert"
)
type SessionState int
@@ -691,37 +687,7 @@ func loadOrGenerateCert(cfg *config.Config, service string) (tls.Certificate, er
}
func generateSelfSignedCert() (tls.Certificate, error) {
- key, err := rsa.GenerateKey(rand.Reader, 2048)
- if err != nil {
- return tls.Certificate{}, fmt.Errorf("rsa key generation: %w", err)
- }
- serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
- if err != nil {
- return tls.Certificate{}, fmt.Errorf("serial number: %w", err)
- }
- now := time.Now()
- template := &x509.Certificate{
- SerialNumber: serial,
- Subject: pkix.Name{
- CommonName: "THOI Self-Signed Certificate",
- },
- NotBefore: now.Add(-1 * time.Hour),
- NotAfter: now.Add(365 * 24 * time.Hour),
- KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
- ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
- BasicConstraintsValid: true,
- IsCA: true,
- DNSNames: []string{"localhost"},
- IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
- }
- certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
- if err != nil {
- return tls.Certificate{}, fmt.Errorf("x509 creation: %w", err)
- }
- return tls.Certificate{
- Certificate: [][]byte{certDER},
- PrivateKey: key,
- }, nil
+ return tlscert.SelfSigned("THOI Self-Signed Certificate")
}
func (s *Server) Hub() *Hub {
diff --git a/internal/object/object.go b/internal/object/object.go
index fc34e7b..376f4be 100644
--- a/internal/object/object.go
+++ b/internal/object/object.go
@@ -42,6 +42,16 @@ type SafespotConfig struct {
Levels []SafespotLevel `yaml:"levels"`
}
+// MobSizeRank orders the mob/safespot size vocabulary. A typo'd size falls
+// back to rank 0, which silently disables safespot cover, so the validator
+// checks membership. Shared so the game and validator cannot disagree.
+var MobSizeRank = map[string]int{
+ "small": 1,
+ "medium": 2,
+ "large": 3,
+ "massive": 4,
+}
+
type SafespotLevel struct {
Message string `yaml:"message"`
DegradeMessage string `yaml:"degrade_message"`
diff --git a/internal/tlscert/tlscert.go b/internal/tlscert/tlscert.go
new file mode 100644
index 0000000..b865a70
--- /dev/null
+++ b/internal/tlscert/tlscert.go
@@ -0,0 +1,52 @@
+// Package tlscert generates the in-memory self-signed certificate used when a
+// TLS transport is enabled without an explicit cert/key pair.
+package tlscert
+
+import (
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/tls"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "fmt"
+ "math/big"
+ "net"
+ "time"
+)
+
+// SelfSigned generates a self-signed certificate for commonName, valid for a
+// year, covering localhost and the loopback addresses. Shared by the game
+// server and the admin server so the two cannot drift.
+func SelfSigned(commonName string) (tls.Certificate, error) {
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ return tls.Certificate{}, fmt.Errorf("rsa key generation: %w", err)
+ }
+ serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
+ if err != nil {
+ return tls.Certificate{}, fmt.Errorf("serial number: %w", err)
+ }
+ now := time.Now()
+ tmpl := &x509.Certificate{
+ SerialNumber: serial,
+ Subject: pkix.Name{
+ CommonName: commonName,
+ },
+ NotBefore: now.Add(-1 * time.Hour),
+ NotAfter: now.Add(365 * 24 * time.Hour),
+ KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+ BasicConstraintsValid: true,
+ IsCA: true,
+ DNSNames: []string{"localhost"},
+ IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
+ }
+ certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
+ if err != nil {
+ return tls.Certificate{}, fmt.Errorf("x509 creation: %w", err)
+ }
+ return tls.Certificate{
+ Certificate: [][]byte{certDER},
+ PrivateKey: key,
+ }, nil
+}
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index 8fe8e72..c636b4f 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -15,6 +15,7 @@ import (
"thehouseoficarus/internal/color"
"thehouseoficarus/internal/combat"
"thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/object"
"thehouseoficarus/internal/world"
)
@@ -427,6 +428,13 @@ func validateMobCombatStats(id string, c *world.MobCombat) []Issue {
Message: fmt.Sprintf("Mob %q: stats.speed must be > 0 (0 is clipped to 1 tick, so the mob would attack every tick)", id),
})
}
+ if c.Size != "" && object.MobSizeRank[c.Size] == 0 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "config",
+ Message: fmt.Sprintf("Mob %q: size %q is not a known size (small/medium/large/massive); safespot cover would misjudge it", id, c.Size),
+ })
+ }
for _, t := range c.AttackTypes {
switch {
case combat.IsMeleeType(t):
@@ -759,6 +767,37 @@ func validateObjects(s Source) []Issue {
issues = append(issues, validateGather(fmt.Sprintf("Object %q: gather", id), obj.Gather, itemIDs, dropIDs)...)
}
+ if obj.Safespot != nil {
+ if obj.Safespot.MaxBlockSize != "" && object.MobSizeRank[obj.Safespot.MaxBlockSize] == 0 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "config",
+ Message: fmt.Sprintf("Object %q: safespot.max_block_size %q is not a known size (small/medium/large/massive); cover would be disabled", id, obj.Safespot.MaxBlockSize),
+ })
+ }
+ if obj.Safespot.MaxOccupants < 0 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "config",
+ Message: fmt.Sprintf("Object %q: safespot.max_occupants must be >= 0", id),
+ })
+ }
+ if obj.Safespot.UnsafeChance < 0 || obj.Safespot.UnsafeChance > 1 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "config",
+ Message: fmt.Sprintf("Object %q: safespot.unsafe_chance must be between 0 and 1", id),
+ })
+ }
+ if obj.Safespot.DecayChance < 0 || obj.Safespot.DecayChance > 1 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "config",
+ Message: fmt.Sprintf("Object %q: safespot.decay_chance must be between 0 and 1", id),
+ })
+ }
+ }
+
for i := range obj.Description {
if c := obj.Description[i].Condition; c != nil {
issues = append(issues, validateCondition(fmt.Sprintf("Object %q: description[%d]", id, i), c, roomIndex, itemIDs)...)
@@ -851,6 +890,8 @@ func validateCraftBlocks(s Source) []Issue {
// item id: every runtime consumer matches it against ItemDef.ToolType() via
// hasToolType. Types such as "pickaxe" or "big_net" have no same-named item
// file, so membership is checked against the types the item defs define.
+ // Capture the package-level set before the loop shadows `item`.
+ knownCraftKeys := item.KnownCraftKeys
toolTypes := make(map[string]bool)
for _, def := range allItems {
if t := def.ToolType(); t != "" {
@@ -870,6 +911,24 @@ func validateCraftBlocks(s Source) []Issue {
craftLabel = c.Type + "/" + c.Subtype
}
+ // The runtime selects the action by subtype (or type when no subtype
+ // is set), defaulting to combine. An unknown key leaves the action
+ // set but never advancing, so the player appears stuck.
+ cKey := c.Subtype
+ if cKey == "" {
+ cKey = c.Type
+ }
+ if cKey == "" {
+ cKey = "combine"
+ }
+ if !knownCraftKeys[cKey] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "config",
+ Message: fmt.Sprintf("Item %q (craft: %s): unknown type/subtype %q; the action would never advance", item.ID, craftLabel, cKey),
+ })
+ }
+
// SuccessChance clamps to Cap; an omitted cap makes the recipe
// fail every time (see craftFailureReachable, which would then
// mask it as an intentional always-fail recipe).
@@ -1491,7 +1550,7 @@ func validateRoomGrid(s Source) []Issue {
onConf := func(origin int) func(c world.GridConflict) {
return func(c world.GridConflict) {
switch c.Kind {
- case "twist":
+ case world.GridConflictTwist:
issues = append(issues, Issue{
Level: "ERROR",
Type: "integrity",
@@ -1499,7 +1558,7 @@ func validateRoomGrid(s Source) []Issue {
"Grid twist: room %d (via %d %s) maps to (%d,%d,%d) but was already placed at (%d,%d,%d) [origin %d]",
c.Target, c.From, c.Dir, c.Want[0], c.Want[1], c.Want[2], c.Existing[0], c.Existing[1], c.Existing[2], origin),
})
- case "overlap":
+ case world.GridConflictOverlap:
issues = append(issues, Issue{
Level: "ERROR",
Type: "integrity",
diff --git a/internal/validate/correct_audit_fixes_test.go b/internal/validate/correct_audit_fixes_test.go
index f11dd3f..c07ffcc 100644
--- a/internal/validate/correct_audit_fixes_test.go
+++ b/internal/validate/correct_audit_fixes_test.go
@@ -1,10 +1,15 @@
package validate
import (
+ "os"
+ "path/filepath"
"strings"
"testing"
"thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/object"
+ "thehouseoficarus/internal/world"
)
// Regression: a talk config with nodes but no "start" node is dead content at
@@ -32,6 +37,70 @@ func TestValidateTalkConfigRequiresStartNode(t *testing.T) {
}
}
+// Regression: an unknown craft type/subtype leaves the action set but never
+// advancing, so the player appears stuck; it must be an ERROR.
+func TestValidateCraftBlocksRejectsUnknownType(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "items"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "items", "thing.yaml"),
+ []byte("name: thing\ncraft:\n - type: smiting\n ingredients:\n - items: [ore]\n quantity: 1\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ src := Source{
+ DataDir: dir,
+ Items: item.NewItemStore(dir),
+ Objects: object.NewObjectStore(dir),
+ Mobs: world.NewMobStore(dir),
+ World: world.New(dir),
+ }
+ found := false
+ for _, issue := range validateCraftBlocks(src) {
+ if strings.Contains(issue.Message, "unknown type/subtype") {
+ found = true
+ if issue.Level != "ERROR" {
+ t.Fatalf("unknown craft type must be an ERROR: %+v", issue)
+ }
+ }
+ }
+ if !found {
+ t.Fatal("expected an ERROR for an unknown craft type/subtype")
+ }
+}
+
+// Regression: an unknown safespot max_block_size silently disables cover; it
+// must be an ERROR.
+func TestValidateObjectsRejectsUnknownSafespotSize(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "objects"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "objects", "rock.yaml"),
+ []byte("name: rock\nsafespot:\n max_block_size: huge\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ src := Source{
+ DataDir: dir,
+ Items: item.NewItemStore(dir),
+ Objects: object.NewObjectStore(dir),
+ Mobs: world.NewMobStore(dir),
+ World: world.New(dir),
+ }
+ found := false
+ for _, issue := range validateObjects(src) {
+ if strings.Contains(issue.Message, "max_block_size") {
+ found = true
+ if issue.Level != "ERROR" {
+ t.Fatalf("unknown safespot size must be an ERROR: %+v", issue)
+ }
+ }
+ }
+ if !found {
+ t.Fatal("expected an ERROR for an unknown safespot.max_block_size")
+ }
+}
+
// Regression: a gather drop with weight <= 0 can never be selected (the
// weighted roll has zero total); it must be flagged.
func TestValidateGatherWarnsOnWeightlessDrop(t *testing.T) {
diff --git a/internal/world/grid.go b/internal/world/grid.go
index 9773241..5229273 100644
--- a/internal/world/grid.go
+++ b/internal/world/grid.go
@@ -2,15 +2,22 @@
// their spawns, and ground items — the spatial state of Vesta.
package world
+// GridConflictKind identifies the kind of grid layout conflict.
+type GridConflictKind string
+
+const (
+ // GridConflictTwist: one room is forced onto two different grid cells (it
+ // was already placed at Existing but a later exit wants it at Want).
+ GridConflictTwist GridConflictKind = "twist"
+ // GridConflictOverlap: two distinct rooms want the same grid cell
+ // (Occupier already holds Want).
+ GridConflictOverlap GridConflictKind = "overlap"
+)
+
// GridConflict describes a layout conflict found while embedding rooms onto a
// 3D grid via their exit deltas.
-//
-// - "twist": one room is forced onto two different grid cells (it was already
-// placed at Existing but a later exit wants it at Want).
-// - "overlap": two distinct rooms want the same grid cell (Occupier already
-// holds Want).
type GridConflict struct {
- Kind string // "twist" or "overlap"
+ Kind GridConflictKind
From int // room whose exit produced the conflict
Dir ExitDir // exit direction taken from From
Target int // room being placed
@@ -76,7 +83,7 @@ func BuildGrid(seed int, load func(int) (*Room, bool), include func(int) bool, e
if existing, seen := g.Coord[target]; seen {
if existing != want && onConflict != nil {
onConflict(GridConflict{
- Kind: "twist", From: rid, Dir: dir,
+ Kind: GridConflictTwist, From: rid, Dir: dir,
Target: target, Want: want, Existing: existing,
})
}
@@ -85,7 +92,7 @@ func BuildGrid(seed int, load func(int) (*Room, bool), include func(int) bool, e
if occupier, used := g.RoomAt[want]; used && occupier != target {
if onConflict != nil {
onConflict(GridConflict{
- Kind: "overlap", From: rid, Dir: dir,
+ Kind: GridConflictOverlap, From: rid, Dir: dir,
Target: target, Want: want, Occupier: occupier,
})
}
diff --git a/internal/world/grid_test.go b/internal/world/grid_test.go
index 6cf53d0..3cbd463 100644
--- a/internal/world/grid_test.go
+++ b/internal/world/grid_test.go
@@ -28,7 +28,7 @@ func loadGridRoom(w *World) func(int) (*Room, bool) {
}
}
-func hasConflictKind(conflicts []GridConflict, kind string) bool {
+func hasConflictKind(conflicts []GridConflict, kind GridConflictKind) bool {
for _, c := range conflicts {
if c.Kind == kind {
return true