From 7d76673fa0707d204c7d084c8f90c8133c22a31a Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Mon, 29 Jun 2026 18:13:33 -0400 Subject: feat: migrated map (and bfs) to full 3D instead of individual 2D maps on different planes. --- AGENTS.md | 5 + data/rooms/intro/1005.yaml | 2 + data/rooms/intro/1009.yaml | 4 +- data/rooms/intro/1016.yaml | 21 +++ data/rooms/intro/1017.yaml | 15 ++ data/rooms/intro/1018.yaml | 15 ++ internal/game/cmd_dig.go | 55 ++---- internal/game/cmd_room_insert.go | 56 ++---- internal/game/command_queue.go | 134 -------------- internal/game/core_command_queue.go | 134 ++++++++++++++ internal/game/core_flagstore.go | 83 +++++++++ internal/game/core_object_def.go | 43 +++++ internal/game/core_validate.go | 53 ++++++ internal/game/craft_index.go | 234 ------------------------- internal/game/flagstore.go | 83 --------- internal/game/map_test.go | 69 +++++++- internal/game/object_def.go | 43 ----- internal/game/production_index.go | 234 +++++++++++++++++++++++++ internal/game/render_map.go | 339 +++++++++++------------------------- internal/game/safespot_manager.go | 85 --------- internal/game/sys_safespot_state.go | 85 +++++++++ internal/game/sys_triggers.go | 307 ++++++++++++++++++++++++++++++++ internal/game/triggers.go | 307 -------------------------------- internal/game/validate_source.go | 53 ------ internal/validate/checks.go | 102 ++++------- internal/validate/grid_test.go | 61 ++++++- internal/world/grid.go | 95 ++++++++++ internal/world/room.go | 20 ++- 28 files changed, 1395 insertions(+), 1342 deletions(-) create mode 100644 data/rooms/intro/1016.yaml create mode 100644 data/rooms/intro/1017.yaml create mode 100644 data/rooms/intro/1018.yaml delete mode 100644 internal/game/command_queue.go create mode 100644 internal/game/core_command_queue.go create mode 100644 internal/game/core_flagstore.go create mode 100644 internal/game/core_object_def.go create mode 100644 internal/game/core_validate.go delete mode 100644 internal/game/craft_index.go delete mode 100644 internal/game/flagstore.go delete mode 100644 internal/game/object_def.go create mode 100644 internal/game/production_index.go delete mode 100644 internal/game/safespot_manager.go create mode 100644 internal/game/sys_safespot_state.go create mode 100644 internal/game/sys_triggers.go delete mode 100644 internal/game/triggers.go delete mode 100644 internal/game/validate_source.go create mode 100644 internal/world/grid.go diff --git a/AGENTS.md b/AGENTS.md index 4f547ab..789b361 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,3 +165,8 @@ Full field reference in `item/item.go`. Key fields: equip_slot, weapon_type, att - **Tick subscriber ordering**: `AdvanceActions`, combat subscriptions, mob attack subscriptions, and hazard ticks are independent subscribers without priority guarantees. A trigger completing and a mob landing a hit on the same tick have non-deterministic ordering. +- **3D map BFS cost**: `buildGraph()` does a full 3D BFS across the entire reachable world on every + map render (`map`/`look`/move). For large worlds (10k+ rooms) this could become expensive. Options: + (a) cache the graph keyed by player room and invalidate on world edits (`dig`/`room insert`/`undig`), + (b) prune by visited rooms (revert to horizontal-only expansion from unvisited), or (c) compute z-level + assignments once at startup and use precomputed plane membership. diff --git a/data/rooms/intro/1005.yaml b/data/rooms/intro/1005.yaml index 10916eb..1d6f521 100644 --- a/data/rooms/intro/1005.yaml +++ b/data/rooms/intro/1005.yaml @@ -4,6 +4,8 @@ color: "" description: - text: A featureless room. exits: + northeast: + room: 1016 south: room: 1006 southeast: diff --git a/data/rooms/intro/1009.yaml b/data/rooms/intro/1009.yaml index 0cd7569..d95cdfd 100644 --- a/data/rooms/intro/1009.yaml +++ b/data/rooms/intro/1009.yaml @@ -1,9 +1,11 @@ -id: 0 +id: 1009 name: New Room color: "" description: - text: A featureless room. exits: + north: + room: 1016 southwest: room: 1006 objects: [] diff --git a/data/rooms/intro/1016.yaml b/data/rooms/intro/1016.yaml new file mode 100644 index 0000000..287af37 --- /dev/null +++ b/data/rooms/intro/1016.yaml @@ -0,0 +1,21 @@ +id: 1016 +name: New Room +color: "" +description: + - text: A featureless room. +exits: + down: + room: 1017 + south: + room: 1009 + southeast: + room: 1018 + southwest: + room: 1005 +objects: [] +item_spawns: [] +mobs: [] +on_enter: [] +hazard: "" +block_transport: false +triggers: [] diff --git a/data/rooms/intro/1017.yaml b/data/rooms/intro/1017.yaml new file mode 100644 index 0000000..358ba9e --- /dev/null +++ b/data/rooms/intro/1017.yaml @@ -0,0 +1,15 @@ +id: 0 +name: New Room +color: "" +description: + - text: A featureless room. +exits: + up: + room: 1016 +objects: [] +item_spawns: [] +mobs: [] +on_enter: [] +hazard: "" +block_transport: false +triggers: [] diff --git a/data/rooms/intro/1018.yaml b/data/rooms/intro/1018.yaml new file mode 100644 index 0000000..1309d65 --- /dev/null +++ b/data/rooms/intro/1018.yaml @@ -0,0 +1,15 @@ +id: 0 +name: New Room +color: "" +description: + - text: A featureless room. +exits: + northwest: + room: 1016 +objects: [] +item_spawns: [] +mobs: [] +on_enter: [] +hazard: "" +block_transport: false +triggers: [] diff --git a/internal/game/cmd_dig.go b/internal/game/cmd_dig.go index 653f045..0691b90 100644 --- a/internal/game/cmd_dig.go +++ b/internal/game/cmd_dig.go @@ -264,58 +264,31 @@ func findNextRoomID(currentRoomPath string) (int, error) { } -func (g *Game) buildGridFrom(fromRoomID int) (coord map[int][2]int, roomAt map[[2]int]int) { +func (g *Game) buildGridFrom(fromRoomID int) (coord map[int][3]int, roomAt map[[3]int]int) { roomIndex := g.World.RoomIndex() - coord = map[int][2]int{fromRoomID: {0, 0}} - roomAt = map[[2]int]int{{0, 0}: fromRoomID} - queue := []int{fromRoomID} - - for len(queue) > 0 { - rid := queue[0] - queue = queue[1:] - room, err := g.World.LoadRoom(rid) - if err != nil { - continue - } - c := coord[rid] - for _, ed := range world.ExitOrder { - exit, ok := room.Exits[ed] - if !ok || exit.Room <= 0 || !roomIndex[exit.Room] { - continue + rg := world.BuildGrid(fromRoomID, + func(id int) (*world.Room, bool) { + r, err := g.World.LoadRoom(id) + if err != nil { + return nil, false } - target := exit.Room - if ed == world.Up || ed == world.Down { - continue - } - gd, ok := world.DirectionDeltas[ed] - if !ok { - continue - } - want := [2]int{c[0] + gd[0], c[1] + gd[1]} - - if _, exists := coord[target]; exists { - continue - } - if occupier, exists := roomAt[want]; exists && occupier != target { - continue - } - coord[target] = want - roomAt[want] = target - queue = append(queue, target) - } - } - return coord, roomAt + return r, true + }, + func(id int) bool { return roomIndex[id] }, + nil, + ) + return rg.Coord, rg.RoomAt } func (g *Game) findGridConflict(fromRoomID int, dir world.ExitDir) int { - delta, ok := world.DirectionDeltas[dir] + delta, ok := world.DirectionDeltas3D[dir] if !ok { return 0 } _, roomAt := g.buildGridFrom(fromRoomID) - targetCoord := [2]int{delta[0], delta[1]} + targetCoord := [3]int{delta[0], delta[1], delta[2]} if occupier, ok := roomAt[targetCoord]; ok && occupier != fromRoomID { return occupier } diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go index 4fbcd10..6164e50 100644 --- a/internal/game/cmd_room_insert.go +++ b/internal/game/cmd_room_insert.go @@ -65,12 +65,16 @@ func (g *Game) roomInsert(sess *net.Session, args []string) { oppositeDir := world.OppositeExit[dir] - if delta, isHorizontal := world.DirectionDeltas[dir]; isHorizontal { + if delta3D, ok := world.DirectionDeltas3D[dir]; ok { coord, roomAt := g.buildGridFrom(p.RoomID) - sSet := g.bfsComponent(targetID, p.RoomID) + sSet := g.bfsReachable(targetID, func(_ int, _ world.ExitDir, target int) bool { + return target == p.RoomID + }) - withoutEdge := g.bfsWithoutEdge(p.RoomID, dir, targetID) + withoutEdge := g.bfsReachable(p.RoomID, func(rid int, d world.ExitDir, target int) bool { + return rid == p.RoomID && d == dir && target == targetID + }) for rid := range sSet { if withoutEdge[rid] { room, _ := g.World.LoadRoom(rid) @@ -90,7 +94,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) { continue } oldPos := coord[rid] - newPos := [2]int{oldPos[0] + delta[0], oldPos[1] + delta[1]} + newPos := [3]int{oldPos[0] + delta3D[0], oldPos[1] + delta3D[1], oldPos[2] + delta3D[2]} if occupier, ok := roomAt[newPos]; ok && !sSet[occupier] { occRoom, _ := g.World.LoadRoom(occupier) occName := fmt.Sprintf("#%d", occupier) @@ -221,7 +225,10 @@ func (g *Game) roomInsert(sess *net.Session, args []string) { g.checkAggro(sess) } -func (g *Game) bfsComponent(startID, excludeID int) map[int]bool { +// bfsReachable returns the set of rooms reachable from startID over the room +// exit graph, restricted to known rooms. skipEdge, when non-nil, prunes an +// individual directed exit (the edge from rid via dir to target) from the walk. +func (g *Game) bfsReachable(startID int, skipEdge func(rid int, dir world.ExitDir, target int) bool) map[int]bool { roomIndex := g.World.RoomIndex() visited := map[int]bool{startID: true} queue := []int{startID} @@ -234,51 +241,14 @@ func (g *Game) bfsComponent(startID, excludeID int) map[int]bool { continue } for _, ed := range world.ExitOrder { - if ed == world.Up || ed == world.Down { - continue - } exit, ok := room.Exits[ed] if !ok || exit.Room <= 0 || !roomIndex[exit.Room] { continue } target := exit.Room - if target == excludeID { - continue - } - if visited[target] { + if skipEdge != nil && skipEdge(rid, ed, target) { continue } - visited[target] = true - queue = append(queue, target) - } - } - return visited -} - -func (g *Game) bfsWithoutEdge(fromID int, skipDir world.ExitDir, skipTo int) map[int]bool { - roomIndex := g.World.RoomIndex() - visited := map[int]bool{fromID: true} - queue := []int{fromID} - - for len(queue) > 0 { - rid := queue[0] - queue = queue[1:] - room, err := g.World.LoadRoom(rid) - if err != nil { - continue - } - for _, ed := range world.ExitOrder { - if ed == world.Up || ed == world.Down { - continue - } - exit, ok := room.Exits[ed] - if !ok || exit.Room <= 0 || !roomIndex[exit.Room] { - continue - } - if rid == fromID && ed == skipDir && exit.Room == skipTo { - continue - } - target := exit.Room if visited[target] { continue } diff --git a/internal/game/command_queue.go b/internal/game/command_queue.go deleted file mode 100644 index 7afc75b..0000000 --- a/internal/game/command_queue.go +++ /dev/null @@ -1,134 +0,0 @@ -package game - -import ( - "sync" - "time" - - "thehouseoficarus/internal/net" -) - -// QueuedCommand represents a player command enqueued for next-tick execution. -type QueuedCommand struct { - Session *net.Session - Command string - Args string - Timestamp time.Time -} - -// CommandQueue manages the three per-player command queues (free, active, -// consume) with proper mutex protection. Free commands stack and execute in -// order; active commands replace each other (only one survives); consume -// commands are a single-slot eat/drink queue. All three are written from -// session goroutines and drained from the tick goroutine. -type CommandQueue struct { - mu sync.Mutex - freeQueue map[string][]QueuedCommand - activeQueue map[string]*QueuedCommand - consumeQueue map[string]*QueuedCommand -} - -func NewCommandQueue() *CommandQueue { - return &CommandQueue{ - freeQueue: make(map[string][]QueuedCommand), - activeQueue: make(map[string]*QueuedCommand), - consumeQueue: make(map[string]*QueuedCommand), - } -} - -// EnqueueFree appends a free command for the player. -func (q *CommandQueue) EnqueueFree(name string, qc QueuedCommand) { - q.mu.Lock() - defer q.mu.Unlock() - q.freeQueue[name] = append(q.freeQueue[name], qc) -} - -// EnqueueActive sets the active command for the player, replacing any prior. -func (q *CommandQueue) EnqueueActive(name string, qc QueuedCommand) { - q.mu.Lock() - defer q.mu.Unlock() - q.activeQueue[name] = &qc -} - -// EnqueueConsume sets the consume command for the player, replacing any prior. -func (q *CommandQueue) EnqueueConsume(name string, qc QueuedCommand) { - q.mu.Lock() - defer q.mu.Unlock() - q.consumeQueue[name] = &qc -} - -// DrainFree returns and clears the player's free commands. -func (q *CommandQueue) DrainFree(name string) []QueuedCommand { - q.mu.Lock() - defer q.mu.Unlock() - cmds := q.freeQueue[name] - delete(q.freeQueue, name) - return cmds -} - -// DrainActive returns all active commands sorted by timestamp (first-to-queue -// wins), then clears the map. -func (q *CommandQueue) DrainActive() []QueuedCommand { - q.mu.Lock() - defer q.mu.Unlock() - var actives []QueuedCommand - for _, qc := range q.activeQueue { - actives = append(actives, *qc) - } - q.activeQueue = make(map[string]*QueuedCommand) - return actives -} - -// PeekActive returns the active command for the player without removing it. -func (q *CommandQueue) PeekActive(name string) (*QueuedCommand, bool) { - q.mu.Lock() - defer q.mu.Unlock() - qc, ok := q.activeQueue[name] - return qc, ok -} - -// PeekFree returns a copy of the free commands for the player without removing. -func (q *CommandQueue) PeekFree(name string) []QueuedCommand { - q.mu.Lock() - defer q.mu.Unlock() - cmds := q.freeQueue[name] - out := make([]QueuedCommand, len(cmds)) - copy(out, cmds) - return out -} - -// DeleteActive removes the player's active command (used by `stop`). -func (q *CommandQueue) DeleteActive(name string) { - q.mu.Lock() - defer q.mu.Unlock() - delete(q.activeQueue, name) -} - -// ActivePositions returns the sorted names of all players with active commands, -// and the position (1-indexed) of the named player among them. Used by the -// `queued` command to show queue order. -func (q *CommandQueue) ActivePositions(name string) (names []string, pos int) { - q.mu.Lock() - defer q.mu.Unlock() - for n := range q.activeQueue { - names = append(names, n) - } - // sort names (caller may want them sorted) - return names, 0 -} - -// DrainConsume returns and removes the consume command for the player. -func (q *CommandQueue) DrainConsume(name string) (*QueuedCommand, bool) { - q.mu.Lock() - defer q.mu.Unlock() - qc, ok := q.consumeQueue[name] - delete(q.consumeQueue, name) - return qc, ok -} - -// ActiveCount returns the number of players with active commands. Used by the -// `queued` command to show position. -func (q *CommandQueue) ActiveCount() int { - q.mu.Lock() - defer q.mu.Unlock() - return len(q.activeQueue) -} diff --git a/internal/game/core_command_queue.go b/internal/game/core_command_queue.go new file mode 100644 index 0000000..7afc75b --- /dev/null +++ b/internal/game/core_command_queue.go @@ -0,0 +1,134 @@ +package game + +import ( + "sync" + "time" + + "thehouseoficarus/internal/net" +) + +// QueuedCommand represents a player command enqueued for next-tick execution. +type QueuedCommand struct { + Session *net.Session + Command string + Args string + Timestamp time.Time +} + +// CommandQueue manages the three per-player command queues (free, active, +// consume) with proper mutex protection. Free commands stack and execute in +// order; active commands replace each other (only one survives); consume +// commands are a single-slot eat/drink queue. All three are written from +// session goroutines and drained from the tick goroutine. +type CommandQueue struct { + mu sync.Mutex + freeQueue map[string][]QueuedCommand + activeQueue map[string]*QueuedCommand + consumeQueue map[string]*QueuedCommand +} + +func NewCommandQueue() *CommandQueue { + return &CommandQueue{ + freeQueue: make(map[string][]QueuedCommand), + activeQueue: make(map[string]*QueuedCommand), + consumeQueue: make(map[string]*QueuedCommand), + } +} + +// EnqueueFree appends a free command for the player. +func (q *CommandQueue) EnqueueFree(name string, qc QueuedCommand) { + q.mu.Lock() + defer q.mu.Unlock() + q.freeQueue[name] = append(q.freeQueue[name], qc) +} + +// EnqueueActive sets the active command for the player, replacing any prior. +func (q *CommandQueue) EnqueueActive(name string, qc QueuedCommand) { + q.mu.Lock() + defer q.mu.Unlock() + q.activeQueue[name] = &qc +} + +// EnqueueConsume sets the consume command for the player, replacing any prior. +func (q *CommandQueue) EnqueueConsume(name string, qc QueuedCommand) { + q.mu.Lock() + defer q.mu.Unlock() + q.consumeQueue[name] = &qc +} + +// DrainFree returns and clears the player's free commands. +func (q *CommandQueue) DrainFree(name string) []QueuedCommand { + q.mu.Lock() + defer q.mu.Unlock() + cmds := q.freeQueue[name] + delete(q.freeQueue, name) + return cmds +} + +// DrainActive returns all active commands sorted by timestamp (first-to-queue +// wins), then clears the map. +func (q *CommandQueue) DrainActive() []QueuedCommand { + q.mu.Lock() + defer q.mu.Unlock() + var actives []QueuedCommand + for _, qc := range q.activeQueue { + actives = append(actives, *qc) + } + q.activeQueue = make(map[string]*QueuedCommand) + return actives +} + +// PeekActive returns the active command for the player without removing it. +func (q *CommandQueue) PeekActive(name string) (*QueuedCommand, bool) { + q.mu.Lock() + defer q.mu.Unlock() + qc, ok := q.activeQueue[name] + return qc, ok +} + +// PeekFree returns a copy of the free commands for the player without removing. +func (q *CommandQueue) PeekFree(name string) []QueuedCommand { + q.mu.Lock() + defer q.mu.Unlock() + cmds := q.freeQueue[name] + out := make([]QueuedCommand, len(cmds)) + copy(out, cmds) + return out +} + +// DeleteActive removes the player's active command (used by `stop`). +func (q *CommandQueue) DeleteActive(name string) { + q.mu.Lock() + defer q.mu.Unlock() + delete(q.activeQueue, name) +} + +// ActivePositions returns the sorted names of all players with active commands, +// and the position (1-indexed) of the named player among them. Used by the +// `queued` command to show queue order. +func (q *CommandQueue) ActivePositions(name string) (names []string, pos int) { + q.mu.Lock() + defer q.mu.Unlock() + for n := range q.activeQueue { + names = append(names, n) + } + // sort names (caller may want them sorted) + return names, 0 +} + +// DrainConsume returns and removes the consume command for the player. +func (q *CommandQueue) DrainConsume(name string) (*QueuedCommand, bool) { + q.mu.Lock() + defer q.mu.Unlock() + qc, ok := q.consumeQueue[name] + delete(q.consumeQueue, name) + return qc, ok +} + +// ActiveCount returns the number of players with active commands. Used by the +// `queued` command to show position. +func (q *CommandQueue) ActiveCount() int { + q.mu.Lock() + defer q.mu.Unlock() + return len(q.activeQueue) +} diff --git a/internal/game/core_flagstore.go b/internal/game/core_flagstore.go new file mode 100644 index 0000000..ecefccf --- /dev/null +++ b/internal/game/core_flagstore.go @@ -0,0 +1,83 @@ +package game + +import "sync" + +// FlagChangeCallback is invoked when a world flag value actually changes +// (old value differs from new, or new flag is created with a truthy value). +type FlagChangeCallback func(name string, value any) + +// FlagStore holds world flags — shared mutable state visible to all players +// (e.g. opened doors, quest state). Player-specific flags live on +// *player.Player.Flags instead. FlagStore is safe for concurrent use. +type FlagStore struct { + mu sync.Mutex + flags map[string]any + callbacks []FlagChangeCallback +} + +func NewFlagStore() *FlagStore { + return &FlagStore{flags: make(map[string]any)} +} + +func (f *FlagStore) OnChange(cb FlagChangeCallback) { + f.mu.Lock() + defer f.mu.Unlock() + f.callbacks = append(f.callbacks, cb) +} + +func (f *FlagStore) Get(name string) (any, bool) { + f.mu.Lock() + defer f.mu.Unlock() + v, ok := f.flags[name] + return v, ok +} + +func (f *FlagStore) Set(name string, value any) { + f.mu.Lock() + old, existed := f.flags[name] + f.flags[name] = value + cbs := f.callbacks + f.mu.Unlock() + + if !existed || !valuesEqual(old, value) { + for _, cb := range cbs { + cb(name, value) + } + } +} + +func (f *FlagStore) SetAll(m map[string]any) { + f.mu.Lock() + changed := make(map[string]any) + for k, v := range m { + old, existed := f.flags[k] + f.flags[k] = v + if !existed || !valuesEqual(old, v) { + changed[k] = v + } + } + cbs := f.callbacks + f.mu.Unlock() + + for k, v := range changed { + for _, cb := range cbs { + cb(k, v) + } + } +} + +func (f *FlagStore) Delete(name string) { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.flags, name) +} + +func (f *FlagStore) All() map[string]any { + f.mu.Lock() + defer f.mu.Unlock() + out := make(map[string]any, len(f.flags)) + for k, v := range f.flags { + out[k] = v + } + return out +} diff --git a/internal/game/core_object_def.go b/internal/game/core_object_def.go new file mode 100644 index 0000000..6cf2464 --- /dev/null +++ b/internal/game/core_object_def.go @@ -0,0 +1,43 @@ +package game + +import "thehouseoficarus/internal/object" + +// resolveObjectDef returns the ObjectDef for a room object instance. File +// objects (the common case) resolve through the cached object store with no +// disk I/O on a hit, and a store miss is just a map lookup; only local objects +// — which never live in the store — fall through to a freshly-read room, so +// they keep live-edit semantics without slowing file-object lookups. defID is +// the ObjState DefID (a normalized name for local objects, a file id +// otherwise). +// +// Use this from display or generic-handling sites. Sites that look up a +// specific interactable behavior (gather/use-station/safespot/steal/etc.) may +// call ObjectStore.Load directly: local objects are guaranteed non-interactable +// and simply fall through those sites' existing error guards. +func (g *Game) resolveObjectDef(roomID int, defID string) (*object.ObjectDef, bool, error) { + def, err := g.ObjectStore.Load(defID) + if err == nil { + if room, rerr := g.World.LoadRoom(roomID); rerr == nil { + for i := range room.Objects { + ro := &room.Objects[i] + if ro.Local == nil && ro.ID == defID && ro.HiddenOverride { + copy := *def + copy.Hidden = true + return ©, false, nil + } + } + } + return def, false, nil + } + if room, rerr := g.World.LoadRoom(roomID); rerr == nil { + for i := range room.Objects { + ro := &room.Objects[i] + if ro.Local != nil && ro.ID == defID { + d := *ro.Local + d.ID = defID + return &d, true, nil + } + } + } + return nil, false, err +} diff --git a/internal/game/core_validate.go b/internal/game/core_validate.go new file mode 100644 index 0000000..4a2e5b9 --- /dev/null +++ b/internal/game/core_validate.go @@ -0,0 +1,53 @@ +package game + +import ( + "thehouseoficarus/internal/validate" +) + +// ValidateAndLog runs all startup integrity checks and logs the results. +func (g *Game) ValidateAndLog() { + validate.LogIssues(validate.Run(g.validationSource())) +} + +// validationSource assembles the read-only view of all data stores that the +// validate package needs, including the game-defined tech and course tables. +func (g *Game) validationSource() validate.Source { + courses := g.CourseStore.AllCourses() + courseViews := make([]validate.CourseView, 0, len(courses)) + for id, cfg := range courses { + rooms := make([]int, 0, len(cfg.Obstacles)) + for _, obs := range cfg.Obstacles { + rooms = append(rooms, obs.RoomID) + } + courseViews = append(courseViews, validate.CourseView{ + ID: id, + StartRoom: cfg.StartRoom, + ObstacleRooms: rooms, + }) + } + + techViews := make([]validate.TechView, 0, len(AllTechs)) + techIDs := make(map[string]bool, len(AllTechs)) + for _, t := range AllTechs { + techViews = append(techViews, validate.TechView{ + ID: t.ID, + Name: t.Name, + Category: t.Category, + DrainRate: t.DrainRate, + }) + techIDs[t.ID] = true + } + + return validate.Source{ + DataDir: g.DataDir, + Items: g.ItemStore, + Objects: g.ObjectStore, + Mobs: g.MobStore, + World: g.World, + Courses: courseViews, + Techs: techViews, + TechIDs: techIDs, + RootRooms: g.ValidationConfig.RootRooms, + IgnoreUnreachable: g.ValidationConfig.IgnoreUnreachable, + } +} diff --git a/internal/game/craft_index.go b/internal/game/craft_index.go deleted file mode 100644 index 648ccc8..0000000 --- a/internal/game/craft_index.go +++ /dev/null @@ -1,234 +0,0 @@ -package game - -import ( - "sync" - - "thehouseoficarus/internal/item" -) - -type CraftIndex struct { - mu sync.RWMutex - byType map[string][]*item.ItemDef - byInput map[string][]*item.ItemDef - byStation map[string][]*item.ItemDef -} - -func NewCraftIndex() *CraftIndex { - return &CraftIndex{ - byType: make(map[string][]*item.ItemDef), - byInput: make(map[string][]*item.ItemDef), - byStation: make(map[string][]*item.ItemDef), - } -} - -func (idx *CraftIndex) Build(items []*item.ItemDef) { - idx.mu.Lock() - defer idx.mu.Unlock() - - idx.byType = make(map[string][]*item.ItemDef) - idx.byInput = make(map[string][]*item.ItemDef) - idx.byStation = make(map[string][]*item.ItemDef) - - for _, def := range items { - if len(def.Craft) == 0 { - continue - } - for ci := range def.Craft { - craft := &def.Craft[ci] - t := craft.Type - if t == "" { - t = "combine" - } - idx.byType[t] = appendUnique(idx.byType[t], def) - - for _, e := range craft.Consume { - for _, id := range e.Items { - idx.byInput[id] = appendUnique(idx.byInput[id], def) - } - } - - for _, sid := range craft.Station { - idx.byStation[sid] = appendUnique(idx.byStation[sid], def) - } - } - } -} - -func appendUnique(items []*item.ItemDef, item *item.ItemDef) []*item.ItemDef { - for _, existing := range items { - if existing == item { - return items - } - } - return append(items, item) -} - -func (idx *CraftIndex) ByType(craftType string) []*item.ItemDef { - idx.mu.RLock() - defer idx.mu.RUnlock() - items := idx.byType[craftType] - out := make([]*item.ItemDef, len(items)) - copy(out, items) - return out -} - -func (idx *CraftIndex) ByStation(stationDefID string) []*item.ItemDef { - idx.mu.RLock() - defer idx.mu.RUnlock() - items := idx.byStation[stationDefID] - out := make([]*item.ItemDef, len(items)) - copy(out, items) - return out -} - -func (idx *CraftIndex) ByInput(itemID string) []*item.ItemDef { - idx.mu.RLock() - defer idx.mu.RUnlock() - items := idx.byInput[itemID] - out := make([]*item.ItemDef, len(items)) - copy(out, items) - return out -} - -func (idx *CraftIndex) FindByTwoInputs(itemA, itemB string) []*item.ItemDef { - idx.mu.RLock() - defer idx.mu.RUnlock() - - candidatesA := idx.byInput[itemA] - if len(candidatesA) == 0 { - return nil - } - - candidateSetB := make(map[string]bool) - for _, def := range idx.byInput[itemB] { - candidateSetB[def.ID] = true - } - - var results []*item.ItemDef - for _, def := range candidatesA { - if !candidateSetB[def.ID] { - continue - } - for _, craft := range def.Craft { - entryA := craftEntry(craft, itemA) - entryB := craftEntry(craft, itemB) - if entryA >= 0 && entryB >= 0 && entryA != entryB { - results = append(results, def) - break - } - } - } - return results -} - -func (idx *CraftIndex) FindByStationInput(stationDefID, itemID string) []*item.ItemDef { - idx.mu.RLock() - defer idx.mu.RUnlock() - - var results []*item.ItemDef - for _, def := range idx.byStation[stationDefID] { - for _, craft := range def.Craft { - if craftMatchesEntry(&craft, itemID) { - results = append(results, def) - break - } - } - } - return results -} - -func craftEntryFor(def *item.ItemDef, itemID string) int { - for _, craft := range def.Craft { - if ei := craftEntry(craft, itemID); ei >= 0 { - return ei - } - } - return -1 -} - -func craftEntry(craft item.CraftDef, itemID string) int { - for ei, e := range craft.Consume { - for _, id := range e.Items { - if id == itemID { - return ei - } - } - } - return -1 -} - -func craftMatchesEntry(c *item.CraftDef, itemID string) bool { - if c == nil { - return false - } - for _, e := range c.Consume { - for _, id := range e.Items { - if id == itemID { - return true - } - } - } - return false -} - -func craftHasAllItems(c *item.CraftDef, hasItem func(string) bool) bool { - if c == nil { - return false - } - for _, e := range c.Consume { - found := false - for _, id := range e.Items { - if hasItem(id) { - found = true - break - } - } - if !found { - return false - } - } - return true -} - -func craftHasAllItemsQty(c *item.CraftDef, countItem func(string) int) bool { - if c == nil { - return false - } - for _, e := range c.Consume { - found := false - for _, id := range e.Items { - qty := e.Quantity - if qty <= 0 { - qty = 1 - } - if countItem(id) >= qty { - found = true - break - } - } - if !found { - return false - } - } - return true -} - -func craftConsumeAll(c *item.CraftDef, hasItem func(string) bool, removeItem func(string, int) bool) bool { - if c == nil { - return false - } - for _, e := range c.Consume { - found := false - for _, id := range e.Items { - if hasItem(id) { - removeItem(id, e.Quantity) - found = true - break - } - } - if !found { - return false - } - } - return true -} diff --git a/internal/game/flagstore.go b/internal/game/flagstore.go deleted file mode 100644 index ecefccf..0000000 --- a/internal/game/flagstore.go +++ /dev/null @@ -1,83 +0,0 @@ -package game - -import "sync" - -// FlagChangeCallback is invoked when a world flag value actually changes -// (old value differs from new, or new flag is created with a truthy value). -type FlagChangeCallback func(name string, value any) - -// FlagStore holds world flags — shared mutable state visible to all players -// (e.g. opened doors, quest state). Player-specific flags live on -// *player.Player.Flags instead. FlagStore is safe for concurrent use. -type FlagStore struct { - mu sync.Mutex - flags map[string]any - callbacks []FlagChangeCallback -} - -func NewFlagStore() *FlagStore { - return &FlagStore{flags: make(map[string]any)} -} - -func (f *FlagStore) OnChange(cb FlagChangeCallback) { - f.mu.Lock() - defer f.mu.Unlock() - f.callbacks = append(f.callbacks, cb) -} - -func (f *FlagStore) Get(name string) (any, bool) { - f.mu.Lock() - defer f.mu.Unlock() - v, ok := f.flags[name] - return v, ok -} - -func (f *FlagStore) Set(name string, value any) { - f.mu.Lock() - old, existed := f.flags[name] - f.flags[name] = value - cbs := f.callbacks - f.mu.Unlock() - - if !existed || !valuesEqual(old, value) { - for _, cb := range cbs { - cb(name, value) - } - } -} - -func (f *FlagStore) SetAll(m map[string]any) { - f.mu.Lock() - changed := make(map[string]any) - for k, v := range m { - old, existed := f.flags[k] - f.flags[k] = v - if !existed || !valuesEqual(old, v) { - changed[k] = v - } - } - cbs := f.callbacks - f.mu.Unlock() - - for k, v := range changed { - for _, cb := range cbs { - cb(k, v) - } - } -} - -func (f *FlagStore) Delete(name string) { - f.mu.Lock() - defer f.mu.Unlock() - delete(f.flags, name) -} - -func (f *FlagStore) All() map[string]any { - f.mu.Lock() - defer f.mu.Unlock() - out := make(map[string]any, len(f.flags)) - for k, v := range f.flags { - out[k] = v - } - return out -} diff --git a/internal/game/map_test.go b/internal/game/map_test.go index ebb2197..712202b 100644 --- a/internal/game/map_test.go +++ b/internal/game/map_test.go @@ -325,7 +325,23 @@ func TestBuildFullMap(t *testing.T) { func TestStripBlankRows(t *testing.T) { input := []string{" ", "hello", "", " world ", " "} - want := []string{"hello", " world "} + want := []string{"hello", "", " world "} + got := stripBlankRows(input) + if len(got) != len(want) { + t.Fatalf("expected %d lines, got %d", len(want), len(got)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("line %d: want %q, got %q", i, want[i], got[i]) + } + } +} + +func TestStripBlankRowsPreservesInternal(t *testing.T) { + // Internal blank rows (between non-blank content) should be preserved. + // They represent real vertical distance between disconnected components. + input := []string{" ", "hello", "", "", "world", " "} + want := []string{"hello", "", "", "world"} got := stripBlankRows(input) if len(got) != len(want) { t.Fatalf("expected %d lines, got %d", len(want), len(got)) @@ -374,3 +390,54 @@ func TestLeftTrimCommonZero(t *testing.T) { t.Errorf("line 1: want %q, got %q", "world", got[1]) } } + +func TestMap3DDisconnectedComponent(t *testing.T) { + // Tower 1 (0,0,0) has down→bridge (0,0,-1). bridge→east→tower2base (1,0,-1). + // tower2base→up→tower2 (1,0,0). Tower 2 is at the same z=0 as tower 1 but + // unreachable via horizontal exits. The 3D map should still show it. + dir := t.TempDir() + writeTempRoom(t, dir, 1, "name: Tower One\nexits:\n down: 2\n") + writeTempRoom(t, dir, 2, "name: Bridge\nexits:\n east: 3\n up: 1\n") + writeTempRoom(t, dir, 3, "name: Tower Two Base\nexits:\n up: 4\n west: 2\n") + writeTempRoom(t, dir, 4, "name: Tower Two\nexits:\n down: 3\n") + + g := &Game{Deps: Deps{World: world.New(dir)}, Flags: NewFlagStore()} + mg := mapGlyphsForPlayer(false) + + // Player has only visited tower 1. Tower 2 should appear but dimmed (unvisited). + sess := &net.Session{Player: &player.Player{ + Stats: player.PlayerStats{RoomsVisited: map[int]bool{1: true}}, + Flags: map[string]any{}, + }} + + out := strings.Join(buildTinyMap(g, sess, 1, mg), "\n") + + // Tower 2 at (1,0,0) should appear on the same z-level map. + // Unvisited rooms render as □ (unicode) or o (ASCII). + if !strings.Contains(out, "□") && !strings.Contains(out, "o") { + t.Errorf("expected tower 2 (unvisited) to appear on the map:\n%s", out) + } +} + +func TestMap3DDifferentZExcluded(t *testing.T) { + // Room 1 at (0,0,0) with up to room 2 at (0,0,1). Room 2 is at z=1, + // different from the player's z=0 level — should NOT show on the map. + dir := t.TempDir() + writeTempRoom(t, dir, 1, "name: Ground\nexits:\n up: 2\n") + writeTempRoom(t, dir, 2, "name: Upper\nexits:\n down: 1\n") + + g := &Game{Deps: Deps{World: world.New(dir)}, Flags: NewFlagStore()} + mg := mapGlyphsForPlayer(false) + sess := &net.Session{Player: &player.Player{ + Stats: player.PlayerStats{RoomsVisited: map[int]bool{1: true, 2: true}}, + Flags: map[string]any{}, + }} + + out := strings.Join(buildTinyMap(g, sess, 1, mg), "\n") + + // Room 2 is at a different z — it should NOT render as a room symbol. + // Unvisited rooms render as □ (unicode) or o (ASCII). + if strings.Contains(out, "□") || strings.Contains(out, " o ") { + t.Errorf("room at different z should not appear:\n%s", out) + } +} diff --git a/internal/game/object_def.go b/internal/game/object_def.go deleted file mode 100644 index 6cf2464..0000000 --- a/internal/game/object_def.go +++ /dev/null @@ -1,43 +0,0 @@ -package game - -import "thehouseoficarus/internal/object" - -// resolveObjectDef returns the ObjectDef for a room object instance. File -// objects (the common case) resolve through the cached object store with no -// disk I/O on a hit, and a store miss is just a map lookup; only local objects -// — which never live in the store — fall through to a freshly-read room, so -// they keep live-edit semantics without slowing file-object lookups. defID is -// the ObjState DefID (a normalized name for local objects, a file id -// otherwise). -// -// Use this from display or generic-handling sites. Sites that look up a -// specific interactable behavior (gather/use-station/safespot/steal/etc.) may -// call ObjectStore.Load directly: local objects are guaranteed non-interactable -// and simply fall through those sites' existing error guards. -func (g *Game) resolveObjectDef(roomID int, defID string) (*object.ObjectDef, bool, error) { - def, err := g.ObjectStore.Load(defID) - if err == nil { - if room, rerr := g.World.LoadRoom(roomID); rerr == nil { - for i := range room.Objects { - ro := &room.Objects[i] - if ro.Local == nil && ro.ID == defID && ro.HiddenOverride { - copy := *def - copy.Hidden = true - return ©, false, nil - } - } - } - return def, false, nil - } - if room, rerr := g.World.LoadRoom(roomID); rerr == nil { - for i := range room.Objects { - ro := &room.Objects[i] - if ro.Local != nil && ro.ID == defID { - d := *ro.Local - d.ID = defID - return &d, true, nil - } - } - } - return nil, false, err -} diff --git a/internal/game/production_index.go b/internal/game/production_index.go new file mode 100644 index 0000000..648ccc8 --- /dev/null +++ b/internal/game/production_index.go @@ -0,0 +1,234 @@ +package game + +import ( + "sync" + + "thehouseoficarus/internal/item" +) + +type CraftIndex struct { + mu sync.RWMutex + byType map[string][]*item.ItemDef + byInput map[string][]*item.ItemDef + byStation map[string][]*item.ItemDef +} + +func NewCraftIndex() *CraftIndex { + return &CraftIndex{ + byType: make(map[string][]*item.ItemDef), + byInput: make(map[string][]*item.ItemDef), + byStation: make(map[string][]*item.ItemDef), + } +} + +func (idx *CraftIndex) Build(items []*item.ItemDef) { + idx.mu.Lock() + defer idx.mu.Unlock() + + idx.byType = make(map[string][]*item.ItemDef) + idx.byInput = make(map[string][]*item.ItemDef) + idx.byStation = make(map[string][]*item.ItemDef) + + for _, def := range items { + if len(def.Craft) == 0 { + continue + } + for ci := range def.Craft { + craft := &def.Craft[ci] + t := craft.Type + if t == "" { + t = "combine" + } + idx.byType[t] = appendUnique(idx.byType[t], def) + + for _, e := range craft.Consume { + for _, id := range e.Items { + idx.byInput[id] = appendUnique(idx.byInput[id], def) + } + } + + for _, sid := range craft.Station { + idx.byStation[sid] = appendUnique(idx.byStation[sid], def) + } + } + } +} + +func appendUnique(items []*item.ItemDef, item *item.ItemDef) []*item.ItemDef { + for _, existing := range items { + if existing == item { + return items + } + } + return append(items, item) +} + +func (idx *CraftIndex) ByType(craftType string) []*item.ItemDef { + idx.mu.RLock() + defer idx.mu.RUnlock() + items := idx.byType[craftType] + out := make([]*item.ItemDef, len(items)) + copy(out, items) + return out +} + +func (idx *CraftIndex) ByStation(stationDefID string) []*item.ItemDef { + idx.mu.RLock() + defer idx.mu.RUnlock() + items := idx.byStation[stationDefID] + out := make([]*item.ItemDef, len(items)) + copy(out, items) + return out +} + +func (idx *CraftIndex) ByInput(itemID string) []*item.ItemDef { + idx.mu.RLock() + defer idx.mu.RUnlock() + items := idx.byInput[itemID] + out := make([]*item.ItemDef, len(items)) + copy(out, items) + return out +} + +func (idx *CraftIndex) FindByTwoInputs(itemA, itemB string) []*item.ItemDef { + idx.mu.RLock() + defer idx.mu.RUnlock() + + candidatesA := idx.byInput[itemA] + if len(candidatesA) == 0 { + return nil + } + + candidateSetB := make(map[string]bool) + for _, def := range idx.byInput[itemB] { + candidateSetB[def.ID] = true + } + + var results []*item.ItemDef + for _, def := range candidatesA { + if !candidateSetB[def.ID] { + continue + } + for _, craft := range def.Craft { + entryA := craftEntry(craft, itemA) + entryB := craftEntry(craft, itemB) + if entryA >= 0 && entryB >= 0 && entryA != entryB { + results = append(results, def) + break + } + } + } + return results +} + +func (idx *CraftIndex) FindByStationInput(stationDefID, itemID string) []*item.ItemDef { + idx.mu.RLock() + defer idx.mu.RUnlock() + + var results []*item.ItemDef + for _, def := range idx.byStation[stationDefID] { + for _, craft := range def.Craft { + if craftMatchesEntry(&craft, itemID) { + results = append(results, def) + break + } + } + } + return results +} + +func craftEntryFor(def *item.ItemDef, itemID string) int { + for _, craft := range def.Craft { + if ei := craftEntry(craft, itemID); ei >= 0 { + return ei + } + } + return -1 +} + +func craftEntry(craft item.CraftDef, itemID string) int { + for ei, e := range craft.Consume { + for _, id := range e.Items { + if id == itemID { + return ei + } + } + } + return -1 +} + +func craftMatchesEntry(c *item.CraftDef, itemID string) bool { + if c == nil { + return false + } + for _, e := range c.Consume { + for _, id := range e.Items { + if id == itemID { + return true + } + } + } + return false +} + +func craftHasAllItems(c *item.CraftDef, hasItem func(string) bool) bool { + if c == nil { + return false + } + for _, e := range c.Consume { + found := false + for _, id := range e.Items { + if hasItem(id) { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func craftHasAllItemsQty(c *item.CraftDef, countItem func(string) int) bool { + if c == nil { + return false + } + for _, e := range c.Consume { + found := false + for _, id := range e.Items { + qty := e.Quantity + if qty <= 0 { + qty = 1 + } + if countItem(id) >= qty { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func craftConsumeAll(c *item.CraftDef, hasItem func(string) bool, removeItem func(string, int) bool) bool { + if c == nil { + return false + } + for _, e := range c.Consume { + found := false + for _, id := range e.Items { + if hasItem(id) { + removeItem(id, e.Quantity) + found = true + break + } + } + if !found { + return false + } + } + return true +} diff --git a/internal/game/render_map.go b/internal/game/render_map.go index 6b80688..b210e77 100644 --- a/internal/game/render_map.go +++ b/internal/game/render_map.go @@ -47,59 +47,19 @@ type mapCell struct { } type mapGraph struct { - posToRoom map[[2]int]int - roomToPos map[int][2]int + posToRoom map[[3]int]int dist map[int]int } +// The map BFS always seeds the player's room at the grid origin, so the +// player's z-plane is constant. +const playerZ = 0 - -func buildGraph(g *Game, startRoomID int, visited map[int]bool) *mapGraph { - mg := &mapGraph{ - posToRoom: make(map[[2]int]int), - roomToPos: make(map[int][2]int), - dist: make(map[int]int), - } - - type node struct { - roomID int - x, y int - } - queue := []node{{startRoomID, 0, 0}} - mg.posToRoom[[2]int{0, 0}] = startRoomID - mg.roomToPos[startRoomID] = [2]int{0, 0} - mg.dist[startRoomID] = 0 - - for len(queue) > 0 { - n := queue[0] - queue = queue[1:] - - room, ok := loadRoom(g, n.roomID) - if !ok { - continue - } - - if visited != nil && !visited[n.roomID] { - continue - } - - for dir, delta := range world.DirectionDeltas { - targetID, ok := exitTarget(room, dir) - if !ok { - continue - } - if _, seen := mg.roomToPos[targetID]; seen { - continue - } - nx, ny := n.x+delta[0], n.y+delta[1] - mg.posToRoom[[2]int{nx, ny}] = targetID - mg.roomToPos[targetID] = [2]int{nx, ny} - mg.dist[targetID] = mg.dist[n.roomID] + 1 - queue = append(queue, node{targetID, nx, ny}) - } - } - - return mg +func buildGraph(g *Game, startRoomID int) *mapGraph { + rg := world.BuildGrid(startRoomID, func(id int) (*world.Room, bool) { + return loadRoom(g, id) + }, nil, nil) + return &mapGraph{posToRoom: rg.RoomAt, dist: rg.Dist} } func renderMapCells(grid [][]mapCell, colorMode string, startRow, endRow int, border rune) []string { @@ -128,7 +88,7 @@ func renderMapCells(grid [][]mapCell, colorMode string, startRow, endRow int, bo func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string { visited := roomsVisited(sess) - bg := buildGraph(g, roomID, visited) + bg := buildGraph(g, roomID) colorMode := colorModeFor(sess) atSpec := resolveMapAt(g, sess) @@ -147,9 +107,10 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string } } + pz := playerZ for y := -1; y <= 1; y++ { for x := -1; x <= 1; x++ { - pos := [2]int{x, y} + pos := [3]int{x, y, pz} rid, ok := bg.posToRoom[pos] if !ok { continue @@ -171,8 +132,8 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string for y := -1; y <= 1; y++ { for x := -1; x <= 0; x++ { - leftPos := [2]int{x, y} - rightPos := [2]int{x + 1, y} + leftPos := [3]int{x, y, pz} + rightPos := [3]int{x + 1, y, pz} leftRoom, leftOK := bg.posToRoom[leftPos] rightRoom, rightOK := bg.posToRoom[rightPos] if !leftOK || !rightOK { @@ -186,8 +147,8 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string for y := -1; y <= 0; y++ { for x := -1; x <= 1; x++ { - topPos := [2]int{x, y} - bottomPos := [2]int{x, y + 1} + topPos := [3]int{x, y, pz} + bottomPos := [3]int{x, y + 1, pz} topRoom, topOK := bg.posToRoom[topPos] bottomRoom, bottomOK := bg.posToRoom[bottomPos] if !topOK || !bottomOK { @@ -199,119 +160,50 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string } } - // NE connectors: (x,y) -> (x+1, y-1), connector at grid[2*y+1][2*x+3] + // Diagonal connectors. Each maps room (x,y) to a diagonal neighbor; the + // grid cell between them is 2*y/2*x offset by ±1 in each axis. for y := 0; y <= 1; y++ { for x := -1; x <= 0; x++ { - aPos, bPos := [2]int{x, y}, [2]int{x + 1, y - 1} - aRoom, aOK := bg.posToRoom[aPos] - bRoom, bOK := bg.posToRoom[bPos] - if !aOK || !bOK { - continue - } - if cell, ok := ctx.connectorCell(aRoom, bRoom, world.Northeast, world.Southwest); ok { - gr, gc := 2*y+1, 2*x+3 - key := [2]int{gr, gc} - if isDiagonalGlyph(grid[gr][gc].char) { - if grid[gr][gc].char != cell.char { - prev := ctx.diagPairs[key] - spec := color.Average( - color.Average(ctx.nodeSpec(prev[0]), ctx.nodeSpec(prev[1])), - color.Average(ctx.nodeSpec(aRoom), ctx.nodeSpec(bRoom)), - ) - grid[gr][gc] = mapCell{char: 'X', spec: spec} - } - } else { - grid[gr][gc] = cell - ctx.diagPairs[key] = [2]int{aRoom, bRoom} - } + // NE: (x,y) -> (x+1, y-1), connector at grid[2*y+1][2*x+3] + aRoom, aOK := bg.posToRoom[[3]int{x, y, pz}] + bRoom, bOK := bg.posToRoom[[3]int{x + 1, y - 1, pz}] + if aOK && bOK { + ctx.placeDiagonal(grid, 2*y+1, 2*x+3, aRoom, bRoom, world.Northeast, world.Southwest) } } } - // NW connectors: (x,y) -> (x-1, y-1), connector at grid[2*y+1][2*x+1] for y := 0; y <= 1; y++ { for x := 0; x <= 1; x++ { - aPos, bPos := [2]int{x, y}, [2]int{x - 1, y - 1} - aRoom, aOK := bg.posToRoom[aPos] - bRoom, bOK := bg.posToRoom[bPos] - if !aOK || !bOK { - continue - } - if cell, ok := ctx.connectorCell(aRoom, bRoom, world.Northwest, world.Southeast); ok { - gr, gc := 2*y+1, 2*x+1 - key := [2]int{gr, gc} - if isDiagonalGlyph(grid[gr][gc].char) { - if grid[gr][gc].char != cell.char { - prev := ctx.diagPairs[key] - spec := color.Average( - color.Average(ctx.nodeSpec(prev[0]), ctx.nodeSpec(prev[1])), - color.Average(ctx.nodeSpec(aRoom), ctx.nodeSpec(bRoom)), - ) - grid[gr][gc] = mapCell{char: 'X', spec: spec} - } - } else { - grid[gr][gc] = cell - ctx.diagPairs[key] = [2]int{aRoom, bRoom} - } + // NW: (x,y) -> (x-1, y-1), connector at grid[2*y+1][2*x+1] + aRoom, aOK := bg.posToRoom[[3]int{x, y, pz}] + bRoom, bOK := bg.posToRoom[[3]int{x - 1, y - 1, pz}] + if aOK && bOK { + ctx.placeDiagonal(grid, 2*y+1, 2*x+1, aRoom, bRoom, world.Northwest, world.Southeast) } } } - // SE connectors: (x,y) -> (x+1, y+1), connector at grid[2*y+3][2*x+3] for y := -1; y <= 0; y++ { for x := -1; x <= 0; x++ { - aPos, bPos := [2]int{x, y}, [2]int{x + 1, y + 1} - aRoom, aOK := bg.posToRoom[aPos] - bRoom, bOK := bg.posToRoom[bPos] - if !aOK || !bOK { - continue - } - if cell, ok := ctx.connectorCell(aRoom, bRoom, world.Southeast, world.Northwest); ok { - gr, gc := 2*y+3, 2*x+3 - key := [2]int{gr, gc} - if isDiagonalGlyph(grid[gr][gc].char) { - if grid[gr][gc].char != cell.char { - prev := ctx.diagPairs[key] - spec := color.Average( - color.Average(ctx.nodeSpec(prev[0]), ctx.nodeSpec(prev[1])), - color.Average(ctx.nodeSpec(aRoom), ctx.nodeSpec(bRoom)), - ) - grid[gr][gc] = mapCell{char: 'X', spec: spec} - } - } else { - grid[gr][gc] = cell - ctx.diagPairs[key] = [2]int{aRoom, bRoom} - } + // SE: (x,y) -> (x+1, y+1), connector at grid[2*y+3][2*x+3] + aRoom, aOK := bg.posToRoom[[3]int{x, y, pz}] + bRoom, bOK := bg.posToRoom[[3]int{x + 1, y + 1, pz}] + if aOK && bOK { + ctx.placeDiagonal(grid, 2*y+3, 2*x+3, aRoom, bRoom, world.Southeast, world.Northwest) } } } - // SW connectors: (x,y) -> (x-1, y+1), connector at grid[2*y+3][2*x+1] for y := -1; y <= 0; y++ { for x := 0; x <= 1; x++ { - aPos, bPos := [2]int{x, y}, [2]int{x - 1, y + 1} - aRoom, aOK := bg.posToRoom[aPos] - bRoom, bOK := bg.posToRoom[bPos] - if !aOK || !bOK { - continue - } - if cell, ok := ctx.connectorCell(aRoom, bRoom, world.Southwest, world.Northeast); ok { - gr, gc := 2*y+3, 2*x+1 - key := [2]int{gr, gc} - if isDiagonalGlyph(grid[gr][gc].char) { - if grid[gr][gc].char != cell.char { - prev := ctx.diagPairs[key] - spec := color.Average( - color.Average(ctx.nodeSpec(prev[0]), ctx.nodeSpec(prev[1])), - color.Average(ctx.nodeSpec(aRoom), ctx.nodeSpec(bRoom)), - ) - grid[gr][gc] = mapCell{char: 'X', spec: spec} - } - } else { - grid[gr][gc] = cell - ctx.diagPairs[key] = [2]int{aRoom, bRoom} - } + // SW: (x,y) -> (x-1, y+1), connector at grid[2*y+3][2*x+1] + aRoom, aOK := bg.posToRoom[[3]int{x, y, pz}] + bRoom, bOK := bg.posToRoom[[3]int{x - 1, y + 1, pz}] + if aOK && bOK { + ctx.placeDiagonal(grid, 2*y+3, 2*x+1, aRoom, bRoom, world.Southwest, world.Northeast) } } } + cur, _ := loadRoom(g, roomID) if cur != nil { if upTarget, hasUp := exitTarget(cur, world.Up); hasUp { @@ -354,7 +246,7 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, mg mapGlyphs) []string { visited := roomsVisited(sess) - bg := buildGraph(g, roomID, visited) + bg := buildGraph(g, roomID) colorMode := colorModeFor(sess) atSpec := resolveMapAt(g, sess) @@ -375,8 +267,12 @@ func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, m cx := mapWidth / 2 cy := mapHeight / 2 + pz := playerZ for pos, rid := range bg.posToRoom { + if pos[2] != pz { + continue + } gr := cy + pos[1]*2 gc := cx + pos[0]*2 if gr < 0 || gr >= mapHeight || gc < 0 || gc >= mapWidth { @@ -395,9 +291,12 @@ func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, m } for pos, rid := range bg.posToRoom { + if pos[2] != pz { + continue + } x, y := pos[0], pos[1] - if rightID, exists := bg.posToRoom[[2]int{x + 1, y}]; exists { + if rightID, exists := bg.posToRoom[[3]int{x + 1, y, pz}]; exists { gr := cy + y*2 gc := cx + x*2 + 1 if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth { @@ -407,7 +306,7 @@ func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, m } } - if bottomID, exists := bg.posToRoom[[2]int{x, y + 1}]; exists { + if bottomID, exists := bg.posToRoom[[3]int{x, y + 1, pz}]; exists { gr := cy + y*2 + 1 gc := cx + x*2 if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth { @@ -417,96 +316,20 @@ func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, m } } - if neID, exists := bg.posToRoom[[2]int{x + 1, y - 1}]; exists { - gr := cy + y*2 - 1 - gc := cx + x*2 + 1 - if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth { - if cell, ok := ctx.connectorCell(rid, neID, world.Northeast, world.Southwest); ok { - key := [2]int{gr, gc} - if isDiagonalGlyph(grid[gr][gc].char) { - if grid[gr][gc].char != cell.char { - prev := ctx.diagPairs[key] - spec := color.Average( - color.Average(ctx.nodeSpec(prev[0]), ctx.nodeSpec(prev[1])), - color.Average(ctx.nodeSpec(rid), ctx.nodeSpec(neID)), - ) - grid[gr][gc] = mapCell{char: 'X', spec: spec} - } - } else { - grid[gr][gc] = cell - ctx.diagPairs[key] = [2]int{rid, neID} - } - } - } + if neID, exists := bg.posToRoom[[3]int{x + 1, y - 1, pz}]; exists { + ctx.placeDiagonal(grid, cy+y*2-1, cx+x*2+1, rid, neID, world.Northeast, world.Southwest) } - if nwID, exists := bg.posToRoom[[2]int{x - 1, y - 1}]; exists { - gr := cy + y*2 - 1 - gc := cx + x*2 - 1 - if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth { - if cell, ok := ctx.connectorCell(rid, nwID, world.Northwest, world.Southeast); ok { - key := [2]int{gr, gc} - if isDiagonalGlyph(grid[gr][gc].char) { - if grid[gr][gc].char != cell.char { - prev := ctx.diagPairs[key] - spec := color.Average( - color.Average(ctx.nodeSpec(prev[0]), ctx.nodeSpec(prev[1])), - color.Average(ctx.nodeSpec(rid), ctx.nodeSpec(nwID)), - ) - grid[gr][gc] = mapCell{char: 'X', spec: spec} - } - } else { - grid[gr][gc] = cell - ctx.diagPairs[key] = [2]int{rid, nwID} - } - } - } + if nwID, exists := bg.posToRoom[[3]int{x - 1, y - 1, pz}]; exists { + ctx.placeDiagonal(grid, cy+y*2-1, cx+x*2-1, rid, nwID, world.Northwest, world.Southeast) } - if seID, exists := bg.posToRoom[[2]int{x + 1, y + 1}]; exists { - gr := cy + y*2 + 1 - gc := cx + x*2 + 1 - if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth { - if cell, ok := ctx.connectorCell(rid, seID, world.Southeast, world.Northwest); ok { - key := [2]int{gr, gc} - if isDiagonalGlyph(grid[gr][gc].char) { - if grid[gr][gc].char != cell.char { - prev := ctx.diagPairs[key] - spec := color.Average( - color.Average(ctx.nodeSpec(prev[0]), ctx.nodeSpec(prev[1])), - color.Average(ctx.nodeSpec(rid), ctx.nodeSpec(seID)), - ) - grid[gr][gc] = mapCell{char: 'X', spec: spec} - } - } else { - grid[gr][gc] = cell - ctx.diagPairs[key] = [2]int{rid, seID} - } - } - } + if seID, exists := bg.posToRoom[[3]int{x + 1, y + 1, pz}]; exists { + ctx.placeDiagonal(grid, cy+y*2+1, cx+x*2+1, rid, seID, world.Southeast, world.Northwest) } - if swID, exists := bg.posToRoom[[2]int{x - 1, y + 1}]; exists { - gr := cy + y*2 + 1 - gc := cx + x*2 - 1 - if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth { - if cell, ok := ctx.connectorCell(rid, swID, world.Southwest, world.Northeast); ok { - key := [2]int{gr, gc} - if isDiagonalGlyph(grid[gr][gc].char) { - if grid[gr][gc].char != cell.char { - prev := ctx.diagPairs[key] - spec := color.Average( - color.Average(ctx.nodeSpec(prev[0]), ctx.nodeSpec(prev[1])), - color.Average(ctx.nodeSpec(rid), ctx.nodeSpec(swID)), - ) - grid[gr][gc] = mapCell{char: 'X', spec: spec} - } - } else { - grid[gr][gc] = cell - ctx.diagPairs[key] = [2]int{rid, swID} - } - } - } + if swID, exists := bg.posToRoom[[3]int{x - 1, y + 1, pz}]; exists { + ctx.placeDiagonal(grid, cy+y*2+1, cx+x*2-1, rid, swID, world.Southwest, world.Northeast) } } @@ -629,6 +452,35 @@ func (c *mapRenderCtx) coloredCell(roomA, roomB int, glyph rune) (mapCell, bool) return mapCell{char: glyph, spec: color.Average(c.nodeSpec(roomA), c.nodeSpec(roomB))}, true } +// placeDiagonal draws the diagonal link between two grid-adjacent rooms at +// grid[gr][gc], handling the criss-cross case: when a diagonal glyph is already +// present and the new link runs the opposite way, the cell becomes a blocked +// 'X' colored from both crossing links' endpoints. Out-of-bounds targets and +// rooms with no link between them are no-ops. +func (c *mapRenderCtx) placeDiagonal(grid [][]mapCell, gr, gc, roomA, roomB int, dirAB, dirBA world.ExitDir) { + if gr < 0 || gr >= len(grid) || gc < 0 || gc >= len(grid[gr]) { + return + } + cell, ok := c.connectorCell(roomA, roomB, dirAB, dirBA) + if !ok { + return + } + key := [2]int{gr, gc} + if isDiagonalGlyph(grid[gr][gc].char) { + if grid[gr][gc].char != cell.char { + prev := c.diagPairs[key] + spec := color.Average( + color.Average(c.nodeSpec(prev[0]), c.nodeSpec(prev[1])), + color.Average(c.nodeSpec(roomA), c.nodeSpec(roomB)), + ) + grid[gr][gc] = mapCell{char: 'X', spec: spec} + } + return + } + grid[gr][gc] = cell + c.diagPairs[key] = [2]int{roomA, roomB} +} + type exitState int const ( @@ -763,13 +615,24 @@ func loadRoom(g *Game, roomID int) (*world.Room, bool) { } func stripBlankRows(lines []string) []string { - var out []string - for _, line := range lines { + top := -1 + for i, line := range lines { if strings.TrimSpace(line) != "" { - out = append(out, line) + top = i + break + } + } + if top < 0 { + return nil + } + bottom := top + for i := len(lines) - 1; i > bottom; i-- { + if strings.TrimSpace(lines[i]) != "" { + bottom = i + break } } - return out + return lines[top : bottom+1] } func leftTrimCommon(lines []string) []string { diff --git a/internal/game/safespot_manager.go b/internal/game/safespot_manager.go deleted file mode 100644 index 1520bc7..0000000 --- a/internal/game/safespot_manager.go +++ /dev/null @@ -1,85 +0,0 @@ -package game - -import "sync" - -// SafespotState tracks a player's active safespot (cover) in a room. -type SafespotState struct { - Active bool - ObjectDefID string - ObjectIndex int - RoomID int - HideCountdown int -} - -// SafespotManager owns the per-player safespot state map and its mutex. -// Callers receive value copies via Get/Peek so they cannot mutate state -// outside the lock — this avoids the lock-copy-pointer-unlock anti-pattern. -type SafespotManager struct { - mu sync.Mutex - states map[string]*SafespotState -} - -func NewSafespotManager() *SafespotManager { - return &SafespotManager{states: make(map[string]*SafespotState)} -} - -// Get returns a value copy of the player's safespot state. -func (m *SafespotManager) Get(name string) (SafespotState, bool) { - m.mu.Lock() - defer m.mu.Unlock() - ss, ok := m.states[name] - if !ok { - return SafespotState{}, false - } - return *ss, true -} - -// Has reports whether any safespot state exists for the player. -func (m *SafespotManager) Has(name string) bool { - m.mu.Lock() - defer m.mu.Unlock() - _, ok := m.states[name] - return ok -} - -// IsActive reports whether the player has an active, fully-hidden safespot -// (hide countdown complete). -func (m *SafespotManager) IsActive(name string) bool { - m.mu.Lock() - defer m.mu.Unlock() - ss, ok := m.states[name] - return ok && ss.Active && ss.HideCountdown <= 0 -} - -// Set stores a safespot state for the player. -func (m *SafespotManager) Set(name string, ss SafespotState) { - m.mu.Lock() - defer m.mu.Unlock() - m.states[name] = &ss -} - -// Delete removes the player's safespot state. Returns true if a state existed. -func (m *SafespotManager) Delete(name string) bool { - m.mu.Lock() - defer m.mu.Unlock() - _, ok := m.states[name] - delete(m.states, name) - return ok -} - -// DecrementHideCountdown decrements the hide countdown for a player who is -// transitioning from hiding to hidden. Returns (newCountdown, ok). A return of -// ok=true with newCountdown > 0 means the player is still counting down; -// newCountdown == 0 means the safespot just activated. -func (m *SafespotManager) DecrementHideCountdown(name string) (int, bool) { - m.mu.Lock() - defer m.mu.Unlock() - ss, ok := m.states[name] - if !ok || !ss.Active { - return 0, false - } - if ss.HideCountdown > 0 { - ss.HideCountdown-- - } - return ss.HideCountdown, true -} diff --git a/internal/game/sys_safespot_state.go b/internal/game/sys_safespot_state.go new file mode 100644 index 0000000..1520bc7 --- /dev/null +++ b/internal/game/sys_safespot_state.go @@ -0,0 +1,85 @@ +package game + +import "sync" + +// SafespotState tracks a player's active safespot (cover) in a room. +type SafespotState struct { + Active bool + ObjectDefID string + ObjectIndex int + RoomID int + HideCountdown int +} + +// SafespotManager owns the per-player safespot state map and its mutex. +// Callers receive value copies via Get/Peek so they cannot mutate state +// outside the lock — this avoids the lock-copy-pointer-unlock anti-pattern. +type SafespotManager struct { + mu sync.Mutex + states map[string]*SafespotState +} + +func NewSafespotManager() *SafespotManager { + return &SafespotManager{states: make(map[string]*SafespotState)} +} + +// Get returns a value copy of the player's safespot state. +func (m *SafespotManager) Get(name string) (SafespotState, bool) { + m.mu.Lock() + defer m.mu.Unlock() + ss, ok := m.states[name] + if !ok { + return SafespotState{}, false + } + return *ss, true +} + +// Has reports whether any safespot state exists for the player. +func (m *SafespotManager) Has(name string) bool { + m.mu.Lock() + defer m.mu.Unlock() + _, ok := m.states[name] + return ok +} + +// IsActive reports whether the player has an active, fully-hidden safespot +// (hide countdown complete). +func (m *SafespotManager) IsActive(name string) bool { + m.mu.Lock() + defer m.mu.Unlock() + ss, ok := m.states[name] + return ok && ss.Active && ss.HideCountdown <= 0 +} + +// Set stores a safespot state for the player. +func (m *SafespotManager) Set(name string, ss SafespotState) { + m.mu.Lock() + defer m.mu.Unlock() + m.states[name] = &ss +} + +// Delete removes the player's safespot state. Returns true if a state existed. +func (m *SafespotManager) Delete(name string) bool { + m.mu.Lock() + defer m.mu.Unlock() + _, ok := m.states[name] + delete(m.states, name) + return ok +} + +// DecrementHideCountdown decrements the hide countdown for a player who is +// transitioning from hiding to hidden. Returns (newCountdown, ok). A return of +// ok=true with newCountdown > 0 means the player is still counting down; +// newCountdown == 0 means the safespot just activated. +func (m *SafespotManager) DecrementHideCountdown(name string) (int, bool) { + m.mu.Lock() + defer m.mu.Unlock() + ss, ok := m.states[name] + if !ok || !ss.Active { + return 0, false + } + if ss.HideCountdown > 0 { + ss.HideCountdown-- + } + return ss.HideCountdown, true +} diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go new file mode 100644 index 0000000..cd1bbd3 --- /dev/null +++ b/internal/game/sys_triggers.go @@ -0,0 +1,307 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func (g *Game) TriggerSeqTick() { + g.processPlayerTriggerSequences() + g.processWorldTriggerSequences() +} + +func (g *Game) TransientMobTick() { + if g.Hub == nil { + return + } + for _, inst := range g.MobStore.AllInstances() { + if inst.Owner == "" || inst.HP <= 0 { + continue + } + if !inst.DespawnOnLeave { + continue + } + + g.charsMu.Lock() + ownerSess := g.loggedInChars[inst.Owner] + g.charsMu.Unlock() + if ownerSess == nil || ownerSess.Player == nil { + continue + } + + ownerRoom := ownerSess.Player.RoomID + allowed := inst.DespawnRooms + if len(allowed) == 0 { + allowed = []int{inst.HomeRoomID} + } + inAllowed := false + for _, r := range allowed { + if ownerRoom == r { + inAllowed = true + break + } + } + if inAllowed { + inst.DespawnCounter = inst.DespawnTickCount() + continue + } + inst.DecrementDespawnCounter() + if inst.DespawnCounter <= 0 { + g.MobStore.RemoveInstance(inst.InstanceID) + for _, sess := range g.Hub.PlayersInRoom(inst.RoomID) { + sess.WriteLine(g.colorize(sess, "broadcast", + fmt.Sprintf("%s fades away.", mobDisplayName(inst, true)))) + } + } + } +} + +func (g *Game) processPlayerTriggerSequences() { + seqs := g.TriggerStore.SnapshotPlayerSeqs() + for _, seq := range seqs { + g.charsMu.Lock() + sess := g.loggedInChars[seq.PlayerName] + g.charsMu.Unlock() + if sess == nil || sess.Player == nil { + g.TriggerStore.RemovePlayerSeq(seq.PlayerName, seq.TriggerID) + continue + } + p := sess.Player + if p.RoomID != seq.RoomID { + continue + } + + seq.Wait-- + if seq.Wait > 0 { + continue + } + + var jobs []world.TriggerStep + for len(seq.Steps) > 0 && seq.Wait <= 0 { + step := seq.Steps[0] + seq.Steps = seq.Steps[1:] + jobs = append(jobs, step) + if len(seq.Steps) > 0 { + seq.Wait = seq.Steps[0].Delay + } + } + + for _, step := range jobs { + s := step + g.executeTriggerStep(sess, p, &s, seq.RoomID, seq.FlagValue) + } + + if len(seq.Steps) == 0 { + g.TriggerStore.RemovePlayerSeq(seq.PlayerName, seq.TriggerID) + if p.RoomID == seq.RoomID && sess.State == net.StateGame { + g.writePrompt(sess) + } + } + } +} + +func (g *Game) processWorldTriggerSequences() { + seqs := g.TriggerStore.SnapshotWorldSeqs() + for _, seq := range seqs { + seq.Wait-- + if seq.Wait > 0 { + continue + } + + var jobs []world.TriggerStep + for len(seq.Steps) > 0 && seq.Wait <= 0 { + step := seq.Steps[0] + seq.Steps = seq.Steps[1:] + jobs = append(jobs, step) + if len(seq.Steps) > 0 { + seq.Wait = seq.Steps[0].Delay + } + } + + for _, step := range jobs { + g.executeWorldTriggerStep(&step, seq.RoomID, seq.FlagValue) + } + + if len(seq.Steps) == 0 { + g.TriggerStore.RemoveWorldSeq(seq.TriggerID) + } + } +} + +func (g *Game) executeTriggerStep(sess *net.Session, p *player.Player, step *world.TriggerStep, roomID int, flagValue any) { + seqSpec := g.resolveColor(sess, "sequence") + broadcastSpec := g.resolveColor(sess, "broadcast") + playerMode := g.colorMode(sess) + if step.Message != "" { + msg := expandTemplate(step.Message, p.Name, flagValue) + msg = color.ExpandTagsDefault(playerMode, seqSpec, msg) + sess.WriteLine(msg) + } + if step.Broadcast != "" && g.Hub != nil { + msg := expandTemplate(step.Broadcast, p.Name, flagValue) + for _, other := range g.Hub.PlayersInRoom(roomID) { + otherMode := g.colorMode(other) + rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg) + other.WriteLine(g.colorize(other, "broadcast", rendered)) + } + } + if step.BroadcastGlobal != "" && g.Hub != nil { + msg := expandTemplate(step.BroadcastGlobal, p.Name, flagValue) + for _, other := range g.Hub.AllSessions() { + if other.Player != nil { + otherMode := g.colorMode(other) + rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg) + other.WriteLine(g.colorize(other, "broadcast", rendered)) + } + } + } + if len(step.SetFlags) > 0 { + g.Flags.SetAll(step.SetFlags) + } + if len(step.SetPlayerFlags) > 0 { + for k, v := range step.SetPlayerFlags { + g.setPlayerFlag(p, k, v) + } + g.AccountStore.SaveCharacter(p) + } + if step.GiveItem != "" { + slot := p.FirstFreeSlot() + if slot == -1 { + sess.WriteLine("Your inventory is too full to receive that.") + } else { + p.SetInvSlot(slot, &player.InventorySlot{ItemID: step.GiveItem, Quantity: 1}) + g.AccountStore.SaveCharacter(p) + } + } + if step.TakeItem != "" { + if p.HasItem(step.TakeItem) { + p.RemoveItem(step.TakeItem, 1) + g.AccountStore.SaveCharacter(p) + } + } + if step.Heal > 0 { + p.HP += step.Heal + if maxHP := p.MaxHP(); p.HP > maxHP { + p.HP = maxHP + } + g.AccountStore.SaveCharacter(p) + sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", step.Heal)) + } + if step.SpawnMob != nil { + g.spawnTriggerMob(sess, p, step.SpawnMob, roomID) + } + if step.DespawnMob != "" { + g.despawnTriggerMobs(step.DespawnMob, p.Name) + } + if step.Teleport > 0 { + g.teleportPlayer(sess, p, step.Teleport) + } +} + +func (g *Game) executeWorldTriggerStep(step *world.TriggerStep, roomID int, flagValue any) { + if step.Broadcast != "" && g.Hub != nil { + msg := expandTemplate(step.Broadcast, "", flagValue) + broadcastSpec := g.resolveColor(nil, "broadcast") + for _, other := range g.Hub.PlayersInRoom(roomID) { + otherMode := g.colorMode(other) + rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg) + other.WriteLine(g.colorize(other, "broadcast", rendered)) + } + } + if step.BroadcastGlobal != "" && g.Hub != nil { + msg := expandTemplate(step.BroadcastGlobal, "", flagValue) + broadcastSpec := g.resolveColor(nil, "broadcast") + for _, other := range g.Hub.AllSessions() { + if other.Player != nil { + otherMode := g.colorMode(other) + rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg) + other.WriteLine(g.colorize(other, "broadcast", rendered)) + } + } + } + if len(step.SetFlags) > 0 { + g.Flags.SetAll(step.SetFlags) + } + if step.SpawnMob != nil { + g.spawnWorldTriggerMob(step.SpawnMob, roomID) + } + if step.DespawnMob != "" { + g.despawnTriggerMobs(step.DespawnMob, "") + } +} + +func (g *Game) spawnTriggerMob(sess *net.Session, p *player.Player, cfg *world.SpawnMobConfig, roomID int) { + if cfg.ID == "" { + return + } + def, err := g.MobStore.LoadDef(cfg.ID) + if err != nil { + return + } + inst := g.MobStore.SpawnTransient(def, cfg, roomID, p.Name) + if inst == nil { + return + } + inst.ResetDespawnCounter(engine.ToTicks(cfg.DespawnTicks)) + sess.WriteLine(g.colorize(sess, "broadcast", mobDisplayName(inst, true)+" appears!")) +} + +func (g *Game) spawnWorldTriggerMob(cfg *world.SpawnMobConfig, roomID int) { + if cfg.ID == "" { + return + } + def, err := g.MobStore.LoadDef(cfg.ID) + if err != nil { + return + } + inst := g.MobStore.SpawnTransient(def, cfg, roomID, "") + if inst == nil { + return + } + inst.ResetDespawnCounter(engine.ToTicks(cfg.DespawnTicks)) + if g.Hub != nil { + for _, sess := range g.Hub.PlayersInRoom(roomID) { + sess.WriteLine(g.colorize(sess, "broadcast", + mobDisplayName(inst, true)+" appears!")) + } + } +} + +func (g *Game) despawnTriggerMobs(mobID string, owner string) { + for _, inst := range g.MobStore.AllInstances() { + if inst.DefID != mobID { + continue + } + if owner != "" && inst.Owner != owner { + continue + } + if inst.HP <= 0 { + g.MobStore.RemoveInstance(inst.InstanceID) + continue + } + if g.Hub != nil { + for _, sess := range g.Hub.PlayersInRoom(inst.RoomID) { + sess.WriteLine(g.colorize(sess, "broadcast", + fmt.Sprintf("%s vanishes.", mobDisplayName(inst, true)))) + } + } + g.MobStore.RemoveInstance(inst.InstanceID) + } +} + +func expandTemplate(tmpl string, playerName string, flagValue any) string { + s := tmpl + if playerName != "" { + s = strings.ReplaceAll(s, "%p", playerName) + } + if flagValue != nil { + s = strings.ReplaceAll(s, "%v", fmt.Sprint(flagValue)) + } + return s +} diff --git a/internal/game/triggers.go b/internal/game/triggers.go deleted file mode 100644 index cd1bbd3..0000000 --- a/internal/game/triggers.go +++ /dev/null @@ -1,307 +0,0 @@ -package game - -import ( - "fmt" - "strings" - - "thehouseoficarus/internal/color" - "thehouseoficarus/internal/engine" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" -) - -func (g *Game) TriggerSeqTick() { - g.processPlayerTriggerSequences() - g.processWorldTriggerSequences() -} - -func (g *Game) TransientMobTick() { - if g.Hub == nil { - return - } - for _, inst := range g.MobStore.AllInstances() { - if inst.Owner == "" || inst.HP <= 0 { - continue - } - if !inst.DespawnOnLeave { - continue - } - - g.charsMu.Lock() - ownerSess := g.loggedInChars[inst.Owner] - g.charsMu.Unlock() - if ownerSess == nil || ownerSess.Player == nil { - continue - } - - ownerRoom := ownerSess.Player.RoomID - allowed := inst.DespawnRooms - if len(allowed) == 0 { - allowed = []int{inst.HomeRoomID} - } - inAllowed := false - for _, r := range allowed { - if ownerRoom == r { - inAllowed = true - break - } - } - if inAllowed { - inst.DespawnCounter = inst.DespawnTickCount() - continue - } - inst.DecrementDespawnCounter() - if inst.DespawnCounter <= 0 { - g.MobStore.RemoveInstance(inst.InstanceID) - for _, sess := range g.Hub.PlayersInRoom(inst.RoomID) { - sess.WriteLine(g.colorize(sess, "broadcast", - fmt.Sprintf("%s fades away.", mobDisplayName(inst, true)))) - } - } - } -} - -func (g *Game) processPlayerTriggerSequences() { - seqs := g.TriggerStore.SnapshotPlayerSeqs() - for _, seq := range seqs { - g.charsMu.Lock() - sess := g.loggedInChars[seq.PlayerName] - g.charsMu.Unlock() - if sess == nil || sess.Player == nil { - g.TriggerStore.RemovePlayerSeq(seq.PlayerName, seq.TriggerID) - continue - } - p := sess.Player - if p.RoomID != seq.RoomID { - continue - } - - seq.Wait-- - if seq.Wait > 0 { - continue - } - - var jobs []world.TriggerStep - for len(seq.Steps) > 0 && seq.Wait <= 0 { - step := seq.Steps[0] - seq.Steps = seq.Steps[1:] - jobs = append(jobs, step) - if len(seq.Steps) > 0 { - seq.Wait = seq.Steps[0].Delay - } - } - - for _, step := range jobs { - s := step - g.executeTriggerStep(sess, p, &s, seq.RoomID, seq.FlagValue) - } - - if len(seq.Steps) == 0 { - g.TriggerStore.RemovePlayerSeq(seq.PlayerName, seq.TriggerID) - if p.RoomID == seq.RoomID && sess.State == net.StateGame { - g.writePrompt(sess) - } - } - } -} - -func (g *Game) processWorldTriggerSequences() { - seqs := g.TriggerStore.SnapshotWorldSeqs() - for _, seq := range seqs { - seq.Wait-- - if seq.Wait > 0 { - continue - } - - var jobs []world.TriggerStep - for len(seq.Steps) > 0 && seq.Wait <= 0 { - step := seq.Steps[0] - seq.Steps = seq.Steps[1:] - jobs = append(jobs, step) - if len(seq.Steps) > 0 { - seq.Wait = seq.Steps[0].Delay - } - } - - for _, step := range jobs { - g.executeWorldTriggerStep(&step, seq.RoomID, seq.FlagValue) - } - - if len(seq.Steps) == 0 { - g.TriggerStore.RemoveWorldSeq(seq.TriggerID) - } - } -} - -func (g *Game) executeTriggerStep(sess *net.Session, p *player.Player, step *world.TriggerStep, roomID int, flagValue any) { - seqSpec := g.resolveColor(sess, "sequence") - broadcastSpec := g.resolveColor(sess, "broadcast") - playerMode := g.colorMode(sess) - if step.Message != "" { - msg := expandTemplate(step.Message, p.Name, flagValue) - msg = color.ExpandTagsDefault(playerMode, seqSpec, msg) - sess.WriteLine(msg) - } - if step.Broadcast != "" && g.Hub != nil { - msg := expandTemplate(step.Broadcast, p.Name, flagValue) - for _, other := range g.Hub.PlayersInRoom(roomID) { - otherMode := g.colorMode(other) - rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg) - other.WriteLine(g.colorize(other, "broadcast", rendered)) - } - } - if step.BroadcastGlobal != "" && g.Hub != nil { - msg := expandTemplate(step.BroadcastGlobal, p.Name, flagValue) - for _, other := range g.Hub.AllSessions() { - if other.Player != nil { - otherMode := g.colorMode(other) - rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg) - other.WriteLine(g.colorize(other, "broadcast", rendered)) - } - } - } - if len(step.SetFlags) > 0 { - g.Flags.SetAll(step.SetFlags) - } - if len(step.SetPlayerFlags) > 0 { - for k, v := range step.SetPlayerFlags { - g.setPlayerFlag(p, k, v) - } - g.AccountStore.SaveCharacter(p) - } - if step.GiveItem != "" { - slot := p.FirstFreeSlot() - if slot == -1 { - sess.WriteLine("Your inventory is too full to receive that.") - } else { - p.SetInvSlot(slot, &player.InventorySlot{ItemID: step.GiveItem, Quantity: 1}) - g.AccountStore.SaveCharacter(p) - } - } - if step.TakeItem != "" { - if p.HasItem(step.TakeItem) { - p.RemoveItem(step.TakeItem, 1) - g.AccountStore.SaveCharacter(p) - } - } - if step.Heal > 0 { - p.HP += step.Heal - if maxHP := p.MaxHP(); p.HP > maxHP { - p.HP = maxHP - } - g.AccountStore.SaveCharacter(p) - sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", step.Heal)) - } - if step.SpawnMob != nil { - g.spawnTriggerMob(sess, p, step.SpawnMob, roomID) - } - if step.DespawnMob != "" { - g.despawnTriggerMobs(step.DespawnMob, p.Name) - } - if step.Teleport > 0 { - g.teleportPlayer(sess, p, step.Teleport) - } -} - -func (g *Game) executeWorldTriggerStep(step *world.TriggerStep, roomID int, flagValue any) { - if step.Broadcast != "" && g.Hub != nil { - msg := expandTemplate(step.Broadcast, "", flagValue) - broadcastSpec := g.resolveColor(nil, "broadcast") - for _, other := range g.Hub.PlayersInRoom(roomID) { - otherMode := g.colorMode(other) - rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg) - other.WriteLine(g.colorize(other, "broadcast", rendered)) - } - } - if step.BroadcastGlobal != "" && g.Hub != nil { - msg := expandTemplate(step.BroadcastGlobal, "", flagValue) - broadcastSpec := g.resolveColor(nil, "broadcast") - for _, other := range g.Hub.AllSessions() { - if other.Player != nil { - otherMode := g.colorMode(other) - rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg) - other.WriteLine(g.colorize(other, "broadcast", rendered)) - } - } - } - if len(step.SetFlags) > 0 { - g.Flags.SetAll(step.SetFlags) - } - if step.SpawnMob != nil { - g.spawnWorldTriggerMob(step.SpawnMob, roomID) - } - if step.DespawnMob != "" { - g.despawnTriggerMobs(step.DespawnMob, "") - } -} - -func (g *Game) spawnTriggerMob(sess *net.Session, p *player.Player, cfg *world.SpawnMobConfig, roomID int) { - if cfg.ID == "" { - return - } - def, err := g.MobStore.LoadDef(cfg.ID) - if err != nil { - return - } - inst := g.MobStore.SpawnTransient(def, cfg, roomID, p.Name) - if inst == nil { - return - } - inst.ResetDespawnCounter(engine.ToTicks(cfg.DespawnTicks)) - sess.WriteLine(g.colorize(sess, "broadcast", mobDisplayName(inst, true)+" appears!")) -} - -func (g *Game) spawnWorldTriggerMob(cfg *world.SpawnMobConfig, roomID int) { - if cfg.ID == "" { - return - } - def, err := g.MobStore.LoadDef(cfg.ID) - if err != nil { - return - } - inst := g.MobStore.SpawnTransient(def, cfg, roomID, "") - if inst == nil { - return - } - inst.ResetDespawnCounter(engine.ToTicks(cfg.DespawnTicks)) - if g.Hub != nil { - for _, sess := range g.Hub.PlayersInRoom(roomID) { - sess.WriteLine(g.colorize(sess, "broadcast", - mobDisplayName(inst, true)+" appears!")) - } - } -} - -func (g *Game) despawnTriggerMobs(mobID string, owner string) { - for _, inst := range g.MobStore.AllInstances() { - if inst.DefID != mobID { - continue - } - if owner != "" && inst.Owner != owner { - continue - } - if inst.HP <= 0 { - g.MobStore.RemoveInstance(inst.InstanceID) - continue - } - if g.Hub != nil { - for _, sess := range g.Hub.PlayersInRoom(inst.RoomID) { - sess.WriteLine(g.colorize(sess, "broadcast", - fmt.Sprintf("%s vanishes.", mobDisplayName(inst, true)))) - } - } - g.MobStore.RemoveInstance(inst.InstanceID) - } -} - -func expandTemplate(tmpl string, playerName string, flagValue any) string { - s := tmpl - if playerName != "" { - s = strings.ReplaceAll(s, "%p", playerName) - } - if flagValue != nil { - s = strings.ReplaceAll(s, "%v", fmt.Sprint(flagValue)) - } - return s -} diff --git a/internal/game/validate_source.go b/internal/game/validate_source.go deleted file mode 100644 index 4a2e5b9..0000000 --- a/internal/game/validate_source.go +++ /dev/null @@ -1,53 +0,0 @@ -package game - -import ( - "thehouseoficarus/internal/validate" -) - -// ValidateAndLog runs all startup integrity checks and logs the results. -func (g *Game) ValidateAndLog() { - validate.LogIssues(validate.Run(g.validationSource())) -} - -// validationSource assembles the read-only view of all data stores that the -// validate package needs, including the game-defined tech and course tables. -func (g *Game) validationSource() validate.Source { - courses := g.CourseStore.AllCourses() - courseViews := make([]validate.CourseView, 0, len(courses)) - for id, cfg := range courses { - rooms := make([]int, 0, len(cfg.Obstacles)) - for _, obs := range cfg.Obstacles { - rooms = append(rooms, obs.RoomID) - } - courseViews = append(courseViews, validate.CourseView{ - ID: id, - StartRoom: cfg.StartRoom, - ObstacleRooms: rooms, - }) - } - - techViews := make([]validate.TechView, 0, len(AllTechs)) - techIDs := make(map[string]bool, len(AllTechs)) - for _, t := range AllTechs { - techViews = append(techViews, validate.TechView{ - ID: t.ID, - Name: t.Name, - Category: t.Category, - DrainRate: t.DrainRate, - }) - techIDs[t.ID] = true - } - - return validate.Source{ - DataDir: g.DataDir, - Items: g.ItemStore, - Objects: g.ObjectStore, - Mobs: g.MobStore, - World: g.World, - Courses: courseViews, - Techs: techViews, - TechIDs: techIDs, - RootRooms: g.ValidationConfig.RootRooms, - IgnoreUnreachable: g.ValidationConfig.IgnoreUnreachable, - } -} diff --git a/internal/validate/checks.go b/internal/validate/checks.go index a41ad86..d835a53 100644 --- a/internal/validate/checks.go +++ b/internal/validate/checks.go @@ -870,20 +870,29 @@ func validateRoomWiring(s Source) []Issue { } -// validateRoomGrid lays each reachable horizontal plane on a 2D grid starting -// from the configured root rooms and reports when the exit layout cannot be -// embedded without conflict: +// validateRoomGrid embeds the entire reachable world on a single 3D grid +// starting from the configured root rooms and reports when the exit layout +// cannot be embedded without conflict: // - overlap: two distinct rooms land on the same grid cell. // - twist: one room is forced onto two different grid cells. // -// Up/Down exits don't move on the grid; each leads to a new plane that is laid -// out independently (fresh origin), so one root validates every reachable -// floor. Exit conditions are ignored (geometry is independent of gating), and +// All 10 exits (including Up/Down) participate in coordinate assignment: +// horizontal exits shift (x,y); Up/Down shift z at the same (x,y). +// Exit conditions are ignored (geometry is independent of gating), and // exits to nonexistent rooms are skipped (covered by referential checks). func validateRoomGrid(s Source) []Issue { var issues []Issue roomIndex := s.World.RoomIndex() + load := func(id int) (*world.Room, bool) { + r, err := s.World.LoadRoom(id) + if err != nil { + return nil, false + } + return r, true + } + include := func(id int) bool { return roomIndex[id] } + placed := make(map[int]bool) var seeds []int for _, r := range s.RootRooms { @@ -892,71 +901,34 @@ func validateRoomGrid(s Source) []Issue { } } - for len(seeds) > 0 { - origin := seeds[0] - seeds = seeds[1:] + for _, origin := range seeds { if placed[origin] { continue } - coordOf := map[int][2]int{origin: {0, 0}} - roomAt := map[[2]int]int{{0, 0}: origin} - placed[origin] = true - queue := []int{origin} - - for len(queue) > 0 { - rid := queue[0] - queue = queue[1:] - room, err := s.World.LoadRoom(rid) - if err != nil { - continue + grid := world.BuildGrid(origin, load, include, func(c world.GridConflict) { + switch c.Kind { + case "twist": + issues = append(issues, Issue{ + Level: "ERROR", + Type: "integrity", + Message: fmt.Sprintf( + "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": + issues = append(issues, Issue{ + Level: "ERROR", + Type: "integrity", + Message: fmt.Sprintf( + "Grid overlap: room %d (via %d %s) wants (%d,%d,%d), already used by room %d [origin %d]", + c.Target, c.From, c.Dir, c.Want[0], c.Want[1], c.Want[2], c.Occupier, origin), + }) } - c := coordOf[rid] - for _, dir := range world.ExitOrder { - exit, ok := room.Exits[dir] - if !ok || exit.Room <= 0 || !roomIndex[exit.Room] { - continue - } - target := exit.Room - - if dir == world.Up || dir == world.Down { - if !placed[target] { - seeds = append(seeds, target) - } - continue - } - - d := world.DirectionDeltas[dir] - want := [2]int{c[0] + d[0], c[1] + d[1]} - - if existing, ok := coordOf[target]; ok { - if existing != want { - issues = append(issues, Issue{ - Level: "ERROR", - Type: "integrity", - Message: fmt.Sprintf( - "Grid twist: room %d (via %d %s) maps to grid (%d,%d) but was already placed at (%d,%d) [plane origin %d]", - target, rid, dir, want[0], want[1], existing[0], existing[1], origin), - }) - } - continue - } - if occupier, ok := roomAt[want]; ok && occupier != target { - issues = append(issues, Issue{ - Level: "ERROR", - Type: "integrity", - Message: fmt.Sprintf( - "Grid overlap: room %d (via %d %s) wants grid (%d,%d), already used by room %d [plane origin %d]", - target, rid, dir, want[0], want[1], occupier, origin), - }) - continue - } + }) - coordOf[target] = want - roomAt[want] = target - placed[target] = true - queue = append(queue, target) - } + for rid := range grid.Coord { + placed[rid] = true } } diff --git a/internal/validate/grid_test.go b/internal/validate/grid_test.go index b3f834a..716cec1 100644 --- a/internal/validate/grid_test.go +++ b/internal/validate/grid_test.go @@ -87,9 +87,8 @@ func TestGridTwist(t *testing.T) { } func TestGridMultiPlaneViaUpDown(t *testing.T) { - // Plane A is just room 1. Going up seeds plane B (origin 10), which has an - // overlap. This proves up/down crosses planes and frames are independent - // (room 1 and room 10 both sit at (0,0) without conflicting). + // Room 1 at (0,0,0) goes up to room 10 at (0,0,1). Rooms 13 and 14 both + // resolve to (1,1,1) — 3D overlap on the upper level. rooms := map[int]string{ 1: "exits:\n up: 10\n", 10: "exits:\n south: 12\n east: 11\n", @@ -99,8 +98,60 @@ func TestGridMultiPlaneViaUpDown(t *testing.T) { 14: "name: fourteen\n", } issues := runGridCheck(t, rooms, 1) - if !containsMsg(issues, "plane origin 10") { - t.Errorf("expected an overlap in plane origin 10, got: %+v", issues) + if !containsMsg(issues, "Grid overlap") { + t.Errorf("expected a 3D grid overlap, got: %+v", issues) + } +} + +func TestGrid3DCleanViaUpDown(t *testing.T) { + // Clean 3D layout: up/down maintain same (x,y) across z-levels. + // 1(0,0,0) up→2(0,0,1) east→3(1,0,1) down→4(1,0,0) south→5(1,1,0) + rooms := map[int]string{ + 1: "exits:\n up: 2\n", + 2: "exits:\n east: 3\n down: 1\n", + 3: "exits:\n down: 4\n west: 2\n", + 4: "exits:\n south: 5\n up: 3\n", + 5: "name: five\n exits:\n north: 4\n", + } + if issues := runGridCheck(t, rooms, 1); len(issues) != 0 { + t.Errorf("expected no grid issues, got: %+v", issues) + } +} + +func TestGrid3DOverlapUpDown(t *testing.T) { + // Two rooms land on the same 3D cell via up/down paths. + // 1(0,0,0) up→2(0,0,1). + // 1(0,0,0) east→3(1,0,0) up→4(1,0,1) west→5 wants (0,0,1) — already room 2. + rooms := map[int]string{ + 1: "exits:\n up: 2\n east: 3\n", + 2: "name: two\n", + 3: "exits:\n up: 4\n west: 1\n", + 4: "exits:\n west: 5\n down: 3\n", + 5: "name: five\n", + } + issues := runGridCheck(t, rooms, 1) + if !containsMsg(issues, "Grid overlap") { + t.Errorf("expected a 3D grid overlap, got: %+v", issues) + } +} + +func TestGrid3DTwistUpDown(t *testing.T) { + // Room 4 forced to two different coordinates via up/down paths. + // path1: 1 up→2(0,0,1) south→3(0,1,1) east→4 places 4 at (1,1,1) + // path2: 1 east→A(1,0,0) up→B(1,0,1) east→C(2,0,1) south→4 wants (2,1,1) + // 4 already at (1,1,1) from path1 → twist. + rooms := map[int]string{ + 1: "exits:\n up: 2\n east: 6\n", + 2: "exits:\n south: 3\n down: 1\n", + 3: "exits:\n east: 4\n north: 2\n", + 4: "name: four\n", + 6: "exits:\n up: 7\n west: 1\n", + 7: "exits:\n east: 8\n down: 6\n", + 8: "exits:\n south: 4\n west: 7\n", + } + issues := runGridCheck(t, rooms, 1) + if !containsMsg(issues, "Grid twist") { + t.Errorf("expected a 3D grid twist, got: %+v", issues) } } diff --git a/internal/world/grid.go b/internal/world/grid.go new file mode 100644 index 0000000..837f737 --- /dev/null +++ b/internal/world/grid.go @@ -0,0 +1,95 @@ +package world + +// 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" + From int // room whose exit produced the conflict + Dir ExitDir // exit direction taken from From + Target int // room being placed + Want [3]int // grid cell Target was assigned + Existing [3]int // where Target was already placed (twist only) + Occupier int // room already at Want (overlap only) +} + +// RoomGrid is the embedding of one connected component onto a 3D grid. +type RoomGrid struct { + Coord map[int][3]int // room -> grid coordinate + RoomAt map[[3]int]int // grid coordinate -> room + Dist map[int]int // room -> BFS hop distance from the seed +} + +// BuildGrid lays out the rooms reachable from seed onto a 3D grid, assigning +// each room a coordinate by accumulating per-exit DirectionDeltas3D offsets in +// BFS order. The seed sits at the origin (0,0,0). +// +// - load returns a room and false to skip one that cannot be read; its exits +// are then not followed, but any coordinate already assigned to it stands. +// - include, when non-nil, restricts placement to targets for which it returns +// true (pass nil to place every reachable target). +// - onConflict, when non-nil, is called for each twist/overlap encountered. +// +// The first assignment to a room or cell always wins; conflicting placements are +// skipped. In a world that passes grid validation there are no conflicts, so the +// layout is unambiguous. +func BuildGrid(seed int, load func(int) (*Room, bool), include func(int) bool, onConflict func(GridConflict)) RoomGrid { + g := RoomGrid{ + Coord: map[int][3]int{seed: {0, 0, 0}}, + RoomAt: map[[3]int]int{{0, 0, 0}: seed}, + Dist: map[int]int{seed: 0}, + } + queue := []int{seed} + + for len(queue) > 0 { + rid := queue[0] + queue = queue[1:] + room, ok := load(rid) + if !ok { + continue + } + c := g.Coord[rid] + for _, dir := range ExitOrder { + exit, ok := room.Exits[dir] + if !ok || exit.Room <= 0 { + continue + } + target := exit.Room + if include != nil && !include(target) { + continue + } + d := DirectionDeltas3D[dir] + want := [3]int{c[0] + d[0], c[1] + d[1], c[2] + d[2]} + + if existing, seen := g.Coord[target]; seen { + if existing != want && onConflict != nil { + onConflict(GridConflict{ + Kind: "twist", From: rid, Dir: dir, + Target: target, Want: want, Existing: existing, + }) + } + continue + } + if occupier, used := g.RoomAt[want]; used && occupier != target { + if onConflict != nil { + onConflict(GridConflict{ + Kind: "overlap", From: rid, Dir: dir, + Target: target, Want: want, Occupier: occupier, + }) + } + continue + } + + g.Coord[target] = want + g.RoomAt[want] = target + g.Dist[target] = g.Dist[rid] + 1 + queue = append(queue, target) + } + } + + return g +} diff --git a/internal/world/room.go b/internal/world/room.go index 15f847f..7cc1f08 100644 --- a/internal/world/room.go +++ b/internal/world/room.go @@ -55,15 +55,17 @@ var ExitOrder = []ExitDir{ Up, Down, } -var DirectionDeltas = map[ExitDir][2]int{ - North: {0, -1}, - South: {0, 1}, - East: {1, 0}, - West: {-1, 0}, - Northeast: {1, -1}, - Northwest: {-1, -1}, - Southeast: {1, 1}, - Southwest: {-1, 1}, +var DirectionDeltas3D = map[ExitDir][3]int{ + North: {0, -1, 0}, + South: {0, 1, 0}, + East: {1, 0, 0}, + West: {-1, 0, 0}, + Northeast: {1, -1, 0}, + Northwest: {-1, -1, 0}, + Southeast: {1, 1, 0}, + Southwest: {-1, 1, 0}, + Up: {0, 0, 1}, + Down: {0, 0, -1}, } type SpawnDef struct { -- cgit v1.2.3