package game import ( "fmt" "strings" "thehouseoficarus/internal/color" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" ) // enterSeq is an in-flight on_enter stepped sequence for a single player. It is // driven one step at a time by EnterSeqTick. type enterSeq struct { sess *net.Session playerName string roomID int steps []world.EnterStep wait int } // runEnterSteps evaluates a room's on_enter script for the player. Steps whose // condition fails are dropped. If none of the surviving steps are "timed" // (no delay, no flag mutation) the messages are printed synchronously. // Otherwise the surviving steps become a scheduled enter sequence driven // by EnterSeqTick. func (g *Game) runEnterSteps(sess *net.Session, roomID int) { room, err := g.World.LoadRoom(roomID) if err != nil || len(room.OnEnter) == 0 { return } p := sess.Player if p == nil { return } var steps []world.EnterStep sequenced := false for _, step := range room.OnEnter { if step.Condition != nil && !g.checkCondition(sess, step.Condition) { continue } steps = append(steps, step) if step.IsTimed() { sequenced = true } } if len(steps) == 0 { return } if !sequenced { for _, step := range steps { if step.Message != "" { sess.WriteLine(step.Message) } } return } g.startEnterSeq(sess, p, roomID, steps) } // startEnterSeq registers a scheduled on_enter sequence and persists a marker // on the player so the sequence can be resumed on reconnect if interrupted // (disconnect, server restart). func (g *Game) startEnterSeq(sess *net.Session, p *player.Player, roomID int, steps []world.EnterStep) { g.cancelEnterSeq(p.Name) if p.EnterSeqRoom != roomID { p.EnterSeqRoom = roomID g.AccountStore.SaveCharacter(p) } g.enterMu.Lock() g.enterSeqs[p.Name] = &enterSeq{ sess: sess, playerName: p.Name, roomID: roomID, steps: steps, wait: steps[0].Delay, } g.enterMu.Unlock() } // EnterSeqTick advances every in-flight enter sequence by one tick. Sequences // for a player who is no longer connected are paused (left for resume); // sequences are removed from the map on completion or cancellation. func (g *Game) EnterSeqTick() { g.enterMu.Lock() seqs := make([]*enterSeq, 0, len(g.enterSeqs)) for _, s := range g.enterSeqs { seqs = append(seqs, s) } g.enterMu.Unlock() for _, seq := range seqs { if !g.isCharLive(seq.playerName, seq.sess) { continue } var jobs []world.EnterStep done := false g.enterMu.Lock() if g.enterSeqs[seq.playerName] != seq { g.enterMu.Unlock() continue } seq.wait-- for seq.wait <= 0 && len(seq.steps) > 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 } else { seq.wait = 0 } } if len(seq.steps) == 0 { done = true delete(g.enterSeqs, seq.playerName) } g.enterMu.Unlock() for _, step := range jobs { g.fireEnterStep(seq, step) } if done { g.completeEnterSeq(seq) } } } func (g *Game) fireEnterStep(seq *enterSeq, step world.EnterStep) { p := seq.sess.Player if p == nil || p.RoomID != seq.roomID { return } seqSpec := g.resolveColor(seq.sess, "sequence") broadcastSpec := g.resolveColor(seq.sess, "broadcast") mode := g.colorMode(seq.sess) if step.Message != "" { msg := expandTemplate(step.Message, p.Name, nil) msg = color.ExpandTagsDefault(mode, seqSpec, msg) seq.sess.WriteLine(msg) } if step.Broadcast != "" && g.Hub != nil { msg := expandTemplate(step.Broadcast, p.Name, nil) msg = color.ExpandTagsDefault(mode, broadcastSpec, msg) for _, other := range g.Hub.PlayersInRoom(seq.roomID) { other.WriteLine(g.colorize(other, "broadcast", msg)) } } if step.BroadcastGlobal != "" && g.Hub != nil { msg := expandTemplate(step.BroadcastGlobal, p.Name, nil) msg = color.ExpandTagsDefault(mode, broadcastSpec, msg) for _, other := range g.Hub.AllSessions() { if other.Player != nil { other.WriteLine(g.colorize(other, "broadcast", msg)) } } } if len(step.SetFlags) > 0 || len(step.SetPlayerFlags) > 0 { g.applyFlagMutations(p, step.SetFlags, step.SetPlayerFlags) g.AccountStore.SaveCharacter(p) } if step.SpawnMob != nil { g.spawnTriggerMob(seq.sess, p, step.SpawnMob, seq.roomID) } if step.DespawnMob != "" { g.despawnTriggerMobs(step.DespawnMob, p.Name) } if step.GiveItem != "" { slot := p.FirstFreeSlot() if slot == -1 { seq.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) seq.sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", step.Heal)) } if step.Teleport > 0 { g.teleportPlayer(seq.sess, p, step.Teleport) } } func (g *Game) completeEnterSeq(seq *enterSeq) { if !g.isCharLive(seq.playerName, seq.sess) { return } p := seq.sess.Player if p == nil { return } if p.EnterSeqRoom == seq.roomID { p.EnterSeqRoom = 0 g.AccountStore.SaveCharacter(p) } g.writePrompt(seq.sess) } // cancelEnterSeq drops any in-flight enter sequence for the player. It does not // touch the persisted EnterSeqRoom marker, so a sequence cancelled by // disconnect can still be resumed on reconnect. func (g *Game) cancelEnterSeq(name string) { g.enterMu.Lock() delete(g.enterSeqs, name) g.enterMu.Unlock() } func (g *Game) isCharLive(name string, sess *net.Session) bool { g.charsMu.Lock() defer g.charsMu.Unlock() return g.loggedInChars[name] == sess } // applyFlagMutations sets world and player flags, shared by on_enter steps and // exit traversal. func (g *Game) applyFlagMutations(p *player.Player, setFlags, setPlayerFlags map[string]any) { g.Flags.SetAll(setFlags) if len(setPlayerFlags) > 0 { for k, v := range setPlayerFlags { g.setPlayerFlag(p, k, v) } } } func (g *Game) BroadcastRespawns() { respawns := g.World.FlushObjRespawns() for _, st := range respawns { def, err := g.ObjectStore.Load(st.DefID) if err != nil || !def.IsGatherable() { continue } cfg := def.Gather if cfg.RespawnBroadcast == "" { continue } name := def.Name if idx := st.Index + 1; len(g.World.FindObjInstances(st.RoomID, st.DefID)) > 1 { name += fmt.Sprintf(" [%d]", idx) } msg := strings.ReplaceAll(cfg.RespawnBroadcast, "{name}", name) if g.Hub != nil { for _, sess := range g.Hub.PlayersInRoom(st.RoomID) { sess.WriteLine(g.colorize(sess, "broadcast", msg)) } } } }