From 52a3ce6a4b4a254dc5d3067979a09e93c060fa20 Mon Sep 17 00:00:00 2001 From: workhorse Date: Fri, 19 Jun 2026 03:57:06 -0400 Subject: feat: shops and rough assassin skill --- internal/game/action.go | 6 + internal/game/action_state.go | 3 + internal/game/action_steal.go | 449 ++++++++++++++++++++++++++++++++++++++++++ internal/game/action_talk.go | 47 +++++ internal/game/assassin.go | 284 ++++++++++++++++++++++++++ internal/game/cmd_attack.go | 54 +++++ internal/game/cmd_shop.go | 295 +++++++++++++++++++++++++++ internal/game/cmd_sneak.go | 71 +++++++ internal/game/cmd_task.go | 34 ++++ internal/game/game.go | 30 ++- 10 files changed, 1269 insertions(+), 4 deletions(-) create mode 100644 internal/game/action_steal.go create mode 100644 internal/game/assassin.go create mode 100644 internal/game/cmd_shop.go create mode 100644 internal/game/cmd_sneak.go create mode 100644 internal/game/cmd_task.go (limited to 'internal/game') diff --git a/internal/game/action.go b/internal/game/action.go index 2d30010..3cc51c6 100644 --- a/internal/game/action.go +++ b/internal/game/action.go @@ -25,6 +25,8 @@ var verbAliases = map[string]string{ "ask": "talk", "pull": "toggle", "push": "toggle", + "steal": "steal", + "thieve": "steal", } var verbSkill = map[string]string{ @@ -33,6 +35,8 @@ var verbSkill = map[string]string{ "cut": "woodcutting", "fish": "fishing", "shear": "crafting", + "steal": "thieving", + "thieve": "thieving", } func normalizeVerb(v string) string { @@ -235,6 +239,8 @@ func (g *Game) AdvanceActions() { g.advanceSearch(sess, p) case "identify": g.advanceIdentify(sess, p) + case "steal": + g.advanceSteal(sess, p) default: if productionActionTypes[p.Action.Type] { g.advanceProduction(sess, p) diff --git a/internal/game/action_state.go b/internal/game/action_state.go index 596eb4e..2dc42ae 100644 --- a/internal/game/action_state.go +++ b/internal/game/action_state.go @@ -26,6 +26,7 @@ const ( ActionMixing ActionType = "mixing" ActionIdentifying ActionType = "identifying" ActionTriggering ActionType = "triggering" + ActionStealing ActionType = "stealing" ) type ActionState struct { @@ -85,6 +86,8 @@ func (a *ActionState) Description() string { return "identifying scrap at " + a.TargetName case ActionTriggering: return "triggering " + a.TargetName + case ActionStealing: + return "stealing from " + a.TargetName } return "" } diff --git a/internal/game/action_steal.go b/internal/game/action_steal.go new file mode 100644 index 0000000..dac90f2 --- /dev/null +++ b/internal/game/action_steal.go @@ -0,0 +1,449 @@ +package game + +import ( + "fmt" + "math/rand" + "sort" + "strconv" + "strings" + + "thehouseoficarus/internal/action" + "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/object" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +var stealSuccess = action.SuccessFormula{ + Base: 0.5, + PerLevel: 0.03, + Cap: 0.95, +} + +func (g *Game) doSteal(sess *net.Session, input string) { + p := sess.Player.(*player.Player) + + if combat.GetCombat(p.Name) != nil { + sess.WriteLine("You can't do that during combat!") + return + } + + g.CancelAction(p) + + targetType, mob, objDef, errMsg := g.resolveStealTarget(sess, p, input) + if errMsg != "" { + sess.WriteLine(errMsg) + return + } + + g.startSteal(sess, p, targetType, mob, objDef) +} + +func (g *Game) resolveStealTarget(sess *net.Session, p *player.Player, input string) (targetType string, mob *world.MobInstance, objDef *object.ObjectDef, errMsg string) { + input = strings.ToLower(strings.TrimSpace(input)) + instanceIdx := -1 + searchName := input + + if dotPos := strings.Index(input, "."); dotPos > 0 { + if n, err := strconv.Atoi(input[:dotPos]); err == nil && n > 0 { + instanceIdx = n - 1 + searchName = input[dotPos+1:] + } + } + + roomMobs := g.MobStore.MobsInRoom(p.RoomID) + var stealMobs []*world.MobInstance + for _, m := range roomMobs { + if m.StealTable != "" { + stealMobs = append(stealMobs, m) + } + } + + roomObjs := g.World.AllObjInstances(p.RoomID) + var stealObjs []*object.ObjectDef + for _, st := range roomObjs { + def, err := g.ObjectStore.Load(st.DefID) + if err != nil || def.StealTable == "" { + continue + } + stealObjs = append(stealObjs, def) + } + + if input == "" { + if len(stealMobs) == 0 && len(stealObjs) == 0 { + return "", nil, nil, "There's nothing here to steal from." + } + + uniqueMobDefs := make(map[string]bool) + for _, m := range stealMobs { + uniqueMobDefs[m.DefID] = true + } + + if len(stealMobs) > 0 && len(stealObjs) == 0 { + if len(uniqueMobDefs) <= 1 { + return "mob", stealMobs[0], nil, "" + } + } + if len(stealObjs) > 0 && len(stealMobs) == 0 { + uniqueObjDefs := make(map[string]bool) + for _, o := range stealObjs { + uniqueObjDefs[o.ID] = true + } + if len(uniqueObjDefs) <= 1 { + return "object", nil, stealObjs[0], "" + } + } + return "", nil, nil, "Steal from what?" + } + + var matchingMobs []*world.MobInstance + for _, m := range stealMobs { + if m.MatchQuality(searchName) >= world.MatchPrefix { + matchingMobs = append(matchingMobs, m) + } + } + sort.Slice(matchingMobs, func(i, j int) bool { + return matchingMobs[i].InstanceID < matchingMobs[j].InstanceID + }) + + if len(matchingMobs) > 0 { + uniqueDefs := make(map[string]bool) + for _, m := range matchingMobs { + uniqueDefs[m.DefID] = true + } + if len(uniqueDefs) > 1 && instanceIdx < 0 { + return "", nil, nil, "Which one?" + } + if instanceIdx >= 0 && instanceIdx < len(matchingMobs) { + return "mob", matchingMobs[instanceIdx], nil, "" + } + if len(matchingMobs) == 1 { + return "mob", matchingMobs[0], nil, "" + } + for i := range matchingMobs { + if instanceIdx == i || instanceIdx < 0 { + return "mob", matchingMobs[i], nil, "" + } + } + } + + var matchingObjs []*object.ObjectDef + for _, o := range stealObjs { + if world.WordPrefixMatch(searchName, o.Name) { + matchingObjs = append(matchingObjs, o) + } + } + if len(matchingObjs) > 1 { + return "", nil, nil, "Which one?" + } + if len(matchingObjs) == 1 { + return "object", nil, matchingObjs[0], "" + } + + return "", nil, nil, "There's nothing here to steal from." +} + +func (g *Game) startSteal(sess *net.Session, p *player.Player, targetType string, mob *world.MobInstance, objDef *object.ObjectDef) { + var stealTable string + var stealLevel int + var stealXP int + var stealSpeed float64 + var targetName string + var targetID string + var mobInstanceID string + var objDefID string + var guardMob string + guardWatching := false + + if targetType == "mob" { + stealTable = mob.StealTable + stealLevel = mob.StealLevel + stealXP = mob.StealXP + stealSpeed = mob.StealSpeed + targetName = mob.Name + targetID = mob.InstanceID + mobInstanceID = mob.InstanceID + + if stealTable == "" { + sess.WriteLine(fmt.Sprintf("You can't steal from the %s.", targetName)) + return + } + if thievingLevel := p.Level(player.Thieving); stealLevel > 0 && thievingLevel < stealLevel { + sess.WriteLine(fmt.Sprintf("You need level %d thieving to steal from the %s.", stealLevel, targetName)) + return + } + if mob.HP <= 0 { + sess.WriteLine("That is already dead.") + return + } + if combat.IsMobInCombat(mob.InstanceID) { + sess.WriteLine(fmt.Sprintf("The %s is busy.", targetName)) + return + } + } else { + stealTable = objDef.StealTable + stealLevel = objDef.StealLevel + stealXP = objDef.StealXP + stealSpeed = objDef.StealSpeed + targetName = objDef.Name + targetID = objDef.ID + objDefID = objDef.ID + guardMob = objDef.GuardMob + + if stealTable == "" { + sess.WriteLine(fmt.Sprintf("You can't steal from the %s.", targetName)) + return + } + if thievingLevel := p.Level(player.Thieving); stealLevel > 0 && thievingLevel < stealLevel { + sess.WriteLine(fmt.Sprintf("You need level %d thieving to steal from the %s.", stealLevel, targetName)) + return + } + + if guardMob != "" { + guard := g.findGuardInRoom(p.RoomID, guardMob) + if guard != nil { + timerKey := fmt.Sprintf("%d:%s", p.RoomID, objDefID) + if _, exists := g.guardWatchTimers[timerKey]; !exists { + g.guardWatchTimers[timerKey] = 0 + } + guardWatching = g.isGuardWatching(p.RoomID, objDefID) + } + } + } + + if p.FirstFreeSlot() == -1 { + sess.WriteLine("Your inventory is too full!") + return + } + + sess.WriteLine(fmt.Sprintf("You attempt to steal from the %s...", targetName)) + p.ActionState = &ActionState{Type: ActionStealing, TargetName: targetName} + + p.Action = &action.Action{ + Type: "steal", + TargetID: targetID, + TargetName: targetName, + WaitLeft: engine.ToTicks(stealSpeed), + Data: map[string]any{ + "target_type": targetType, + "steal_table": stealTable, + "steal_level": stealLevel, + "steal_xp": stealXP, + "steal_speed": stealSpeed, + "target_name": targetName, + "mob_instance_id": mobInstanceID, + "obj_def_id": objDefID, + "guard_mob": guardMob, + "guard_watching": guardWatching, + }, + } +} + +func (g *Game) advanceSteal(sess *net.Session, p *player.Player) { + data := p.Action.Data + targetType := data["target_type"].(string) + stealTable := data["steal_table"].(string) + stealLevel := data["steal_level"].(int) + stealXP := data["steal_xp"].(int) + stealSpeed := data["steal_speed"].(float64) + targetName := data["target_name"].(string) + guardMob := data["guard_mob"].(string) + guardWatching := data["guard_watching"].(bool) + + if targetType == "mob" { + mobInstanceID := data["mob_instance_id"].(string) + mob := g.MobStore.GetInstance(mobInstanceID) + if mob == nil || mob.HP <= 0 || mob.RoomID != p.RoomID { + sess.WriteLine("Your target is gone.") + g.CancelAction(p) + return + } + if combat.IsMobInCombat(mob.InstanceID) { + sess.WriteLine(fmt.Sprintf("The %s is busy.", targetName)) + g.CancelAction(p) + return + } + data["steal_level"] = mob.StealLevel + data["steal_xp"] = mob.StealXP + data["steal_speed"] = mob.StealSpeed + stealLevel = mob.StealLevel + stealXP = mob.StealXP + stealSpeed = mob.StealSpeed + } else { + objDefID := data["obj_def_id"].(string) + found := false + for _, st := range g.World.AllObjInstances(p.RoomID) { + if st.DefID == objDefID && !st.Depleted { + found = true + break + } + } + if !found { + sess.WriteLine("Your target is gone.") + g.CancelAction(p) + return + } + objDef, err := g.ObjectStore.Load(objDefID) + if err == nil { + data["steal_level"] = objDef.StealLevel + data["steal_xp"] = objDef.StealXP + data["steal_speed"] = objDef.StealSpeed + stealLevel = objDef.StealLevel + stealXP = objDef.StealXP + stealSpeed = objDef.StealSpeed + data["guard_mob"] = objDef.GuardMob + guardMob = objDef.GuardMob + + if guardMob != "" { + guard := g.findGuardInRoom(p.RoomID, guardMob) + if guard != nil { + timerKey := fmt.Sprintf("%d:%s", p.RoomID, objDefID) + if _, exists := g.guardWatchTimers[timerKey]; !exists { + g.guardWatchTimers[timerKey] = 0 + } + guardWatching = g.isGuardWatching(p.RoomID, objDefID) + data["guard_watching"] = guardWatching + } + } + } + } + + level := p.Level(player.Thieving) + chance := action.SuccessChance(stealSuccess, level, stealLevel) + if guardWatching { + chance *= 0.5 + if chance < 0.05 { + chance = 0.05 + } + } + + if rand.Float64() < chance { + dt, err := g.BehaviorStore.LoadDropTable(stealTable) + if err != nil || len(dt.Drops) == 0 { + sess.WriteLine("You steal nothing of value.") + } else { + drop := g.BehaviorStore.ResolveDrop(dt.Drops) + if drop == nil || drop.ItemID == "" { + sess.WriteLine("You steal nothing of value.") + } else { + g.giveStealLoot(sess, p, drop) + } + } + + if stealXP > 0 { + prevLevel := p.Level(player.Thieving) + p.AddSkillXP(player.Thieving, stealXP) + newLevel := p.Level(player.Thieving) + if newLevel > prevLevel { + sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d thieving! ***", newLevel))) + } + } + + g.AccountStore.SaveCharacter(p) + + if p.FirstFreeSlot() == -1 { + sess.WriteLine("Your inventory is full.") + g.CancelAction(p) + return + } + + p.Action.WaitLeft = engine.ToTicks(stealSpeed) + } else { + if targetType == "mob" { + mobInstanceID := data["mob_instance_id"].(string) + mob := g.MobStore.GetInstance(mobInstanceID) + sess.WriteLine(fmt.Sprintf("The %s notices you! They attack!", targetName)) + g.CancelAction(p) + if mob != nil && mob.HP > 0 { + g.startCombat(sess, p, mob) + } + } else { + if guardMob != "" && guardWatching { + guard := g.findGuardInRoom(p.RoomID, guardMob) + if guard != nil { + sess.WriteLine("You fumble and the Guard spots you!") + g.CancelAction(p) + g.startStealGuardTalk(sess, p, guard) + return + } + } + sess.WriteLine("You fail to steal anything.") + p.Action.WaitLeft = engine.ToTicks(stealSpeed) + } + } +} + +func (g *Game) giveStealLoot(sess *net.Session, p *player.Player, drop *action.DropEntry) { + qty := drop.Quantity + if qty <= 0 { + qty = 1 + } + + if drop.ItemID == "credits" { + p.Credits += qty + sess.WriteLine(g.colorize(sess, "credits_pickup", fmt.Sprintf("You steal %d credits.", qty))) + return + } + + name := drop.ItemID + lootDef, _ := g.ItemStore.Load(drop.ItemID) + if lootDef != nil { + name = lootDef.Name + } + + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + g.World.AddGroundItem(p.RoomID, drop.ItemID, qty) + sess.WriteLine(fmt.Sprintf("You steal %s but your inventory is full. It falls to the ground.", g.itemColorize(sess, lootDef, name))) + return + } + + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty}) + sess.WriteLine(fmt.Sprintf("You steal %s.", g.itemColorize(sess, lootDef, name))) +} + +func (g *Game) findGuardInRoom(roomID int, guardDefID string) *world.MobInstance { + mobs := g.MobStore.MobsInRoom(roomID) + for _, m := range mobs { + if m.DefID == guardDefID && m.HP > 0 { + return m + } + } + return nil +} + +const guardWatchDuration = 8 +const guardLookAwayDuration = 4 +const guardCycleLength = guardWatchDuration + guardLookAwayDuration + +func (g *Game) isGuardWatching(roomID int, objDefID string) bool { + key := fmt.Sprintf("%d:%s", roomID, objDefID) + counter := g.guardWatchTimers[key] + return counter%guardCycleLength < guardWatchDuration +} + +func (g *Game) startStealGuardTalk(sess *net.Session, p *player.Player, guardMob *world.MobInstance) { + cfg, err := g.BehaviorStore.LoadTalk("stall_guard_talk") + if err != nil { + sess.WriteLine("The Guard glares at you but says nothing.") + return + } + + node, ok := cfg.Nodes["start"] + if !ok { + return + } + + p.ActionState = &ActionState{Type: ActionTalking, TargetName: guardMob.Name} + + p.Action = &action.Action{ + Type: "talk", + TargetID: guardMob.DefID, + TargetName: guardMob.Name, + Data: map[string]any{"node": "start", "behavior_id": "stall_guard_talk", "steal_guard": true}, + } + + g.showTalkNode(sess, node) +} diff --git a/internal/game/action_talk.go b/internal/game/action_talk.go index 2d4a814..1a21e7f 100644 --- a/internal/game/action_talk.go +++ b/internal/game/action_talk.go @@ -57,7 +57,27 @@ func (g *Game) showTalkNode(sess *net.Session, node action.TalkNode) { sess.WriteLine(fmt.Sprintf("\n%s", g.colorize(sess, "dialog", node.Message))) if node.Action != nil { + if node.Action.Shop != nil { + g.applyNodeAction(sess, node.Action) + g.enterShop(sess, node.Action.Shop) + return + } g.applyNodeAction(sess, node.Action) + + if g.WorldFlags["guard_hostile"] != nil { + delete(g.WorldFlags, "guard_hostile") + p, _ := sess.Player.(*player.Player) + if p != nil && p.Action != nil && p.Action.Data["steal_guard"] == true { + guardMob := g.findGuardInRoom(p.RoomID, "guard") + if guardMob != nil { + sess.State = net.StateGame + g.CancelAction(p) + guardMob.Protected = false + g.startCombat(sess, p, guardMob) + } + return + } + } } if len(node.Options) == 0 { @@ -162,11 +182,38 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) { g.writePrompt(sess) } +func (g *Game) enterShop(sess *net.Session, cfg *action.ShopConfig) { + sess.Shop = cfg + sess.State = net.StateShop + g.showShopBrowse(sess) + g.writeShopPrompt(sess) +} + func (g *Game) applyNodeAction(sess *net.Session, na *action.NodeAction) { p, ok := sess.Player.(*player.Player) if !ok { return } + + if na.AssignTask { + g.assignAssassinTask(sess, p) + } + if na.SkipTask { + g.skipAssassinTask(sess, p) + } + if na.ExtendTask { + g.extendAssassinTask(sess, p) + } + if na.ReputationCost > 0 { + rep := getPlayerFlagInt(p, "assassin_reputation") + if rep < na.ReputationCost { + sess.WriteLine(fmt.Sprintf("You don't have enough Reputation. (Need %d, have %d)", na.ReputationCost, rep)) + return + } + setPlayerFlag(p, "assassin_reputation", rep-na.ReputationCost) + g.AccountStore.SaveCharacter(p) + } + for k, v := range na.SetFlags { g.WorldFlags[k] = v } diff --git a/internal/game/assassin.go b/internal/game/assassin.go new file mode 100644 index 0000000..d7af1a4 --- /dev/null +++ b/internal/game/assassin.go @@ -0,0 +1,284 @@ +package game + +import ( + "fmt" + "math/rand" + "strings" + + "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func getPlayerFlagInt(p *player.Player, key string) int { + if p.Flags == nil { + return 0 + } + val, ok := p.Flags[key] + if !ok { + return 0 + } + switch v := val.(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + } + return 0 +} + +func getPlayerFlagString(p *player.Player, key string) string { + if p.Flags == nil { + return "" + } + val, ok := p.Flags[key] + if !ok { + return "" + } + s, _ := val.(string) + return s +} + +func setPlayerFlag(p *player.Player, key string, val any) { + if p.Flags == nil { + p.Flags = make(map[string]any) + } + p.Flags[key] = val +} + +type assassinTaskEntry struct { + MobID string + MinLevel int + MaxLevel int + MinCount int + MaxCount int + Weight int +} + +var assassinTaskTable = []assassinTaskEntry{ + {"man", 1, 15, 10, 25, 8}, + {"cow", 1, 15, 10, 25, 8}, + {"slug", 1, 99, 15, 45, 15}, + {"drone", 15, 99, 20, 50, 12}, + {"crawler", 30, 99, 15, 40, 10}, + {"phantom", 45, 99, 10, 30, 8}, +} + +func (g *Game) assignAssassinTask(sess *net.Session, p *player.Player) { + level := p.Level(player.Assassin) + var eligible []assassinTaskEntry + totalWeight := 0 + for _, entry := range assassinTaskTable { + if level >= entry.MinLevel && level <= entry.MaxLevel { + eligible = append(eligible, entry) + totalWeight += entry.Weight + } + } + if len(eligible) == 0 { + sess.WriteLine("The Client shakes their head. \"Nothing available for your level.\"") + return + } + roll := rand.Intn(totalWeight) + var chosen assassinTaskEntry + for _, entry := range eligible { + roll -= entry.Weight + if roll < 0 { + chosen = entry + break + } + } + count := chosen.MinCount + rand.Intn(chosen.MaxCount-chosen.MinCount+1) + setPlayerFlag(p, "assassin_task_mob", chosen.MobID) + setPlayerFlag(p, "assassin_task_total", count) + setPlayerFlag(p, "assassin_task_remaining", count) + g.AccountStore.SaveCharacter(p) + + def, err := g.MobStore.LoadDef(chosen.MobID) + name := chosen.MobID + if err == nil { + name = def.Name + } + sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf("\"Your target: %d %ss. Get to work.\"", count, name))) +} + +func (g *Game) onAssassinKill(sess *net.Session, p *player.Player, mob *world.MobInstance) { + taskMob := getPlayerFlagString(p, "assassin_task_mob") + if taskMob == "" || taskMob != mob.DefID { + return + } + remaining := getPlayerFlagInt(p, "assassin_task_remaining") + if remaining <= 0 { + return + } + xp := mob.MaxHP * 2 + newLevel := p.AddSkillXP(player.Assassin, xp) + if newLevel > 0 { + sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d Assassin! ***", newLevel))) + } + if p.OptionBool("xp_drops") { + sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp asm)", xp))) + } + remaining-- + setPlayerFlag(p, "assassin_task_remaining", remaining) + if remaining <= 0 { + completed := getPlayerFlagInt(p, "assassin_tasks_completed") + 1 + streak := getPlayerFlagInt(p, "assassin_streak") + 1 + setPlayerFlag(p, "assassin_tasks_completed", completed) + setPlayerFlag(p, "assassin_streak", streak) + delete(p.Flags, "assassin_task_mob") + setPlayerFlag(p, "assassin_task_remaining", 0) + setPlayerFlag(p, "assassin_task_total", 0) + rep := 1 + bonus := streakBonus(streak) + rep += bonus + currentRep := getPlayerFlagInt(p, "assassin_reputation") + setPlayerFlag(p, "assassin_reputation", currentRep+rep) + sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf("\n*** Assassin task complete! ***"))) + if bonus > 0 { + sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf(" Streak bonus! %d tasks in a row. +%d bonus reputation.", streak, bonus))) + } + sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf(" Reputation earned: %d (total: %d)", rep, currentRep+rep))) + } else { + total := getPlayerFlagInt(p, "assassin_task_total") + def, _ := g.MobStore.LoadDef(taskMob) + name := taskMob + if def != nil { + name = def.Name + } + sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf(" Assassin task: %d of %d %ss remaining.", remaining, total, name))) + } + g.AccountStore.SaveCharacter(p) +} + +func (g *Game) skipAssassinTask(sess *net.Session, p *player.Player) { + rep := getPlayerFlagInt(p, "assassin_reputation") + if rep < 30 { + sess.WriteLine("You don't have enough Reputation to skip. (Need 30, have " + fmt.Sprint(rep) + ")") + return + } + setPlayerFlag(p, "assassin_reputation", rep-30) + delete(p.Flags, "assassin_task_mob") + setPlayerFlag(p, "assassin_task_remaining", 0) + setPlayerFlag(p, "assassin_task_total", 0) + setPlayerFlag(p, "assassin_streak", 0) + g.AccountStore.SaveCharacter(p) +} + +func (g *Game) extendAssassinTask(sess *net.Session, p *player.Player) { + rep := getPlayerFlagInt(p, "assassin_reputation") + if rep < 30 { + sess.WriteLine("You don't have enough Reputation to extend. (Need 30, have " + fmt.Sprint(rep) + ")") + return + } + taskMob := getPlayerFlagString(p, "assassin_task_mob") + if taskMob == "" { + sess.WriteLine("You don't have an active task to extend.") + return + } + setPlayerFlag(p, "assassin_reputation", rep-30) + total := getPlayerFlagInt(p, "assassin_task_total") + remaining := getPlayerFlagInt(p, "assassin_task_remaining") + extension := total / 2 + if extension < 5 { + extension = 5 + } + setPlayerFlag(p, "assassin_task_total", total+extension) + setPlayerFlag(p, "assassin_task_remaining", remaining+extension) + g.AccountStore.SaveCharacter(p) + + def, _ := g.MobStore.LoadDef(taskMob) + name := taskMob + if def != nil { + name = def.Name + } + sess.WriteLine(fmt.Sprintf("Task extended by %d. Kill %d more %ss (%d total).", extension, remaining+extension, name, total+extension)) +} + +func streakBonus(streak int) int { + if streak%1000 == 0 { + return 50 + } + if streak%250 == 0 { + return 35 + } + if streak%100 == 0 { + return 25 + } + if streak%50 == 0 { + return 15 + } + if streak%10 == 0 { + return 5 + } + return 0 +} + +func (g *Game) tryFinishingBlow(sess *net.Session, input string) bool { + p := sess.Player.(*player.Player) + cs := combat.GetCombat(p.Name) + if cs == nil { + return false + } + mob := g.MobStore.GetInstance(cs.MobID) + if mob == nil || mob.FinishingBlow == "" || mob.HP != 1 { + return false + } + lower := strings.ToLower(input) + var itemPart, targetPart string + for _, sep := range []string{" on ", " with "} { + if idx := strings.Index(lower, sep); idx > 0 { + itemPart = strings.TrimSpace(input[:idx]) + targetPart = strings.TrimSpace(input[idx+len(sep):]) + break + } + } + if targetPart == "" { + itemPart = strings.TrimSpace(input) + if mob.MatchQuality(itemPart) != world.MatchNone { + targetPart = itemPart + itemPart = strings.TrimSpace(mob.FinishingBlow) + } + } + if targetPart == "" { + return false + } + if mob.MatchQuality(targetPart) == world.MatchNone { + return false + } + g.doFinishingBlow(sess, p, mob, itemPart) + return true +} + +func (g *Game) doFinishingBlow(sess *net.Session, p *player.Player, mob *world.MobInstance, itemInput string) { + fbDef, err := g.ItemStore.Load(mob.FinishingBlow) + if err != nil { + sess.WriteLine("Something went wrong.") + return + } + if !fbDef.MatchesName(itemInput) && !strings.EqualFold(itemInput, fbDef.ID) { + sess.WriteLine(fmt.Sprintf("That won't work on %s. You need %s.", mobDisplayName(mob, true), fbDef.Name)) + return + } + if !p.HasItem(mob.FinishingBlow) { + sess.WriteLine(fmt.Sprintf("You don't have any %s.", fbDef.Name)) + return + } + autoKey := "assassin_unlocked_auto_" + mob.FinishingBlow + consumed := true + if p.Flags != nil { + if val, ok := p.Flags[autoKey]; ok { + if b, ok := val.(bool); ok && b { + consumed = false + } + } + } + if consumed { + p.RemoveItem(mob.FinishingBlow, 1) + } + sess.WriteLine(fmt.Sprintf("\nYou use the %s on %s!", g.itemColorize(sess, fbDef, fbDef.Name), g.colorize(sess, "mob_name", mobDisplayName(mob, true)))) + mob.HP = 0 + g.endCombat(sess, p, mob) +} diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go index 29bff30..a8dc5d3 100644 --- a/internal/game/cmd_attack.go +++ b/internal/game/cmd_attack.go @@ -41,6 +41,11 @@ func (g *Game) doAttack(sess *net.Session, input string) { return } + if mob.AssassinLevel > 0 && p.Level(player.Assassin) < mob.AssassinLevel { + sess.WriteLine(fmt.Sprintf("You need Assassin level %d to attack %s.", mob.AssassinLevel, mobDisplayName(mob, true))) + return + } + if combat.IsMobInCombat(mob.InstanceID) { sess.WriteLine(fmt.Sprintf("%s is already engaged in combat!", mobDisplayName(mob, false))) return @@ -268,9 +273,23 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI if mob.HP < 0 { mob.HP = 0 } + if mob.FinishingBlow != "" && mob.HP <= 0 { + mob.HP = 1 + } if mob.HP < mob.MaxHP && mob.HP > 0 { mob.StartRegen() } + + if mob.FinishingBlow != "" && mob.HP == 1 { + fbDef, _ := g.ItemStore.Load(mob.FinishingBlow) + fbName := mob.FinishingBlow + if fbDef != nil { + fbName = fbDef.Name + } + sess.WriteLine(g.colorize(sess, "warning", + fmt.Sprintf(" %s resists death! Use %s on it to finish it off.", + mobDisplayName(mob, false), fbName))) + } gains, leveled := g.awardCombatXP(p, dmg, isRanged) if isRanged { @@ -329,6 +348,7 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI } func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) { + cs := combat.GetCombat(p.Name) _, _, defStyleBonus := combat.AttackStyleBonus(string(p.AttackStyle)) mobAttackType := mob.AttackType @@ -349,6 +369,37 @@ func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInst dmg := combat.RollDamage(maxHit) dmg = g.applyTechProtection(p, mob, dmg) + if mob.DamageWithout != "" { + hasProtection := false + for _, itemID := range p.Equipment { + if itemID == mob.DamageWithout { + hasProtection = true + break + } + } + if !hasProtection { + dmg = dmg * 3 / 2 + if dmg < 1 { + dmg = 1 + } + if cs != nil && !cs.DamageWarningShown { + cs.DamageWarningShown = true + fbDef, _ := g.ItemStore.Load(mob.DamageWithout) + fbName := mob.DamageWithout + if fbDef != nil { + fbName = fbDef.Name + } + attacker := mob.Name + if !mob.Unique { + attacker = "The " + mob.Name + } + sess.WriteLine(g.colorize(sess, "warning", + fmt.Sprintf(" %s's attack is extra effective! Equip %s for protection.", + attacker, fbName))) + } + } + } + p.HP -= dmg if p.HP < 0 { p.HP = 0 @@ -421,6 +472,9 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst if mob != nil && mob.HP <= 0 { sess.WriteLine(g.colorize(sess, "victory", fmt.Sprintf("\nYou have defeated %s!", mobDisplayName(mob, true)))) + + g.onAssassinKill(sess, p, mob) + if g.Hub != nil { for _, other := range g.Hub.PlayersInRoom(p.RoomID) { if other != sess && other.Player != nil { diff --git a/internal/game/cmd_shop.go b/internal/game/cmd_shop.go new file mode 100644 index 0000000..a3f7613 --- /dev/null +++ b/internal/game/cmd_shop.go @@ -0,0 +1,295 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/action" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) handleShopInput(sess *net.Session, input string) { + if sess.Player == nil { + sess.State = net.StateGame + g.writePrompt(sess) + return + } + + input = strings.TrimSpace(input) + parts := strings.Fields(strings.ToLower(input)) + if len(parts) == 0 { + g.showShopBrowse(sess) + return + } + + cmd := parts[0] + + switch cmd { + case "buy": + if len(parts) < 2 { + sess.WriteLine("Buy what? Use 'browse' to see available items.") + } else { + g.doShopBuy(sess, strings.Join(parts[1:], " ")) + } + case "sell": + if len(parts) < 2 { + sess.WriteLine("Sell what?") + } else { + g.doShopSell(sess, strings.Join(parts[1:], " ")) + } + case "browse", "list": + g.showShopBrowse(sess) + case "leave", "bye", "exit", "quit": + g.leaveShop(sess) + return + default: + sess.WriteLine("Commands: buy , sell , browse, leave") + } + g.writeShopPrompt(sess) +} + +func (g *Game) writeShopPrompt(sess *net.Session) { + sess.Write(fmt.Sprintf("\n%s", g.colorize(sess, "dialog", "> "))) +} + +func (g *Game) shopConfig(sess *net.Session) *action.ShopConfig { + cfg, _ := sess.Shop.(*action.ShopConfig) + return cfg +} + +func (g *Game) showShopBrowse(sess *net.Session) { + cfg := g.shopConfig(sess) + if cfg == nil { + return + } + + if cfg.Message != "" { + sess.WriteLine(fmt.Sprintf("\n%s", g.colorize(sess, "dialog", cfg.Message))) + } + + if len(cfg.Items) == 0 { + sess.WriteLine("This shop has nothing for sale.") + return + } + + unicode := true + if p, ok := sess.Player.(*player.Player); ok { + unicode = p.OptionBool("unicode") + } + + t := Table{ + Title: "Shop Inventory", + Columns: []string{"#", "Item", "Buy Price", "Sell Price"}, + } + + for i, si := range cfg.Items { + def, _ := g.ItemStore.Load(si.ItemID) + itemName := si.ItemID + if def != nil { + itemName = def.Name + } + buyStr := fmt.Sprintf("%d cr", si.BuyPrice) + sellStr := "\u2014" + if si.SellPrice > 0 { + sellStr = fmt.Sprintf("%d cr", si.SellPrice) + } + t.Rows = append(t.Rows, []string{ + fmt.Sprintf("%d", i+1), + g.colorize(sess, "item", itemName), + g.colorize(sess, "credits_pickup", buyStr), + g.colorize(sess, "credits_pickup", sellStr), + }) + } + + for _, line := range t.Render(unicode) { + sess.WriteLine(line) + } +} + +func (g *Game) doShopBuy(sess *net.Session, input string) { + cfg := g.shopConfig(sess) + if cfg == nil { + return + } + + idx, err := parseChoiceIndex(input) + if err == nil && idx > 0 && idx <= len(cfg.Items) { + si := cfg.Items[idx-1] + g.buyShopItem(sess, si) + return + } + + var match *action.ShopItem + for i := range cfg.Items { + si := &cfg.Items[i] + def, _ := g.ItemStore.Load(si.ItemID) + itemName := si.ItemID + if def != nil { + itemName = def.Name + } + + invMatches := g.collectMatches(input, func(yield func(string, int) bool) { + yield(itemName, -1) + }) + + if len(invMatches) > 0 { + if match != nil { + sess.WriteLine(fmt.Sprintf("That's ambiguous, which one? (use #, e.g. buy %s 1)", si.ItemID)) + return + } + match = si + } + } + + if match != nil { + g.buyShopItem(sess, *match) + return + } + + sess.WriteLine("That item isn't for sale here. Use 'browse' to see shop inventory.") +} + +func (g *Game) buyShopItem(sess *net.Session, si action.ShopItem) { + p, _ := sess.Player.(*player.Player) + + if p.Credits < si.BuyPrice { + sess.WriteLine(fmt.Sprintf("You need %d credits to buy that (you have %d).", si.BuyPrice, p.Credits)) + return + } + + def, _ := g.ItemStore.Load(si.ItemID) + displayName := si.ItemID + if def != nil { + displayName = def.Name + } + + if def != nil && def.Stackable { + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot != nil && slot.ItemID == si.ItemID { + p.Credits -= si.BuyPrice + slot.Quantity++ + g.AccountStore.SaveCharacter(p) + itemColor := g.itemColorize(sess, def, displayName) + sess.WriteLine(fmt.Sprintf("You buy a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", si.BuyPrice)))) + return + } + } + } + + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + sess.WriteLine("Your inventory is full.") + return + } + + p.Credits -= si.BuyPrice + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: si.ItemID, Quantity: 1}) + g.AccountStore.SaveCharacter(p) + + itemColor := g.itemColorize(sess, def, displayName) + sess.WriteLine(fmt.Sprintf("You buy a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", si.BuyPrice)))) +} + +func (g *Game) doShopSell(sess *net.Session, input string) { + cfg := g.shopConfig(sess) + if cfg == nil { + return + } + + p, _ := sess.Player.(*player.Player) + + matches := g.findInventoryMatches(input, p) + if len(matches) == 0 { + sess.WriteLine("You don't have that item.") + return + } + + if len(matches) > 1 { + sess.WriteLine("That's ambiguous, which one?") + return + } + + match := matches[0] + + var shopItem *action.ShopItem + for i := range cfg.Items { + si := &cfg.Items[i] + if si.ItemID == match.ID && si.SellPrice > 0 { + shopItem = si + break + } + } + + if shopItem == nil { + def, _ := g.ItemStore.Load(match.ID) + displayName := match.ID + if def != nil { + displayName = def.Name + } + sess.WriteLine(fmt.Sprintf("The shop doesn't want to buy %s.", g.colorize(sess, "item", displayName))) + return + } + + p.RemoveItem(match.ID, 1) + p.Credits += shopItem.SellPrice + g.AccountStore.SaveCharacter(p) + + def, _ := g.ItemStore.Load(match.ID) + displayName := match.ID + if def != nil { + displayName = def.Name + } + itemColor := g.itemColorize(sess, def, displayName) + sess.WriteLine(fmt.Sprintf("You sell a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", shopItem.SellPrice)))) +} + +func (g *Game) leaveShop(sess *net.Session) { + sess.Shop = nil + p, ok := sess.Player.(*player.Player) + if !ok || p == nil || p.Action == nil || p.Action.Type != "talk" { + sess.State = net.StateGame + if p != nil { + g.CancelAction(p) + } + g.writePrompt(sess) + return + } + + behaviorID, _ := p.Action.Data["behavior_id"].(string) + nodeKey, _ := p.Action.Data["node"].(string) + + cfg, err := g.BehaviorStore.LoadTalk(behaviorID) + if err != nil { + sess.State = net.StateGame + g.CancelAction(p) + g.writePrompt(sess) + return + } + + node, ok := cfg.Nodes[nodeKey] + if !ok { + sess.State = net.StateGame + g.CancelAction(p) + g.writePrompt(sess) + return + } + + sess.State = net.StateTalk + visible := 0 + for _, opt := range node.Options { + if opt.Condition != nil && !g.checkCondition(sess, opt.Condition) { + continue + } + visible++ + sess.WriteLine(fmt.Sprintf(" %d. %s", visible, opt.Text)) + } + if visible == 0 { + sess.State = net.StateGame + g.CancelAction(p) + g.writePrompt(sess) + return + } + sess.Write("\nChoice: ") +} diff --git a/internal/game/cmd_sneak.go b/internal/game/cmd_sneak.go new file mode 100644 index 0000000..97aab8a --- /dev/null +++ b/internal/game/cmd_sneak.go @@ -0,0 +1,71 @@ +package game + +import ( + "fmt" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) doSneak(sess *net.Session) { + p := sess.Player.(*player.Player) + p.Sneaking = !p.Sneaking + if p.Sneaking { + p.SneakNotified = make(map[string]bool) + sess.WriteLine("You begin sneaking.") + } else { + p.SneakNotified = nil + sess.WriteLine("You stop sneaking.") + } +} + +func (g *Game) SneakTick() { + if g.Hub == nil { + return + } + + for key := range g.guardWatchTimers { + g.guardWatchTimers[key]++ + } + + for _, sess := range g.Hub.AllSessions() { + p, ok := sess.Player.(*player.Player) + if !ok || p == nil || !p.Sneaking { + continue + } + + objs := g.World.AllObjInstances(p.RoomID) + for _, st := range objs { + objDef, err := g.ObjectStore.Load(st.DefID) + if err != nil || objDef.GuardMob == "" { + continue + } + + guard := g.findGuardInRoom(p.RoomID, objDef.GuardMob) + if guard == nil { + continue + } + + timerKey := fmt.Sprintf("%d:%s", p.RoomID, st.DefID) + if _, exists := g.guardWatchTimers[timerKey]; !exists { + g.guardWatchTimers[timerKey] = 0 + } + + watching := g.isGuardWatching(p.RoomID, st.DefID) + + if p.SneakNotified == nil { + p.SneakNotified = make(map[string]bool) + } + + lastState, known := p.SneakNotified[timerKey] + if !known || lastState != watching { + if watching { + sess.WriteLine(fmt.Sprintf("The %s is watching the %s.", guard.Name, objDef.Name)) + } else { + sess.WriteLine(fmt.Sprintf("The %s looks away from the %s.", guard.Name, objDef.Name)) + } + p.SneakNotified[timerKey] = watching + } + } + } +} diff --git a/internal/game/cmd_task.go b/internal/game/cmd_task.go new file mode 100644 index 0000000..2dcf3f4 --- /dev/null +++ b/internal/game/cmd_task.go @@ -0,0 +1,34 @@ +package game + +import ( + "fmt" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) doTask(sess *net.Session) { + p := sess.Player.(*player.Player) + + mobID := getPlayerFlagString(p, "assassin_task_mob") + remaining := getPlayerFlagInt(p, "assassin_task_remaining") + total := getPlayerFlagInt(p, "assassin_task_total") + streak := getPlayerFlagInt(p, "assassin_streak") + rep := getPlayerFlagInt(p, "assassin_reputation") + + if mobID == "" || remaining <= 0 { + if getPlayerFlagInt(p, "assassin_tasks_completed") > 0 { + sess.WriteLine("You have no active task. Talk to The Client for a new assignment.") + } else { + sess.WriteLine("You don't have an Assassin task. Talk to The Client to get one.") + } + } else { + def, err := g.MobStore.LoadDef(mobID) + name := mobID + if err == nil { + name = def.Name + } + sess.WriteLine(fmt.Sprintf("Assassin task: Kill %ss. %d of %d remaining.", name, remaining, total)) + } + sess.WriteLine(fmt.Sprintf("Streak: %d | Reputation: %d", streak, rep)) +} diff --git a/internal/game/game.go b/internal/game/game.go index 9d8a2c9..8b99731 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -52,8 +52,9 @@ type Game struct { combatPadWidth int freeQueue map[string][]QueuedCommand activeQueue map[string]*QueuedCommand - consumeQueue map[string]*QueuedCommand + consumeQueue map[string]*QueuedCommand pendingDepletions []pendingDepletion + guardWatchTimers map[string]int } func New(dataDir string, colorConfig *config.ColorsConfig) *Game { @@ -75,6 +76,7 @@ func New(dataDir string, colorConfig *config.ColorsConfig) *Game { activeQueue: make(map[string]*QueuedCommand), consumeQueue: make(map[string]*QueuedCommand), pendingDepletions: nil, + guardWatchTimers: make(map[string]int), } } @@ -121,6 +123,8 @@ func (g *Game) HandleSession(sess *net.Session, input string) { g.handleDescriptionChange(sess, input) case net.StateTalk: g.handleTalkInput(sess, input) + case net.StateShop: + g.handleShopInput(sess, input) case net.StateDropAllConfirm: g.handleDropAllConfirm(sess, input) case net.StateRecipeChoice: @@ -141,8 +145,9 @@ func classifyCommand(cmd string) CommandClass { "map", "option", "options", "alias", "unalias", "description", "desc", "queued", "color", "colors", "colortable", "prompt", "style", "stats", - "tech", "t", - "autocast", "auto", "mods", "modlist": + "tech", "t", "sneak", + "autocast", "auto", "mods", "modlist", + "task": return ClassInstant case "equip", "eq", "equipment", "wear", "wield", "remove", "unwear", "unwield": return ClassFree @@ -152,6 +157,7 @@ func classifyCommand(cmd string) CommandClass { "west", "w", "up", "u", "down", "d", "quit", "use", "burn", "stoke", "search", "walk", "cook", "smelt", "smith", "craft", "mix", "id", "identify", + "steal", "thieve", "trigger", "cast": return ClassActive case "eat", "fletch", "clean": @@ -319,6 +325,8 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI } case "sc", "score": g.doScore(sess) + case "task": + g.doTask(sess) case "stats": g.doStats(sess) case "tech", "t": @@ -374,6 +382,9 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI return } case "use": + if g.tryFinishingBlow(sess, strings.Join(args, " ")) { + return + } g.doUse(sess, strings.Join(args, " ")) return case "cook": @@ -407,6 +418,8 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI g.doAutocast(sess, strings.Join(args, " ")) case "mods", "modlist": g.doMods(sess) + case "sneak": + g.doSneak(sess) case "trigger", "cast": g.doTrigger(sess, strings.Join(args, " ")) return @@ -440,6 +453,14 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI g.doSearch(sess, strings.Join(args, " ")) } return + case "steal", "thieve": + g.CancelAction(p) + if len(args) == 0 { + g.doSteal(sess, "") + } else { + g.doSteal(sess, strings.Join(args, " ")) + } + return case "walk": g.doWalk(sess, args) return @@ -495,7 +516,8 @@ func (g *Game) ProcessQueuedCommands() { } switch as.Type { case ActionGathering, ActionCombating, ActionUsing, ActionTalking, - ActionToggling, ActionBurning, ActionStoking, ActionResting, ActionWalking, ActionProducing: + ActionToggling, ActionBurning, ActionStoking, ActionResting, ActionWalking, ActionProducing, + ActionStealing: default: if as.Type != ActionMoving || p.MoveTicks <= 0 { p.ActionState = nil -- cgit v1.2.3