diff options
Diffstat (limited to 'internal/game')
35 files changed, 2073 insertions, 89 deletions
diff --git a/internal/game/act.go b/internal/game/act.go index 654b65c..aa25625 100644 --- a/internal/game/act.go +++ b/internal/game/act.go @@ -105,7 +105,7 @@ func (g *Game) startAction(sess *net.Session, verb, target string) { return } var err error - obj, err = g.resolveObjectDef(p.RoomID, chosen.DefID) + obj, _, err = g.resolveObjectDef(p.RoomID, chosen.DefID) if err != nil { sess.WriteLine(fmt.Sprintf("There's nothing here to %s.", verb)) return diff --git a/internal/game/cmd_close.go b/internal/game/cmd_close.go new file mode 100644 index 0000000..8dab0d9 --- /dev/null +++ b/internal/game/cmd_close.go @@ -0,0 +1,90 @@ +package game + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/world" +) + +func (g *Game) executeClose(sess *net.Session, args []string, rawInput string) { + if !g.checkAdmin(sess) { + sess.WriteLine("Unknown command.") + return + } + p := sess.Player + if p == nil { + return + } + if len(args) == 0 { + sess.WriteLine("Close which direction?") + return + } + + dir := g.World.ResolveExit(args[0]) + if dir == "" { + sess.WriteLine("Invalid direction. Use north/south/east/west/up/down.") + return + } + + curRoom, err := g.World.LoadRoom(p.RoomID) + if err != nil { + sess.WriteLine("Error loading current room.") + return + } + + curPath, ok := g.World.GetRoomPath(p.RoomID) + if !ok { + sess.WriteLine("Error: can't find current room file.") + return + } + + exitDef, ok := curRoom.Exits[dir] + if !ok { + sess.WriteLine(fmt.Sprintf("There is no exit to the %s from here.", dir)) + return + } + + targetID := exitDef.Room + targetRoom, err := g.World.LoadRoom(targetID) + targetName := fmt.Sprintf("#%d", targetID) + if err == nil { + targetName = fmt.Sprintf("#%d (%s)", targetID, targetRoom.Name) + } + + delete(curRoom.Exits, dir) + curData, err := yaml.Marshal(curRoom) + if err != nil { + sess.WriteLine("Error updating current room YAML.") + return + } + if err := os.WriteFile(curPath, curData, 0644); err != nil { + sess.WriteLine("Error writing current room file.") + return + } + + reciprocalRemoved := false + if targetRoom != nil { + oppositeDir := world.OppositeExit[dir] + if targetExit, tok := targetRoom.Exits[oppositeDir]; tok && targetExit.Room == p.RoomID { + delete(targetRoom.Exits, oppositeDir) + targetPath, tok2 := g.World.GetRoomPath(targetID) + if tok2 { + targetData, terr := yaml.Marshal(targetRoom) + if terr == nil { + if werr := os.WriteFile(targetPath, targetData, 0644); werr == nil { + reciprocalRemoved = true + } + } + } + } + } + + msg := fmt.Sprintf("Closed exit %s to %s.", dir, targetName) + if reciprocalRemoved { + msg += fmt.Sprintf(" Removed reciprocal exit from %s as well.", targetName) + } + sess.WriteLine(msg) +} diff --git a/internal/game/cmd_dig.go b/internal/game/cmd_dig.go new file mode 100644 index 0000000..e7023b2 --- /dev/null +++ b/internal/game/cmd_dig.go @@ -0,0 +1,281 @@ +package game + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "gopkg.in/yaml.v3" + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/world" +) + +func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) { + if !g.checkAdmin(sess) { + sess.WriteLine("Unknown command.") + return + } + p := sess.Player + if p == nil { + return + } + if len(args) == 0 { + sess.WriteLine("Dig where? (north/south/east/west/up/down)") + return + } + + dir := g.World.ResolveExit(args[0]) + if dir == "" { + sess.WriteLine("Invalid direction. Use north/south/east/west/up/down.") + return + } + + curPath, ok := g.World.GetRoomPath(p.RoomID) + if !ok { + sess.WriteLine("Error: can't find current room file.") + return + } + + curRoom, err := g.World.LoadRoom(p.RoomID) + if err != nil { + sess.WriteLine("Error loading current room.") + return + } + if _, hasExit := curRoom.Exits[dir]; hasExit { + sess.WriteLine(fmt.Sprintf("There is already an exit to the %s.", dir)) + return + } + + if conflictID := g.findGridConflict(p.RoomID, dir); conflictID != 0 { + conflictRoom, err := g.World.LoadRoom(conflictID) + conflictName := fmt.Sprintf("#%d", conflictID) + if err == nil { + conflictName = fmt.Sprintf("#%d (%s)", conflictID, conflictRoom.Name) + } + + oppositeDir := world.OppositeExit[dir] + curRoom.Exits[dir] = world.ExitDef{Room: conflictID} + curData, err := yaml.Marshal(curRoom) + if err != nil { + sess.WriteLine("Error updating current room YAML.") + return + } + if err := os.WriteFile(curPath, curData, 0644); err != nil { + sess.WriteLine("Error writing current room file.") + return + } + + if conflictRoom != nil { + if _, exists := conflictRoom.Exits[oppositeDir]; !exists { + conflictRoom.Exits[oppositeDir] = world.ExitDef{Room: p.RoomID} + conflictPath, ok := g.World.GetRoomPath(conflictID) + if ok { + conflictData, cerr := yaml.Marshal(conflictRoom) + if cerr == nil { + os.WriteFile(conflictPath, conflictData, 0644) + } + } + } + } + + sess.WriteLine(fmt.Sprintf("That spot is already occupied by %s. Created a two-way link instead.", conflictName)) + return + } + + newID, err := findNextRoomID(curPath) + if err != nil { + sess.WriteLine("Error scanning room directory.") + return + } + + var name string + if len(args) > 1 { + name = strings.Join(args[1:], " ") + } else { + name = "New Room" + } + + oppositeDir := world.OppositeExit[dir] + dirPath := filepath.Dir(curPath) + newPath := filepath.Join(dirPath, strconv.Itoa(newID)+".yaml") + + newRoom := &world.Room{ + Name: name, + Description: behavior.DescList{ + {Text: "A featureless room."}, + }, + Exits: map[world.ExitDir]world.ExitDef{ + oppositeDir: {Room: p.RoomID}, + }, + } + + data, err := yaml.Marshal(newRoom) + if err != nil { + sess.WriteLine("Error creating room YAML.") + return + } + if err := os.MkdirAll(dirPath, 0755); err != nil { + sess.WriteLine("Error creating directory.") + return + } + if err := os.WriteFile(newPath, data, 0644); err != nil { + sess.WriteLine("Error writing room file.") + return + } + + g.World.AddRoomPath(newID, newPath) + + curRoom.Exits[dir] = world.ExitDef{Room: newID} + curData, err := yaml.Marshal(curRoom) + if err != nil { + sess.WriteLine("Error updating current room YAML.") + return + } + if err := os.WriteFile(curPath, curData, 0644); err != nil { + sess.WriteLine("Error writing current room file.") + return + } + + oldRoom := p.RoomID + p.ClearMoveState() + if g.Combat.Get(p.Name) != nil { + g.stopCombat(p.Name) + } + p.Action = nil + g.cancelRest(p.Name) + g.cancelEnterSeq(p.Name) + if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom { + p.EnterSeqRoom = 0 + } + if ss, ok := g.safespot.Get(p.Name); ok { + g.forceLeaveSafespot(sess, p, &ss, "") + } + + p.RoomID = newID + p.HazardTimer = 0 + p.Stats.RecordRoomVisit(newID) + g.AccountStore.SaveCharacter(p) + + g.World.SeedGroundItems(newID) + g.seedRoomMobs(newID) + g.seedRoomObjects(newID) + + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(oldRoom) { + if other != sess { + other.WriteLine(fmt.Sprintf("%s digs to the %s and vanishes.", p.Name, dir)) + } + } + g.Hub.EnterRoom(sess, newID) + for _, other := range g.Hub.PlayersInRoom(newID) { + if other != sess { + other.WriteLine(fmt.Sprintf("%s appears from a freshly dug tunnel.", p.Name)) + } + } + } + + sess.WriteLine(fmt.Sprintf("You dig %s and create Room #%d (%s).", dir, newID, name)) + g.doLook(sess) + g.runEnterSteps(sess, newID) + g.checkAggro(sess) +} + +func findNextRoomID(currentRoomPath string) (int, error) { + dir := filepath.Dir(currentRoomPath) + entries, err := os.ReadDir(dir) + if err != nil { + return 0, err + } + + used := make(map[int]bool) + minID := 0 + first := true + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") { + continue + } + idStr := strings.TrimSuffix(entry.Name(), ".yaml") + id, err := strconv.Atoi(idStr) + if err != nil { + continue + } + used[id] = true + if first || id < minID { + minID = id + first = false + } + } + + if first { + return 1, nil + } + + for id := minID; ; id++ { + if !used[id] { + return id, nil + } + } +} + +var gridDeltas = map[world.ExitDir][2]int{ + world.North: {0, -1}, + world.South: {0, 1}, + world.East: {1, 0}, + world.West: {-1, 0}, +} + +func (g *Game) findGridConflict(fromRoomID int, dir world.ExitDir) int { + delta, ok := gridDeltas[dir] + if !ok { + return 0 + } + + 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 + } + target := exit.Room + if ed == world.Up || ed == world.Down { + continue + } + gd, ok := gridDeltas[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) + } + } + + targetCoord := [2]int{delta[0], delta[1]} + if occupier, ok := roomAt[targetCoord]; ok && occupier != fromRoomID { + return occupier + } + return 0 +} diff --git a/internal/game/cmd_farm.go b/internal/game/cmd_farm.go index 87d3d81..19cdf12 100644 --- a/internal/game/cmd_farm.go +++ b/internal/game/cmd_farm.go @@ -59,14 +59,6 @@ func (g *Game) executeCure(sess *net.Session, args []string, rawInput string) { } } -func (g *Game) executeInspect(sess *net.Session, args []string, rawInput string) { - if len(args) == 0 { - g.doInspect(sess, "") - } else { - g.doInspect(sess, strings.Join(args, " ")) - } -} - func (g *Game) doPlant(sess *net.Session, input string) { p := sess.Player @@ -447,61 +439,64 @@ func (g *Game) doInspect(sess *net.Session, input string) { } sess.WriteLine(fmt.Sprintf("=== %s%s ===", patchName, indexStr)) + g.ShowFarmPatchDetail(sess, p, fp.prefix, fp.defID, fp.index) + } +} - weeds, _ := p.Flags[fp.prefix+"_weeds"].(bool) - if weeds { - sess.WriteLine(" Status: Weeds") - sess.WriteLine(" (Rake to clear before planting)") - continue - } - - seedID, _ := p.Flags[fp.prefix+"_seed"].(string) - if seedID == "" { - sess.WriteLine(" Status: Empty") - sess.WriteLine(" (Ready to plant)") - continue - } - - seedName := seedID - if def, err := g.ItemStore.Load(seedID); err == nil { - seedName = def.Name - } +func (g *Game) ShowFarmPatchDetail(sess *net.Session, p *player.Player, prefix, defID string, index int) { + weeds, _ := p.Flags[prefix+"_weeds"].(bool) + if weeds { + sess.WriteLine(" Status: Weeds") + sess.WriteLine(" (Rake to clear before planting)") + return + } - dead, _ := p.Flags[fp.prefix+"_dead"].(bool) - if dead { - sess.WriteLine(fmt.Sprintf(" Status: Dead")) - sess.WriteLine(fmt.Sprintf(" Planted: %s", seedName)) - sess.WriteLine(" (Rake to clear)") - continue - } + seedID, _ := p.Flags[prefix+"_seed"].(string) + if seedID == "" { + sess.WriteLine(" Status: Empty") + sess.WriteLine(" (Ready to plant)") + return + } - diseased, _ := p.Flags[fp.prefix+"_diseased"].(bool) - if diseased { - sess.WriteLine(fmt.Sprintf(" Status: Diseased!")) - sess.WriteLine(fmt.Sprintf(" Planted: %s", seedName)) - sess.WriteLine(" (Use plant cure to save it)") - continue - } + seedName := seedID + if def, err := g.ItemStore.Load(seedID); err == nil { + seedName = def.Name + } - ready, _ := p.Flags[fp.prefix+"_ready"].(bool) - if ready { - sess.WriteLine(fmt.Sprintf(" Status: Ready to harvest!")) - sess.WriteLine(fmt.Sprintf(" Planted: %s", seedName)) - continue - } + dead, _ := p.Flags[prefix+"_dead"].(bool) + if dead { + sess.WriteLine(fmt.Sprintf(" Status: Dead")) + sess.WriteLine(fmt.Sprintf(" Planted: %s", seedName)) + sess.WriteLine(" (Rake to clear)") + return + } - stage := intFromFlag(p.Flags, fp.prefix+"_stage") - maxStages := 4 - if def, err := g.ItemStore.Load(seedID); err == nil && def.FarmStages > 0 { - maxStages = def.FarmStages - } - watered, _ := p.Flags[fp.prefix+"_watered"].(bool) + diseased, _ := p.Flags[prefix+"_diseased"].(bool) + if diseased { + sess.WriteLine(fmt.Sprintf(" Status: Diseased!")) + sess.WriteLine(fmt.Sprintf(" Planted: %s", seedName)) + sess.WriteLine(" (Use plant cure to save it)") + return + } - sess.WriteLine(fmt.Sprintf(" Status: Growing (stage %d/%d)", stage, maxStages)) + ready, _ := p.Flags[prefix+"_ready"].(bool) + if ready { + sess.WriteLine(fmt.Sprintf(" Status: Ready to harvest!")) sess.WriteLine(fmt.Sprintf(" Planted: %s", seedName)) - sess.WriteLine(fmt.Sprintf(" Watered: %s", boolToYes(watered))) - sess.WriteLine(fmt.Sprintf(" Diseased: %s", boolToYes(diseased))) + return + } + + stage := intFromFlag(p.Flags, prefix+"_stage") + maxStages := 4 + if def, err := g.ItemStore.Load(seedID); err == nil && def.FarmStages > 0 { + maxStages = def.FarmStages } + watered, _ := p.Flags[prefix+"_watered"].(bool) + + sess.WriteLine(fmt.Sprintf(" Status: Growing (stage %d/%d)", stage, maxStages)) + sess.WriteLine(fmt.Sprintf(" Planted: %s", seedName)) + sess.WriteLine(fmt.Sprintf(" Watered: %s", boolToYes(watered))) + sess.WriteLine(fmt.Sprintf(" Diseased: %s", boolToYes(diseased))) } func boolToYes(b bool) string { diff --git a/internal/game/cmd_god.go b/internal/game/cmd_god.go new file mode 100644 index 0000000..0545c3f --- /dev/null +++ b/internal/game/cmd_god.go @@ -0,0 +1,63 @@ +package game + +import ( + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) executeGod(sess *net.Session, args []string, rawInput string) { + if !g.checkAdmin(sess) { + sess.WriteLine("Unknown command.") + return + } + p := sess.Player + if p == nil { + return + } + if p.GodMode { + sess.WriteLine("You are already in god mode.") + return + } + + p.GodBackup = make(map[player.SkillName]int, len(p.Skills)) + for k, v := range p.Skills { + p.GodBackup[k] = v + } + + lvl99xp := player.XPForLevel(99) + for _, s := range player.AllSkills { + p.Skills[s] = lvl99xp + } + p.GodMode = true + p.HP = p.MaxHP() + + sess.WriteLine("God mode activated. All skills set to 99.") +} + +func (g *Game) executeUnGod(sess *net.Session, args []string, rawInput string) { + if !g.checkAdmin(sess) { + sess.WriteLine("Unknown command.") + return + } + p := sess.Player + if p == nil { + return + } + if !p.GodMode { + sess.WriteLine("You are not in god mode.") + return + } + g.restoreGodPlayer(p) + sess.WriteLine("God mode deactivated. Skills restored.") +} + +func (g *Game) restoreGodPlayer(p *player.Player) { + if p == nil || !p.GodMode { + return + } + if p.GodBackup != nil { + p.Skills = p.GodBackup + } + p.GodMode = false + p.GodBackup = nil +} diff --git a/internal/game/cmd_goto.go b/internal/game/cmd_goto.go new file mode 100644 index 0000000..41e7b37 --- /dev/null +++ b/internal/game/cmd_goto.go @@ -0,0 +1,74 @@ +package game + +import ( + "fmt" + "strconv" + + "thehouseoficarus/internal/net" +) + +func (g *Game) executeGoto(sess *net.Session, args []string, rawInput string) { + if !g.checkAdmin(sess) { + sess.WriteLine("Unknown command.") + return + } + p := sess.Player + if p == nil { + return + } + if len(args) == 0 { + sess.WriteLine("Goto where?") + return + } + roomID, err := strconv.Atoi(args[0]) + if err != nil { + sess.WriteLine("Invalid room ID.") + return + } + if _, err := g.World.LoadRoom(roomID); err != nil { + sess.WriteLine(fmt.Sprintf("Room %d not found.", roomID)) + return + } + + oldRoom := p.RoomID + p.ClearMoveState() + if g.Combat.Get(p.Name) != nil { + g.stopCombat(p.Name) + } + p.Action = nil + g.cancelRest(p.Name) + g.cancelEnterSeq(p.Name) + if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom { + p.EnterSeqRoom = 0 + } + if ss, ok := g.safespot.Get(p.Name); ok { + g.forceLeaveSafespot(sess, p, &ss, "") + } + + p.RoomID = roomID + p.HazardTimer = 0 + p.Stats.RecordRoomVisit(roomID) + g.AccountStore.SaveCharacter(p) + + g.World.SeedGroundItems(roomID) + g.seedRoomMobs(roomID) + g.seedRoomObjects(roomID) + + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(oldRoom) { + if other != sess { + other.WriteLine(fmt.Sprintf("%s vanishes.", p.Name)) + } + } + g.Hub.EnterRoom(sess, roomID) + for _, other := range g.Hub.PlayersInRoom(roomID) { + if other != sess { + other.WriteLine(fmt.Sprintf("%s appears.", p.Name)) + } + } + } + + g.doLook(sess) + g.runEnterSteps(sess, roomID) + g.checkAggro(sess) +} diff --git a/internal/game/cmd_inspect.go b/internal/game/cmd_inspect.go new file mode 100644 index 0000000..62392b7 --- /dev/null +++ b/internal/game/cmd_inspect.go @@ -0,0 +1,147 @@ +package game + +import ( + "fmt" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/world" +) + +func (g *Game) executeInspect(sess *net.Session, args []string, rawInput string) { + if !g.checkAdmin(sess) { + sess.WriteLine("Unknown command.") + return + } + p := sess.Player + if p == nil { + return + } + + room, err := g.World.LoadRoom(p.RoomID) + if err != nil { + sess.WriteLine("Error loading room.") + return + } + + sess.WriteLine(fmt.Sprintf("=== Room #%d: %s ===", room.ID, room.Name)) + if room.Color != "" { + sess.WriteLine(fmt.Sprintf("Color: %s", room.Color)) + } + if room.Hazard != "" { + hazardName := room.Hazard + if hz, herr := g.World.LoadHazard(room.Hazard); herr == nil && hz.Name != "" { + hazardName = hz.Name + } + sess.WriteLine(fmt.Sprintf("Hazard: %s (%s)", room.Hazard, hazardName)) + } + if room.BlockTransport { + sess.WriteLine("BlockTransport: true") + } + + sess.WriteLine(fmt.Sprintf("\nExits (%d):", len(room.Exits))) + for _, dir := range world.ExitOrder { + if exit, ok := room.Exits[dir]; ok { + cond := "" + if exit.Condition != nil { + cond = " [conditional]" + } + sess.WriteLine(fmt.Sprintf(" %s -> Room #%d%s", dir, exit.Room, cond)) + } + } + + sess.WriteLine(fmt.Sprintf("\nObjects (%d):", len(room.Objects))) + for _, obj := range room.Objects { + kind := "global" + if obj.Local != nil { + kind = "local" + } + hidden := "" + if obj.Local != nil && obj.Local.Hidden { + hidden = " [hidden]" + } else if obj.HiddenOverride { + hidden = " [hidden]" + } + wander := "" + if len(obj.WanderRooms) > 0 { + wander = fmt.Sprintf(" [wanders:%d,%.0fs]", len(obj.WanderRooms), obj.WanderInterval) + } + sess.WriteLine(fmt.Sprintf(" %s (%s)%s%s", obj.ID, kind, hidden, wander)) + } + + sess.WriteLine(fmt.Sprintf("\nMobs (%d):", len(room.Mobs))) + for _, mob := range room.Mobs { + wander := "" + if mob.WanderInterval > 0 { + wander = fmt.Sprintf(" [wander:%.0ft]", mob.WanderInterval) + } + sess.WriteLine(fmt.Sprintf(" %s%s", mob.ID, wander)) + } + + if len(room.ItemSpawns) > 0 { + sess.WriteLine(fmt.Sprintf("\nItem Spawns (%d):", len(room.ItemSpawns))) + for _, spawn := range room.ItemSpawns { + sess.WriteLine(fmt.Sprintf(" %s x%d (respawn: %.0ft)", spawn.ID, spawn.Quantity, spawn.RespawnTicks)) + } + } + + if len(room.OnEnter) > 0 { + sess.WriteLine(fmt.Sprintf("\nOn-Enter Steps: %d", len(room.OnEnter))) + } + + if len(room.Triggers) > 0 { + sess.WriteLine(fmt.Sprintf("\nRoom Triggers: %d", len(room.Triggers))) + for _, trigger := range room.Triggers { + kind := "" + if trigger.OnFlag != "" { + kind = fmt.Sprintf("world flag %q", trigger.OnFlag) + } else if trigger.OnPlayerFlag != "" { + kind = fmt.Sprintf("player flag %q", trigger.OnPlayerFlag) + } + sess.WriteLine(fmt.Sprintf(" %s: %s (%d steps)", trigger.ID, kind, len(trigger.Steps))) + } + } + + sess.WriteLine("\n--- World Flags ---") + allFlags := g.Flags.All() + if len(allFlags) == 0 { + sess.WriteLine(" (none)") + } else { + for k, v := range allFlags { + sess.WriteLine(fmt.Sprintf(" %s = %v", k, v)) + } + } + + sess.WriteLine("\n--- Your Flags ---") + p.EnsureFlags() + if len(p.Flags) == 0 { + sess.WriteLine(" (none)") + } else { + for k, v := range p.Flags { + sess.WriteLine(fmt.Sprintf(" %s = %v", k, v)) + } + } + + if g.Hub != nil { + players := g.Hub.PlayersInRoom(p.RoomID) + sess.WriteLine(fmt.Sprintf("\n--- Players Here (%d) ---", len(players))) + for _, ps := range players { + if ps.Player != nil { + marker := "" + if ps.Player.Name == p.Name { + marker = " (you)" + } + sess.WriteLine(fmt.Sprintf(" %s%s", ps.Player.Name, marker)) + } + } + } + + mobs := g.MobStore.MobsInRoom(p.RoomID) + if len(mobs) > 0 { + sess.WriteLine(fmt.Sprintf("\n--- Live Mobs Here (%d) ---", len(mobs))) + for _, m := range mobs { + sess.WriteLine(fmt.Sprintf(" %s (HP: %d/%d, def: %s)", m.Name, m.HP, m.MaxHP, m.DefID)) + } + } + + sess.WriteLine("") +} diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go index 4bf227c..54c94c4 100644 --- a/internal/game/cmd_move.go +++ b/internal/game/cmd_move.go @@ -31,7 +31,7 @@ func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) { return } - if exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) { + if !p.GodMode && exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) { msg := exitDef.BlockedMessage if msg == "" { msg = fmt.Sprintf("The way %s is blocked.", exitDir) @@ -247,8 +247,8 @@ func (g *Game) seedRoomObjects(roomID int) { g.World.EnsureObjectStates(roomID, ids) for _, obj := range room.Objects { var def *object.ObjectDef - if obj.Inline != nil { - def = obj.Inline + if obj.Local != nil { + def = obj.Local } else { var err error def, err = g.ObjectStore.Load(obj.ID) diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go index 60b1495..12d3879 100644 --- a/internal/game/cmd_quit.go +++ b/internal/game/cmd_quit.go @@ -33,6 +33,7 @@ func (g *Game) doQuit(sess *net.Session) { sess.WriteLine("You close your eyes...") case 0: g.cancelRest(p.Name) + g.restoreGodPlayer(p) g.AccountStore.SaveCharacter(p) g.charsMu.Lock() delete(g.loggedInChars, p.Name) diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index 2cf0de1..562fc71 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -135,6 +135,19 @@ var commandRegistry = map[string]commandDef{ "safespot": {(*Game).executeHide, ClassActive}, "unhide": {(*Game).executeUnhide, ClassActive}, "unsafespot": {(*Game).executeUnhide, ClassActive}, + "goto": {(*Game).executeGoto, ClassInstant}, + "summon": {(*Game).executeSummon, ClassInstant}, + "dig": {(*Game).executeDig, ClassInstant}, + "setflag": {(*Game).executeSetFlag, ClassInstant}, + "setplayerflag": {(*Game).executeSetPlayerFlag, ClassInstant}, + "reload": {(*Game).executeReload, ClassInstant}, + "shutdown": {(*Game).executeShutdown, ClassInstant}, + "room": {(*Game).executeRoom, ClassInstant}, + "swapid": {(*Game).executeSwapID, ClassInstant}, + "undig": {(*Game).executeUndig, ClassInstant}, + "close": {(*Game).executeClose, ClassInstant}, + "god": {(*Game).executeGod, ClassInstant}, + "ungod": {(*Game).executeUnGod, ClassInstant}, } func classifyCommand(cmd string) CommandClass { diff --git a/internal/game/cmd_reload.go b/internal/game/cmd_reload.go new file mode 100644 index 0000000..2b8230a --- /dev/null +++ b/internal/game/cmd_reload.go @@ -0,0 +1,37 @@ +package game + +import ( + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/net" +) + +func (g *Game) executeReload(sess *net.Session, args []string, rawInput string) { + if !g.checkAdmin(sess) { + sess.WriteLine("Unknown command.") + return + } + + g.ItemStore.Reload(g.DataDir) + g.ObjectStore.Reload(g.DataDir) + g.MobStore.ReloadDefs(g.DataDir) + behavior.ClearDropIndex() + + items, err := g.ItemStore.LoadAll() + if err == nil { + g.CraftIndex.Build(items) + } + + g.LoadTechs() + g.LoadMods() + + g.CourseStore.ReloadAll() + + g.World.RebuildRoomIndex(g.DataDir) + g.World.ClearHazardCache() + + g.TriggerStore.ClearTriggers() + g.TriggerStore.LoadGlobal(g.DataDir) + g.seedRoomTriggers() + + sess.WriteLine("All caches reloaded.") +} diff --git a/internal/game/cmd_room.go b/internal/game/cmd_room.go new file mode 100644 index 0000000..11f919a --- /dev/null +++ b/internal/game/cmd_room.go @@ -0,0 +1,118 @@ +package game + +import ( + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func (g *Game) executeRoom(sess *net.Session, args []string, rawInput string) { + if !g.checkAdmin(sess) { + sess.WriteLine("Unknown command.") + return + } + p := sess.Player + if p == nil { + return + } + if len(args) == 0 { + g.showRoomHelp(sess) + return + } + + action := strings.ToLower(args[0]) + rest := args[1:] + + switch action { + case "name": + g.roomSetName(sess, rest) + case "desc": + g.roomSetDesc(sess, rest) + case "addobj", "remobj", "addlocalobj", "remlocalobj", "hide", "unhide": + g.roomObject(sess, action, rest) + case "addmob", "remmob": + g.roomMob(sess, action, rest) + case "addspawn", "remspawn": + g.roomSpawn(sess, action, rest) + default: + sess.WriteLine(fmt.Sprintf("Unknown room action: %s", action)) + g.showRoomHelp(sess) + } +} + +func (g *Game) showRoomHelp(sess *net.Session) { + sess.WriteLine("room actions:") + sess.WriteLine(" room name <text> — set room name") + sess.WriteLine(" room desc <text> — set room description") + sess.WriteLine(" room addobj <object_id> — add a referenced object") + sess.WriteLine(" room remobj <object_id> — remove a referenced object") + sess.WriteLine(" room addlocalobj <name> <description> — add a local object") + sess.WriteLine(" room remlocalobj <name> — remove a local object") + sess.WriteLine(" room hide <name_or_id> — hide all matching objects") + sess.WriteLine(" room unhide <name_or_id> — unhide all matching objects") + sess.WriteLine(" room addmob <mob_id> — add a mob spawn") + sess.WriteLine(" room remmob <mob_id> — remove a mob spawn") + sess.WriteLine(" room addspawn <item_id> [qty] [respawn_ticks] — add an item spawn") + sess.WriteLine(" room remspawn <item_id> — remove an item spawn") +} + +func (g *Game) roomSetName(sess *net.Session, args []string) { + if len(args) == 0 { + sess.WriteLine("Usage: room name <text>") + return + } + text := strings.Join(args, " ") + g.writeRoom(sess, func(room *world.Room) { + room.Name = text + }) + sess.WriteLine(fmt.Sprintf("Room name set to: %s", text)) +} + +func (g *Game) roomSetDesc(sess *net.Session, args []string) { + if len(args) == 0 { + sess.WriteLine("Usage: room desc <text>") + return + } + text := strings.Join(args, " ") + g.writeRoom(sess, func(room *world.Room) { + room.Description = behavior.DescList{{Text: text}} + }) + sess.WriteLine("Room description updated.") +} + +func (g *Game) loadRoomWrite(p *player.Player) (*world.Room, string, error) { + path, ok := g.World.GetRoomPath(p.RoomID) + if !ok { + return nil, "", fmt.Errorf("room file not found") + } + room, err := g.World.LoadRoom(p.RoomID) + if err != nil { + return nil, "", err + } + return room, path, nil +} + +func (g *Game) writeRoom(sess *net.Session, fn func(*world.Room)) { + p := sess.Player + room, path, err := g.loadRoomWrite(p) + if err != nil { + sess.WriteLine(fmt.Sprintf("Error loading room: %v", err)) + return + } + fn(room) + data, err := yaml.Marshal(room) + if err != nil { + sess.WriteLine("Error marshaling room YAML.") + return + } + if err := os.WriteFile(path, data, 0644); err != nil { + sess.WriteLine("Error writing room file.") + return + } +} diff --git a/internal/game/cmd_room_mob.go b/internal/game/cmd_room_mob.go new file mode 100644 index 0000000..ced170f --- /dev/null +++ b/internal/game/cmd_room_mob.go @@ -0,0 +1,82 @@ +package game + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/world" +) + +func (g *Game) roomMob(sess *net.Session, action string, args []string) { + switch action { + case "addmob": + g.roomAddMob(sess, args) + case "remmob": + g.roomRemMob(sess, args) + } +} + +func (g *Game) roomAddMob(sess *net.Session, args []string) { + if len(args) == 0 { + sess.WriteLine("Usage: room addmob <mob_id>") + return + } + mobID := args[0] + if _, err := g.MobStore.LoadDef(mobID); err != nil { + sess.WriteLine(fmt.Sprintf("Mob '%s' not found in data/mobs/.", mobID)) + return + } + p := sess.Player + room, _, err := g.loadRoomWrite(p) + if err != nil { + sess.WriteLine(fmt.Sprintf("Error loading room: %v", err)) + return + } + for _, m := range room.Mobs { + if m.ID == mobID { + sess.WriteLine(fmt.Sprintf("Mob '%s' is already in this room.", mobID)) + return + } + } + room.Mobs = append(room.Mobs, world.RoomMob{ID: mobID}) + + path, _ := g.World.GetRoomPath(p.RoomID) + data, _ := yaml.Marshal(room) + os.WriteFile(path, data, 0644) + sess.WriteLine(fmt.Sprintf("Added mob '%s' to room.", mobID)) +} + +func (g *Game) roomRemMob(sess *net.Session, args []string) { + if len(args) == 0 { + sess.WriteLine("Usage: room remmob <mob_id>") + return + } + mobID := args[0] + p := sess.Player + room, _, err := g.loadRoomWrite(p) + if err != nil { + sess.WriteLine(fmt.Sprintf("Error loading room: %v", err)) + return + } + found := false + filtered := room.Mobs[:0] + for _, m := range room.Mobs { + if m.ID == mobID { + found = true + } else { + filtered = append(filtered, m) + } + } + if !found { + sess.WriteLine(fmt.Sprintf("Mob '%s' not found in this room.", mobID)) + return + } + room.Mobs = filtered + + path, _ := g.World.GetRoomPath(p.RoomID) + data, _ := yaml.Marshal(room) + os.WriteFile(path, data, 0644) + sess.WriteLine(fmt.Sprintf("Removed mob '%s' from room.", mobID)) +} diff --git a/internal/game/cmd_room_object.go b/internal/game/cmd_room_object.go new file mode 100644 index 0000000..f682d62 --- /dev/null +++ b/internal/game/cmd_room_object.go @@ -0,0 +1,235 @@ +package game + +import ( + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/object" + "thehouseoficarus/internal/world" +) + +func (g *Game) roomObject(sess *net.Session, action string, args []string) { + switch action { + case "addobj": + g.roomAddObj(sess, args) + case "remobj": + g.roomRemObj(sess, args) + case "addlocalobj": + g.roomAddLocalObj(sess, args) + case "remlocalobj": + g.roomRemLocalObj(sess, args) + case "hide": + g.roomHide(sess, args) + case "unhide": + g.roomUnhide(sess, args) + } +} + +func (g *Game) roomAddObj(sess *net.Session, args []string) { + if len(args) == 0 { + sess.WriteLine("Usage: room addobj <object_id>") + return + } + objID := args[0] + if _, err := g.ObjectStore.Load(objID); err != nil { + sess.WriteLine(fmt.Sprintf("Object '%s' not found in data/objects/.", objID)) + return + } + p := sess.Player + room, _, err := g.loadRoomWrite(p) + if err != nil { + sess.WriteLine(fmt.Sprintf("Error loading room: %v", err)) + return + } + for _, obj := range room.Objects { + if obj.ID == objID && obj.Local == nil { + sess.WriteLine(fmt.Sprintf("Object '%s' is already in this room.", objID)) + return + } + } + room.Objects = append(room.Objects, world.RoomObject{ID: objID}) + g.writeRoom(sess, func(r *world.Room) { *r = *room }) + sess.WriteLine(fmt.Sprintf("Added object '%s' to room.", objID)) +} + +func (g *Game) roomRemObj(sess *net.Session, args []string) { + if len(args) == 0 { + sess.WriteLine("Usage: room remobj <object_id>") + return + } + objID := args[0] + p := sess.Player + room, _, err := g.loadRoomWrite(p) + if err != nil { + sess.WriteLine(fmt.Sprintf("Error loading room: %v", err)) + return + } + found := false + filtered := room.Objects[:0] + for _, obj := range room.Objects { + if obj.ID == objID && obj.Local == nil { + found = true + } else { + filtered = append(filtered, obj) + } + } + if !found { + sess.WriteLine(fmt.Sprintf("Referenced object '%s' not found in this room.", objID)) + return + } + room.Objects = filtered + g.writeRoom(sess, func(r *world.Room) { *r = *room }) + sess.WriteLine(fmt.Sprintf("Removed object '%s' from room.", objID)) +} + +func (g *Game) roomAddLocalObj(sess *net.Session, args []string) { + if len(args) < 2 { + sess.WriteLine("Usage: room addlocalobj <name> <description>") + return + } + name := args[0] + desc := strings.Join(args[1:], " ") + + normName := world.NormalizeObjectName(name) + if normName == "" { + sess.WriteLine("Invalid object name.") + return + } + + p := sess.Player + room, _, err := g.loadRoomWrite(p) + if err != nil { + sess.WriteLine(fmt.Sprintf("Error loading room: %v", err)) + return + } + + for _, obj := range room.Objects { + if obj.Local != nil && world.NormalizeObjectName(obj.Local.Name) == normName { + sess.WriteLine(fmt.Sprintf("A local object named '%s' already exists in this room.", name)) + return + } + } + + room.Objects = append(room.Objects, world.RoomObject{ + ID: normName, + Local: &object.ObjectDef{ + Name: name, + Description: behavior.DescList{{Text: desc}}, + Aliases: []string{name}, + }, + }) + + path, _ := g.World.GetRoomPath(p.RoomID) + data, _ := yaml.Marshal(room) + os.WriteFile(path, data, 0644) + sess.WriteLine(fmt.Sprintf("Added local object '%s' to room.", name)) +} + +func (g *Game) roomRemLocalObj(sess *net.Session, args []string) { + if len(args) == 0 { + sess.WriteLine("Usage: room remlocalobj <name>") + return + } + name := args[0] + normName := world.NormalizeObjectName(name) + + p := sess.Player + room, _, err := g.loadRoomWrite(p) + if err != nil { + sess.WriteLine(fmt.Sprintf("Error loading room: %v", err)) + return + } + + found := false + filtered := room.Objects[:0] + for _, obj := range room.Objects { + if obj.Local != nil && world.NormalizeObjectName(obj.Local.Name) == normName { + found = true + } else { + filtered = append(filtered, obj) + } + } + if !found { + sess.WriteLine(fmt.Sprintf("No local object named '%s' found in this room.", name)) + return + } + room.Objects = filtered + + path, _ := g.World.GetRoomPath(p.RoomID) + data, _ := yaml.Marshal(room) + os.WriteFile(path, data, 0644) + sess.WriteLine(fmt.Sprintf("Removed local object '%s' from room.", name)) +} + +func (g *Game) roomHide(sess *net.Session, args []string) { + if len(args) == 0 { + sess.WriteLine("Usage: room hide <name_or_object_id>") + return + } + g.roomToggleHidden(sess, args[0], true) +} + +func (g *Game) roomUnhide(sess *net.Session, args []string) { + if len(args) == 0 { + sess.WriteLine("Usage: room unhide <name_or_object_id>") + return + } + g.roomToggleHidden(sess, args[0], false) +} + +func (g *Game) roomToggleHidden(sess *net.Session, input string, hidden bool) { + p := sess.Player + room, _, err := g.loadRoomWrite(p) + if err != nil { + sess.WriteLine(fmt.Sprintf("Error loading room: %v", err)) + return + } + + norm := world.NormalizeObjectName(input) + var count int + + for i := range room.Objects { + obj := &room.Objects[i] + if obj.Local != nil { + if world.NormalizeObjectName(obj.Local.Name) == norm { + obj.Local.Hidden = hidden + count++ + } + continue + } + if strings.EqualFold(obj.ID, input) { + obj.HiddenOverride = hidden + count++ + continue + } + if def, err := g.ObjectStore.Load(obj.ID); err == nil { + if world.NormalizeObjectName(def.Name) == norm { + obj.HiddenOverride = hidden + count++ + } + } + } + + if count == 0 { + label := "hide" + if !hidden { + label = "unhide" + } + sess.WriteLine(fmt.Sprintf("No objects matching '%s' found for %s.", input, label)) + return + } + + path, _ := g.World.GetRoomPath(p.RoomID) + data, _ := yaml.Marshal(room) + os.WriteFile(path, data, 0644) + + action := "Hidden" + if !hidden { + action = "Unhidden" + } + sess.WriteLine(fmt.Sprintf("%s %d object(s) matching '%s'.", action, count, input)) +} diff --git a/internal/game/cmd_room_spawn.go b/internal/game/cmd_room_spawn.go new file mode 100644 index 0000000..d4353e4 --- /dev/null +++ b/internal/game/cmd_room_spawn.go @@ -0,0 +1,101 @@ +package game + +import ( + "fmt" + "os" + "strconv" + + "gopkg.in/yaml.v3" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/world" +) + +func (g *Game) roomSpawn(sess *net.Session, action string, args []string) { + switch action { + case "addspawn": + g.roomAddSpawn(sess, args) + case "remspawn": + g.roomRemSpawn(sess, args) + } +} + +func (g *Game) roomAddSpawn(sess *net.Session, args []string) { + if len(args) == 0 { + sess.WriteLine("Usage: room addspawn <item_id> [quantity] [respawn_ticks]") + return + } + itemID := args[0] + if _, err := g.ItemStore.Load(itemID); err != nil { + sess.WriteLine(fmt.Sprintf("Item '%s' not found in data/items/.", itemID)) + return + } + + quantity := 1 + respawnTicks := float64(0) + if len(args) >= 2 { + if n, err := strconv.Atoi(args[1]); err == nil && n > 0 { + quantity = n + } + } + if len(args) >= 3 { + if n, err := strconv.ParseFloat(args[2], 64); err == nil && n >= 0 { + respawnTicks = n + } + } + + p := sess.Player + room, _, err := g.loadRoomWrite(p) + if err != nil { + sess.WriteLine(fmt.Sprintf("Error loading room: %v", err)) + return + } + for _, s := range room.ItemSpawns { + if s.ID == itemID { + sess.WriteLine(fmt.Sprintf("Item spawn '%s' already exists in this room.", itemID)) + return + } + } + room.ItemSpawns = append(room.ItemSpawns, world.SpawnDef{ + ID: itemID, + Quantity: quantity, + RespawnTicks: respawnTicks, + }) + + path, _ := g.World.GetRoomPath(p.RoomID) + data, _ := yaml.Marshal(room) + os.WriteFile(path, data, 0644) + sess.WriteLine(fmt.Sprintf("Added item spawn '%s' x%d (respawn: %.0f ticks) to room.", itemID, quantity, respawnTicks)) +} + +func (g *Game) roomRemSpawn(sess *net.Session, args []string) { + if len(args) == 0 { + sess.WriteLine("Usage: room remspawn <item_id>") + return + } + itemID := args[0] + p := sess.Player + room, _, err := g.loadRoomWrite(p) + if err != nil { + sess.WriteLine(fmt.Sprintf("Error loading room: %v", err)) + return + } + found := false + filtered := room.ItemSpawns[:0] + for _, s := range room.ItemSpawns { + if s.ID == itemID { + found = true + } else { + filtered = append(filtered, s) + } + } + if !found { + sess.WriteLine(fmt.Sprintf("Item spawn '%s' not found in this room.", itemID)) + return + } + room.ItemSpawns = filtered + + path, _ := g.World.GetRoomPath(p.RoomID) + data, _ := yaml.Marshal(room) + os.WriteFile(path, data, 0644) + sess.WriteLine(fmt.Sprintf("Removed item spawn '%s' from room.", itemID)) +} diff --git a/internal/game/cmd_setflag.go b/internal/game/cmd_setflag.go new file mode 100644 index 0000000..a18c635 --- /dev/null +++ b/internal/game/cmd_setflag.go @@ -0,0 +1,38 @@ +package game + +import ( + "fmt" + "strconv" + + "thehouseoficarus/internal/net" +) + +func (g *Game) executeSetFlag(sess *net.Session, args []string, rawInput string) { + if !g.checkAdmin(sess) { + sess.WriteLine("Unknown command.") + return + } + if len(args) == 0 { + sess.WriteLine("Usage: setflag <flag_name> [value]") + return + } + name := args[0] + var value any = true + if len(args) > 1 { + valStr := args[1] + switch valStr { + case "true": + value = true + case "false": + value = false + default: + if n, err := strconv.Atoi(valStr); err == nil { + value = n + } else { + value = valStr + } + } + } + g.Flags.Set(name, value) + sess.WriteLine(fmt.Sprintf("Flag '%s' set to %v.", name, value)) +} diff --git a/internal/game/cmd_setplayerflag.go b/internal/game/cmd_setplayerflag.go new file mode 100644 index 0000000..3d0bdbf --- /dev/null +++ b/internal/game/cmd_setplayerflag.go @@ -0,0 +1,60 @@ +package game + +import ( + "fmt" + "strconv" + "strings" + + "thehouseoficarus/internal/net" +) + +func (g *Game) executeSetPlayerFlag(sess *net.Session, args []string, rawInput string) { + if !g.checkAdmin(sess) { + sess.WriteLine("Unknown command.") + return + } + if len(args) < 2 { + sess.WriteLine("Usage: setplayerflag <player_name> <flag_name> [value]") + return + } + targetName := args[0] + flagName := args[1] + + g.charsMu.Lock() + targetSess, ok := g.loggedInChars[targetName] + g.charsMu.Unlock() + if !ok { + for _, other := range g.Hub.AllSessions() { + if other.Player != nil && strings.EqualFold(other.Player.Name, targetName) { + targetSess = other + break + } + } + } + if targetSess == nil || targetSess.Player == nil { + sess.WriteLine("Player not found.") + return + } + + tp := targetSess.Player + var value any = true + if len(args) > 2 { + valStr := args[2] + switch valStr { + case "true": + value = true + case "false": + value = false + default: + if n, err := strconv.Atoi(valStr); err == nil { + value = n + } else { + value = valStr + } + } + } + + g.setPlayerFlag(tp, flagName, value) + g.AccountStore.SaveCharacter(tp) + sess.WriteLine(fmt.Sprintf("Player flag '%s' set to %v for %s.", flagName, value, tp.Name)) +} diff --git a/internal/game/cmd_shutdown.go b/internal/game/cmd_shutdown.go new file mode 100644 index 0000000..0bb0f8f --- /dev/null +++ b/internal/game/cmd_shutdown.go @@ -0,0 +1,102 @@ +package game + +import ( + "fmt" + "os" + "strconv" + "time" + + "thehouseoficarus/internal/net" +) + +func (g *Game) executeShutdown(sess *net.Session, args []string, rawInput string) { + if !g.checkAdmin(sess) { + sess.WriteLine("Unknown command.") + return + } + if len(args) == 0 { + sess.WriteLine("Usage: shutdown <minutes> or shutdown cancel") + return + } + + if args[0] == "cancel" { + g.shutdownMu.Lock() + if !g.shutdownActive { + g.shutdownMu.Unlock() + sess.WriteLine("No shutdown in progress.") + return + } + close(g.shutdownCancel) + g.shutdownActive = false + g.shutdownMu.Unlock() + + for _, other := range g.Hub.AllSessions() { + if other.Player != nil { + other.WriteLine("[Server] Shutdown cancelled.") + } + } + return + } + + minutes, err := strconv.Atoi(args[0]) + if err != nil || minutes <= 0 { + sess.WriteLine("Invalid minutes.") + return + } + + g.shutdownMu.Lock() + if g.shutdownActive { + g.shutdownMu.Unlock() + sess.WriteLine("A shutdown is already in progress.") + return + } + g.shutdownActive = true + g.shutdownCancel = make(chan struct{}) + g.shutdownMu.Unlock() + + go func() { + remaining := minutes * 60 + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for _, other := range g.Hub.AllSessions() { + if other.Player != nil { + other.WriteLine(fmt.Sprintf("[Server] Shutting down in %d minute(s).", minutes)) + } + } + + for { + select { + case <-ticker.C: + remaining -= 30 + if remaining <= 0 { + for _, other := range g.Hub.AllSessions() { + if other.Player != nil { + other.WriteLine("[Server] Shutting down NOW.") + } + } + os.Exit(0) + } + mins := remaining / 60 + secs := remaining % 60 + if secs == 0 { + for _, other := range g.Hub.AllSessions() { + if other.Player != nil { + other.WriteLine(fmt.Sprintf("[Server] Shutting down in %d minute(s).", mins)) + } + } + } else { + for _, other := range g.Hub.AllSessions() { + if other.Player != nil { + other.WriteLine(fmt.Sprintf("[Server] Shutting down in %d minute(s) %d seconds.", mins, secs)) + } + } + } + case <-g.shutdownCancel: + return + } + } + }() + + sess.WriteLine(fmt.Sprintf("Shutdown scheduled in %d minute(s).", minutes)) +} diff --git a/internal/game/cmd_summon.go b/internal/game/cmd_summon.go new file mode 100644 index 0000000..3aa8d7b --- /dev/null +++ b/internal/game/cmd_summon.go @@ -0,0 +1,92 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/net" +) + +func (g *Game) executeSummon(sess *net.Session, args []string, rawInput string) { + if !g.checkAdmin(sess) { + sess.WriteLine("Unknown command.") + return + } + adminP := sess.Player + if adminP == nil { + return + } + if len(args) == 0 { + sess.WriteLine("Summon whom?") + return + } + targetName := args[0] + + g.charsMu.Lock() + targetSess, ok := g.loggedInChars[targetName] + g.charsMu.Unlock() + if !ok { + for _, other := range g.Hub.AllSessions() { + if other.Player != nil && strings.EqualFold(other.Player.Name, targetName) { + targetSess = other + break + } + } + } + if targetSess == nil || targetSess.Player == nil { + sess.WriteLine("Player not found.") + return + } + if targetSess == sess { + sess.WriteLine("You can't summon yourself.") + return + } + + tp := targetSess.Player + adminRoom := adminP.RoomID + oldRoom := tp.RoomID + + tp.ClearMoveState() + if g.Combat.Get(tp.Name) != nil { + g.stopCombat(tp.Name) + } + tp.Action = nil + g.cancelRest(tp.Name) + g.cancelEnterSeq(tp.Name) + if tp.EnterSeqRoom != 0 && tp.EnterSeqRoom == oldRoom { + tp.EnterSeqRoom = 0 + } + if ss, ok := g.safespot.Get(tp.Name); ok { + g.forceLeaveSafespot(targetSess, tp, &ss, "") + } + + tp.RoomID = adminRoom + tp.HazardTimer = 0 + tp.Stats.RecordRoomVisit(adminRoom) + g.AccountStore.SaveCharacter(tp) + + g.World.SeedGroundItems(adminRoom) + g.seedRoomMobs(adminRoom) + g.seedRoomObjects(adminRoom) + + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(oldRoom) { + if other != targetSess { + other.WriteLine(fmt.Sprintf("%s vanishes.", tp.Name)) + } + } + g.Hub.EnterRoom(targetSess, adminRoom) + for _, other := range g.Hub.PlayersInRoom(adminRoom) { + if other != targetSess { + other.WriteLine(fmt.Sprintf("%s appears.", tp.Name)) + } + } + } + + targetSess.WriteLine(fmt.Sprintf("You have been summoned by %s.", adminP.Name)) + sess.WriteLine(fmt.Sprintf("Summoned %s.", tp.Name)) + + g.doLook(targetSess) + g.runEnterSteps(targetSess, adminRoom) + g.checkAggro(targetSess) +} diff --git a/internal/game/cmd_swapid.go b/internal/game/cmd_swapid.go new file mode 100644 index 0000000..6ee1118 --- /dev/null +++ b/internal/game/cmd_swapid.go @@ -0,0 +1,126 @@ +package game + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + + "thehouseoficarus/internal/net" +) + +func (g *Game) executeSwapID(sess *net.Session, args []string, rawInput string) { + if !g.checkAdmin(sess) { + sess.WriteLine("Unknown command.") + return + } + p := sess.Player + if p == nil { + return + } + + var id1, id2 int + switch len(args) { + case 1: + id1 = p.RoomID + var err error + id2, err = strconv.Atoi(args[0]) + if err != nil { + sess.WriteLine("Invalid room ID.") + return + } + case 2: + var err error + id1, err = strconv.Atoi(args[0]) + if err != nil { + sess.WriteLine("Invalid room ID.") + return + } + id2, err = strconv.Atoi(args[1]) + if err != nil { + sess.WriteLine("Invalid room ID.") + return + } + default: + sess.WriteLine("Usage: swapid <id> or swapid <id1> <id2>") + return + } + + if id1 == id2 { + sess.WriteLine("Cannot swap a room with itself.") + return + } + + path1, ok1 := g.World.GetRoomPath(id1) + path2, ok2 := g.World.GetRoomPath(id2) + if !ok1 { + sess.WriteLine(fmt.Sprintf("Room %d not found.", id1)) + return + } + if !ok2 { + sess.WriteLine(fmt.Sprintf("Room %d not found.", id2)) + return + } + + roomsDir := filepath.Join(g.DataDir, "rooms") + re1 := regexp.MustCompile(fmt.Sprintf(`\b%d\b`, id1)) + re2 := regexp.MustCompile(fmt.Sprintf(`\b%d\b`, id2)) + placeholder := fmt.Sprintf("__SWAPID_TEMP_%d__", id1^id2) + + var modified []string + err := filepath.WalkDir(roomsDir, func(roomPath string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() || filepath.Ext(roomPath) != ".yaml" { + return nil + } + data, rerr := os.ReadFile(roomPath) + if rerr != nil { + return nil + } + text := string(data) + if !re1.MatchString(text) && !re2.MatchString(text) { + return nil + } + text = re1.ReplaceAllString(text, placeholder) + text = re2.ReplaceAllString(text, strconv.Itoa(id1)) + text = regexp.MustCompile(placeholder).ReplaceAllString(text, strconv.Itoa(id2)) + if werr := os.WriteFile(roomPath, []byte(text), 0644); werr != nil { + return werr + } + modified = append(modified, roomPath) + return nil + }) + if err != nil { + sess.WriteLine(fmt.Sprintf("Error updating room files: %v", err)) + return + } + + tempPath := path1 + ".swapid-tmp" + if rerr := os.Rename(path1, tempPath); rerr != nil { + sess.WriteLine(fmt.Sprintf("Error renaming room %d: %v", id1, rerr)) + return + } + if rerr := os.Rename(path2, path1); rerr != nil { + os.Rename(tempPath, path1) + sess.WriteLine(fmt.Sprintf("Error renaming room %d: %v", id2, rerr)) + return + } + if rerr := os.Rename(tempPath, path2); rerr != nil { + os.Rename(path2, path1) + os.Rename(tempPath, path1) + sess.WriteLine(fmt.Sprintf("Error finishing rename: %v", rerr)) + return + } + + g.World.RebuildRoomIndex(g.DataDir) + g.World.LoadRoom(id1) + g.World.LoadRoom(id2) + + if p.RoomID == id1 { + p.RoomID = id2 + } else if p.RoomID == id2 { + p.RoomID = id1 + } + + sess.WriteLine(fmt.Sprintf("Swapped room IDs %d and %d (%d files updated).", id1, id2, len(modified))) +} diff --git a/internal/game/cmd_undig.go b/internal/game/cmd_undig.go new file mode 100644 index 0000000..2cfae70 --- /dev/null +++ b/internal/game/cmd_undig.go @@ -0,0 +1,248 @@ +package game + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "gopkg.in/yaml.v3" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func (g *Game) executeUndig(sess *net.Session, args []string, rawInput string) { + if !g.checkAdmin(sess) { + sess.WriteLine("Unknown command.") + return + } + p := sess.Player + if p == nil { + return + } + if len(args) == 0 { + sess.WriteLine("Undig which direction?") + return + } + + dir := g.World.ResolveExit(args[0]) + if dir == "" { + sess.WriteLine("Invalid direction. Use north/south/east/west/up/down.") + return + } + + curRoom, err := g.World.LoadRoom(p.RoomID) + if err != nil { + sess.WriteLine("Error loading current room.") + return + } + + exitDef, ok := curRoom.Exits[dir] + if !ok { + sess.WriteLine(fmt.Sprintf("There is no exit to the %s from here.", dir)) + return + } + + targetID := exitDef.Room + targetRoom, err := g.World.LoadRoom(targetID) + if err != nil { + sess.WriteLine(fmt.Sprintf("Target room %d not found.", targetID)) + return + } + + sess.PendingUndigDir = string(dir) + sess.PendingUndigRoom = targetID + sess.State = net.StateUndigConfirm + + sess.WriteLine(fmt.Sprintf("You are about to DELETE Room #%d (%s) and all exits leading to it.", targetID, targetRoom.Name)) + sess.WriteLine("") + g.listUndigOrphans(sess, targetID) + sess.WriteLine("") + sess.WriteLine(fmt.Sprintf("Type UNDIG %s to confirm, or anything else to cancel.", strings.ToUpper(string(dir)))) +} + +func (g *Game) listUndigOrphans(sess *net.Session, targetID int) { + roomIndex := g.World.RoomIndex() + + inbound := make(map[int]bool) + roomsDir := filepath.Join(g.DataDir, "rooms") + re := regexp.MustCompile(fmt.Sprintf(`\b%d\b`, targetID)) + _ = filepath.WalkDir(roomsDir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() || filepath.Ext(path) != ".yaml" { + return nil + } + data, rerr := os.ReadFile(path) + if rerr != nil { + return nil + } + if re.MatchString(string(data)) { + idStr := strings.TrimSuffix(filepath.Base(path), ".yaml") + if rid, serr := strconv.Atoi(idStr); serr == nil && rid != targetID { + inbound[rid] = true + } + } + return nil + }) + + if len(inbound) == 0 { + return + } + + var orphans []int + for rid := range inbound { + room, err := g.World.LoadRoom(rid) + if err != nil { + continue + } + hasOtherExit := false + for _, exitDef := range room.Exits { + if roomIndex[exitDef.Room] && exitDef.Room != targetID { + hasOtherExit = true + break + } + } + if !hasOtherExit { + orphans = append(orphans, rid) + } + } + + if len(orphans) > 0 { + sess.WriteLine(g.colorize(sess, "warning", + fmt.Sprintf("WARNING: The following rooms will become unreachable after deletion:"))) + for _, rid := range orphans { + room, err := g.World.LoadRoom(rid) + name := fmt.Sprintf("#%d", rid) + if err == nil { + name = fmt.Sprintf("#%d (%s)", rid, room.Name) + } + sess.WriteLine(fmt.Sprintf(" %s", name)) + } + } +} + +func (g *Game) handleUndigConfirm(sess *net.Session, input string) { + p := sess.Player + if p == nil { + sess.State = net.StateGame + g.writePrompt(sess) + return + } + + choice := strings.TrimSpace(input) + dir := sess.PendingUndigDir + targetID := sess.PendingUndigRoom + sess.PendingUndigDir = "" + sess.PendingUndigRoom = 0 + sess.State = net.StateGame + + if dir == "" || targetID == 0 { + g.writePrompt(sess) + return + } + + expected := "UNDIG " + strings.ToUpper(dir) + if strings.ToUpper(choice) != expected { + sess.WriteLine("Undig cancelled.") + g.writePrompt(sess) + return + } + + g.performUndig(sess, p, targetID) + g.writePrompt(sess) +} + +func (g *Game) performUndig(sess *net.Session, p *player.Player, targetID int) { + targetRoom, err := g.World.LoadRoom(targetID) + roomName := fmt.Sprintf("#%d", targetID) + if err == nil { + roomName = fmt.Sprintf("#%d (%s)", targetID, targetRoom.Name) + } else { + sess.WriteLine(fmt.Sprintf("Target room %d no longer exists.", targetID)) + return + } + + roomsDir := filepath.Join(g.DataDir, "rooms") + re := regexp.MustCompile(fmt.Sprintf(`\b%d\b`, targetID)) + var cleaned int + _ = filepath.WalkDir(roomsDir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() || filepath.Ext(path) != ".yaml" { + return nil + } + data, rerr := os.ReadFile(path) + if rerr != nil { + return nil + } + if !re.MatchString(string(data)) { + return nil + } + + idStr := strings.TrimSuffix(filepath.Base(path), ".yaml") + rid, serr := strconv.Atoi(idStr) + if serr != nil || rid == targetID { + return nil + } + + var room world.Room + if yerr := yaml.Unmarshal(data, &room); yerr != nil { + return nil + } + if room.Exits == nil { + return nil + } + + changed := false + for ed, exitDef := range room.Exits { + if exitDef.Room == targetID { + delete(room.Exits, ed) + changed = true + } + } + + if changed { + newData, merr := yaml.Marshal(&room) + if merr != nil { + return nil + } + if werr := os.WriteFile(path, newData, 0644); werr != nil { + return werr + } + cleaned++ + } + return nil + }) + + targetPath, ok := g.World.GetRoomPath(targetID) + if !ok { + sess.WriteLine(fmt.Sprintf("Room file for %d not found.", targetID)) + return + } + if rerr := os.Remove(targetPath); rerr != nil { + sess.WriteLine(fmt.Sprintf("Error deleting room file: %v", rerr)) + return + } + + g.World.RebuildRoomIndex(g.DataDir) + + if p.RoomID == targetID { + p.RoomID = 0 + } + + for _, other := range g.Hub.AllSessions() { + if other.Player != nil && other.Player.RoomID == targetID { + other.Player.RoomID = p.RoomID + if g.Hub != nil { + g.Hub.EnterRoom(other, p.RoomID) + } + other.WriteLine(fmt.Sprintf("The room around you dissolves. You find yourself elsewhere.")) + g.doLook(other) + } + } + + g.MobStore.RemoveMobsInRoom(targetID) + g.World.ClearRoomState(targetID) + + sess.WriteLine(fmt.Sprintf("Deleted room %s and %d exit(s) pointing to it.", roomName, cleaned)) +} diff --git a/internal/game/cmd_verbs.go b/internal/game/cmd_verbs.go index f2c4a18..d07ef2f 100644 --- a/internal/game/cmd_verbs.go +++ b/internal/game/cmd_verbs.go @@ -27,7 +27,6 @@ var alwaysAvailable = []string{ "prompt", "alias", "unalias", "help", - "inspect", "queued", "stop", "aps", @@ -56,8 +55,8 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) { var sectionGround []string for _, st := range g.World.AllObjInstances(p.RoomID) { - def, err := g.resolveObjectDef(p.RoomID, st.DefID) - if err != nil || def.Hidden { + def, _, err := g.resolveObjectDef(p.RoomID, st.DefID) + if err != nil || (def.Hidden && !p.GodMode) { continue } name := strings.ToLower(def.Name) diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go index 7096b66..a4bf435 100644 --- a/internal/game/combat_mob.go +++ b/internal/game/combat_mob.go @@ -237,6 +237,12 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst // mob may be nil (e.g. a hazard kill); it is only used for Dead Man's Switch // retribution. func (g *Game) killPlayer(sess *net.Session, p *player.Player, mob *world.MobInstance) { + if p.GodMode { + p.HP = 1 + sess.WriteLine(g.colorize(sess, "miss", "Your divine power prevents death.")) + g.writePrompt(sess) + return + } g.Combat.Leave(p.Name) if p.HasActiveTech("retribution") { diff --git a/internal/game/core_admin.go b/internal/game/core_admin.go new file mode 100644 index 0000000..e1236ab --- /dev/null +++ b/internal/game/core_admin.go @@ -0,0 +1,7 @@ +package game + +import "thehouseoficarus/internal/net" + +func (g *Game) checkAdmin(sess *net.Session) bool { + return sess.Account != nil && sess.Account.Admin +} diff --git a/internal/game/core_course.go b/internal/game/core_course.go index 2b6b28f..5d5c7c5 100644 --- a/internal/game/core_course.go +++ b/internal/game/core_course.go @@ -77,6 +77,14 @@ func (cs *CourseStore) LoadAll() { cs.loadAllLocked() } +func (cs *CourseStore) ReloadAll() { + cs.mu.Lock() + cs.loaded = false + cs.courses = make(map[string]*CourseConfig) + cs.mu.Unlock() + cs.LoadAll() +} + // AllCourses returns the full course config map, loading on first access. func (cs *CourseStore) AllCourses() map[string]*CourseConfig { cs.mu.Lock() diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go index fc4a764..45dc2e8 100644 --- a/internal/game/core_login_account.go +++ b/internal/game/core_login_account.go @@ -71,6 +71,7 @@ func (g *Game) handlePassword(sess *net.Session, input string) { Aliases: acc.Aliases, Colors: acc.Colors, Options: acc.Options, + Admin: acc.Admin, } if sess.Account.Aliases == nil { sess.Account.Aliases = make(map[string]string) diff --git a/internal/game/flagstore.go b/internal/game/flagstore.go index bc72c60..ecefccf 100644 --- a/internal/game/flagstore.go +++ b/internal/game/flagstore.go @@ -71,3 +71,13 @@ func (f *FlagStore) Delete(name string) { 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/game.go b/internal/game/game.go index 22dd74d..bed75b0 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -62,11 +62,15 @@ type Game struct { loggedInChars map[string]*net.Session restTimers map[string]uint64 guardWatchTimers map[string]int - hackingStates map[string]*hacking.Session + hackingStates map[string]*hacking.Session pendingDepletions []pendingDepletion farmTickCounter int enterMu sync.Mutex enterSeqs map[string]*enterSeq + + shutdownCancel chan struct{} + shutdownActive bool + shutdownMu sync.Mutex } func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.ValidationConfig) *Game { @@ -108,6 +112,7 @@ func (g *Game) SetHub(hub *net.Hub) { g.Hub = hub hub.OnRemove(func(sess *net.Session) { if p := sess.Player; p != nil { + g.restoreGodPlayer(p) p.DeactivateAllTechs() g.cancelEnterSeq(p.Name) g.charsMu.Lock() @@ -173,6 +178,8 @@ func (g *Game) HandleSession(sess *net.Session, input string) { g.handleHackingInput(sess, input) case net.StateDangerConfirm: g.handleDangerConfirm(sess, input) + case net.StateUndigConfirm: + g.handleUndigConfirm(sess, input) } } diff --git a/internal/game/look_entities.go b/internal/game/look_entities.go index def505a..2a9a360 100644 --- a/internal/game/look_entities.go +++ b/internal/game/look_entities.go @@ -88,12 +88,12 @@ func (g *Game) showRoomObjects(sess *net.Session, p *player.Player, room *world. } for _, objID := range order { count := grouped[objID] - def, err := g.resolveObjectDef(p.RoomID, objID) + def, isLocal, err := g.resolveObjectDef(p.RoomID, objID) if err != nil { continue } - if def.Hidden { + if def.Hidden && !p.GodMode { continue } @@ -101,6 +101,20 @@ func (g *Game) showRoomObjects(sess *net.Session, p *player.Player, room *world. continue } + var godPrefix string + if p.GodMode { + var tags []string + if isLocal { + tags = append(tags, color.Render(g.colorMode(sess), color.ColorSpec{Bold: true}, "[LOCAL]")) + } else { + tags = append(tags, color.Render(g.colorMode(sess), color.ColorSpec{Bold: true}, "[GLOBAL]")) + } + if def.Hidden { + tags = append(tags, color.Render(g.colorMode(sess), color.ColorSpec{Bold: true}, "(HIDDEN)")) + } + godPrefix = strings.Join(tags, " ") + " " + } + type instInfo struct { idx int depleted bool @@ -163,7 +177,7 @@ func (g *Game) showRoomObjects(sess *net.Session, p *player.Player, room *world. if showTimers && len(instances) > 0 && instances[0].quality > 0 { qualityTimer = fmt.Sprintf(" (Burning for %d more ticks)", instances[0].quality) } - lines = append(lines, fmt.Sprintf("%s%s%s", line, suffix, qualityTimer)) + lines = append(lines, fmt.Sprintf("%s%s%s%s", godPrefix, line, suffix, qualityTimer)) } for _, ins := range timed { @@ -181,7 +195,7 @@ func (g *Game) showRoomObjects(sess *net.Session, p *player.Player, room *world. if showTimers { timer = fmt.Sprintf(" (despawn: %d/%d)", ins.sharedCur, ins.sharedMax) } - lines = append(lines, fmt.Sprintf("%s%s%s", line, tag, timer)) + lines = append(lines, fmt.Sprintf("%s%s%s%s", godPrefix, line, tag, timer)) } for _, ins := range depleted { @@ -199,7 +213,7 @@ func (g *Game) showRoomObjects(sess *net.Session, p *player.Player, room *world. if showTimers { timer = fmt.Sprintf(" (respawns in %d ticks)", ins.respawnIn) } - lines = append(lines, fmt.Sprintf("%s (depleted)%s%s", line, tag, timer)) + lines = append(lines, fmt.Sprintf("%s%s (depleted)%s%s", godPrefix, line, tag, timer)) } } diff --git a/internal/game/look_target.go b/internal/game/look_target.go index 743844f..915e4a7 100644 --- a/internal/game/look_target.go +++ b/internal/game/look_target.go @@ -27,12 +27,14 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { return } if exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) { - msg := exitDef.BlockedMessage - if msg == "" { - msg = fmt.Sprintf("The way %s is blocked.", exitDir) + if !p.GodMode { + msg := exitDef.BlockedMessage + if msg == "" { + msg = fmt.Sprintf("The way %s is blocked.", exitDir) + } + sess.WriteLine(msg) + return } - sess.WriteLine(msg) - return } g.World.SeedGroundItems(exitDef.Room) g.seedRoomMobs(exitDef.Room) @@ -104,7 +106,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { byDef := map[string][]world.ObjState{} descByDef := map[string]string{} for _, ist := range rawInstances { - def, err := g.resolveObjectDef(p.RoomID, ist.DefID) + def, _, err := g.resolveObjectDef(p.RoomID, ist.DefID) if err != nil { continue } @@ -122,7 +124,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { if len(presentDefs) > 1 { sess.WriteLine("That's ambiguous, which one?") for _, defID := range presentDefs { - if def, err := g.resolveObjectDef(p.RoomID, defID); err == nil { + if def, _, err := g.resolveObjectDef(p.RoomID, defID); err == nil { sess.WriteLine(fmt.Sprintf(" %s", def.Name)) } } @@ -133,7 +135,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { instances := byDef[presentDefs[0]] objDescText := descByDef[presentDefs[0]] st := &instances[0] - def, _ := g.resolveObjectDef(p.RoomID, st.DefID) + def, _, _ := g.resolveObjectDef(p.RoomID, st.DefID) if st.DefID == "estate_directory" { g.lookEstateDirectory(sess) @@ -199,9 +201,17 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { } } - farmSuffix := g.farmObjSuffix(p, st.DefID, st.Index) - if farmSuffix != "" { - sess.WriteLine(farmSuffix) + if farmPatchDefIDs[st.DefID] { + prefix := farmFlagPrefix(st.DefID, st.Index) + if prefix != "" { + g.ensureFarmState(p, prefix) + g.ShowFarmPatchDetail(sess, p, prefix, st.DefID, st.Index) + } + } else { + farmSuffix := g.farmObjSuffix(p, st.DefID, st.Index) + if farmSuffix != "" { + sess.WriteLine(farmSuffix) + } } return } diff --git a/internal/game/object_def.go b/internal/game/object_def.go index e27aeb0..6cf2464 100644 --- a/internal/game/object_def.go +++ b/internal/game/object_def.go @@ -4,30 +4,40 @@ 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 inline objects +// 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 inline objects, a file id +// 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: inline objects are guaranteed non-interactable +// 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, error) { +func (g *Game) resolveObjectDef(roomID int, defID string) (*object.ObjectDef, bool, error) { def, err := g.ObjectStore.Load(defID) if err == nil { - return def, 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.Inline != nil && ro.ID == defID { - d := *ro.Inline + if ro.Local != nil && ro.ID == defID { + d := *ro.Local d.ID = defID - return &d, nil + return &d, true, nil } } } - return nil, err + return nil, false, err } diff --git a/internal/game/render_map.go b/internal/game/render_map.go index ff9ffa5..58e0613 100644 --- a/internal/game/render_map.go +++ b/internal/game/render_map.go @@ -407,7 +407,7 @@ func exitStateTo(g *Game, sess *net.Session, from int, dir world.ExitDir, neighb if exit.Condition == nil || sess == nil || sess.Player == nil { return exitOpen } - if g.checkCondition(sess, exit.Condition) { + if sess.Player.GodMode || g.checkCondition(sess, exit.Condition) { return exitOpen } return exitBlocked diff --git a/internal/game/sys_combat.go b/internal/game/sys_combat.go index 19dbb98..bf4f882 100644 --- a/internal/game/sys_combat.go +++ b/internal/game/sys_combat.go @@ -71,6 +71,10 @@ func (g *Game) dropItemsOnDeath(p *player.Player) { func (g *Game) checkAggro(sess *net.Session) { p := sess.Player + if p.GodMode { + return + } + if g.Combat.Get(p.Name) != nil { return } diff --git a/internal/game/sys_hazard.go b/internal/game/sys_hazard.go index c3b52b7..971da99 100644 --- a/internal/game/sys_hazard.go +++ b/internal/game/sys_hazard.go @@ -26,6 +26,10 @@ func (g *Game) HazardTick() { continue } + if p.GodMode { + continue + } + room, err := g.World.LoadRoom(p.RoomID) if err != nil || room.Hazard == "" { p.HazardTimer = 0 diff --git a/internal/game/tick.go b/internal/game/tick.go index 7f593a4..eba8438 100644 --- a/internal/game/tick.go +++ b/internal/game/tick.go @@ -27,6 +27,7 @@ func (g *Game) DisconnectTick() { } sess.DisconnectTicks-- if sess.DisconnectTicks <= 0 { + g.restoreGodPlayer(p) g.AccountStore.SaveCharacter(p) g.Hub.HardRemove(sess) } |
