diff options
| author | historia <[not public]> | 2026-10-05 19:20:17 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-10-05 19:20:17 -0400 |
| commit | 1091d0fae96d152c11aec1bb2dd949f51ed5aa1e (patch) | |
| tree | 0d867d42726cd3910c9851ec69e15b9a4b031001 /internal | |
| parent | 9d8c43cd68c960b9a6b8f781684f763e204a4498 (diff) | |
| download | thehouseoficarus-1091d0fae96d152c11aec1bb2dd949f51ed5aa1e.tar.gz | |
feat: shared tls cert, validation fixes
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/admin/api_map.go | 2 | ||||
| -rw-r--r-- | internal/admin/api_room_insert_remove.go | 4 | ||||
| -rw-r--r-- | internal/admin/server.go | 37 | ||||
| -rw-r--r-- | internal/game/cmd_room_insert.go | 4 | ||||
| -rw-r--r-- | internal/game/cmd_room_remove.go | 4 | ||||
| -rw-r--r-- | internal/game/sys_safespot.go | 13 | ||||
| -rw-r--r-- | internal/item/item.go | 16 | ||||
| -rw-r--r-- | internal/net/server.go | 38 | ||||
| -rw-r--r-- | internal/object/object.go | 10 | ||||
| -rw-r--r-- | internal/tlscert/tlscert.go | 52 | ||||
| -rw-r--r-- | internal/validate/checks.go | 63 | ||||
| -rw-r--r-- | internal/validate/correct_audit_fixes_test.go | 69 | ||||
| -rw-r--r-- | internal/world/grid.go | 23 | ||||
| -rw-r--r-- | internal/world/grid_test.go | 2 |
14 files changed, 238 insertions, 99 deletions
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 |
