diff options
| author | historia <[not public]> | 2026-06-10 01:48:28 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-06-10 01:48:28 -0400 |
| commit | a226d72e51eecb768b13600303f73483118d9104 (patch) | |
| tree | 458d15ef049732c15dacc591a830d3beddbedfde /internal/game/action.go | |
| parent | 6a0f3d7a252de4b1741cfe5e1c561412c602becc (diff) | |
| download | thehouseoficarus-a226d72e51eecb768b13600303f73483118d9104.tar.gz | |
feat: implemented janky object interaction model
Diffstat (limited to 'internal/game/action.go')
| -rw-r--r-- | internal/game/action.go | 765 |
1 files changed, 765 insertions, 0 deletions
diff --git a/internal/game/action.go b/internal/game/action.go new file mode 100644 index 0000000..029fec3 --- /dev/null +++ b/internal/game/action.go @@ -0,0 +1,765 @@ +package game + +import ( + "fmt" + "math/rand" + "sort" + "strconv" + "strings" + + "thirdcollapse/internal/action" + "thirdcollapse/internal/combat" + "thirdcollapse/internal/net" + "thirdcollapse/internal/object" + "thirdcollapse/internal/player" + "thirdcollapse/internal/world" +) + +func (g *Game) StartAction(sess *net.Session, verb, target string) { + p := sess.Player.(*player.Player) + + verb = normalizeVerb(verb) + + if combat.GetCombat(p.Name) != nil { + sess.WriteLine("You can't do that during combat!") + return + } + + if p.Action != nil { + g.CancelAction(p) + } + + lower := strings.ToLower(target) + instanceIdx := -1 + if dotPos := strings.Index(lower, "."); dotPos > 0 { + if n, err := strconv.Atoi(lower[:dotPos]); err == nil && n > 0 { + instanceIdx = n - 1 + lower = lower[dotPos+1:] + } + } + + instances := g.World.FindObjInstances(p.RoomID, lower) + sort.Slice(instances, func(i, j int) bool { + return instances[i].Index < instances[j].Index + }) + + var obj *object.ObjectDef + var mob *world.MobInstance + var behaviorID string + + if len(instances) > 0 { + var chosen *world.ObjState + if instanceIdx >= 0 && instanceIdx < len(instances) { + chosen = &instances[instanceIdx] + } else { + for i := range instances { + if !instances[i].Depleted { + chosen = &instances[i] + break + } + } + if chosen == nil { + chosen = &instances[0] + } + } + if chosen == nil { + sess.WriteLine(fmt.Sprintf("There's nothing here to %s.", verb)) + return + } + var err error + obj, err = g.ObjectStore.Load(chosen.DefID) + if err != nil || obj.BehaviorID == "" { + sess.WriteLine(fmt.Sprintf("You can't %s the %s.", verb, obj.Name)) + return + } + behaviorID = obj.BehaviorID + } else { + mobs := g.MobStore.MobsInRoom(p.RoomID) + var candidates []*world.MobInstance + for _, m := range mobs { + if m.BehaviorID == "" { + continue + } + if q := m.MatchQuality(lower); q >= world.MatchPrefix { + candidates = append(candidates, m) + } + } + if len(candidates) == 0 { + sess.WriteLine(fmt.Sprintf("There's nothing here to %s.", verb)) + return + } + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].InstanceID < candidates[j].InstanceID + }) + if instanceIdx >= 0 && instanceIdx < len(candidates) { + mob = candidates[instanceIdx] + } else if len(candidates) == 1 { + mob = candidates[0] + } else { + sess.WriteLine("Which one?") + return + } + behaviorID = mob.BehaviorID + } + + bh, err := g.BehaviorStore.Load(behaviorID) + if err != nil { + if obj != nil { + sess.WriteLine(fmt.Sprintf("Something is wrong with the %s.", obj.Name)) + } else { + sess.WriteLine("Something is wrong with that.") + } + return + } + + if bh.Type != verb { + if obj != nil { + sess.WriteLine(fmt.Sprintf("You can't %s the %s.", verb, obj.Name)) + } else { + sess.WriteLine(fmt.Sprintf("You can't %s that.", verb)) + } + return + } + + switch bh.Type { + case "gather": + if obj == nil { + sess.WriteLine(fmt.Sprintf("You can't %s that.", verb)) + return + } + // Find the actual ObjState for the chosen object + chosenObj := g.World.FindObjInstances(p.RoomID, obj.ID) + sort.Slice(chosenObj, func(i, j int) bool { + return chosenObj[i].Index < chosenObj[j].Index + }) + var st *world.ObjState + for i := range chosenObj { + if !chosenObj[i].Depleted { + st = &chosenObj[i] + break + } + } + if st == nil && len(chosenObj) > 0 { + st = &chosenObj[0] + } + if st == nil { + sess.WriteLine(fmt.Sprintf("There's nothing here to %s.", verb)) + return + } + g.startGather(sess, p, obj, st) + case "talk": + if mob != nil { + g.startMobTalk(sess, p, mob) + } else { + g.startTalk(sess, p, obj) + } + case "use": + g.startUse(sess, p, obj) + case "toggle": + g.startToggle(sess, p, obj) + default: + sess.WriteLine(fmt.Sprintf("You can't %s that.", verb)) + } +} + +func (g *Game) CancelAction(p *player.Player) { + p.Action = nil +} + +func (g *Game) AdvanceActions() { + if g.Hub == nil { + return + } + for _, sess := range g.Hub.AllSessions() { + p, ok := sess.Player.(*player.Player) + if !ok || p.Action == nil { + continue + } + if !p.Action.Advance() { + continue + } + switch p.Action.Type { + case "gather": + g.advanceGather(sess, p) + case "use": + g.advanceUse(sess, p) + } + } +} + +func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.ObjectDef, st *world.ObjState) { + cfg, err := g.BehaviorStore.LoadGather(obj.BehaviorID) + if err != nil { + sess.WriteLine("Something is wrong with this object.") + return + } + + skillLevel := p.Level(player.SkillName(cfg.Skill)) + if cfg.Level > 0 && skillLevel < cfg.Level { + sess.WriteLine(fmt.Sprintf("You need level %d %s to do that.", cfg.Level, cfg.Skill)) + return + } + + if st.Depleted { + sess.WriteLine(fmt.Sprintf("The %s is depleted for %d more ticks.", obj.Name, st.DepleteTimer)) + return + } + + wait := cfg.BaseWait + + if cfg.Tool != "" { + bestSpeed := -1 + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot == nil { + continue + } + def, err := g.ItemStore.Load(slot.ItemID) + if err == nil && def.ToolType == cfg.Tool && def.ToolSpeed > bestSpeed { + bestSpeed = def.ToolSpeed + } + } + for _, itemID := range p.Toolbelt { + def, err := g.ItemStore.Load(itemID) + if err == nil && def.ToolType == cfg.Tool && def.ToolSpeed > bestSpeed { + bestSpeed = def.ToolSpeed + } + } + if bestSpeed < 0 { + sess.WriteLine(fmt.Sprintf("You need a %s to mine the %s.", cfg.Tool, obj.Name)) + return + } + wait = cfg.BaseWait - bestSpeed + if wait < 1 { + wait = 1 + } + } + + if p.FirstFreeSlot() == -1 { + sess.WriteLine("Your inventory is too full!") + return + } + + p.Action = &action.Action{ + Type: "gather", + TargetID: st.DefID, + TargetName: obj.Name, + WaitLeft: 0, + Data: map[string]any{ + "behavior_id": obj.BehaviorID, + "instance_key": g.World.ObjStateKey(st.RoomID, st.DefID, st.Index), + "instance_idx": st.Index + 1, + "effective_wait": wait, + "step": 0, + }, + } +} + +func (g *Game) advanceGather(sess *net.Session, p *player.Player) { + behaviorID := p.Action.Data["behavior_id"].(string) + cfg, err := g.BehaviorStore.LoadGather(behaviorID) + if err != nil { + g.CancelAction(p) + return + } + + step := p.Action.Data["step"].(int) + wait := p.Action.Data["effective_wait"].(int) + instanceKey := p.Action.Data["instance_key"].(string) + + if step == 0 { + sess.WriteLine(fmt.Sprintf("\n%s", cfg.GatherMsg)) + p.Action.Data["step"] = 1 + p.Action.WaitLeft = wait + return + } + + if step == 2 { + st := g.World.GetObjStateByKey(instanceKey) + if st != nil && st.Depleted { + p.Action.WaitLeft = 3 + return + } + if cfg.RespawnMsg != "" { + sess.WriteLine(fmt.Sprintf("\n%s", cfg.RespawnMsg)) + } + p.Action.Data["step"] = 0 + p.Action.WaitLeft = wait + return + } + + skillLevel := p.Level(player.SkillName(cfg.Skill)) + chance := action.SuccessChance(cfg.Success, skillLevel, cfg.Level) + + if rand.Float64() < chance { + drop := g.BehaviorStore.ResolveDrop(cfg.Drops) + if drop != nil { + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + sess.WriteLine("Your inventory is too full!") + g.CancelAction(p) + return + } + + qty := drop.Quantity + if qty <= 0 { + qty = 1 + } + + itemName := drop.ItemID + if def, err := g.ItemStore.Load(drop.ItemID); err == nil { + itemName = def.Name + } + + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty}) + + msg := drop.Message + if msg == "" { + msg = fmt.Sprintf("You manage to get some %s.", itemName) + } + sess.WriteLine(msg) + + g.AccountStore.SaveCharacter(p) + + if drop.Depletes { + delay := cfg.DepleteDelay + if delay <= 0 { + delay = 10 + } + st := g.World.GetObjStateByKey(instanceKey) + if st != nil { + st.Depleted = true + st.DepleteTimer = delay + } + + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other == sess { + continue + } + op, ok := other.Player.(*player.Player) + if !ok || op.Action == nil || op.Action.Type != "gather" { + continue + } + if op.Action.Data["instance_key"] == instanceKey { + other.WriteLine(fmt.Sprintf("\nThe %s was depleted by %s!", p.Action.TargetName, p.Name)) + g.CancelAction(op) + } + } + + p.Action.Data["step"] = 2 + p.Action.WaitLeft = 1 + return + } + + // Non-depleting drop: loop back. Check inventory before looping. + if p.FirstFreeSlot() == -1 { + sess.WriteLine("Your inventory is too full!") + g.CancelAction(p) + return + } + p.Action.Data["step"] = 0 + p.Action.WaitLeft = wait + return + } + } + + // Failed: show fail message, loop back + sess.WriteLine(cfg.FailMsg) + if p.FirstFreeSlot() == -1 { + sess.WriteLine("Your inventory is too full!") + g.CancelAction(p) + return + } + p.Action.Data["step"] = 0 + p.Action.WaitLeft = wait +} + +func (g *Game) startMobTalk(sess *net.Session, p *player.Player, mob *world.MobInstance) { + cfg, err := g.BehaviorStore.LoadTalk(mob.BehaviorID) + if err != nil { + sess.WriteLine("This person has nothing to say.") + return + } + + if node, ok := cfg.Nodes["start"]; ok { + p.Action = &action.Action{ + Type: "talk", + TargetID: mob.DefID, + TargetName: mob.Name, + Data: map[string]any{"node": "start", "behavior_id": mob.BehaviorID}, + } + g.showTalkNode(sess, node) + } else { + sess.WriteLine("This person has nothing to say.") + } +} + +func (g *Game) startTalk(sess *net.Session, p *player.Player, obj *object.ObjectDef) { + cfg, err := g.BehaviorStore.LoadTalk(obj.BehaviorID) + if err != nil { + sess.WriteLine("This person has nothing to say.") + return + } + + if node, ok := cfg.Nodes["start"]; ok { + p.Action = &action.Action{ + Type: "talk", + TargetID: obj.ID, + TargetName: obj.Name, + Data: map[string]any{"node": "start", "behavior_id": obj.BehaviorID}, + } + g.showTalkNode(sess, node) + } else { + sess.WriteLine("This person has nothing to say.") + } +} + +func (g *Game) showTalkNode(sess *net.Session, node action.TalkNode) { + sess.WriteLine(fmt.Sprintf("\n%s", node.Message)) + + if node.Action != nil { + g.applyNodeAction(sess, node.Action) + } + + if len(node.Options) == 0 { + sess.State = net.StateGame + sess.Write("\r\n> ") + return + } + + for i, opt := range node.Options { + if opt.Condition != nil && !g.checkCondition(sess, opt.Condition) { continue + } + sess.WriteLine(fmt.Sprintf(" %d. %s", i+1, opt.Text)) + } + sess.State = net.StateTalk + sess.Write("\nChoice: ") +} + +func (g *Game) handleTalkInput(sess *net.Session, input string) { + p, ok := sess.Player.(*player.Player) + if !ok || p.Action == nil || p.Action.Type != "talk" { + sess.State = net.StateGame + sess.Write("\r\n> ") + return + } + + input = strings.TrimSpace(input) + lower := strings.ToLower(input) + if lower == "bye" || lower == "exit" || lower == "end" || lower == "quit" { + g.CancelAction(p) + sess.State = net.StateGame + sess.Write("\r\n> ") + return + } + + behaviorID := p.Action.Data["behavior_id"].(string) + cfg, err := g.BehaviorStore.LoadTalk(behaviorID) + if err != nil { + g.CancelAction(p) + sess.State = net.StateGame + sess.Write("\r\n> ") + return + } + + nodeKey := p.Action.Data["node"].(string) + node, ok := cfg.Nodes[nodeKey] + if !ok { + g.CancelAction(p) + sess.State = net.StateGame + sess.Write("\r\n> ") + return + } + + idx, err := parseIndex(input) + if err != nil || idx <= 0 { + g.CancelAction(p) + sess.State = net.StateGame + sess.Write("\r\n> ") + return + } + + validIdx := 0 + var chosen *action.TalkOption + for i := range node.Options { + opt := &node.Options[i] + if opt.Condition != nil && !g.checkCondition(sess, opt.Condition) { continue + } + validIdx++ + if validIdx == idx { + chosen = opt + break + } + } + + if chosen == nil { + g.CancelAction(p) + sess.State = net.StateGame + sess.Write("\r\n> ") + return + } + + if chosen.End { + g.CancelAction(p) + sess.State = net.StateGame + sess.Write("\r\n> ") + return + } + + if chosen.Goto != "" { + if nextNode, ok := cfg.Nodes[chosen.Goto]; ok { + p.Action.Data["node"] = chosen.Goto + g.showTalkNode(sess, nextNode) + return + } + } + + g.CancelAction(p) + sess.State = net.StateGame + sess.Write("\r\n> ") +} + +func (g *Game) applyNodeAction(sess *net.Session, na *action.NodeAction) { + p, ok := sess.Player.(*player.Player) + if !ok { + return + } + for k, v := range na.SetFlags { + g.WorldFlags[k] = v + } + if na.TakeItem != "" { + if p.HasItem(na.TakeItem) { + p.RemoveItem(na.TakeItem, 1) + } + } + if na.GiveItem != "" { + slot := p.FirstFreeSlot() + if slot == -1 { + sess.WriteLine("Your inventory is too full to receive that.") + } else { + p.SetInvSlot(slot, &player.InventorySlot{ItemID: na.GiveItem, Quantity: 1}) + g.AccountStore.SaveCharacter(p) + } + } +} + +func (g *Game) checkCondition(sess *net.Session, c *action.Condition) bool { + p, _ := sess.Player.(*player.Player) + if c.Flag != "" { + val, ok := g.WorldFlags[c.Flag] + if c.Not { + return !ok || val != c.Value + } + return ok && val == c.Value + } + if c.HasItem != "" { + has := p != nil && p.HasItem(c.HasItem) + if c.Not { + return !has + } + return has + } + return true +} + +func (g *Game) startUse(sess *net.Session, p *player.Player, obj *object.ObjectDef) { + cfg, err := g.BehaviorStore.LoadUse(obj.BehaviorID) + if err != nil { + sess.WriteLine("You can't use this.") + return + } + + for itemID, qty := range cfg.Consume { + if !p.HasItem(itemID) { + defName := itemID + if def, err := g.ItemStore.Load(itemID); err == nil { + defName = def.Name + } + sess.WriteLine(fmt.Sprintf("You need %s to use this.", defName)) + return + } + _ = qty + } + + if cfg.Success == nil && p.FirstFreeSlot() == -1 { + sess.WriteLine("Your inventory is full.") + return + } + + p.Action = &action.Action{ + Type: "use", + TargetID: obj.ID, + TargetName: obj.Name, + WaitLeft: 1, + Data: map[string]any{"step": 0, "behavior_id": obj.BehaviorID}, + } + + sess.WriteLine(fmt.Sprintf("\n%s", cfg.Message)) +} + +func (g *Game) advanceUse(sess *net.Session, p *player.Player) { + behaviorID := p.Action.Data["behavior_id"].(string) + cfg, err := g.BehaviorStore.LoadUse(behaviorID) + if err != nil { + g.CancelAction(p) + return + } + + for itemID, qty := range cfg.Consume { + if !p.HasItem(itemID) { + defName := itemID + if def, err := g.ItemStore.Load(itemID); err == nil { + defName = def.Name + } + sess.WriteLine(fmt.Sprintf("You've run out of %s.", defName)) + g.CancelAction(p) + return + } + if !p.RemoveItem(itemID, qty) { + sess.WriteLine(fmt.Sprintf("You need %d of %s.", qty, itemID)) + g.CancelAction(p) + return + } + } + + if cfg.Success != nil { + skillLevel := p.Level(player.SkillName(cfg.Skill)) + chance := action.SuccessChance(*cfg.Success, skillLevel, cfg.Level) + if rand.Float64() >= chance { + if cfg.FailMsg != "" { + sess.WriteLine(cfg.FailMsg) + } + p.Action.WaitLeft = cfg.Wait + return + } + } + + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + sess.WriteLine("Your inventory is full.") + g.CancelAction(p) + return + } + + qty := cfg.Reward.Quantity + if qty <= 0 { + qty = 1 + } + + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: cfg.Reward.ItemID, Quantity: qty}) + + g.AccountStore.SaveCharacter(p) + + itemName := cfg.Reward.ItemID + if def, err := g.ItemStore.Load(cfg.Reward.ItemID); err == nil { + itemName = def.Name + } + sess.WriteLine(fmt.Sprintf("You make a %s.", itemName)) + + if p.FirstFreeSlot() == -1 { + sess.WriteLine("Your inventory is full.") + g.CancelAction(p) + return + } + + p.Action.WaitLeft = cfg.Wait +} + +func (g *Game) startToggle(sess *net.Session, p *player.Player, obj *object.ObjectDef) { + cfg, err := g.BehaviorStore.LoadToggle(obj.BehaviorID) + if err != nil { + sess.WriteLine("Nothing happens.") + return + } + + if cfg.Check != nil { + val, ok := g.WorldFlags[cfg.Check.Flag] + if cfg.Check.Not { + if ok && val == cfg.Check.Value { + sess.WriteLine("Nothing happens.") + return + } + } else { + if !ok || val != cfg.Check.Value { + sess.WriteLine("Nothing happens.") + return + } + } + } + + for flag, val := range cfg.SetFlags { + g.WorldFlags[flag] = val + } + + sess.WriteLine(fmt.Sprintf("\n%s", cfg.Message)) + + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other != sess && other.Player != nil { + other.WriteLine(fmt.Sprintf("\n%s uses the %s.", p.Name, obj.Name)) + } + } + } +} + +func normalizeVerb(v string) string { + switch v { + case "mine", "chop", "fish": + return "gather" + case "talk", "speak", "ask": + return "talk" + case "pull", "push": + return "toggle" + } + return v +} + +func (g *Game) CheckExitCondition(c *world.ExitCondition) bool { + if c.Flag != "" { + val, ok := g.WorldFlags[c.Flag] + if c.Not { + return !ok || val != c.Value + } + return ok && val == c.Value + } + return true +} + +func (g *Game) RunEnterSteps(sess *net.Session, roomID int) { + room, err := g.World.LoadRoom(roomID) + if err != nil || len(room.OnEnter) == 0 { + return + } + for _, step := range room.OnEnter { + if step.Condition != nil && !g.CheckExitCondition(step.Condition) { + continue + } + if step.Message != "" { + sess.WriteLine(fmt.Sprintf("\n%s", step.Message)) + } + } +} + +func (g *Game) BroadcastRespawns() { + respawns := g.World.FlushObjRespawns() + for _, st := range respawns { + def, err := g.ObjectStore.Load(st.DefID) + if err != nil || def.BehaviorID == "" { + continue + } + cfg, err := g.BehaviorStore.LoadGather(def.BehaviorID) + if err != nil || 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(fmt.Sprintf("\n%s", msg)) + } + } + } +} |
