package game import ( "fmt" "strings" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/net" "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" ) func (g *Game) doMove(sess *net.Session, dir string, isWalk bool) { p := sess.Player exitDir := g.World.ResolveExit(dir) if exitDir == "" { sess.WriteLine("Go where?") return } room, err := g.World.LoadRoom(p.RoomID) if err != nil { sess.WriteLine("You can't move from here.") return } exitDef, ok := room.Exits[exitDir] if !ok { sess.WriteLine("You can't go that way.") return } if ed := g.exitDisplayState(sess, room.ID, exitDir, exitDef); ed.Blocked { msg := exitDef.BlockedMessage if msg == "" { msg = fmt.Sprintf("The way %s is blocked.", exitDir) } sess.WriteLine(msg) return } targetID := exitDef.Room targetRoom, err := g.World.LoadRoom(targetID) if err != nil { sess.WriteLine("That path seems blocked.") return } // Warn before walking from a safe room into a dangerous (hazardous) one. // Dangerous->dangerous never prompts (the current room is already hazardous). if targetRoom.Hazard != "" && room.Hazard == "" && p.OptionBool("danger_warning") { if p.PendingDangerDir == dir { p.PendingDangerDir = "" } else { p.PendingDangerDir = dir p.WalkSequence = nil sess.State = net.StateDangerConfirm name := targetRoom.Hazard if hz, herr := g.World.LoadHazard(targetRoom.Hazard); herr == nil && hz.Name != "" { name = hz.Name } sess.WriteLine(g.colorize(sess, "warning", fmt.Sprintf("WARNING: that area is exposed to %s.", name))) sess.WriteLine("Enter anyway? [Y/n]") return } } // Stash the first on_traverse interaction whose condition passes — it will // fire in completeMove once the player arrives. ExitDef.Condition (above, // evaluated by exitDisplayState) gates whether the exit is passable at // all; each on_traverse entry has its own additional Condition gate. p.MovePendingTrigger = g.matchExitTrigger(sess, p, exitDef) inCombat := g.Combat.Get(p.Name) != nil if !inCombat && p.Action != nil { g.cancelAction(p) } ticks, usesEnergy := g.moveTicks(p, isWalk) p.MoveDirection = string(exitDir) p.MoveTarget = targetID if ticks <= 0 { // Instant-move gear (Cape of Agility, GodMode): escape the miss-gate // entirely. Stash the usesEnergy flag so completeMove drains if due. p.MoveUsesEnergy = usesEnergy g.completeMove(sess, p) return } if inCombat { // Defer the move: stash it in the Escape* fields and wait for a mob // miss, mob death, or the 3-strike "power through" relief. Do NOT set // MoveUsesEnergy here — no move is in flight yet, and beginEscapeMove // recomputes (and stores) the flag when the gate fires. firstEscape := p.EscapeDir == "" p.EscapeDir = string(exitDir) p.EscapeTarget = targetID p.EscapeTrigger = p.MovePendingTrigger p.EscapeIsWalk = isWalk p.MovePendingTrigger = nil p.MoveDirection = "" p.MoveTarget = 0 if firstEscape { mobName := "" if cs := g.Combat.Get(p.Name); cs != nil { if mob := g.MobStore.GetInstance(cs.MobID); mob != nil { mobName = mobDisplayName(mob, false) } } if mobName != "" { sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf("You get ready to flee as soon as %s misses you...", mobName))) } } return } p.MoveUsesEnergy = usesEnergy p.MoveTicks = ticks } // matchExitTrigger returns the first on_traverse Trigger on exitDef whose // Condition (and item filter, n/a for traverse) passes for this player, or nil // if the exit has no matching on_traverse entry. The matched trigger's Steps // run as a sequence once the player arrives in the target room. func (g *Game) matchExitTrigger(sess *net.Session, p *player.Player, exitDef world.ExitDef) *behavior.Trigger { for i := range exitDef.OnTraverse { t := &exitDef.OnTraverse[i] if t.Condition != nil && !g.checkCondition(sess, t.Condition) { continue } return t } return nil } func (g *Game) completeMove(sess *net.Session, p *player.Player) { exitDir := p.MoveDirection targetID := p.MoveTarget pendingTrigger := p.MovePendingTrigger usesEnergy := p.MoveUsesEnergy p.ClearMoveState() // Drain one run energy point for moves that ran on energy (single moves // with energy, or run-walks enabled by the full Graceful set). Cape of // Agility and plain (non-full-set) walks never drain. if usesEnergy && p.HasRunEnergy() { p.RunEnergy-- } if g.Combat.Get(p.Name) != nil { g.stopCombat(p.Name) } p.Action = nil if ss, ok := g.safespot.Get(p.Name); ok { g.forceLeaveSafespot(sess, p, &ss, "") } oldRoom := p.RoomID if oldRoom != targetID && exitDir != "" { if room, err := g.World.LoadRoom(oldRoom); err == nil { if ed, ok := room.Exits[g.World.ResolveExit(exitDir)]; ok && ed.Hidden && ed.Room == targetID { g.markExitDiscovered(p, oldRoom, g.World.ResolveExit(exitDir)) } } } // Interruptable: an unlocked in-flight verb sequence is cancelled by the // move. (Locked sequences block movement entirely at the command router.) g.cancelUnlockedSequences(p.Name) // Fire the on_exit block of the room being left BEFORE updating RoomID, so // the exit scene resolves against the old room. if oldRoom != targetID { g.runExitSteps(sess, oldRoom) } p.RoomID = targetID p.HazardTimer = 0 p.Stats.RecordRoomVisit(targetID) // Fire the on_traverse trigger (if any matched at classification time) // AFTER p.RoomID is set to the new room, so effects like aps_node/teleport // operate on the destination as their reference frame. if pendingTrigger != nil { g.runTrigger(sess, p, []behavior.Trigger{*pendingTrigger}, p.RoomID, nil, false) } g.AccountStore.SaveCharacter(p) g.World.SeedGroundItems(p.RoomID) g.seedRoomMobs(p.RoomID) g.seedRoomObjects(p.RoomID) if g.Hub != nil { for _, other := range g.Hub.PlayersInRoom(oldRoom) { if other != sess { other.WriteLine(fmt.Sprintf("%s leaves to the %s.", p.Name, exitDir)) } } g.Hub.EnterRoom(sess, targetID) for _, other := range g.Hub.PlayersInRoom(targetID) { if other != sess { other.WriteLine(fmt.Sprintf("%s arrives.", p.Name)) } } } sess.WriteLine(fmt.Sprintf("You walk %s.", g.colorize(sess, "direction", exitDir))) if p.OptionBool("description") { g.doLook(sess) } else { targetRoom, _ := g.World.LoadRoom(targetID) if targetRoom != nil { sess.WriteLine(g.colorize(sess, "room_name", targetRoom.Name)) } } if p.OptionBool("automap") { g.doMap(sess) } g.runEnterSteps(sess, targetID) g.checkAggro(sess) } func (g *Game) seedRoomMobs(roomID int) { room, err := g.World.LoadRoom(roomID) if err != nil || len(room.Mobs) == 0 { return } g.MobStore.SeedMobs(roomID, room.Mobs) } func (g *Game) executeMove(sess *net.Session, args []string, rawInput string) { g.doMove(sess, strings.TrimSpace(rawInput), false) } // handleDangerConfirm processes the [Y/n] reply to the dangerous-area warning. // Pressing return (empty), "y", or "yes" enters; anything else cancels. func (g *Game) handleDangerConfirm(sess *net.Session, input string) { p := sess.Player if p == nil { sess.State = net.StateGame g.writePrompt(sess) return } dir := p.PendingDangerDir choice := strings.TrimSpace(strings.ToLower(input)) sess.State = net.StateGame if choice != "" && choice != "y" && choice != "yes" { p.PendingDangerDir = "" sess.WriteLine("You decide against it.") g.writePrompt(sess) return } if dir == "" { g.writePrompt(sess) return } g.doMove(sess, dir, false) g.writePrompt(sess) } func (g *Game) seedRoomObjects(roomID int) { room, err := g.World.LoadRoom(roomID) if err != nil { return } var ids []string for _, obj := range room.Objects { ids = append(ids, obj.ID) } g.World.EnsureObjectStates(roomID, ids) for _, obj := range room.Objects { var def *object.ObjectDef if obj.Local != nil { def = obj.Local } else { var err error def, err = g.ObjectStore.Load(obj.ID) if err != nil { continue } } g.World.SetObjName(roomID, obj.ID, def.Name) if len(def.Aliases) > 0 { g.World.SetObjAliases(roomID, obj.ID, def.Aliases) } if len(obj.WanderRooms) > 0 { g.World.SetObjWander(roomID, obj.ID, obj.WanderRooms, obj.WanderInterval) } if def.IsGatherable() && def.Gather.DepleteTimer > 0 { g.World.SetObjDepleteTimer(roomID, obj.ID, def.Gather.DepleteTimer) } } }