From 8ee8982b8759bcae116647cc993867f4c8b0a6ce Mon Sep 17 00:00:00 2001 From: workhorse Date: Fri, 19 Jun 2026 20:24:52 -0400 Subject: reorganized items, objects, and rooms in directories. oranized module names. added startup checks. --- internal/game/action_burn.go | 13 + internal/game/action_steal.go | 10 + internal/game/aggro.go | 53 --- internal/game/assassin.go | 174 ------- internal/game/buff.go | 55 --- internal/game/cmd_alias.go | 8 + internal/game/cmd_attack.go | 14 + internal/game/cmd_autotrigger.go | 4 + internal/game/cmd_bank.go | 4 + internal/game/cmd_clean.go | 4 + internal/game/cmd_color.go | 4 + internal/game/cmd_colortable.go | 4 + internal/game/cmd_consume.go | 293 ++++++++++++ internal/game/cmd_cook.go | 5 + internal/game/cmd_craft.go | 8 + internal/game/cmd_description.go | 4 + internal/game/cmd_drink.go | 150 ------ internal/game/cmd_drop.go | 14 + internal/game/cmd_eat.go | 145 ------ internal/game/cmd_estate_directory.go | 2 +- internal/game/cmd_farm.go | 58 +++ internal/game/cmd_fletch.go | 4 + internal/game/cmd_get.go | 15 + internal/game/cmd_handlers.go | 370 --------------- internal/game/cmd_id.go | 5 + internal/game/cmd_inventory.go | 4 + internal/game/cmd_jack.go | 6 + internal/game/cmd_look.go | 12 + internal/game/cmd_map.go | 4 + internal/game/cmd_mix.go | 6 + internal/game/cmd_mods.go | 5 + internal/game/cmd_move.go | 5 + internal/game/cmd_option.go | 4 + internal/game/cmd_prompt.go | 14 + internal/game/cmd_queued.go | 4 + internal/game/cmd_quit.go | 4 + internal/game/cmd_registry.go | 26 ++ internal/game/cmd_remove.go | 4 + internal/game/cmd_say.go | 14 + internal/game/cmd_score.go | 4 + internal/game/cmd_search.go | 10 + internal/game/cmd_smelt.go | 5 + internal/game/cmd_smith.go | 4 + internal/game/cmd_sneak.go | 4 + internal/game/cmd_stats.go | 4 + internal/game/cmd_style.go | 8 + internal/game/cmd_task.go | 4 + internal/game/cmd_tech.go | 4 + internal/game/cmd_trigger.go | 637 +------------------------- internal/game/cmd_trigger_combat.go | 104 +++++ internal/game/cmd_trigger_enchant.go | 116 +++++ internal/game/cmd_trigger_transport.go | 54 +++ internal/game/cmd_trigger_utility.go | 397 ++++++++++++++++ internal/game/cmd_use.go | 7 + internal/game/cmd_walk.go | 4 + internal/game/cmd_wear.go | 4 + internal/game/color.go | 79 ---- internal/game/construction.go | 74 --- internal/game/core_course.go | 135 ++++++ internal/game/core_equip.go | 41 ++ internal/game/core_flags.go | 55 +++ internal/game/core_hacking.go | 93 ++++ internal/game/core_login_account.go | 351 ++++++++++++++ internal/game/core_login_char.go | 244 ++++++++++ internal/game/core_messages.go | 13 + internal/game/core_production.go | 803 +++++++++++++++++++++++++++++++++ internal/game/core_skill.go | 15 + internal/game/core_startup.go | 770 +++++++++++++++++++++++++++++++ internal/game/core_stations.go | 28 ++ internal/game/core_types.go | 29 ++ internal/game/core_utils.go | 116 +++++ internal/game/course.go | 141 ------ internal/game/death.go | 66 --- internal/game/equip_stats.go | 41 -- internal/game/game.go | 29 +- internal/game/hacking.go | 93 ---- internal/game/help.go | 155 ------- internal/game/login_account.go | 351 -------------- internal/game/login_char.go | 244 ---------- internal/game/map.go | 317 ------------- internal/game/messages.go | 13 - internal/game/player_flags.go | 55 --- internal/game/production_core.go | 606 ------------------------- internal/game/production_menu.go | 209 --------- internal/game/prompt.go | 100 ---- internal/game/science.go | 231 ---------- internal/game/stations.go | 28 -- internal/game/sys_assassin.go | 174 +++++++ internal/game/sys_buff.go | 55 +++ internal/game/sys_combat.go | 114 +++++ internal/game/sys_construction.go | 74 +++ internal/game/sys_science.go | 220 +++++++++ internal/game/sys_technology.go | 250 ++++++++++ internal/game/table.go | 150 ------ internal/game/tech.go | 250 ---------- internal/game/types.go | 29 -- internal/game/ui_color.go | 79 ++++ internal/game/ui_help.go | 152 +++++++ internal/game/ui_map.go | 317 +++++++++++++ internal/game/ui_prompt.go | 100 ++++ internal/game/ui_table.go | 150 ++++++ internal/game/utils.go | 121 ----- 102 files changed, 5715 insertions(+), 4947 deletions(-) delete mode 100644 internal/game/aggro.go delete mode 100644 internal/game/assassin.go delete mode 100644 internal/game/buff.go create mode 100644 internal/game/cmd_consume.go delete mode 100644 internal/game/cmd_drink.go delete mode 100644 internal/game/cmd_eat.go delete mode 100644 internal/game/cmd_handlers.go create mode 100644 internal/game/cmd_trigger_combat.go create mode 100644 internal/game/cmd_trigger_enchant.go create mode 100644 internal/game/cmd_trigger_transport.go create mode 100644 internal/game/cmd_trigger_utility.go delete mode 100644 internal/game/color.go delete mode 100644 internal/game/construction.go create mode 100644 internal/game/core_course.go create mode 100644 internal/game/core_equip.go create mode 100644 internal/game/core_flags.go create mode 100644 internal/game/core_hacking.go create mode 100644 internal/game/core_login_account.go create mode 100644 internal/game/core_login_char.go create mode 100644 internal/game/core_messages.go create mode 100644 internal/game/core_production.go create mode 100644 internal/game/core_skill.go create mode 100644 internal/game/core_startup.go create mode 100644 internal/game/core_stations.go create mode 100644 internal/game/core_types.go create mode 100644 internal/game/core_utils.go delete mode 100644 internal/game/course.go delete mode 100644 internal/game/death.go delete mode 100644 internal/game/equip_stats.go delete mode 100644 internal/game/hacking.go delete mode 100644 internal/game/help.go delete mode 100644 internal/game/login_account.go delete mode 100644 internal/game/login_char.go delete mode 100644 internal/game/map.go delete mode 100644 internal/game/messages.go delete mode 100644 internal/game/player_flags.go delete mode 100644 internal/game/production_core.go delete mode 100644 internal/game/production_menu.go delete mode 100644 internal/game/prompt.go delete mode 100644 internal/game/science.go delete mode 100644 internal/game/stations.go create mode 100644 internal/game/sys_assassin.go create mode 100644 internal/game/sys_buff.go create mode 100644 internal/game/sys_combat.go create mode 100644 internal/game/sys_construction.go create mode 100644 internal/game/sys_science.go create mode 100644 internal/game/sys_technology.go delete mode 100644 internal/game/table.go delete mode 100644 internal/game/tech.go delete mode 100644 internal/game/types.go create mode 100644 internal/game/ui_color.go create mode 100644 internal/game/ui_help.go create mode 100644 internal/game/ui_map.go create mode 100644 internal/game/ui_prompt.go create mode 100644 internal/game/ui_table.go delete mode 100644 internal/game/utils.go (limited to 'internal/game') diff --git a/internal/game/action_burn.go b/internal/game/action_burn.go index 4d8b65e..c31d7d5 100644 --- a/internal/game/action_burn.go +++ b/internal/game/action_burn.go @@ -3,6 +3,7 @@ package game import ( "fmt" "math/rand" + "strings" "thehouseoficarus/internal/action" "thehouseoficarus/internal/engine" @@ -11,6 +12,18 @@ import ( "thehouseoficarus/internal/player" ) +func (g *Game) executeBurn(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + g.doBurn(sess, p, strings.Join(args, " ")) +} + +func (g *Game) executeStoke(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + g.doStoke(sess, p, strings.Join(args, " ")) +} + func (g *Game) doBurn(sess *net.Session, p *player.Player, input string) { if p.Action != nil { g.cancelAction(p) diff --git a/internal/game/action_steal.go b/internal/game/action_steal.go index ee5456c..3510ce7 100644 --- a/internal/game/action_steal.go +++ b/internal/game/action_steal.go @@ -16,6 +16,16 @@ import ( "thehouseoficarus/internal/world" ) +func (g *Game) executeSteal(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + g.doSteal(sess, "") + } else { + g.doSteal(sess, strings.Join(args, " ")) + } +} + var stallGuardTalk = &action.TalkConfig{ Nodes: map[string]action.TalkNode{ "start": { diff --git a/internal/game/aggro.go b/internal/game/aggro.go deleted file mode 100644 index 2a9fff4..0000000 --- a/internal/game/aggro.go +++ /dev/null @@ -1,53 +0,0 @@ -package game - -import ( - "fmt" - - "thehouseoficarus/internal/combat" - "thehouseoficarus/internal/engine" - "thehouseoficarus/internal/net" -) - -func (g *Game) checkAggro(sess *net.Session) { - p := sess.Player - - if combat.GetCombat(p.Name) != nil { - return - } - - playerLevel := p.CombatLevel() - mobs := g.MobStore.MobsInRoom(p.RoomID) - - for _, mob := range mobs { - if !mob.Aggressive || mob.HP <= 0 || mob.Protected { - continue - } - if combat.IsMobInCombat(mob.InstanceID) { - continue - } - mobLevel := mobCombatLevel(mob) - if playerLevel > mobLevel*2 { - continue - } - aggMob := mob - g.Ticks.Subscribe(engine.ToTicks(1), func() bool { - if combat.GetCombat(p.Name) != nil { - return false - } - if aggMob.HP <= 0 || aggMob.RoomID != p.RoomID { - return false - } - if combat.IsMobInCombat(aggMob.InstanceID) { - return false - } - attacker := aggMob.Name - if !aggMob.Unique { - attacker = "The " + aggMob.Name - } - sess.WriteLine(fmt.Sprintf("\n%s attacks you!", g.colorize(sess, "mob_name", attacker))) - g.startCombat(sess, p, aggMob) - return false - }) - break - } -} diff --git a/internal/game/assassin.go b/internal/game/assassin.go deleted file mode 100644 index 1927399..0000000 --- a/internal/game/assassin.go +++ /dev/null @@ -1,174 +0,0 @@ -package game - -import ( - "fmt" - "math/rand" - - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" -) - -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 - g.awardSkillXP(sess, p, player.Assassin, xp) - 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 -} diff --git a/internal/game/buff.go b/internal/game/buff.go deleted file mode 100644 index 9449731..0000000 --- a/internal/game/buff.go +++ /dev/null @@ -1,55 +0,0 @@ -package game - -import ( - "strings" - - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" -) - -func (g *Game) BuffTick() { - if g.Hub == nil { - return - } - for _, sess := range g.Hub.AllSessions() { - if sess.Player == nil { - continue - } - p := sess.Player - remaining := make([]player.PotionBuff, 0, len(p.ActiveBuffs)) - dirty := false - for _, buff := range p.ActiveBuffs { - buff.TicksLeft-- - if buff.TicksLeft > 0 { - remaining = append(remaining, buff) - } else { - dirty = true - } - } - if dirty { - p.ActiveBuffs = remaining - sess.WriteLine(g.colorize(sess, "broadcast", "\nYour potion buff has worn off.")) - } - } -} - -func (g *Game) buffLevelBonus(p *player.Player, stat string) int { - if g.Hub == nil { - return 0 - } - totalPercent := 0 - for _, buff := range p.ActiveBuffs { - if strings.EqualFold(buff.Stat, stat) { - totalPercent += buff.BonusPercent - } - } - if totalPercent == 0 { - return 0 - } - baseLevel := p.Level(player.SkillName(strings.ToLower(stat))) - return baseLevel * totalPercent / 100 -} - -func (g *Game) totalBuffedLevel(sess *net.Session, p *player.Player, stat string) int { - return p.Level(player.SkillName(strings.ToLower(stat))) + g.buffLevelBonus(p, stat) -} diff --git a/internal/game/cmd_alias.go b/internal/game/cmd_alias.go index e7e4e12..890b904 100644 --- a/internal/game/cmd_alias.go +++ b/internal/game/cmd_alias.go @@ -8,6 +8,14 @@ import ( "thehouseoficarus/internal/net" ) +func (g *Game) executeAlias(sess *net.Session, args []string, rawInput string) { + g.doAlias(sess, args) +} + +func (g *Game) executeUnalias(sess *net.Session, args []string, rawInput string) { + g.doUnalias(sess, args) +} + func (g *Game) doAlias(sess *net.Session, args []string) { if sess.Account == nil { sess.WriteLine("No account loaded.") diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go index b0ce214..222e3c3 100644 --- a/internal/game/cmd_attack.go +++ b/internal/game/cmd_attack.go @@ -608,6 +608,20 @@ func (g *Game) stopCombat(playerName string) { } } +func (g *Game) executeAttack(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + p := sess.Player + target := g.resolveDefaultMob(p.RoomID) + if target == "" { + sess.WriteLine("Attack what?") + return + } + g.doAttack(sess, target) + } else { + g.doAttack(sess, strings.Join(args, " ")) + } +} + func (g *Game) respawnMob(instanceID string) { inst := g.MobStore.GetInstance(instanceID) if inst == nil { diff --git a/internal/game/cmd_autotrigger.go b/internal/game/cmd_autotrigger.go index 3c41735..e2ecd1a 100644 --- a/internal/game/cmd_autotrigger.go +++ b/internal/game/cmd_autotrigger.go @@ -52,3 +52,7 @@ func (g *Game) doAutotrigger(sess *net.Session, input string) { p.AutotriggerMod = mod.ID sess.WriteLine(fmt.Sprintf("Autotrigger set to: %s", mod.Name)) } + +func (g *Game) executeAutotrigger(sess *net.Session, args []string, rawInput string) { + g.doAutotrigger(sess, strings.Join(args, " ")) +} diff --git a/internal/game/cmd_bank.go b/internal/game/cmd_bank.go index 0785b81..d91e115 100644 --- a/internal/game/cmd_bank.go +++ b/internal/game/cmd_bank.go @@ -10,6 +10,10 @@ import ( "thehouseoficarus/internal/player" ) +func (g *Game) executeBank(sess *net.Session, args []string, rawInput string) { + g.doBank(sess) +} + func (g *Game) handleBankInput(sess *net.Session, input string) { if sess.Player == nil { sess.State = net.StateGame diff --git a/internal/game/cmd_clean.go b/internal/game/cmd_clean.go index 1f00d75..47896bb 100644 --- a/internal/game/cmd_clean.go +++ b/internal/game/cmd_clean.go @@ -11,6 +11,10 @@ import ( "thehouseoficarus/internal/player" ) +func (g *Game) executeClean(sess *net.Session, args []string, rawInput string) { + g.doClean(sess, strings.Join(args, " ")) +} + func (g *Game) doClean(sess *net.Session, input string) { p := sess.Player diff --git a/internal/game/cmd_color.go b/internal/game/cmd_color.go index 3fb7716..257c965 100644 --- a/internal/game/cmd_color.go +++ b/internal/game/cmd_color.go @@ -128,6 +128,10 @@ func (g *Game) colorSource(sess *net.Session, category string) string { return "builtin" } +func (g *Game) executeColor(sess *net.Session, args []string, rawInput string) { + g.doColor(sess, strings.Join(args, " ")) +} + var colorCategoryOrder = []string{ "room_name", "room_number", diff --git a/internal/game/cmd_colortable.go b/internal/game/cmd_colortable.go index 5aa36d9..86299dc 100644 --- a/internal/game/cmd_colortable.go +++ b/internal/game/cmd_colortable.go @@ -37,6 +37,10 @@ func (g *Game) doColortable(sess *net.Session) { writeANSITable(sess, mode) } +func (g *Game) executeColortable(sess *net.Session, args []string, rawInput string) { + g.doColortable(sess) +} + func writeSectionHeader(sess *net.Session, title string, unicode bool) { if unicode { sess.WriteLine(fmt.Sprintf("── %s ──", title)) diff --git a/internal/game/cmd_consume.go b/internal/game/cmd_consume.go new file mode 100644 index 0000000..0a7d335 --- /dev/null +++ b/internal/game/cmd_consume.go @@ -0,0 +1,293 @@ +package game + +import ( + "fmt" + "strings" + "time" + + "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/object" + "thehouseoficarus/internal/player" +) + +func (g *Game) executeEat(sess *net.Session, args []string, rawInput string) { + g.doEat(sess, strings.Join(args, " ")) +} + +func (g *Game) executeDrink(sess *net.Session, args []string, rawInput string) { + g.doDrink(sess, args) +} + +func (g *Game) doEat(sess *net.Session, input string) { + p := sess.Player + + if input == "" { + sess.WriteLine("Eat what?") + return + } + + qty, itemName := parseQty(input) + _ = qty + + matches := g.findInventoryMatches(itemName, p) + if len(matches) == 0 { + sess.WriteLine(fmt.Sprintf("You don't have any '%s'.", itemName)) + return + } + + unique := uniqueItemNames(matches) + if len(unique) > 1 { + g.showWhichOne(sess, matches) + return + } + + itemID := matches[0].ID + def, _ := g.ItemStore.Load(itemID) + + if def == nil || def.EatMessage == "" { + sess.WriteLine("You can't eat that.") + return + } + + if p.ConsumeCooldown > 0 { + sess.WriteLine("You're still digesting your last meal.") + return + } + + g.cancelRest(p.Name) + + if p.Action == nil && len(p.WalkSequence) == 0 { + g.doEatNow(sess, p, itemID, def) + return + } + + g.cancelAction(p) + + g.consumeQueue[p.Name] = &QueuedCommand{ + Session: sess, + Command: "eat", + Args: itemID, + Timestamp: time.Now(), + } + + if !p.OptionBool("queue_silently") { + sess.WriteLine(fmt.Sprintf("\nYou prepare to eat %s.", g.itemColorize(sess, def, def.Name))) + } +} + +func (g *Game) doEatNow(sess *net.Session, p *player.Player, itemID string, def *object.ItemDef) { + if def == nil { + var err error + def, err = g.ItemStore.Load(itemID) + if err != nil || def.EatMessage == "" { + return + } + } + + p.RemoveItem(itemID, 1) + + p.HP += def.HealValue + maxHP := p.MaxHP() + if p.HP > maxHP { + p.HP = maxHP + } + if p.HP < 0 { + p.HP = 0 + } + + if def.HealValue != 0 { + p.StartRegen() + } + + p.ConsumeCooldown = 3 + g.AccountStore.SaveCharacter(p) + + p.ActionState = &ActionState{Type: ActionEating, TargetName: def.Name} + + sess.WriteLine(g.colorize(sess, "eat_food", def.EatMessage)) + + if p.HP <= 0 { + g.endCombat(sess, p, nil) + } +} + +func (g *Game) processConsumeQueue(p *player.Player, sess *net.Session) bool { + qc, ok := g.consumeQueue[p.Name] + if !ok { + return false + } + delete(g.consumeQueue, p.Name) + + itemID := qc.Args + def, err := g.ItemStore.Load(itemID) + + if qc.Command == "drink" { + if err != nil || (def.PotionEffect == "" && def.HealValue <= 0) { + return false + } + if p.ConsumeCooldown > 0 { + return false + } + if !p.HasItem(itemID) { + sess.WriteLine("You no longer have that to drink.") + return false + } + g.applyPotion(sess, p, itemID, def) + return true + } + + if err != nil || def.EatMessage == "" { + return false + } + + if p.ConsumeCooldown > 0 { + return false + } + + if !p.HasItem(itemID) { + sess.WriteLine("You no longer have that to eat.") + return false + } + + g.doEatNow(sess, p, itemID, def) + return true +} + +func (g *Game) doDrink(sess *net.Session, args []string) { + p := sess.Player + + if len(args) == 0 { + sess.WriteLine("Drink what?") + return + } + + input := strings.Join(args, " ") + matches := g.findInventoryMatches(input, p) + if len(matches) == 0 { + sess.WriteLine(fmt.Sprintf("You don't have any '%s'.", input)) + return + } + + unique := uniqueItemNames(matches) + if len(unique) > 1 { + g.showWhichOne(sess, matches) + return + } + + itemID := matches[0].ID + def, _ := g.ItemStore.Load(itemID) + + if def == nil { + sess.WriteLine("Something went wrong.") + return + } + + if def.PotionEffect == "" && def.HealValue <= 0 { + sess.WriteLine(fmt.Sprintf("You can't drink the %s.", g.itemColorize(sess, def, def.Name))) + return + } + + if p.ConsumeCooldown > 0 { + sess.WriteLine("You're still recovering from your last drink.") + return + } + + g.cancelRest(p.Name) + + if p.Action == nil && len(p.WalkSequence) == 0 { + g.applyPotion(sess, p, itemID, def) + return + } + + g.cancelAction(p) + + g.consumeQueue[p.Name] = &QueuedCommand{ + Session: sess, + Command: "drink", + Args: itemID, + Timestamp: time.Now(), + } + + if !p.OptionBool("queue_silently") { + sess.WriteLine(fmt.Sprintf("\nYou prepare to drink the %s.", g.itemColorize(sess, def, def.Name))) + } +} + +func (g *Game) applyPotion(sess *net.Session, p *player.Player, itemID string, def *object.ItemDef) { + if def == nil { + var err error + def, err = g.ItemStore.Load(itemID) + if err != nil || (def.PotionEffect == "" && def.HealValue <= 0) { + return + } + } + + p.RemoveItem(itemID, 1) + + effect := strings.ToLower(def.PotionEffect) + + switch effect { + case "battery": + batteryRestore := def.PotionBonus + p.Battery += float64(batteryRestore) + if p.Battery > 100 { + p.Battery = 100 + } + sess.WriteLine(fmt.Sprintf("\nYou drink the %s. Your battery is restored by %d%%.", + g.itemColorize(sess, def, def.Name), batteryRestore)) + + case "heal", "": + healAmount := def.PotionBonus + if healAmount <= 0 { + healAmount = def.HealValue + } + if healAmount <= 0 { + healAmount = 50 + } + before := p.HP + maxHP := p.Level(player.Hitpoints) + if p.HP+healAmount > maxHP { + p.HP = maxHP + } else { + p.HP += healAmount + } + actualHeal := p.HP - before + sess.WriteLine(fmt.Sprintf("\nYou drink the %s. You restore %d hitpoints.", + g.itemColorize(sess, def, def.Name), actualHeal)) + + case "all_combat": + duration := engine.ToTicks(def.PotionDuration) + buffs := []string{"attack", "strength", "defense"} + for _, stat := range buffs { + p.ActiveBuffs = append(p.ActiveBuffs, player.PotionBuff{ + Stat: stat, + BonusPercent: def.PotionBonus, + TicksLeft: duration, + }) + } + sess.WriteLine(fmt.Sprintf("\nYou drink the %s. Your combat stats are boosted by %d%% for %d ticks.", + g.itemColorize(sess, def, def.Name), def.PotionBonus, duration)) + + default: + duration := engine.ToTicks(def.PotionDuration) + p.ActiveBuffs = append(p.ActiveBuffs, player.PotionBuff{ + Stat: effect, + BonusPercent: def.PotionBonus, + TicksLeft: duration, + }) + sess.WriteLine(fmt.Sprintf("\nYou drink the %s. Your %s is boosted by %d%% for %d ticks.", + g.itemColorize(sess, def, def.Name), effect, def.PotionBonus, duration)) + } + + p.ConsumeCooldown = 3 + p.ActionState = &ActionState{Type: ActionEating} + + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other != sess && other.Player != nil { + other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s drinks a %s.", p.Name, def.Name))) + } + } + } +} diff --git a/internal/game/cmd_cook.go b/internal/game/cmd_cook.go index 5b78745..288afb0 100644 --- a/internal/game/cmd_cook.go +++ b/internal/game/cmd_cook.go @@ -2,12 +2,17 @@ package game import ( "fmt" + "strings" "thehouseoficarus/internal/action" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) +func (g *Game) executeCook(sess *net.Session, args []string, rawInput string) { + g.doCook(sess, strings.Join(args, " ")) +} + func (g *Game) doCook(sess *net.Session, input string) { p := sess.Player g.cancelAction(p) diff --git a/internal/game/cmd_craft.go b/internal/game/cmd_craft.go index 7f8808b..447d980 100644 --- a/internal/game/cmd_craft.go +++ b/internal/game/cmd_craft.go @@ -8,6 +8,14 @@ import ( "thehouseoficarus/internal/player" ) +func (g *Game) executeCraft(sess *net.Session, args []string, rawInput string) { + g.doCraft(sess, strings.Join(args, " ")) +} + +func (g *Game) executeConstruct(sess *net.Session, args []string, rawInput string) { + g.doConstruct(sess, strings.Join(args, " ")) +} + func (g *Game) doCraft(sess *net.Session, input string) { g.doGenericStationSkill(sess, input, "crafting", player.Crafting, "last_craft", "Crafting", "craft", "Craft") } diff --git a/internal/game/cmd_description.go b/internal/game/cmd_description.go index ced5dcc..5d0c5c1 100644 --- a/internal/game/cmd_description.go +++ b/internal/game/cmd_description.go @@ -6,6 +6,10 @@ import ( "thehouseoficarus/internal/net" ) +func (g *Game) executeDescription(sess *net.Session, args []string, rawInput string) { + g.doDescription(sess) +} + func (g *Game) doDescription(sess *net.Session) { p := sess.Player sess.WriteLine("") diff --git a/internal/game/cmd_drink.go b/internal/game/cmd_drink.go deleted file mode 100644 index 44ef991..0000000 --- a/internal/game/cmd_drink.go +++ /dev/null @@ -1,150 +0,0 @@ -package game - -import ( - "fmt" - "strings" - "time" - - "thehouseoficarus/internal/engine" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" - "thehouseoficarus/internal/player" -) - -func (g *Game) doDrink(sess *net.Session, args []string) { - p := sess.Player - - if len(args) == 0 { - sess.WriteLine("Drink what?") - return - } - - input := strings.Join(args, " ") - matches := g.findInventoryMatches(input, p) - if len(matches) == 0 { - sess.WriteLine(fmt.Sprintf("You don't have any '%s'.", input)) - return - } - - unique := uniqueItemNames(matches) - if len(unique) > 1 { - g.showWhichOne(sess, matches) - return - } - - itemID := matches[0].ID - def, _ := g.ItemStore.Load(itemID) - - if def == nil { - sess.WriteLine("Something went wrong.") - return - } - - if def.PotionEffect == "" && def.HealValue <= 0 { - sess.WriteLine(fmt.Sprintf("You can't drink the %s.", g.itemColorize(sess, def, def.Name))) - return - } - - if p.ConsumeCooldown > 0 { - sess.WriteLine("You're still recovering from your last drink.") - return - } - - g.cancelRest(p.Name) - - if p.Action == nil && len(p.WalkSequence) == 0 { - g.applyPotion(sess, p, itemID, def) - return - } - - g.cancelAction(p) - - g.consumeQueue[p.Name] = &QueuedCommand{ - Session: sess, - Command: "drink", - Args: itemID, - Timestamp: time.Now(), - } - - if !p.OptionBool("queue_silently") { - sess.WriteLine(fmt.Sprintf("\nYou prepare to drink the %s.", g.itemColorize(sess, def, def.Name))) - } -} - -func (g *Game) applyPotion(sess *net.Session, p *player.Player, itemID string, def *object.ItemDef) { - if def == nil { - var err error - def, err = g.ItemStore.Load(itemID) - if err != nil || (def.PotionEffect == "" && def.HealValue <= 0) { - return - } - } - - p.RemoveItem(itemID, 1) - - effect := strings.ToLower(def.PotionEffect) - - switch effect { - case "battery": - batteryRestore := def.PotionBonus - p.Battery += float64(batteryRestore) - if p.Battery > 100 { - p.Battery = 100 - } - sess.WriteLine(fmt.Sprintf("\nYou drink the %s. Your battery is restored by %d%%.", - g.itemColorize(sess, def, def.Name), batteryRestore)) - - case "heal", "": - healAmount := def.PotionBonus - if healAmount <= 0 { - healAmount = def.HealValue - } - if healAmount <= 0 { - healAmount = 50 - } - before := p.HP - maxHP := p.Level(player.Hitpoints) - if p.HP+healAmount > maxHP { - p.HP = maxHP - } else { - p.HP += healAmount - } - actualHeal := p.HP - before - sess.WriteLine(fmt.Sprintf("\nYou drink the %s. You restore %d hitpoints.", - g.itemColorize(sess, def, def.Name), actualHeal)) - - case "all_combat": - duration := engine.ToTicks(def.PotionDuration) - buffs := []string{"attack", "strength", "defense"} - for _, stat := range buffs { - p.ActiveBuffs = append(p.ActiveBuffs, player.PotionBuff{ - Stat: stat, - BonusPercent: def.PotionBonus, - TicksLeft: duration, - }) - } - sess.WriteLine(fmt.Sprintf("\nYou drink the %s. Your combat stats are boosted by %d%% for %d ticks.", - g.itemColorize(sess, def, def.Name), def.PotionBonus, duration)) - - default: - duration := engine.ToTicks(def.PotionDuration) - p.ActiveBuffs = append(p.ActiveBuffs, player.PotionBuff{ - Stat: effect, - BonusPercent: def.PotionBonus, - TicksLeft: duration, - }) - sess.WriteLine(fmt.Sprintf("\nYou drink the %s. Your %s is boosted by %d%% for %d ticks.", - g.itemColorize(sess, def, def.Name), effect, def.PotionBonus, duration)) - } - - p.ConsumeCooldown = 3 - p.ActionState = &ActionState{Type: ActionEating} - - if g.Hub != nil { - for _, other := range g.Hub.PlayersInRoom(p.RoomID) { - if other != sess && other.Player != nil { - other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s drinks a %s.", p.Name, def.Name))) - } - } - } -} diff --git a/internal/game/cmd_drop.go b/internal/game/cmd_drop.go index d3141e3..551b014 100644 --- a/internal/game/cmd_drop.go +++ b/internal/game/cmd_drop.go @@ -180,6 +180,20 @@ func (g *Game) doDrop(sess *net.Session, input string) { } } +func (g *Game) executeDrop(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + sess.WriteLine("Drop what?") + } else if args[0] == "all" { + if len(args) == 1 { + g.doDropAll(sess) + } else { + g.doDropAllNamed(sess, strings.Join(args[1:], " ")) + } + } else { + g.doDrop(sess, strings.Join(args, " ")) + } +} + func (g *Game) handleDropAllConfirm(sess *net.Session, input string) { p := sess.Player if p == nil { diff --git a/internal/game/cmd_eat.go b/internal/game/cmd_eat.go deleted file mode 100644 index f94ade3..0000000 --- a/internal/game/cmd_eat.go +++ /dev/null @@ -1,145 +0,0 @@ -package game - -import ( - "fmt" - "time" - - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" - "thehouseoficarus/internal/player" -) - -func (g *Game) doEat(sess *net.Session, input string) { - p := sess.Player - - if input == "" { - sess.WriteLine("Eat what?") - return - } - - qty, itemName := parseQty(input) - _ = qty - - matches := g.findInventoryMatches(itemName, p) - if len(matches) == 0 { - sess.WriteLine(fmt.Sprintf("You don't have any '%s'.", itemName)) - return - } - - unique := uniqueItemNames(matches) - if len(unique) > 1 { - g.showWhichOne(sess, matches) - return - } - - itemID := matches[0].ID - def, _ := g.ItemStore.Load(itemID) - - if def == nil || def.EatMessage == "" { - sess.WriteLine("You can't eat that.") - return - } - - if p.ConsumeCooldown > 0 { - sess.WriteLine("You're still digesting your last meal.") - return - } - - g.cancelRest(p.Name) - - if p.Action == nil && len(p.WalkSequence) == 0 { - g.doEatNow(sess, p, itemID, def) - return - } - - g.cancelAction(p) - - g.consumeQueue[p.Name] = &QueuedCommand{ - Session: sess, - Command: "eat", - Args: itemID, - Timestamp: time.Now(), - } - - if !p.OptionBool("queue_silently") { - sess.WriteLine(fmt.Sprintf("\nYou prepare to eat %s.", g.itemColorize(sess, def, def.Name))) - } -} - -func (g *Game) doEatNow(sess *net.Session, p *player.Player, itemID string, def *object.ItemDef) { - if def == nil { - var err error - def, err = g.ItemStore.Load(itemID) - if err != nil || def.EatMessage == "" { - return - } - } - - p.RemoveItem(itemID, 1) - - p.HP += def.HealValue - maxHP := p.MaxHP() - if p.HP > maxHP { - p.HP = maxHP - } - if p.HP < 0 { - p.HP = 0 - } - - if def.HealValue != 0 { - p.StartRegen() - } - - p.ConsumeCooldown = 3 - g.AccountStore.SaveCharacter(p) - - p.ActionState = &ActionState{Type: ActionEating, TargetName: def.Name} - - sess.WriteLine(g.colorize(sess, "eat_food", def.EatMessage)) - - if p.HP <= 0 { - g.endCombat(sess, p, nil) - } -} - -func (g *Game) processConsumeQueue(p *player.Player, sess *net.Session) bool { - qc, ok := g.consumeQueue[p.Name] - if !ok { - return false - } - delete(g.consumeQueue, p.Name) - - itemID := qc.Args - def, err := g.ItemStore.Load(itemID) - - if qc.Command == "drink" { - if err != nil || (def.PotionEffect == "" && def.HealValue <= 0) { - return false - } - if p.ConsumeCooldown > 0 { - return false - } - if !p.HasItem(itemID) { - sess.WriteLine("You no longer have that to drink.") - return false - } - g.applyPotion(sess, p, itemID, def) - return true - } - - if err != nil || def.EatMessage == "" { - return false - } - - if p.ConsumeCooldown > 0 { - return false - } - - if !p.HasItem(itemID) { - sess.WriteLine("You no longer have that to eat.") - return false - } - - g.doEatNow(sess, p, itemID, def) - return true -} diff --git a/internal/game/cmd_estate_directory.go b/internal/game/cmd_estate_directory.go index c79135f..dbc79f6 100644 --- a/internal/game/cmd_estate_directory.go +++ b/internal/game/cmd_estate_directory.go @@ -186,7 +186,7 @@ func (g *Game) getOwnedHouseRoom(p *player.Player, flagKey string) int { } houseMap := map[string]int{ - "local_neighborhood": 200, + "local_neighborhood": 300, } return houseMap[v] } diff --git a/internal/game/cmd_farm.go b/internal/game/cmd_farm.go index 5ef36d9..791cb6d 100644 --- a/internal/game/cmd_farm.go +++ b/internal/game/cmd_farm.go @@ -9,6 +9,64 @@ import ( "thehouseoficarus/internal/player" ) +func (g *Game) executePlant(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + sess.WriteLine("Plant what?") + } else { + g.doPlant(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeHarvest(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + g.doHarvest(sess, "") + } else { + g.doHarvest(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeRake(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + g.doRake(sess, "") + } else { + g.doRake(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeWater(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + g.doWater(sess, "") + } else { + g.doWater(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeCure(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + g.doCure(sess, "") + } else { + g.doCure(sess, strings.Join(args, " ")) + } +} + +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 diff --git a/internal/game/cmd_fletch.go b/internal/game/cmd_fletch.go index 1f6a075..e218dca 100644 --- a/internal/game/cmd_fletch.go +++ b/internal/game/cmd_fletch.go @@ -9,6 +9,10 @@ import ( "thehouseoficarus/internal/player" ) +func (g *Game) executeFletch(sess *net.Session, args []string, rawInput string) { + g.doFletch(sess, strings.Join(args, " ")) +} + var fletchLogItems = map[string]bool{ "logs": true, "oak_logs": true, "willow_logs": true, "maple_logs": true, "yew_logs": true, "magic_logs": true, diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go index bae721b..f3485fe 100644 --- a/internal/game/cmd_get.go +++ b/internal/game/cmd_get.go @@ -2,6 +2,7 @@ package game import ( "fmt" + "strings" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" @@ -419,3 +420,17 @@ func (g *Game) findInventoryMatches(input string, p *player.Player) []itemMatch } }) } + +func (g *Game) executeGet(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + sess.WriteLine("Get what?") + } else if args[0] == "all" { + if len(args) == 1 { + g.doGetAll(sess) + } else { + g.doGetAllNamed(sess, strings.Join(args[1:], " ")) + } + } else { + g.doGet(sess, strings.Join(args, " ")) + } +} diff --git a/internal/game/cmd_handlers.go b/internal/game/cmd_handlers.go deleted file mode 100644 index b8db359..0000000 --- a/internal/game/cmd_handlers.go +++ /dev/null @@ -1,370 +0,0 @@ -package game - -import ( - "fmt" - "strings" - - "thehouseoficarus/internal/net" -) - -func (g *Game) executeGet(sess *net.Session, args []string, rawInput string) { - if len(args) == 0 { - sess.WriteLine("Get what?") - } else if args[0] == "all" { - if len(args) == 1 { - g.doGetAll(sess) - } else { - g.doGetAllNamed(sess, strings.Join(args[1:], " ")) - } - } else { - g.doGet(sess, strings.Join(args, " ")) - } -} - -func (g *Game) executeDrop(sess *net.Session, args []string, rawInput string) { - if len(args) == 0 { - sess.WriteLine("Drop what?") - } else if args[0] == "all" { - if len(args) == 1 { - g.doDropAll(sess) - } else { - g.doDropAllNamed(sess, strings.Join(args[1:], " ")) - } - } else { - g.doDrop(sess, strings.Join(args, " ")) - } -} - -func (g *Game) executeAttack(sess *net.Session, args []string, rawInput string) { - if len(args) == 0 { - p := sess.Player - target := g.resolveDefaultMob(p.RoomID) - if target == "" { - sess.WriteLine("Attack what?") - return - } - g.doAttack(sess, target) - } else { - g.doAttack(sess, strings.Join(args, " ")) - } -} - -func (g *Game) executeStyle(sess *net.Session, args []string, rawInput string) { - if len(args) == 0 { - g.doStyle(sess, "") - } else { - g.doStyle(sess, args[0]) - } -} - -func (g *Game) executeLook(sess *net.Session, args []string, rawInput string) { - if len(args) == 0 { - g.doLook(sess) - } else { - g.doLookTarget(sess, strings.Join(args, " ")) - } -} - -func (g *Game) executeMove(sess *net.Session, args []string, rawInput string) { - g.doMove(sess, strings.TrimSpace(rawInput), 1.0) -} - -func (g *Game) executeSay(sess *net.Session, args []string, rawInput string) { - if len(args) == 0 { - sess.WriteLine("Say what?") - } else { - msgStart := strings.Index(strings.ToLower(rawInput), "say ") + 4 - if msgStart >= 4 && msgStart < len(rawInput) { - g.doSay(sess, rawInput[msgStart:]) - } else { - g.doSay(sess, strings.Join(args, " ")) - } - } -} - -func (g *Game) executeScore(sess *net.Session, args []string, rawInput string) { - g.doScore(sess) -} - -func (g *Game) executeTask(sess *net.Session, args []string, rawInput string) { - g.doTask(sess) -} - -func (g *Game) executeStats(sess *net.Session, args []string, rawInput string) { - g.doStats(sess) -} - -func (g *Game) executeTech(sess *net.Session, args []string, rawInput string) { - g.doTech(sess, strings.Join(args, " ")) -} - -func (g *Game) executeInventory(sess *net.Session, args []string, rawInput string) { - g.doInventory(sess) -} - -func (g *Game) executeQuit(sess *net.Session, args []string, rawInput string) { - g.doQuit(sess) -} - -func (g *Game) executeDescription(sess *net.Session, args []string, rawInput string) { - g.doDescription(sess) -} - -func (g *Game) executeOption(sess *net.Session, args []string, rawInput string) { - g.doOption(sess, strings.Join(args, " ")) -} - -func (g *Game) executeColor(sess *net.Session, args []string, rawInput string) { - g.doColor(sess, strings.Join(args, " ")) -} - -func (g *Game) executeColortable(sess *net.Session, args []string, rawInput string) { - g.doColortable(sess) -} - -func (g *Game) executePrompt(sess *net.Session, args []string, rawInput string) { - if len(args) == 0 { - g.doPrompt(sess, "") - } else { - msgStart := strings.Index(strings.ToLower(rawInput), "prompt ") + 7 - if msgStart >= 7 && msgStart < len(rawInput) { - g.doPrompt(sess, rawInput[msgStart:]) - } else { - g.doPrompt(sess, strings.Join(args, " ")) - } - } -} - -func (g *Game) executeExits(sess *net.Session, args []string, rawInput string) { - g.doExits(sess) -} - -func (g *Game) executeMap(sess *net.Session, args []string, rawInput string) { - g.doMap(sess) -} - -func (g *Game) executeQueued(sess *net.Session, args []string, rawInput string) { - g.doQueued(sess) -} - -func (g *Game) executeHelp(sess *net.Session, args []string, rawInput string) { - if len(args) == 0 { - g.doHelp(sess, "") - } else { - g.doHelp(sess, strings.Join(args, " ")) - } -} - -func (g *Game) executeGather(sess *net.Session, args []string, rawInput string) { - verb := strings.TrimSpace(rawInput) - p := sess.Player - g.cancelAction(p) - if len(args) == 0 { - target := g.resolveDefaultTarget(p.RoomID, verb) - if target == "" { - sess.WriteLine(fmt.Sprintf("%s what?", verb)) - return - } - g.startAction(sess, verb, target) - } else { - g.startAction(sess, verb, strings.Join(args, " ")) - } -} - -func (g *Game) executeUse(sess *net.Session, args []string, rawInput string) { - if g.tryFinishingBlow(sess, strings.Join(args, " ")) { - return - } - g.doUse(sess, strings.Join(args, " ")) -} - -func (g *Game) executeCook(sess *net.Session, args []string, rawInput string) { - g.doCook(sess, strings.Join(args, " ")) -} - -func (g *Game) executeSmelt(sess *net.Session, args []string, rawInput string) { - g.doSmelt(sess, strings.Join(args, " ")) -} - -func (g *Game) executeSmith(sess *net.Session, args []string, rawInput string) { - g.doSmith(sess, strings.Join(args, " ")) -} - -func (g *Game) executeCraft(sess *net.Session, args []string, rawInput string) { - g.doCraft(sess, strings.Join(args, " ")) -} - -func (g *Game) executeEat(sess *net.Session, args []string, rawInput string) { - g.doEat(sess, strings.Join(args, " ")) -} - -func (g *Game) executeDrink(sess *net.Session, args []string, rawInput string) { - g.doDrink(sess, args) -} - -func (g *Game) executeFletch(sess *net.Session, args []string, rawInput string) { - g.doFletch(sess, strings.Join(args, " ")) -} - -func (g *Game) executeClean(sess *net.Session, args []string, rawInput string) { - g.doClean(sess, strings.Join(args, " ")) -} - -func (g *Game) executeMix(sess *net.Session, args []string, rawInput string) { - g.doMix(sess, strings.Join(args, " ")) -} - -func (g *Game) executeIdentify(sess *net.Session, args []string, rawInput string) { - g.doIdentify(sess, strings.Join(args, " ")) -} - -func (g *Game) executeAutotrigger(sess *net.Session, args []string, rawInput string) { - g.doAutotrigger(sess, strings.Join(args, " ")) -} - -func (g *Game) executeMods(sess *net.Session, args []string, rawInput string) { - showAll := len(args) > 0 && args[0] == "all" - g.doMods(sess, showAll) -} - -func (g *Game) executeSneak(sess *net.Session, args []string, rawInput string) { - g.doSneak(sess) -} - -func (g *Game) executeTrigger(sess *net.Session, args []string, rawInput string) { - g.doTrigger(sess, strings.Join(args, " ")) -} - -func (g *Game) executeTalk(sess *net.Session, args []string, rawInput string) { - p := sess.Player - g.cancelAction(p) - if len(args) == 0 { - sess.WriteLine("Talk to whom?") - } else { - g.startAction(sess, "talk", strings.Join(args, " ")) - } -} - -func (g *Game) executeBurn(sess *net.Session, args []string, rawInput string) { - p := sess.Player - g.cancelAction(p) - g.doBurn(sess, p, strings.Join(args, " ")) -} - -func (g *Game) executeStoke(sess *net.Session, args []string, rawInput string) { - p := sess.Player - g.cancelAction(p) - g.doStoke(sess, p, strings.Join(args, " ")) -} - -func (g *Game) executeAlias(sess *net.Session, args []string, rawInput string) { - g.doAlias(sess, args) -} - -func (g *Game) executeUnalias(sess *net.Session, args []string, rawInput string) { - g.doUnalias(sess, args) -} - -func (g *Game) executeSearch(sess *net.Session, args []string, rawInput string) { - p := sess.Player - g.cancelAction(p) - if len(args) == 0 { - sess.WriteLine("Search what?") - } else { - g.doSearch(sess, strings.Join(args, " ")) - } -} - -func (g *Game) executeSteal(sess *net.Session, args []string, rawInput string) { - p := sess.Player - g.cancelAction(p) - if len(args) == 0 { - g.doSteal(sess, "") - } else { - g.doSteal(sess, strings.Join(args, " ")) - } -} - -func (g *Game) executePlant(sess *net.Session, args []string, rawInput string) { - p := sess.Player - g.cancelAction(p) - if len(args) == 0 { - sess.WriteLine("Plant what?") - } else { - g.doPlant(sess, strings.Join(args, " ")) - } -} - -func (g *Game) executeHarvest(sess *net.Session, args []string, rawInput string) { - p := sess.Player - g.cancelAction(p) - if len(args) == 0 { - g.doHarvest(sess, "") - } else { - g.doHarvest(sess, strings.Join(args, " ")) - } -} - -func (g *Game) executeRake(sess *net.Session, args []string, rawInput string) { - p := sess.Player - g.cancelAction(p) - if len(args) == 0 { - g.doRake(sess, "") - } else { - g.doRake(sess, strings.Join(args, " ")) - } -} - -func (g *Game) executeWater(sess *net.Session, args []string, rawInput string) { - p := sess.Player - g.cancelAction(p) - if len(args) == 0 { - g.doWater(sess, "") - } else { - g.doWater(sess, strings.Join(args, " ")) - } -} - -func (g *Game) executeCure(sess *net.Session, args []string, rawInput string) { - p := sess.Player - g.cancelAction(p) - if len(args) == 0 { - g.doCure(sess, "") - } else { - g.doCure(sess, strings.Join(args, " ")) - } -} - -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) executeWalk(sess *net.Session, args []string, rawInput string) { - g.doWalk(sess, args) -} - -func (g *Game) executeBank(sess *net.Session, args []string, rawInput string) { - g.doBank(sess) -} - -func (g *Game) executeConstruct(sess *net.Session, args []string, rawInput string) { - g.doConstruct(sess, strings.Join(args, " ")) -} - -func (g *Game) executeJack(sess *net.Session, args []string, rawInput string) { - p := sess.Player - g.cancelAction(p) - g.doJack(sess, strings.Join(args, " ")) -} - -func (g *Game) executeWear(sess *net.Session, args []string, rawInput string) { - g.doWear(sess, strings.Join(args, " ")) -} - -func (g *Game) executeRemove(sess *net.Session, args []string, rawInput string) { - g.doRemove(sess, strings.Join(args, " ")) -} diff --git a/internal/game/cmd_id.go b/internal/game/cmd_id.go index f7158e9..cb886dc 100644 --- a/internal/game/cmd_id.go +++ b/internal/game/cmd_id.go @@ -2,6 +2,7 @@ package game import ( "fmt" + "strings" "thehouseoficarus/internal/action" "thehouseoficarus/internal/net" @@ -81,6 +82,10 @@ func junkMultiplier(junkID string, level int) int { return mult } +func (g *Game) executeIdentify(sess *net.Session, args []string, rawInput string) { + g.doIdentify(sess, strings.Join(args, " ")) +} + func (g *Game) doIdentify(sess *net.Session, input string) { p := sess.Player g.cancelAction(p) diff --git a/internal/game/cmd_inventory.go b/internal/game/cmd_inventory.go index ae7b328..5024642 100644 --- a/internal/game/cmd_inventory.go +++ b/internal/game/cmd_inventory.go @@ -6,6 +6,10 @@ import ( "thehouseoficarus/internal/net" ) +func (g *Game) executeInventory(sess *net.Session, args []string, rawInput string) { + g.doInventory(sess) +} + func (g *Game) doInventory(sess *net.Session) { p := sess.Player sess.WriteLine("") diff --git a/internal/game/cmd_jack.go b/internal/game/cmd_jack.go index 8cb5062..16f8b2a 100644 --- a/internal/game/cmd_jack.go +++ b/internal/game/cmd_jack.go @@ -10,6 +10,12 @@ import ( "thehouseoficarus/internal/player" ) +func (g *Game) executeJack(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + g.doJack(sess, strings.Join(args, " ")) +} + func (g *Game) doJack(sess *net.Session, target string) { p := sess.Player diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go index 5fa2cf4..6913333 100644 --- a/internal/game/cmd_look.go +++ b/internal/game/cmd_look.go @@ -768,6 +768,18 @@ func (g *Game) writeLookSideBySide(sess *net.Session, p *player.Player, descLine } } +func (g *Game) executeLook(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + g.doLook(sess) + } else { + g.doLookTarget(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeExits(sess *net.Session, args []string, rawInput string) { + g.doExits(sess) +} + func (g *Game) showItemStats(sess *net.Session, def *object.ItemDef) { s := def.Stats hasAttack := s.StabAttack != 0 || s.SlashAttack != 0 || s.CrushAttack != 0 || diff --git a/internal/game/cmd_map.go b/internal/game/cmd_map.go index 7cad008..be35a66 100644 --- a/internal/game/cmd_map.go +++ b/internal/game/cmd_map.go @@ -4,6 +4,10 @@ import ( "thehouseoficarus/internal/net" ) +func (g *Game) executeMap(sess *net.Session, args []string, rawInput string) { + g.doMap(sess) +} + func (g *Game) doMap(sess *net.Session) { p := sess.Player mapWidth := p.OptionInt("mapwidth") diff --git a/internal/game/cmd_mix.go b/internal/game/cmd_mix.go index 8fc600b..7bf1f7b 100644 --- a/internal/game/cmd_mix.go +++ b/internal/game/cmd_mix.go @@ -1,11 +1,17 @@ package game import ( + "strings" + "thehouseoficarus/internal/action" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) +func (g *Game) executeMix(sess *net.Session, args []string, rawInput string) { + g.doMix(sess, strings.Join(args, " ")) +} + func (g *Game) doMix(sess *net.Session, input string) { p := sess.Player g.cancelAction(p) diff --git a/internal/game/cmd_mods.go b/internal/game/cmd_mods.go index 65d7ad7..438e11a 100644 --- a/internal/game/cmd_mods.go +++ b/internal/game/cmd_mods.go @@ -9,6 +9,11 @@ import ( "thehouseoficarus/internal/player" ) +func (g *Game) executeMods(sess *net.Session, args []string, rawInput string) { + showAll := len(args) > 0 && args[0] == "all" + g.doMods(sess, showAll) +} + func (g *Game) doMods(sess *net.Session, showAll bool) { p := sess.Player sciLevel := p.Level(player.Science) diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go index 5d6ea66..e93b14b 100644 --- a/internal/game/cmd_move.go +++ b/internal/game/cmd_move.go @@ -2,6 +2,7 @@ package game import ( "fmt" + "strings" "thehouseoficarus/internal/combat" "thehouseoficarus/internal/engine" @@ -165,6 +166,10 @@ func (g *Game) seedRoomMobs(roomID int) { g.MobStore.SeedMobs(roomID, room.Mobs) } +func (g *Game) executeMove(sess *net.Session, args []string, rawInput string) { + g.doMove(sess, strings.TrimSpace(rawInput), 1.0) +} + func (g *Game) seedRoomObjects(roomID int) { room, err := g.World.LoadRoom(roomID) if err != nil { diff --git a/internal/game/cmd_option.go b/internal/game/cmd_option.go index fdf5371..19256ba 100644 --- a/internal/game/cmd_option.go +++ b/internal/game/cmd_option.go @@ -85,6 +85,10 @@ func (g *Game) doOption(sess *net.Session, input string) { sess.WriteLine(fmt.Sprintf("\n%s set to %s.", def.Name, formatOptionValue(p, def))) } +func (g *Game) executeOption(sess *net.Session, args []string, rawInput string) { + g.doOption(sess, strings.Join(args, " ")) +} + func formatOptionValue(p *player.Player, def *player.OptionDef) string { switch def.Type { case player.OptBool: diff --git a/internal/game/cmd_prompt.go b/internal/game/cmd_prompt.go index 8850ea1..d91556a 100644 --- a/internal/game/cmd_prompt.go +++ b/internal/game/cmd_prompt.go @@ -2,10 +2,24 @@ package game import ( "fmt" + "strings" "thehouseoficarus/internal/net" ) +func (g *Game) executePrompt(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + g.doPrompt(sess, "") + } else { + msgStart := strings.Index(strings.ToLower(rawInput), "prompt ") + 7 + if msgStart >= 7 && msgStart < len(rawInput) { + g.doPrompt(sess, rawInput[msgStart:]) + } else { + g.doPrompt(sess, strings.Join(args, " ")) + } + } +} + func (g *Game) doPrompt(sess *net.Session, input string) { p := sess.Player diff --git a/internal/game/cmd_queued.go b/internal/game/cmd_queued.go index fdb1643..58ac74b 100644 --- a/internal/game/cmd_queued.go +++ b/internal/game/cmd_queued.go @@ -7,6 +7,10 @@ import ( "thehouseoficarus/internal/net" ) +func (g *Game) executeQueued(sess *net.Session, args []string, rawInput string) { + g.doQueued(sess) +} + func (g *Game) doQueued(sess *net.Session) { p := sess.Player diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go index d52c9ae..46403f4 100644 --- a/internal/game/cmd_quit.go +++ b/internal/game/cmd_quit.go @@ -8,6 +8,10 @@ import ( "thehouseoficarus/internal/net" ) +func (g *Game) executeQuit(sess *net.Session, args []string, rawInput string) { + g.doQuit(sess) +} + func (g *Game) doQuit(sess *net.Session) { p := sess.Player diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index e5bf30e..0439cc2 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -168,3 +168,29 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI sess.WriteLine("Unknown command.") } + +func (g *Game) executeGather(sess *net.Session, args []string, rawInput string) { + verb := strings.TrimSpace(rawInput) + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + target := g.resolveDefaultTarget(p.RoomID, verb) + if target == "" { + sess.WriteLine(fmt.Sprintf("%s what?", verb)) + return + } + g.startAction(sess, verb, target) + } else { + g.startAction(sess, verb, strings.Join(args, " ")) + } +} + +func (g *Game) executeTalk(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + sess.WriteLine("Talk to whom?") + } else { + g.startAction(sess, "talk", strings.Join(args, " ")) + } +} diff --git a/internal/game/cmd_remove.go b/internal/game/cmd_remove.go index e168fd9..c046537 100644 --- a/internal/game/cmd_remove.go +++ b/internal/game/cmd_remove.go @@ -9,6 +9,10 @@ import ( "thehouseoficarus/internal/player" ) +func (g *Game) executeRemove(sess *net.Session, args []string, rawInput string) { + g.doRemove(sess, strings.Join(args, " ")) +} + func (g *Game) doRemove(sess *net.Session, input string) { p := sess.Player diff --git a/internal/game/cmd_say.go b/internal/game/cmd_say.go index 3d619a0..f21ed88 100644 --- a/internal/game/cmd_say.go +++ b/internal/game/cmd_say.go @@ -2,10 +2,24 @@ package game import ( "fmt" + "strings" "thehouseoficarus/internal/net" ) +func (g *Game) executeSay(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + sess.WriteLine("Say what?") + } else { + msgStart := strings.Index(strings.ToLower(rawInput), "say ") + 4 + if msgStart >= 4 && msgStart < len(rawInput) { + g.doSay(sess, rawInput[msgStart:]) + } else { + g.doSay(sess, strings.Join(args, " ")) + } + } +} + func (g *Game) doSay(sess *net.Session, msg string) { p := sess.Player roomID := p.RoomID diff --git a/internal/game/cmd_score.go b/internal/game/cmd_score.go index 0b0caf2..b49fd7f 100644 --- a/internal/game/cmd_score.go +++ b/internal/game/cmd_score.go @@ -9,6 +9,10 @@ import ( "thehouseoficarus/internal/player" ) +func (g *Game) executeScore(sess *net.Session, args []string, rawInput string) { + g.doScore(sess) +} + func (g *Game) doScore(sess *net.Session) { p := sess.Player mode := g.colorMode(sess) diff --git a/internal/game/cmd_search.go b/internal/game/cmd_search.go index cc22d49..4122e0a 100644 --- a/internal/game/cmd_search.go +++ b/internal/game/cmd_search.go @@ -7,6 +7,16 @@ import ( "thehouseoficarus/internal/net" ) +func (g *Game) executeSearch(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + sess.WriteLine("Search what?") + } else { + g.doSearch(sess, strings.Join(args, " ")) + } +} + func (g *Game) doSearch(sess *net.Session, input string) { p := sess.Player g.cancelAction(p) diff --git a/internal/game/cmd_smelt.go b/internal/game/cmd_smelt.go index 53ff426..a72cf53 100644 --- a/internal/game/cmd_smelt.go +++ b/internal/game/cmd_smelt.go @@ -2,12 +2,17 @@ package game import ( "fmt" + "strings" "thehouseoficarus/internal/action" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) +func (g *Game) executeSmelt(sess *net.Session, args []string, rawInput string) { + g.doSmelt(sess, strings.Join(args, " ")) +} + func (g *Game) doSmelt(sess *net.Session, input string) { p := sess.Player g.cancelAction(p) diff --git a/internal/game/cmd_smith.go b/internal/game/cmd_smith.go index 067d251..a2eaad7 100644 --- a/internal/game/cmd_smith.go +++ b/internal/game/cmd_smith.go @@ -10,6 +10,10 @@ import ( "thehouseoficarus/internal/player" ) +func (g *Game) executeSmith(sess *net.Session, args []string, rawInput string) { + g.doSmith(sess, strings.Join(args, " ")) +} + func (g *Game) doSmith(sess *net.Session, input string) { p := sess.Player g.cancelAction(p) diff --git a/internal/game/cmd_sneak.go b/internal/game/cmd_sneak.go index b37862d..325a36b 100644 --- a/internal/game/cmd_sneak.go +++ b/internal/game/cmd_sneak.go @@ -6,6 +6,10 @@ import ( "thehouseoficarus/internal/net" ) +func (g *Game) executeSneak(sess *net.Session, args []string, rawInput string) { + g.doSneak(sess) +} + func (g *Game) doSneak(sess *net.Session) { p := sess.Player p.Sneaking = !p.Sneaking diff --git a/internal/game/cmd_stats.go b/internal/game/cmd_stats.go index 51bacf1..906abf2 100644 --- a/internal/game/cmd_stats.go +++ b/internal/game/cmd_stats.go @@ -9,6 +9,10 @@ import ( "thehouseoficarus/internal/player" ) +func (g *Game) executeStats(sess *net.Session, args []string, rawInput string) { + g.doStats(sess) +} + func (g *Game) doStats(sess *net.Session) { p := sess.Player totals := g.playerEquipBonuses(p) diff --git a/internal/game/cmd_style.go b/internal/game/cmd_style.go index 269fa3f..94dd234 100644 --- a/internal/game/cmd_style.go +++ b/internal/game/cmd_style.go @@ -43,3 +43,11 @@ func (g *Game) doStyle(sess *net.Session, input string) { } sess.WriteLine(fmt.Sprintf("\nUnknown style: %s. Choices: accurate, aggressive, defensive, balanced", input)) } + +func (g *Game) executeStyle(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + g.doStyle(sess, "") + } else { + g.doStyle(sess, args[0]) + } +} diff --git a/internal/game/cmd_task.go b/internal/game/cmd_task.go index 5b67421..e54db15 100644 --- a/internal/game/cmd_task.go +++ b/internal/game/cmd_task.go @@ -6,6 +6,10 @@ import ( "thehouseoficarus/internal/net" ) +func (g *Game) executeTask(sess *net.Session, args []string, rawInput string) { + g.doTask(sess) +} + func (g *Game) doTask(sess *net.Session) { p := sess.Player diff --git a/internal/game/cmd_tech.go b/internal/game/cmd_tech.go index 89d5c2a..cb4c309 100644 --- a/internal/game/cmd_tech.go +++ b/internal/game/cmd_tech.go @@ -194,3 +194,7 @@ func (g *Game) doTechQuick(sess *net.Session, p *player.Player, input string) { g.AccountStore.SaveCharacter(p) sess.WriteLine(fmt.Sprintf("\nQuick tech set to %s.", matches[0].Name)) } + +func (g *Game) executeTech(sess *net.Session, args []string, rawInput string) { + g.doTech(sess, strings.Join(args, " ")) +} diff --git a/internal/game/cmd_trigger.go b/internal/game/cmd_trigger.go index 89ed397..7d637ca 100644 --- a/internal/game/cmd_trigger.go +++ b/internal/game/cmd_trigger.go @@ -7,9 +7,12 @@ import ( "thehouseoficarus/internal/combat" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" ) +func (g *Game) executeTrigger(sess *net.Session, args []string, rawInput string) { + g.doTrigger(sess, strings.Join(args, " ")) +} + func (g *Game) doTrigger(sess *net.Session, input string) { p := sess.Player input = strings.TrimSpace(input) @@ -106,635 +109,3 @@ func (g *Game) triggerCombatMod(sess *net.Session, p *player.Player, mod *ModDef p.AutotriggerMod = mod.ID g.startCombat(sess, p, mob) } - -func (g *Game) triggerTransport(sess *net.Session, p *player.Player, mod *ModDef) { - if combat.GetCombat(p.Name) != nil { - sess.WriteLine("You can't teleport during combat!") - return - } - - if p.Action != nil { - g.cancelAction(p) - } - - g.consumeJunkCost(p, mod) - - sciXP := mod.BaseXP - g.awardSkillXP(sess, p, player.Science, sciXP) - if p.OptionBool("xp_drops") { - sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP))) - } - g.AccountStore.SaveCharacter(p) - - if g.Hub != nil { - for _, other := range g.Hub.PlayersInRoom(p.RoomID) { - if other != sess { - other.WriteLine(fmt.Sprintf("\n%s teleports away.", p.Name)) - } - } - } - - sess.WriteLine(fmt.Sprintf("\nYou activate %s...", mod.Name)) - - p.RoomID = mod.Destination - if g.Hub != nil { - g.Hub.LeaveRoom(sess) - g.Hub.EnterRoom(sess, p.RoomID) - } - - room, _ := g.World.LoadRoom(p.RoomID) - destName := fmt.Sprintf("room %d", p.RoomID) - if room != nil { - destName = room.Name - } - sess.WriteLine(fmt.Sprintf("You materialize at %s.", destName)) - g.AccountStore.SaveCharacter(p) - g.doLook(sess) -} - -func (g *Game) triggerProcessing(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { - if combat.GetCombat(p.Name) != nil { - sess.WriteLine("You can't do that during combat!") - return - } - - if targetArg == "" { - sess.WriteLine(fmt.Sprintf("Usage: trigger %s ", strings.ReplaceAll(mod.ID, "_", " "))) - return - } - - slot, inv := g.findInventoryItem(p, targetArg) - if slot < 0 { - sess.WriteLine("You don't have that item.") - return - } - - itemDef, err := g.ItemStore.Load(inv.ItemID) - if err != nil || itemDef.Value <= 0 { - sess.WriteLine("That item has no value.") - return - } - - if p.Action != nil { - g.cancelAction(p) - } - - g.consumeJunkCost(p, mod) - - creditValue := itemDef.Value - if mod.ID == "low_process" { - creditValue = itemDef.Value / 2 - if creditValue < 1 { - creditValue = 1 - } - } - - if inv.Quantity > 1 { - inv.Quantity-- - } else { - p.SetInvSlot(slot, nil) - } - - p.Credits += creditValue - - sciXP := mod.BaseXP - g.awardSkillXP(sess, p, player.Science, sciXP) - g.AccountStore.SaveCharacter(p) - - sess.WriteLine(fmt.Sprintf("You process the %s. You receive %s credits.", - itemDef.Name, g.colorize(sess, "credits_pickup", fmt.Sprint(creditValue)))) - - if p.OptionBool("xp_drops") { - sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP))) - } -} - -func (g *Game) triggerUtility(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { - switch mod.ID { - case "low_process", "high_process": - g.triggerProcessing(sess, p, mod, targetArg) - case "bones_to_nutrients": - g.triggerBonesToNutrients(sess, p, mod) - case "em_grab": - g.triggerEmGrab(sess, p, mod, targetArg) - case "superheat": - g.triggerSuperheat(sess, p, mod, targetArg) - case "plank_make": - g.triggerPlankMake(sess, p, mod, targetArg) - } -} - -func (g *Game) triggerBonesToNutrients(sess *net.Session, p *player.Player, mod *ModDef) { - if combat.GetCombat(p.Name) != nil { - sess.WriteLine("You can't do that during combat!") - return - } - - boneCount := p.CountItem("bones") - if boneCount == 0 { - sess.WriteLine("You don't have any bones.") - return - } - - if p.Action != nil { - g.cancelAction(p) - } - - g.consumeJunkCost(p, mod) - - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == "bones" { - slot.ItemID = "nutrient_bar" - } - } - - sciXP := mod.BaseXP * boneCount - g.awardSkillXP(sess, p, player.Science, sciXP) - g.AccountStore.SaveCharacter(p) - - sess.WriteLine(fmt.Sprintf("You convert %d bones into nutrient bars.", boneCount)) - if p.OptionBool("xp_drops") { - sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP))) - } -} - -func (g *Game) triggerEmGrab(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { - if targetArg == "" { - sess.WriteLine("Grab what? Usage: trigger em grab ") - return - } - - if p.FirstFreeSlot() < 0 { - sess.WriteLine("Your inventory is full.") - return - } - - groundItems := g.World.GroundItems(p.RoomID) - var matchedID string - for itemID := range groundItems { - def, _ := g.ItemStore.Load(itemID) - if def != nil && def.MatchesName(targetArg) { - matchedID = itemID - break - } - if strings.HasPrefix(itemID, strings.ToLower(targetArg)) { - matchedID = itemID - break - } - } - - if matchedID == "" { - sess.WriteLine("You don't see that here.") - return - } - - if p.Action != nil { - g.cancelAction(p) - } - - g.consumeJunkCost(p, mod) - - qty := groundItems[matchedID] - g.World.RemoveGroundItem(p.RoomID, matchedID, qty) - - def, _ := g.ItemStore.Load(matchedID) - name := matchedID - if def != nil { - name = def.Name - } - - freeSlot := p.FirstFreeSlot() - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: matchedID, Quantity: qty}) - - sciXP := mod.BaseXP - g.awardSkillXP(sess, p, player.Science, sciXP) - g.AccountStore.SaveCharacter(p) - - sess.WriteLine(fmt.Sprintf("You magnetically pull the %s toward you.", name)) - if p.OptionBool("xp_drops") { - sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP))) - } -} - -func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { - if combat.GetCombat(p.Name) != nil { - sess.WriteLine("You can't do that during combat!") - return - } - - if targetArg == "" { - sess.WriteLine("Superheat what? Usage: trigger superheat ") - return - } - - slot, inv := g.findInventoryItem(p, targetArg) - if slot < 0 { - sess.WriteLine("You don't have that item.") - return - } - - recipe := g.RecipeStore.FindByInput("smelt", inv.ItemID) - if recipe == nil { - sess.WriteLine("You can't superheat that.") - return - } - - if recipe.Level > 0 && p.Level(player.SkillName(recipe.Skill)) < recipe.Level { - sess.WriteLine(fmt.Sprintf("You need level %d %s to smelt that.", recipe.Level, recipe.Skill)) - return - } - - for _, e := range recipe.Consume { - for _, itemID := range e.Items { - qty := e.Qty - if qty <= 0 { - qty = 1 - } - if p.CountItem(itemID) < qty { - def, _ := g.ItemStore.Load(itemID) - name := itemID - if def != nil { - name = def.Name - } - sess.WriteLine(fmt.Sprintf("You need %d %s.", qty, name)) - return - } - } - } - - if p.Action != nil { - g.cancelAction(p) - } - - g.consumeJunkCost(p, mod) - - for _, e := range recipe.Consume { - for _, itemID := range e.Items { - qty := e.Qty - if qty <= 0 { - qty = 1 - } - p.RemoveItem(itemID, qty) - } - } - - outputQty := recipe.OutputQty - if outputQty <= 0 { - outputQty = 1 - } - placed := false - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == recipe.Output { - slot.Quantity += outputQty - placed = true - break - } - } - if !placed { - freeSlot := p.FirstFreeSlot() - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: recipe.Output, Quantity: outputQty}) - } - - sciXP := mod.BaseXP - g.awardSkillXP(sess, p, player.Science, sciXP) - if recipe.XP > 0 { - g.awardSkillXP(sess, p, player.SkillName(recipe.Skill), recipe.XP) - } - g.AccountStore.SaveCharacter(p) - - outputDef, _ := g.ItemStore.Load(recipe.Output) - outputName := recipe.Output - if outputDef != nil { - outputName = outputDef.Name - } - inputDef, _ := g.ItemStore.Load(inv.ItemID) - inputName := inv.ItemID - if inputDef != nil { - inputName = inputDef.Name - } - - sess.WriteLine(fmt.Sprintf("You superheat the %s and produce a %s.", inputName, outputName)) - if p.OptionBool("xp_drops") { - parts := []string{fmt.Sprintf("+%dxp sci", sciXP)} - if recipe.XP > 0 { - parts = append(parts, fmt.Sprintf("+%dxp %s", recipe.XP, player.SkillAbbr[player.SkillName(recipe.Skill)])) - } - sess.WriteLine(g.colorize(sess, "xp", "("+strings.Join(parts, ", ")+")")) - } -} - -func (g *Game) triggerPlankMake(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { - if combat.GetCombat(p.Name) != nil { - sess.WriteLine("You can't do that during combat!") - return - } - - logTypes := map[string]struct { - plankID string - name string - cost int - }{ - "logs": {"planks", "planks", 3}, - "oak_logs": {"oak_planks", "oak planks", 7}, - "teak_logs": {"teak_planks", "teak planks", 14}, - "mahogany_logs": {"mahogany_planks", "mahogany planks", 28}, - } - - if targetArg != "" { - slot, inv := g.findInventoryItem(p, targetArg) - if slot < 0 { - sess.WriteLine("You don't have that item.") - return - } - info, ok := logTypes[inv.ItemID] - if !ok { - sess.WriteLine("You can't turn that into planks.") - return - } - - if p.Credits < info.cost { - sess.WriteLine(fmt.Sprintf("You need %d credits for Plank Make (70%% of sawmill cost).", info.cost)) - return - } - - if p.Action != nil { - g.cancelAction(p) - } - - g.consumeJunkCost(p, mod) - - p.RemoveItem(inv.ItemID, 1) - p.Credits -= info.cost - - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - sess.WriteLine("Your inventory is full.") - return - } - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: info.plankID, Quantity: 1}) - - sciXP := mod.BaseXP - g.awardSkillXP(sess, p, player.Science, sciXP) - g.awardSkillXP(sess, p, player.Construction, 10) - g.AccountStore.SaveCharacter(p) - - sess.WriteLine(fmt.Sprintf("You convert the log into %s.", info.name)) - if p.OptionBool("xp_drops") { - parts := []string{fmt.Sprintf("+%dxp sci", sciXP), "+10xp con"} - sess.WriteLine(g.colorize(sess, "xp", "("+strings.Join(parts, ", ")+")")) - } - return - } - - for logID, info := range logTypes { - qty := p.CountItem(logID) - if qty == 0 { - continue - } - - totalCost := info.cost * qty - if p.Credits < totalCost { - continue - } - - if p.Action != nil { - g.cancelAction(p) - } - - g.consumeJunkCost(p, mod) - - p.RemoveItem(logID, qty) - p.Credits -= totalCost - - processed := 0 - for i := 0; i < qty; i++ { - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - g.World.AddGroundItem(p.RoomID, info.plankID, qty-i) - break - } - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: info.plankID, Quantity: 1}) - processed++ - } - - sciXP := mod.BaseXP * qty - g.awardSkillXP(sess, p, player.Science, sciXP) - conXP := 10 * qty - g.awardSkillXP(sess, p, player.Construction, conXP) - g.AccountStore.SaveCharacter(p) - - sess.WriteLine(fmt.Sprintf("You convert %d logs into %s for %d credits.", processed, info.name, totalCost)) - if processed < qty { - sess.WriteLine(fmt.Sprintf("Your inventory is full. The remaining %d planks fall to the ground.", qty-processed)) - } - if p.OptionBool("xp_drops") { - parts := []string{fmt.Sprintf("+%dxp sci", sciXP), fmt.Sprintf("+%dxp con", conXP)} - sess.WriteLine(g.colorize(sess, "xp", "("+strings.Join(parts, ", ")+")")) - } - return - } - - sess.WriteLine("You don't have any logs to convert.") -} - -func (g *Game) triggerEnchant(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { - if chipInfo, ok := chipMap[mod.ID]; ok { - g.triggerChipBolts(sess, p, mod, chipInfo) - return - } - - enchants, ok := enchantMap[mod.ID] - if !ok { - sess.WriteLine("That enchantment has no known recipes.") - return - } - - if targetArg == "" { - sess.WriteLine(fmt.Sprintf("Enchant what? Use: trigger %s ", strings.ReplaceAll(mod.ID, "_", " "))) - return - } - - slot, inv := g.findInventoryItem(p, targetArg) - if slot < 0 { - sess.WriteLine("You don't have that item.") - return - } - - outputID, ok := enchants[inv.ItemID] - if !ok { - sess.WriteLine("You can't enchant that with this mod.") - return - } - - if p.Action != nil { - g.cancelAction(p) - } - - g.consumeJunkCost(p, mod) - - inv.ItemID = outputID - - sciXP := mod.BaseXP - g.awardSkillXP(sess, p, player.Science, sciXP) - g.AccountStore.SaveCharacter(p) - - outputDef, _ := g.ItemStore.Load(outputID) - outputName := outputID - if outputDef != nil { - outputName = outputDef.Name - } - inputDef, _ := g.ItemStore.Load(inv.ItemID) - inputName := inv.ItemID - if inputDef != nil { - inputName = inputDef.Name - } - - sess.WriteLine(fmt.Sprintf("You enchant the %s and it becomes a %s!", inputName, outputName)) - if p.OptionBool("xp_drops") { - sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP))) - } -} - -func (g *Game) triggerChipBolts(sess *net.Session, p *player.Player, mod *ModDef, chip chipEntry) { - count := p.CountItem(chip.Input) - if count < chip.Qty { - inputDef, _ := g.ItemStore.Load(chip.Input) - name := chip.Input - if inputDef != nil { - name = inputDef.Name - } - sess.WriteLine(fmt.Sprintf("You need at least %d %s.", chip.Qty, name)) - return - } - - if p.Action != nil { - g.cancelAction(p) - } - - g.consumeJunkCost(p, mod) - - p.RemoveItem(chip.Input, chip.Qty) - placed := false - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == chip.Output { - slot.Quantity += chip.Qty - placed = true - break - } - } - if !placed { - freeSlot := p.FirstFreeSlot() - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: chip.Output, Quantity: chip.Qty}) - } - - sciXP := mod.BaseXP - g.awardSkillXP(sess, p, player.Science, sciXP) - g.AccountStore.SaveCharacter(p) - - inputDef, _ := g.ItemStore.Load(chip.Input) - name := chip.Input - if inputDef != nil { - name = inputDef.Name - } - - sess.WriteLine(fmt.Sprintf("You chip %d %s with arcane circuitry.", chip.Qty, name)) - if p.OptionBool("xp_drops") { - sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP))) - } -} - -func (g *Game) scienceAttack(sess *net.Session, p *player.Player, mob *world.MobInstance, mod *ModDef) bool { - if p.Level(player.Science) < mod.Level { - sess.WriteLine(fmt.Sprintf("You need level %d science to trigger %s.", mod.Level, mod.Name)) - return false - } - if !g.hasJunkCost(p, mod) { - return false - } - - if g.processConsumeQueue(p, sess) { - p.ActionState = &ActionState{Type: ActionEating, TargetName: "food"} - return true - } - - g.consumeJunkCost(p, mod) - - equipSciBonus := g.totalEquipScienceAttack(p) - attRoll := (p.Level(player.Science) + g.buffLevelBonus(p, "science") + 8) * (equipSciBonus + 64) - - mobSciDef := mob.ScienceDefense - defRoll := (mob.Defense + 9) * (mobSciDef + 64) - - if mob.Weakness == mod.Element { - attRoll = attRoll * 13 / 10 - } - - if combat.HitCheck(attRoll, defRoll) { - dmg := combat.RollDamage(mod.MaxHit) - if dmg < 0 { - dmg = 0 - } - mob.HP -= dmg - if mob.HP < 0 { - mob.HP = 0 - } - if mob.HP < mob.MaxHP && mob.HP > 0 { - mob.StartRegen() - } - - sciXP := mod.BaseXP - hpXP := mod.BaseXP / 3 - if hpXP < 1 { - hpXP = 1 - } - var gains []xpGain - - g.awardSkillXP(sess, p, player.Science, sciXP) - gains = append(gains, xpGain{string(player.Science), sciXP}) - - g.awardSkillXP(sess, p, player.Hitpoints, hpXP) - gains = append(gains, xpGain{string(player.Hitpoints), hpXP}) - - g.AccountStore.SaveCharacter(p) - - mobName := mobDisplayName(mob, true) - prefix := fmt.Sprintf(" %s hits %s for %s damage.", - g.colorize(sess, "science_mod", mod.Name), - g.colorize(sess, "mob_name", mobName), - g.colorize(sess, "damage", fmt.Sprint(dmg))) - hpPart := fmt.Sprintf("[%s/%dhp]", g.colorize(sess, "enemy_hp", fmt.Sprint(mob.HP)), mob.MaxHP) - line := prefix + " " + hpPart - if p.OptionBool("xp_drops") && len(gains) > 0 { - var parts []string - for _, gain := range gains { - parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)])) - } - line += g.colorize(sess, "xp", " ("+strings.Join(parts, ", ")+")") - } - sess.WriteLine(line) - } else { - sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf(" %s fails to connect.", mod.Name))) - } - return true -} - -func (g *Game) findInventoryItem(p *player.Player, input string) (int, *player.InventorySlot) { - lower := strings.ToLower(strings.TrimSpace(input)) - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot == nil { - continue - } - def, err := g.ItemStore.Load(slot.ItemID) - if err != nil { - continue - } - if def.MatchesName(lower) { - return i, slot - } - } - return -1, nil -} diff --git a/internal/game/cmd_trigger_combat.go b/internal/game/cmd_trigger_combat.go new file mode 100644 index 0000000..545a117 --- /dev/null +++ b/internal/game/cmd_trigger_combat.go @@ -0,0 +1,104 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func (g *Game) scienceAttack(sess *net.Session, p *player.Player, mob *world.MobInstance, mod *ModDef) bool { + if p.Level(player.Science) < mod.Level { + sess.WriteLine(fmt.Sprintf("You need level %d science to trigger %s.", mod.Level, mod.Name)) + return false + } + if !g.hasJunkCost(p, mod) { + return false + } + + if g.processConsumeQueue(p, sess) { + p.ActionState = &ActionState{Type: ActionEating, TargetName: "food"} + return true + } + + g.consumeJunkCost(p, mod) + + equipSciBonus := g.totalEquipScienceAttack(p) + attRoll := (p.Level(player.Science) + g.buffLevelBonus(p, "science") + 8) * (equipSciBonus + 64) + + mobSciDef := mob.ScienceDefense + defRoll := (mob.Defense + 9) * (mobSciDef + 64) + + if mob.Weakness == mod.Element { + attRoll = attRoll * 13 / 10 + } + + if combat.HitCheck(attRoll, defRoll) { + dmg := combat.RollDamage(mod.MaxHit) + if dmg < 0 { + dmg = 0 + } + mob.HP -= dmg + if mob.HP < 0 { + mob.HP = 0 + } + if mob.HP < mob.MaxHP && mob.HP > 0 { + mob.StartRegen() + } + + sciXP := mod.BaseXP + hpXP := mod.BaseXP / 3 + if hpXP < 1 { + hpXP = 1 + } + var gains []xpGain + + g.awardSkillXP(sess, p, player.Science, sciXP) + gains = append(gains, xpGain{string(player.Science), sciXP}) + + g.awardSkillXP(sess, p, player.Hitpoints, hpXP) + gains = append(gains, xpGain{string(player.Hitpoints), hpXP}) + + g.AccountStore.SaveCharacter(p) + + mobName := mobDisplayName(mob, true) + prefix := fmt.Sprintf(" %s hits %s for %s damage.", + g.colorize(sess, "science_mod", mod.Name), + g.colorize(sess, "mob_name", mobName), + g.colorize(sess, "damage", fmt.Sprint(dmg))) + hpPart := fmt.Sprintf("[%s/%dhp]", g.colorize(sess, "enemy_hp", fmt.Sprint(mob.HP)), mob.MaxHP) + line := prefix + " " + hpPart + if p.OptionBool("xp_drops") && len(gains) > 0 { + var parts []string + for _, gain := range gains { + parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)])) + } + line += g.colorize(sess, "xp", " ("+strings.Join(parts, ", ")+")") + } + sess.WriteLine(line) + } else { + sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf(" %s fails to connect.", mod.Name))) + } + return true +} + +func (g *Game) findInventoryItem(p *player.Player, input string) (int, *player.InventorySlot) { + lower := strings.ToLower(strings.TrimSpace(input)) + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot == nil { + continue + } + def, err := g.ItemStore.Load(slot.ItemID) + if err != nil { + continue + } + if def.MatchesName(lower) { + return i, slot + } + } + return -1, nil +} diff --git a/internal/game/cmd_trigger_enchant.go b/internal/game/cmd_trigger_enchant.go new file mode 100644 index 0000000..535616b --- /dev/null +++ b/internal/game/cmd_trigger_enchant.go @@ -0,0 +1,116 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) triggerEnchant(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { + if chipInfo, ok := chipMap[mod.ID]; ok { + g.triggerChipBolts(sess, p, mod, chipInfo) + return + } + + enchants, ok := enchantMap[mod.ID] + if !ok { + sess.WriteLine("That enchantment has no known recipes.") + return + } + + if targetArg == "" { + sess.WriteLine(fmt.Sprintf("Enchant what? Use: trigger %s ", strings.ReplaceAll(mod.ID, "_", " "))) + return + } + + slot, inv := g.findInventoryItem(p, targetArg) + if slot < 0 { + sess.WriteLine("You don't have that item.") + return + } + + outputID, ok := enchants[inv.ItemID] + if !ok { + sess.WriteLine("You can't enchant that with this mod.") + return + } + + if p.Action != nil { + g.cancelAction(p) + } + + g.consumeJunkCost(p, mod) + + inv.ItemID = outputID + + sciXP := mod.BaseXP + g.awardSkillXP(sess, p, player.Science, sciXP) + g.AccountStore.SaveCharacter(p) + + outputDef, _ := g.ItemStore.Load(outputID) + outputName := outputID + if outputDef != nil { + outputName = outputDef.Name + } + inputDef, _ := g.ItemStore.Load(inv.ItemID) + inputName := inv.ItemID + if inputDef != nil { + inputName = inputDef.Name + } + + sess.WriteLine(fmt.Sprintf("You enchant the %s and it becomes a %s!", inputName, outputName)) + if p.OptionBool("xp_drops") { + sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP))) + } +} + +func (g *Game) triggerChipBolts(sess *net.Session, p *player.Player, mod *ModDef, chip chipEntry) { + count := p.CountItem(chip.Input) + if count < chip.Qty { + inputDef, _ := g.ItemStore.Load(chip.Input) + name := chip.Input + if inputDef != nil { + name = inputDef.Name + } + sess.WriteLine(fmt.Sprintf("You need at least %d %s.", chip.Qty, name)) + return + } + + if p.Action != nil { + g.cancelAction(p) + } + + g.consumeJunkCost(p, mod) + + p.RemoveItem(chip.Input, chip.Qty) + placed := false + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot != nil && slot.ItemID == chip.Output { + slot.Quantity += chip.Qty + placed = true + break + } + } + if !placed { + freeSlot := p.FirstFreeSlot() + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: chip.Output, Quantity: chip.Qty}) + } + + sciXP := mod.BaseXP + g.awardSkillXP(sess, p, player.Science, sciXP) + g.AccountStore.SaveCharacter(p) + + inputDef, _ := g.ItemStore.Load(chip.Input) + name := chip.Input + if inputDef != nil { + name = inputDef.Name + } + + sess.WriteLine(fmt.Sprintf("You chip %d %s with arcane circuitry.", chip.Qty, name)) + if p.OptionBool("xp_drops") { + sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP))) + } +} diff --git a/internal/game/cmd_trigger_transport.go b/internal/game/cmd_trigger_transport.go new file mode 100644 index 0000000..2ca8db2 --- /dev/null +++ b/internal/game/cmd_trigger_transport.go @@ -0,0 +1,54 @@ +package game + +import ( + "fmt" + + "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) triggerTransport(sess *net.Session, p *player.Player, mod *ModDef) { + if combat.GetCombat(p.Name) != nil { + sess.WriteLine("You can't teleport during combat!") + return + } + + if p.Action != nil { + g.cancelAction(p) + } + + g.consumeJunkCost(p, mod) + + sciXP := mod.BaseXP + g.awardSkillXP(sess, p, player.Science, sciXP) + if p.OptionBool("xp_drops") { + sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP))) + } + g.AccountStore.SaveCharacter(p) + + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other != sess { + other.WriteLine(fmt.Sprintf("\n%s teleports away.", p.Name)) + } + } + } + + sess.WriteLine(fmt.Sprintf("\nYou activate %s...", mod.Name)) + + p.RoomID = mod.Destination + if g.Hub != nil { + g.Hub.LeaveRoom(sess) + g.Hub.EnterRoom(sess, p.RoomID) + } + + room, _ := g.World.LoadRoom(p.RoomID) + destName := fmt.Sprintf("room %d", p.RoomID) + if room != nil { + destName = room.Name + } + sess.WriteLine(fmt.Sprintf("You materialize at %s.", destName)) + g.AccountStore.SaveCharacter(p) + g.doLook(sess) +} diff --git a/internal/game/cmd_trigger_utility.go b/internal/game/cmd_trigger_utility.go new file mode 100644 index 0000000..efb23e7 --- /dev/null +++ b/internal/game/cmd_trigger_utility.go @@ -0,0 +1,397 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) triggerUtility(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { + switch mod.ID { + case "low_process", "high_process": + g.triggerProcessing(sess, p, mod, targetArg) + case "bones_to_nutrients": + g.triggerBonesToNutrients(sess, p, mod) + case "em_grab": + g.triggerEmGrab(sess, p, mod, targetArg) + case "superheat": + g.triggerSuperheat(sess, p, mod, targetArg) + case "plank_make": + g.triggerPlankMake(sess, p, mod, targetArg) + } +} + +func (g *Game) triggerProcessing(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { + if combat.GetCombat(p.Name) != nil { + sess.WriteLine("You can't do that during combat!") + return + } + + if targetArg == "" { + sess.WriteLine(fmt.Sprintf("Usage: trigger %s ", strings.ReplaceAll(mod.ID, "_", " "))) + return + } + + slot, inv := g.findInventoryItem(p, targetArg) + if slot < 0 { + sess.WriteLine("You don't have that item.") + return + } + + itemDef, err := g.ItemStore.Load(inv.ItemID) + if err != nil || itemDef.Value <= 0 { + sess.WriteLine("That item has no value.") + return + } + + if p.Action != nil { + g.cancelAction(p) + } + + g.consumeJunkCost(p, mod) + + creditValue := itemDef.Value + if mod.ID == "low_process" { + creditValue = itemDef.Value / 2 + if creditValue < 1 { + creditValue = 1 + } + } + + if inv.Quantity > 1 { + inv.Quantity-- + } else { + p.SetInvSlot(slot, nil) + } + + p.Credits += creditValue + + sciXP := mod.BaseXP + g.awardSkillXP(sess, p, player.Science, sciXP) + g.AccountStore.SaveCharacter(p) + + sess.WriteLine(fmt.Sprintf("You process the %s. You receive %s credits.", + itemDef.Name, g.colorize(sess, "credits_pickup", fmt.Sprint(creditValue)))) + + if p.OptionBool("xp_drops") { + sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP))) + } +} + +func (g *Game) triggerBonesToNutrients(sess *net.Session, p *player.Player, mod *ModDef) { + if combat.GetCombat(p.Name) != nil { + sess.WriteLine("You can't do that during combat!") + return + } + + boneCount := p.CountItem("bones") + if boneCount == 0 { + sess.WriteLine("You don't have any bones.") + return + } + + if p.Action != nil { + g.cancelAction(p) + } + + g.consumeJunkCost(p, mod) + + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot != nil && slot.ItemID == "bones" { + slot.ItemID = "nutrient_bar" + } + } + + sciXP := mod.BaseXP * boneCount + g.awardSkillXP(sess, p, player.Science, sciXP) + g.AccountStore.SaveCharacter(p) + + sess.WriteLine(fmt.Sprintf("You convert %d bones into nutrient bars.", boneCount)) + if p.OptionBool("xp_drops") { + sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP))) + } +} + +func (g *Game) triggerEmGrab(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { + if targetArg == "" { + sess.WriteLine("Grab what? Usage: trigger em grab ") + return + } + + if p.FirstFreeSlot() < 0 { + sess.WriteLine("Your inventory is full.") + return + } + + groundItems := g.World.GroundItems(p.RoomID) + var matchedID string + for itemID := range groundItems { + def, _ := g.ItemStore.Load(itemID) + if def != nil && def.MatchesName(targetArg) { + matchedID = itemID + break + } + if strings.HasPrefix(itemID, strings.ToLower(targetArg)) { + matchedID = itemID + break + } + } + + if matchedID == "" { + sess.WriteLine("You don't see that here.") + return + } + + if p.Action != nil { + g.cancelAction(p) + } + + g.consumeJunkCost(p, mod) + + qty := groundItems[matchedID] + g.World.RemoveGroundItem(p.RoomID, matchedID, qty) + + def, _ := g.ItemStore.Load(matchedID) + name := matchedID + if def != nil { + name = def.Name + } + + freeSlot := p.FirstFreeSlot() + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: matchedID, Quantity: qty}) + + sciXP := mod.BaseXP + g.awardSkillXP(sess, p, player.Science, sciXP) + g.AccountStore.SaveCharacter(p) + + sess.WriteLine(fmt.Sprintf("You magnetically pull the %s toward you.", name)) + if p.OptionBool("xp_drops") { + sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP))) + } +} + +func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { + if combat.GetCombat(p.Name) != nil { + sess.WriteLine("You can't do that during combat!") + return + } + + if targetArg == "" { + sess.WriteLine("Superheat what? Usage: trigger superheat ") + return + } + + slot, inv := g.findInventoryItem(p, targetArg) + if slot < 0 { + sess.WriteLine("You don't have that item.") + return + } + + recipe := g.RecipeStore.FindByInput("smelt", inv.ItemID) + if recipe == nil { + sess.WriteLine("You can't superheat that.") + return + } + + if recipe.Level > 0 && p.Level(player.SkillName(recipe.Skill)) < recipe.Level { + sess.WriteLine(fmt.Sprintf("You need level %d %s to smelt that.", recipe.Level, recipe.Skill)) + return + } + + for _, e := range recipe.Consume { + for _, itemID := range e.Items { + qty := e.Quantity + if qty <= 0 { + qty = 1 + } + if p.CountItem(itemID) < qty { + def, _ := g.ItemStore.Load(itemID) + name := itemID + if def != nil { + name = def.Name + } + sess.WriteLine(fmt.Sprintf("You need %d %s.", qty, name)) + return + } + } + } + + if p.Action != nil { + g.cancelAction(p) + } + + g.consumeJunkCost(p, mod) + + for _, e := range recipe.Consume { + for _, itemID := range e.Items { + qty := e.Quantity + if qty <= 0 { + qty = 1 + } + p.RemoveItem(itemID, qty) + } + } + + outputQty := recipe.OutputQty + if outputQty <= 0 { + outputQty = 1 + } + placed := false + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot != nil && slot.ItemID == recipe.Output { + slot.Quantity += outputQty + placed = true + break + } + } + if !placed { + freeSlot := p.FirstFreeSlot() + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: recipe.Output, Quantity: outputQty}) + } + + sciXP := mod.BaseXP + g.awardSkillXP(sess, p, player.Science, sciXP) + if recipe.XP > 0 { + g.awardSkillXP(sess, p, player.SkillName(recipe.Skill), recipe.XP) + } + g.AccountStore.SaveCharacter(p) + + outputDef, _ := g.ItemStore.Load(recipe.Output) + outputName := recipe.Output + if outputDef != nil { + outputName = outputDef.Name + } + inputDef, _ := g.ItemStore.Load(inv.ItemID) + inputName := inv.ItemID + if inputDef != nil { + inputName = inputDef.Name + } + + sess.WriteLine(fmt.Sprintf("You superheat the %s and produce a %s.", inputName, outputName)) + if p.OptionBool("xp_drops") { + parts := []string{fmt.Sprintf("+%dxp sci", sciXP)} + if recipe.XP > 0 { + parts = append(parts, fmt.Sprintf("+%dxp %s", recipe.XP, player.SkillAbbr[player.SkillName(recipe.Skill)])) + } + sess.WriteLine(g.colorize(sess, "xp", "("+strings.Join(parts, ", ")+")")) + } +} + +func (g *Game) triggerPlankMake(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { + if combat.GetCombat(p.Name) != nil { + sess.WriteLine("You can't do that during combat!") + return + } + + logTypes := map[string]struct { + plankID string + name string + cost int + }{ + "logs": {"planks", "planks", 3}, + "oak_logs": {"oak_planks", "oak planks", 7}, + "teak_logs": {"teak_planks", "teak planks", 14}, + "mahogany_logs": {"mahogany_planks", "mahogany planks", 28}, + } + + if targetArg != "" { + slot, inv := g.findInventoryItem(p, targetArg) + if slot < 0 { + sess.WriteLine("You don't have that item.") + return + } + info, ok := logTypes[inv.ItemID] + if !ok { + sess.WriteLine("You can't turn that into planks.") + return + } + + if p.Credits < info.cost { + sess.WriteLine(fmt.Sprintf("You need %d credits for Plank Make (70%% of sawmill cost).", info.cost)) + return + } + + if p.Action != nil { + g.cancelAction(p) + } + + g.consumeJunkCost(p, mod) + + p.RemoveItem(inv.ItemID, 1) + p.Credits -= info.cost + + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + sess.WriteLine("Your inventory is full.") + return + } + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: info.plankID, Quantity: 1}) + + sciXP := mod.BaseXP + g.awardSkillXP(sess, p, player.Science, sciXP) + g.awardSkillXP(sess, p, player.Construction, 10) + g.AccountStore.SaveCharacter(p) + + sess.WriteLine(fmt.Sprintf("You convert the log into %s.", info.name)) + if p.OptionBool("xp_drops") { + parts := []string{fmt.Sprintf("+%dxp sci", sciXP), "+10xp con"} + sess.WriteLine(g.colorize(sess, "xp", "("+strings.Join(parts, ", ")+")")) + } + return + } + + for logID, info := range logTypes { + qty := p.CountItem(logID) + if qty == 0 { + continue + } + + totalCost := info.cost * qty + if p.Credits < totalCost { + continue + } + + if p.Action != nil { + g.cancelAction(p) + } + + g.consumeJunkCost(p, mod) + + p.RemoveItem(logID, qty) + p.Credits -= totalCost + + processed := 0 + for i := 0; i < qty; i++ { + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + g.World.AddGroundItem(p.RoomID, info.plankID, qty-i) + break + } + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: info.plankID, Quantity: 1}) + processed++ + } + + sciXP := mod.BaseXP * qty + g.awardSkillXP(sess, p, player.Science, sciXP) + conXP := 10 * qty + g.awardSkillXP(sess, p, player.Construction, conXP) + g.AccountStore.SaveCharacter(p) + + sess.WriteLine(fmt.Sprintf("You convert %d logs into %s for %d credits.", processed, info.name, totalCost)) + if processed < qty { + sess.WriteLine(fmt.Sprintf("Your inventory is full. The remaining %d planks fall to the ground.", qty-processed)) + } + if p.OptionBool("xp_drops") { + parts := []string{fmt.Sprintf("+%dxp sci", sciXP), fmt.Sprintf("+%dxp con", conXP)} + sess.WriteLine(g.colorize(sess, "xp", "("+strings.Join(parts, ", ")+")")) + } + return + } + + sess.WriteLine("You don't have any logs to convert.") +} diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go index fbe5945..4f830a8 100644 --- a/internal/game/cmd_use.go +++ b/internal/game/cmd_use.go @@ -10,6 +10,13 @@ import ( "thehouseoficarus/internal/player" ) +func (g *Game) executeUse(sess *net.Session, args []string, rawInput string) { + if g.tryFinishingBlow(sess, strings.Join(args, " ")) { + return + } + g.doUse(sess, strings.Join(args, " ")) +} + func (g *Game) doUse(sess *net.Session, input string) { if strings.TrimSpace(input) == "" { g.doHelp(sess, "use") diff --git a/internal/game/cmd_walk.go b/internal/game/cmd_walk.go index eca608f..9485af5 100644 --- a/internal/game/cmd_walk.go +++ b/internal/game/cmd_walk.go @@ -10,6 +10,10 @@ import ( "thehouseoficarus/internal/world" ) +func (g *Game) executeWalk(sess *net.Session, args []string, rawInput string) { + g.doWalk(sess, args) +} + func (g *Game) doWalk(sess *net.Session, args []string) { p := sess.Player if len(args) == 0 { diff --git a/internal/game/cmd_wear.go b/internal/game/cmd_wear.go index 36765e8..f2fd84c 100644 --- a/internal/game/cmd_wear.go +++ b/internal/game/cmd_wear.go @@ -9,6 +9,10 @@ import ( "thehouseoficarus/internal/player" ) +func (g *Game) executeWear(sess *net.Session, args []string, rawInput string) { + g.doWear(sess, strings.Join(args, " ")) +} + func (g *Game) checkRequirements(p *player.Player, def *object.ItemDef) string { if len(def.Requirements) == 0 { return "" diff --git a/internal/game/color.go b/internal/game/color.go deleted file mode 100644 index 962728a..0000000 --- a/internal/game/color.go +++ /dev/null @@ -1,79 +0,0 @@ -package game - -import ( - "thehouseoficarus/internal/color" - "thehouseoficarus/internal/config" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" -) - -func (g *Game) colorMode(sess *net.Session) string { - if p := sess.Player; p != nil { - return p.OptionString("color") - } - return "none" -} - -func (g *Game) resolveColor(sess *net.Session, category string) color.ColorSpec { - if sess.Account != nil && sess.Account.Colors != nil { - if val, ok := sess.Account.Colors[category]; ok { - if val == "off" { - return color.NoColor() - } - if val != "" { - return color.Parse(val) - } - } - } - if g.ColorConfig != nil { - if val, ok := (*g.ColorConfig)[category]; ok && val != "" { - return color.Parse(val) - } - } - if val, ok := config.DefaultColors()[category]; ok && val != "" { - return color.Parse(val) - } - return color.NoColor() -} - -func (g *Game) colorize(sess *net.Session, category, text string) string { - spec := g.resolveColor(sess, category) - return color.Render(g.colorMode(sess), spec, text) -} - -func levelColorSpec(myLevel, theirLevel int) color.ColorSpec { - diff := theirLevel - myLevel - switch { - case diff == 0: - return color.Parse("231") - case diff > 0 && diff < 5: - return color.Parse("208") - case diff >= 5: - return color.Parse("196") - case diff < 0 && diff > -5: - return color.Parse("190") - default: - return color.Parse("34") - } -} - -func (g *Game) levelColorize(sess *net.Session, myLevel, theirLevel int, text string) string { - spec := levelColorSpec(myLevel, theirLevel) - return color.Render(g.colorMode(sess), spec, text) -} - -func (g *Game) objColorize(sess *net.Session, objDef *object.ObjectDef, text string) string { - if objDef != nil && objDef.Color != "" { - spec := color.Parse(objDef.Color) - return color.Render(g.colorMode(sess), spec, text) - } - return text -} - -func (g *Game) itemColorize(sess *net.Session, itemDef *object.ItemDef, text string) string { - if itemDef != nil && itemDef.Color != "" { - spec := color.Parse(itemDef.Color) - return color.Render(g.colorMode(sess), spec, text) - } - return g.colorize(sess, "item", text) -} diff --git a/internal/game/construction.go b/internal/game/construction.go deleted file mode 100644 index 06713cd..0000000 --- a/internal/game/construction.go +++ /dev/null @@ -1,74 +0,0 @@ -package game - -import ( - "fmt" - - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" -) - -func (g *Game) processSawmill(sess *net.Session, p *player.Player) { - logTypes := map[string]struct { - plankID string - cost int - name string - }{ - "logs": {"planks", 5, "regular planks"}, - "oak_logs": {"oak_planks", 10, "oak planks"}, - "teak_logs": {"teak_planks", 20, "teak planks"}, - "mahogany_logs": {"mahogany_planks", 40, "mahogany planks"}, - } - - totalCost := 0 - totalPlanks := 0 - type plankResult struct { - plankID string - qty int - } - - var results []plankResult - - for logID, info := range logTypes { - qty := p.CountItem(logID) - if qty == 0 { - continue - } - cost := qty * info.cost - totalCost += cost - totalPlanks += qty - results = append(results, plankResult{info.plankID, qty}) - p.RemoveItem(logID, qty) - } - - if totalPlanks == 0 { - sess.WriteLine("You don't have any logs to process.") - return - } - - if p.Credits < totalCost { - sess.WriteLine(fmt.Sprintf("You need %d credits to process all your logs (you have %d).", totalCost, p.Credits)) - return - } - - p.Credits -= totalCost - - processed := 0 - for _, r := range results { - for i := 0; i < r.qty; i++ { - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - g.World.AddGroundItem(p.RoomID, r.plankID, r.qty-i) - break - } - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: r.plankID, Quantity: 1}) - processed++ - } - } - - g.AccountStore.SaveCharacter(p) - - sess.WriteLine(fmt.Sprintf("The sawmill operator processes your logs into %d planks for %d credits.", processed, totalCost)) - if processed < totalPlanks { - sess.WriteLine(fmt.Sprintf("Your inventory is full. The remaining %d planks fall to the ground.", totalPlanks-processed)) - } -} diff --git a/internal/game/core_course.go b/internal/game/core_course.go new file mode 100644 index 0000000..908466d --- /dev/null +++ b/internal/game/core_course.go @@ -0,0 +1,135 @@ +package game + +import ( + "path/filepath" + "sync" + + "thehouseoficarus/internal/action" + "gopkg.in/yaml.v3" +) + +type ObstacleDef struct { + RoomID int `yaml:"room_id"` + Verb string `yaml:"verb"` + TicksPerPhase float64 `yaml:"ticks_per_phase"` + XP int `yaml:"xp"` + FailDamage [2]int `yaml:"fail_damage"` + Messages []string `yaml:"messages"` +} + +type CourseConfig struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + RequiredLevel int `yaml:"required_level"` + StartRoom int `yaml:"start_room"` + CompletionXP int `yaml:"completion_xp"` + Obstacles []ObstacleDef `yaml:"obstacles"` +} + +type ObstacleInfo struct { + CourseID string + CourseName string + ObstacleIndex int + TotalObstacles int + Verb string + Messages []string + TicksPerPhase float64 + ObstacleXP int + CompletionXP int + FailDamage [2]int + NextRoom int + StartRoom int + RequiredLevel int +} + +var obstacleVerbs = map[string]bool{} + +var verbGerund = map[string]string{ + "scramble": "scrambling", + "jump": "jumping", + "swing": "swinging", + "balance": "balancing", + "climb": "climbing", + "crawl": "crawling", + "vault": "vaulting", + "leap": "leaping", + "slide": "sliding", +} + +type CourseStore struct { + dataDir string + mu sync.Mutex + courses map[string]*CourseConfig + roomToObstacle map[int]*ObstacleInfo + loaded bool +} + +func NewCourseStore(dataDir string) *CourseStore { + return &CourseStore{ + dataDir: dataDir, + courses: make(map[string]*CourseConfig), + } +} + +func (cs *CourseStore) LoadAll() { + cs.mu.Lock() + defer cs.mu.Unlock() + cs.loadAllLocked() +} + +func (cs *CourseStore) GetObstacle(roomID int) *ObstacleInfo { + cs.mu.Lock() + defer cs.mu.Unlock() + if !cs.loaded { + cs.loadAllLocked() + } + return cs.roomToObstacle[roomID] +} + +func (cs *CourseStore) loadAllLocked() { + cs.loaded = true + cs.roomToObstacle = make(map[int]*ObstacleInfo) + localVerbs := make(map[string]bool) + + action.WalkYAMLDir(filepath.Join(cs.dataDir, "courses"), func(path, id string, data []byte) error { + var cfg CourseConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil + } + cs.courses[cfg.ID] = &cfg + + totalObstacles := len(cfg.Obstacles) + for i, obs := range cfg.Obstacles { + nextRoom := 0 + if i < totalObstacles-1 { + nextRoom = cfg.Obstacles[i+1].RoomID + } + + completionXP := 0 + if i == totalObstacles-1 { + completionXP = cfg.CompletionXP + } + + info := &ObstacleInfo{ + CourseID: cfg.ID, + CourseName: cfg.Name, + ObstacleIndex: i, + TotalObstacles: totalObstacles, + Verb: obs.Verb, + Messages: obs.Messages, + TicksPerPhase: obs.TicksPerPhase, + ObstacleXP: obs.XP, + CompletionXP: completionXP, + FailDamage: obs.FailDamage, + NextRoom: nextRoom, + StartRoom: cfg.StartRoom, + RequiredLevel: cfg.RequiredLevel, + } + cs.roomToObstacle[obs.RoomID] = info + localVerbs[obs.Verb] = true + } + return nil + }) + + obstacleVerbs = localVerbs +} diff --git a/internal/game/core_equip.go b/internal/game/core_equip.go new file mode 100644 index 0000000..02b8348 --- /dev/null +++ b/internal/game/core_equip.go @@ -0,0 +1,41 @@ +package game + +import ( + "thehouseoficarus/internal/object" + "thehouseoficarus/internal/player" +) + +func (g *Game) playerEquipBonuses(p *player.Player) object.ItemStats { + var totals object.ItemStats + for _, itemID := range p.Equipment { + def, err := g.ItemStore.Load(itemID) + if err != nil { + continue + } + totals.StabAttack += def.Stats.StabAttack + totals.SlashAttack += def.Stats.SlashAttack + totals.CrushAttack += def.Stats.CrushAttack + totals.ScienceAttack += def.Stats.ScienceAttack + totals.RangedAttack += def.Stats.RangedAttack + totals.StabDefense += def.Stats.StabDefense + totals.SlashDefense += def.Stats.SlashDefense + totals.CrushDefense += def.Stats.CrushDefense + totals.ScienceDefense += def.Stats.ScienceDefense + totals.RangedDefense += def.Stats.RangedDefense + totals.StrengthBonus += def.Stats.StrengthBonus + totals.RangedStrength += def.Stats.RangedStrength + totals.ScienceDamage += def.Stats.ScienceDamage + totals.TechnologyBonus += def.Stats.TechnologyBonus + } + return totals +} + +func (g *Game) playerWeaponSpeed(p *player.Player) float64 { + if itemID, ok := p.Equipment[object.SlotMainHand]; ok { + def, err := g.ItemStore.Load(itemID) + if err == nil && def.Speed > 0 { + return def.Speed + } + } + return 5.0 +} diff --git a/internal/game/core_flags.go b/internal/game/core_flags.go new file mode 100644 index 0000000..6595cc4 --- /dev/null +++ b/internal/game/core_flags.go @@ -0,0 +1,55 @@ +package game + +import "thehouseoficarus/internal/player" + +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) { + p.EnsureFlags() + p.Flags[key] = val +} + +func intFromFlag(flags map[string]any, key string) int { + val, ok := 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 +} diff --git a/internal/game/core_hacking.go b/internal/game/core_hacking.go new file mode 100644 index 0000000..3457728 --- /dev/null +++ b/internal/game/core_hacking.go @@ -0,0 +1,93 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/game/hacking" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) handleHackingInput(sess *net.Session, input string) { + p := sess.Player + if p == nil { + sess.State = net.StateGame + return + } + + hs, ok := g.hackingStates[p.Name] + if !ok { + sess.State = net.StateGame + g.writePrompt(sess) + return + } + + input = strings.TrimSpace(input) + lower := strings.ToLower(input) + + if lower == "jack out" || lower == "quit" || lower == "disconnect" { + g.endHacking(sess, p, false, false) + return + } + + hs.Started = true + output, done, won := hs.Minigame.HandleInput(input) + + sess.WriteLine(output) + + if done { + g.endHacking(sess, p, true, won) + return + } + + sess.Write("\nhack> ") +} + +func (g *Game) endHacking(sess *net.Session, p *player.Player, completed bool, won bool) { + hs, ok := g.hackingStates[p.Name] + if !ok { + sess.State = net.StateGame + g.writePrompt(sess) + return + } + + tDef := hacking.Terminals[hs.TerminalID] + var xp int + + if completed && won { + xp = hacking.CalcXP(tDef.BaseXPWin, hs.Level, hs.ReqLevel) + sess.WriteLine("\nConnection terminated. Contract complete.") + } else if completed { + xp = hacking.CalcXP(tDef.BaseXPLose, hs.Level, hs.ReqLevel) + sess.WriteLine("\nConnection lost.") + } else if hs.Started { + xp = hacking.CalcXP(tDef.BaseXPLose, hs.Level, hs.ReqLevel) + sess.WriteLine("\nYou jack out of the terminal.") + } else { + sess.WriteLine("\nYou jack out of the terminal.") + } + + if won { + if b, ok := hs.Minigame.(hacking.XPBonuser); ok { + bonus := b.BonusXP() + if bonus > 0 { + xp += hacking.CalcXP(bonus, hs.Level, hs.ReqLevel) + } + } + } + + if xp > 0 { + g.awardSkillXP(sess, p, player.Hacking, xp) + if p.OptionBool("xp_drops") { + sess.WriteLine(g.colorize(sess, "xp", + fmt.Sprintf("(+%dxp %s)", xp, player.SkillAbbr[player.Hacking]))) + } + g.AccountStore.SaveCharacter(p) + } + + delete(g.hackingStates, p.Name) + p.ActionState = nil + sess.State = net.StateGame + g.writePrompt(sess) +} diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go new file mode 100644 index 0000000..87f8c62 --- /dev/null +++ b/internal/game/core_login_account.go @@ -0,0 +1,351 @@ +package game + +import ( + "fmt" + "os" + "strings" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) handleAccountName(sess *net.Session, input string) { + name := strings.TrimSpace(input) + if name == "" { + sess.Write("Account name: ") + return + } + actualName, err := g.AccountStore.FindAccount(name) + if err == nil { + sess.Account = &player.Account{Name: actualName} + sess.State = net.StatePassword + sess.Conn.SetEcho(false) + sess.Write("Password: ") + } else { + if verr := player.ValidName(name); verr != nil { + sess.WriteLine(verr.Error()) + sess.Write("Account name: ") + return + } + sess.Account = &player.Account{Name: name} + sess.State = net.StateNewAccountPass + sess.Conn.SetEcho(false) + sess.WriteLine("\r\nOh, a new visitor to this rock!") + sess.Write("Choose password: ") + } +} + +func (g *Game) handlePassword(sess *net.Session, input string) { + if input == "" { + sess.Write("Password: ") + return + } + if len(input) > 128 { + sess.Conn.SetEcho(true) + sess.WriteLine("Wrong password.") + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + acc, err := g.AccountStore.LoadAccount(sess.Account.Name) + if err != nil { + sess.Conn.SetEcho(true) + sess.WriteLine("Error loading account.") + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + if !player.CheckPassword(input, acc.PasswordHash) { + sess.Conn.SetEcho(true) + sess.WriteLine("Wrong password.") + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + sess.Conn.SetEcho(true) + sess.Account = &player.Account{ + Name: acc.Name, + PasswordHash: acc.PasswordHash, + Characters: acc.Characters, + Aliases: acc.Aliases, + Colors: acc.Colors, + Options: acc.Options, + } + if sess.Account.Aliases == nil { + sess.Account.Aliases = make(map[string]string) + } + if sess.Account.Colors == nil { + sess.Account.Colors = make(map[string]string) + } + if sess.Account.Options == nil { + sess.Account.Options = make(map[string]any) + } + g.showMenu(sess) +} + +func (g *Game) handleNewAccountPass(sess *net.Session, input string) { + input = strings.TrimSpace(input) + if input == "" { + sess.Conn.SetEcho(true) + sess.Account = nil + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + if verr := player.ValidPassword(input); verr != nil { + sess.WriteLine(verr.Error()) + sess.Write("Choose password: ") + return + } + sess.PendingPass = input + sess.State = net.StateNewAccountConfirm + sess.Write("Confirm password: ") +} + +func (g *Game) handleNewAccountConfirm(sess *net.Session, input string) { + input = strings.TrimSpace(input) + if input == "" { + sess.Conn.SetEcho(true) + sess.Account = nil + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + if input != sess.PendingPass { + sess.Conn.SetEcho(true) + sess.WriteLine("Passwords do not match.") + sess.Account = nil + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + + hash, err := player.HashPassword(input) + if err != nil { + sess.Conn.SetEcho(true) + sess.WriteLine(fmt.Sprintf("Error creating account: %v", err)) + sess.Account = nil + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + + acc := &player.Account{ + Name: sess.Account.Name, + PasswordHash: hash, + } + if err := g.AccountStore.SaveAccount(acc); err != nil { + sess.Conn.SetEcho(true) + sess.WriteLine(fmt.Sprintf("Error creating account: %v", err)) + sess.Account = nil + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + + sess.Conn.SetEcho(true) + sess.Account = &player.Account{ + Name: acc.Name, + PasswordHash: acc.PasswordHash, + Aliases: make(map[string]string), + Colors: make(map[string]string), + Options: make(map[string]any), + } + sess.PendingPass = "" + sess.State = net.StateColorChoice + sess.Write("\nShould I disable color? [y/N] ") +} + +func (g *Game) handleColorChoice(sess *net.Session, input string) { + input = strings.ToLower(strings.TrimSpace(input)) + if input == "y" || input == "yes" { + sess.Account.Options["color"] = "none" + acc, err := g.AccountStore.LoadAccount(sess.Account.Name) + if err == nil { + if acc.Options == nil { + acc.Options = make(map[string]any) + } + acc.Options["color"] = "none" + g.AccountStore.SaveAccount(acc) + } + } + g.showMenu(sess) +} + +func (g *Game) showMenu(sess *net.Session) { + sess.State = net.StateMenu + lines := []string{ + "", + fmt.Sprintf("Welcome back to Gaia 04, %s.", sess.Account.Name), + "", + } + if len(sess.Account.Characters) > 0 { + lines = append(lines, + " (C)onnect character to THOI", + "", + " (L)ist characters", + " (R)ename character", + " (D)elete character", + ) + } + lines = append(lines, + " (N)ew character", + " (P)urge account", + " (A)ccount rename", + " (Q)uit", + "", + ) + sess.WriteLines(lines...) + sess.Write("> ") +} + +func (g *Game) handleMenu(sess *net.Session, input string) { + switch strings.ToLower(strings.TrimSpace(input)) { + case "": + sess.Write("> ") + case "c": + if len(sess.Account.Characters) == 0 { + sess.WriteLine("\nHey, make a character with 'N' first!") + sess.Write("\n> ") + return + } + if len(sess.Account.Characters) == 1 { + g.connectCharacter(sess, sess.Account.Characters[0]) + return + } + sess.WriteLine("\nSelect character:") + for i, name := range sess.Account.Characters { + sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name)) + } + sess.State = net.StateNewCharName + sess.PendingChar = "connect" + sess.Write("\n> ") + + case "l": + if len(sess.Account.Characters) == 0 { + sess.WriteLine("\nYou don't have any characters! Make one with 'N'!") + } else { + sess.WriteLine("\nCharacters:") + for _, name := range sess.Account.Characters { + sess.WriteLine(fmt.Sprintf(" - %s", name)) + } + } + sess.Write("\n> ") + + case "n": + sess.State = net.StateNewCharName + sess.PendingChar = "" + sess.Write("What's your character name?: ") + + case "a": + sess.State = net.StateRenameAccount + sess.Write("New account name: ") + + case "r": + if len(sess.Account.Characters) == 0 { + sess.WriteLine("\nRename who? You don't have any characters!") + sess.Write("\n> ") + return + } + if len(sess.Account.Characters) == 1 { + sess.PendingChar = sess.Account.Characters[0] + sess.State = net.StateRenameCharName + sess.WriteLine(fmt.Sprintf("\nRenaming %s.", sess.PendingChar)) + sess.Write("New name: ") + return + } + sess.State = net.StateRenameChar + sess.WriteLine("\nRename which character?") + for i, name := range sess.Account.Characters { + sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name)) + } + sess.Write("\n> ") + + case "d": + if len(sess.Account.Characters) == 0 { + sess.WriteLine("\nNo characters to delete.") + sess.Write("\n> ") + return + } + if len(sess.Account.Characters) == 1 { + sess.PendingChar = sess.Account.Characters[0] + } else { + sess.State = net.StateDeleteChar + sess.WriteLine("\nDelete which character?") + for i, name := range sess.Account.Characters { + sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name)) + } + sess.Write("\n> ") + return + } + g.showDeleteConfirm(sess) + + case "p": + sess.State = net.StatePurgeAccount + sess.WriteLine(fmt.Sprintf("\nPurge account %s and all characters?\nTo be clear you are about to PERMANENTLY DELETE EVERYTHING!\nType PURGE %s to confirm, or anything else to cancel.", sess.Account.Name, sess.Account.Name)) + sess.Write("\n> ") + + case "q": + sess.WriteLine("Later.") + sess.Close() + return + + default: + sess.Write("> ") + } +} + +func (g *Game) handleRenameAccount(sess *net.Session, input string) { + newName := strings.TrimSpace(input) + if newName == "" { + sess.Write("New account name: ") + return + } + if verr := player.ValidName(newName); verr != nil { + sess.WriteLine(verr.Error()) + sess.Write("New account name: ") + return + } + oldName := sess.Account.Name + if newName == oldName { + sess.WriteLine("That's already your account name.") + g.showMenu(sess) + return + } + if g.AccountStore.AccountExists(newName) { + sess.WriteLine("An account with that name already exists.") + sess.Write("New account name: ") + return + } + + oldPath := g.AccountStore.AccountPath(oldName) + newPath := g.AccountStore.AccountPath(newName) + if err := os.Rename(oldPath, newPath); err != nil { + sess.WriteLine(fmt.Sprintf("Error renaming account: %v", err)) + g.showMenu(sess) + return + } + + sess.Account.Name = newName + sess.WriteLine(fmt.Sprintf("Account renamed to %s.", newName)) + g.showMenu(sess) +} + +func (g *Game) handlePurgeAccount(sess *net.Session, input string) { + input = strings.TrimSpace(input) + expected := "PURGE " + sess.Account.Name + if strings.ToUpper(input) != strings.ToUpper(expected) { + sess.WriteLine("Purge cancelled.") + g.showMenu(sess) + return + } + + for _, name := range sess.Account.Characters { + os.Remove(g.AccountStore.CharPath(name)) + } + + os.Remove(g.AccountStore.AccountPath(sess.Account.Name)) + + sess.WriteLine(fmt.Sprintf("Account %s has been purged. Thanks for playing.", sess.Account.Name)) + sess.Close() +} diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go new file mode 100644 index 0000000..84f146b --- /dev/null +++ b/internal/game/core_login_char.go @@ -0,0 +1,244 @@ +package game + +import ( + "fmt" + "os" + "strings" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) handleNewCharName(sess *net.Session, input string) { + name := strings.TrimSpace(input) + + if sess.PendingChar == "connect" && len(sess.Account.Characters) > 0 { + if idx, err := parseChoiceIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { + sess.PendingChar = "" + g.connectCharacter(sess, sess.Account.Characters[idx-1]) + return + } + sess.WriteLine("Invalid choice.") + sess.Write("\n> ") + return + } + + if name == "" { + sess.Write("Character name: ") + return + } + + if verr := player.ValidName(name); verr != nil { + sess.WriteLine(verr.Error()) + sess.Write("Character name: ") + return + } + + if g.AccountStore.CharacterExists(name) { + sess.WriteLine("A character with that name already exists.") + sess.Write("Character name: ") + return + } + + p := player.New(name) + p.RoomID = 1 + + if err := g.AccountStore.SaveCharacter(p); err != nil { + sess.WriteLine(fmt.Sprintf("Error creating character: %v", err)) + sess.State = net.StateMenu + g.showMenu(sess) + return + } + + acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) + acc.Characters = append(acc.Characters, name) + g.AccountStore.SaveAccount(acc) + sess.Account.Characters = acc.Characters + + g.connectCharacter(sess, name) +} + +func (g *Game) connectCharacter(sess *net.Session, name string) { + g.charsMu.Lock() + if existing := g.loggedInChars[name]; existing != nil { + g.charsMu.Unlock() + sess.WriteLine("This character is logged in elsewhere.") + sess.State = net.StateMenu + g.showMenu(sess) + return + } + + p, err := g.AccountStore.LoadCharacter(name) + if err != nil { + g.charsMu.Unlock() + sess.WriteLine(fmt.Sprintf("Error loading character: %v", err)) + sess.State = net.StateMenu + g.showMenu(sess) + return + } + + sess.Player = p + sess.State = net.StateGame + g.loggedInChars[name] = sess + g.charsMu.Unlock() + + if sess.Account != nil && sess.Account.Options != nil { + p.Options = make(map[string]any) + for k, v := range sess.Account.Options { + p.Options[k] = v + } + } + + g.World.SeedGroundItems(p.RoomID) + g.seedRoomMobs(p.RoomID) + g.seedRoomObjects(p.RoomID) + + if g.Hub != nil { + g.Hub.EnterRoom(sess, p.RoomID) + } + + g.doLook(sess) + g.checkAggro(sess) + sess.Write("\r\n> ") +} + +func (g *Game) handleRenameChar(sess *net.Session, input string) { + name := strings.TrimSpace(input) + if idx, err := parseChoiceIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { + sess.PendingChar = sess.Account.Characters[idx-1] + } else { + sess.PendingChar = name + } + + found := false + for _, c := range sess.Account.Characters { + if c == sess.PendingChar { + found = true + break + } + } + if !found { + sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar)) + g.showMenu(sess) + return + } + + sess.State = net.StateRenameCharName + sess.WriteLine(fmt.Sprintf("Renaming %s.", sess.PendingChar)) + sess.Write("New name: ") +} + +func (g *Game) handleRenameCharName(sess *net.Session, input string) { + newName := strings.TrimSpace(input) + oldName := sess.PendingChar + if newName == "" { + sess.Write("New name: ") + return + } + if verr := player.ValidName(newName); verr != nil { + sess.WriteLine(verr.Error()) + sess.Write("New name: ") + return + } + if newName == oldName { + sess.WriteLine("That's already the character's name.") + g.showMenu(sess) + return + } + if g.AccountStore.CharacterExists(newName) { + sess.WriteLine("A character with that name already exists.") + sess.Write("New name: ") + return + } + + oldPath := g.AccountStore.CharPath(oldName) + newPath := g.AccountStore.CharPath(newName) + if err := os.Rename(oldPath, newPath); err != nil { + sess.WriteLine(fmt.Sprintf("Error renaming: %v", err)) + g.showMenu(sess) + return + } + + p, _ := g.AccountStore.LoadCharacter(newName) + if p != nil { + p.Name = newName + g.AccountStore.SaveCharacter(p) + } + + acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) + for i, c := range acc.Characters { + if c == oldName { + acc.Characters[i] = newName + break + } + } + g.AccountStore.SaveAccount(acc) + sess.Account.Characters = acc.Characters + + sess.PendingChar = "" + sess.WriteLine(fmt.Sprintf("%s renamed to %s.", oldName, newName)) + g.showMenu(sess) +} + +func (g *Game) showDeleteConfirm(sess *net.Session) { + sess.State = net.StateDeleteChar + sess.WriteLine(fmt.Sprintf("\nDelete %s? Type DELETE %s to confirm, or anything else to cancel.", sess.PendingChar, sess.PendingChar)) +} + +func (g *Game) handleDeleteChar(sess *net.Session, input string) { + if sess.PendingChar == "" { + name := strings.TrimSpace(input) + if idx, err := parseChoiceIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { + sess.PendingChar = sess.Account.Characters[idx-1] + } else { + sess.PendingChar = name + } + found := false + for _, c := range sess.Account.Characters { + if c == sess.PendingChar { + found = true + break + } + } + if !found { + sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar)) + sess.PendingChar = "" + g.showMenu(sess) + return + } + g.showDeleteConfirm(sess) + return + } + + input = strings.TrimSpace(input) + expected := "DELETE " + sess.PendingChar + if strings.ToUpper(input) != strings.ToUpper(expected) { + sess.WriteLine("Delete cancelled.") + sess.PendingChar = "" + g.showMenu(sess) + return + } + + charPath := g.AccountStore.CharPath(sess.PendingChar) + if err := os.Remove(charPath); err != nil { + sess.WriteLine(fmt.Sprintf("Error deleting: %v", err)) + sess.PendingChar = "" + g.showMenu(sess) + return + } + + acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) + var newChars []string + for _, c := range acc.Characters { + if c != sess.PendingChar { + newChars = append(newChars, c) + } + } + acc.Characters = newChars + g.AccountStore.SaveAccount(acc) + sess.Account.Characters = newChars + + sess.WriteLine(fmt.Sprintf("%s has been deleted.", sess.PendingChar)) + sess.PendingChar = "" + g.showMenu(sess) +} diff --git a/internal/game/core_messages.go b/internal/game/core_messages.go new file mode 100644 index 0000000..1c9e85a --- /dev/null +++ b/internal/game/core_messages.go @@ -0,0 +1,13 @@ +package game + +const ( + MsgErrLoadRecipes = "Error loading recipes." + MsgNoCombat = "You can't do that during combat!" + MsgNoDontHave = "You don't have any '%s'." + MsgNeverMind = "Never mind." + MsgSomethingWrongObject = "Something is wrong with this object." + MsgSomethingWrongThat = "Something is wrong with that." + MsgInventoryFull = "Your inventory is too full!" + MsgAmbiguous = "That's ambiguous, which one?" + MsgWhichOne = "Which one?" +) diff --git a/internal/game/core_production.go b/internal/game/core_production.go new file mode 100644 index 0000000..227c4ad --- /dev/null +++ b/internal/game/core_production.go @@ -0,0 +1,803 @@ +package game + +import ( + "fmt" + "math/rand" + "sort" + "strconv" + "strings" + + "thehouseoficarus/internal/action" + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/object" + "thehouseoficarus/internal/player" + +) + +func (g *Game) startProduction(sess *net.Session, p *player.Player, recipe *action.RecipeDef, actionType, displayVerb, startMsg, endMsg string, count int) { + g.cancelAction(p) + g.cancelBackgroundAction(p) + + skill := recipe.EffectiveSkill() + if skill != "" { + skillLevel := p.Level(player.SkillName(skill)) + if skillLevel < recipe.Level { + sess.WriteLine(fmt.Sprintf("You need level %d %s to do that.", recipe.Level, skill)) + g.reprompt(sess) + return + } + } + + if !recipe.HasAllItemsQty(p.CountItem) { + sess.WriteLine("You don't have the required materials.") + g.reprompt(sess) + return + } + + outDef, _ := g.ItemStore.Load(recipe.Output) + + wait := recipe.Wait + if wait <= 0 { + wait = 4 + } + + outputName := recipe.Output + if outDef != nil { + outputName = outDef.Name + } + + p.Action = &action.Action{ + Type: actionType, + TargetID: recipe.ID, + TargetName: outputName, + Data: map[string]any{ + "recipe_id": recipe.ID, + "phase": 0, + "wait": wait, + "start_msg": startMsg, + "end_msg": endMsg, + "remaining": count, + }, + WaitLeft: engine.ToTicks(1), + } + + p.ActionState = &ActionState{Type: ActionProducing, TargetName: outputName, Verb: displayVerb} + + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other != sess && other.Player != nil { + other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s starts %s.", p.Name, displayVerb))) + } + } + } +} + +func (g *Game) startProductionFromRecipe(sess *net.Session, p *player.Player, recipe *action.RecipeDef, count int) { + _, stationName := g.findStation(p.RoomID, recipe.Station) + if stationName == "" { + stationName = "inventory" + } + + firstItem := recipe.FirstItemName(func(id string) (string, bool) { + def, err := g.ItemStore.Load(id) + if err != nil { + return id, false + } + return def.Name, true + }) + + info, ok := productionTypes[recipe.Type] + if !ok { + info = productionTypeInfo{recipe.Type, recipe.Type} + } + actionType := info.ActionType + displayVerb := info.DisplayVerb + + var startMsg, endMsg string + if stationName != "inventory" { + startMsg = fmt.Sprintf("You start %s %s on the %s.", displayVerb, firstItem, stationName) + } else { + startMsg = fmt.Sprintf("You start %s %s.", displayVerb, firstItem) + } + endMsg = fmt.Sprintf("You've finished %s.", displayVerb) + + g.startProduction(sess, p, recipe, actionType, displayVerb, startMsg, endMsg, count) +} + +func (g *Game) loadProductionRecipe(recipeID string) *action.RecipeDef { + all, err := g.RecipeStore.LoadAll() + if err == nil { + for _, r := range all { + if r.ID == recipeID { + return &r + } + } + } + if strings.HasPrefix(recipeID, "combine_") { + itemID := strings.TrimPrefix(recipeID, "combine_") + if itemDef, err := g.ItemStore.Load(itemID); err == nil && len(itemDef.MadeFrom) > 0 { + return g.buildCombineRecipe(itemDef) + } + } + return nil +} + +func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool { + recipeID := p.Action.Data["recipe_id"].(string) + phase := p.Action.Data["phase"].(int) + wait := p.Action.Data["wait"].(float64) + startMsg, _ := p.Action.Data["start_msg"].(string) + endMsg, _ := p.Action.Data["end_msg"].(string) + remaining, _ := p.Action.Data["remaining"].(int) + + recipe := g.loadProductionRecipe(recipeID) + if recipe == nil { + g.cancelAction(p) + return false + } + + if phase == 0 { + if startMsg != "" { + sess.WriteLine(fmt.Sprintf("\n%s", startMsg)) + } + p.Action.Data["phase"] = 1 + p.Action.WaitLeft = engine.ToTicks(wait) + return true + } + + skill := recipe.EffectiveSkill() + skillLevel := p.Level(player.SkillName(skill)) + chance := 1.0 + if recipe.Success != nil { + chance = action.SuccessChance(*recipe.Success, skillLevel, recipe.Level) + } + + if rand.Float64() < chance { + outputQty := recipe.OutputQty + if outputQty <= 0 { + outputQty = 1 + } + + byproducts := g.collectByproducts(p, recipe) + + placed := false + outDef, _ := g.ItemStore.Load(recipe.Output) + if outDef != nil && outDef.Stackable { + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot != nil && slot.ItemID == recipe.Output { + recipe.ConsumeAll(p.HasItem, p.RemoveItem) + slot.Quantity += outputQty + placed = true + break + } + } + } + if !placed { + recipe.ConsumeAll(p.HasItem, p.RemoveItem) + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + sess.WriteLine("Your inventory is too full!") + g.cancelAction(p) + return false + } + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: recipe.Output, Quantity: outputQty}) + } + + for _, bp := range byproducts { + if slot := p.FirstFreeSlot(); slot >= 0 { + p.SetInvSlot(slot, &player.InventorySlot{ItemID: bp, Quantity: 1}) + } + } + + if recipe.XP > 0 { + if newLevel := p.AddSkillXP(player.SkillName(skill), recipe.XP); newLevel > 0 { + sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, skill))) + } + } + g.AccountStore.SaveCharacter(p) + + msg := recipe.Message + if msg == "" { + outputName := recipe.Output + if outDef != nil { + outputName = outDef.Name + } + msg = fmt.Sprintf("You produce %s.", outputName) + } + if p.OptionBool("xp_drops") && recipe.XP > 0 { + abbr := player.SkillAbbr[player.SkillName(skill)] + msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp %s)", recipe.XP, abbr)) + } + sess.WriteLine(msg) + } else { + recipe.ConsumeAll(p.HasItem, p.RemoveItem) + + if recipe.Fail != "" { + freeSlot := p.FirstFreeSlot() + if freeSlot >= 0 { + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: recipe.Fail, Quantity: 1}) + } + } + g.AccountStore.SaveCharacter(p) + + msg := recipe.FailMessage + if msg == "" { + msg = "You fail and the materials are lost." + } + sess.WriteLine(g.colorize(sess, "damage", msg)) + } + + if remaining > 0 { + remaining-- + p.Action.Data["remaining"] = remaining + if remaining <= 0 { + if endMsg != "" { + sess.WriteLine(fmt.Sprintf("\n%s", endMsg)) + } + g.cancelAction(p) + return false + } + } + + if g.canContinueProduction(p, recipe) { + p.Action.WaitLeft = engine.ToTicks(wait) + return true + } + + if endMsg != "" { + sess.WriteLine(fmt.Sprintf("\n%s", endMsg)) + } + g.cancelAction(p) + return false +} + +func (g *Game) collectByproducts(p *player.Player, recipe *action.RecipeDef) []string { + var byproducts []string + for _, e := range recipe.Consume { + if len(e.Byproducts) == 0 { + continue + } + for i, id := range e.Items { + if p.HasItem(id) && i < len(e.Byproducts) && e.Byproducts[i] != "" { + byproducts = append(byproducts, e.Byproducts[i]) + break + } + } + } + return byproducts +} + +func (g *Game) canContinueProduction(p *player.Player, recipe *action.RecipeDef) bool { + if !recipe.HasAllItemsQty(p.CountItem) { + return false + } + outputQty := recipe.OutputQty + if outputQty <= 0 { + outputQty = 1 + } + outDef, _ := g.ItemStore.Load(recipe.Output) + if outDef != nil && outDef.Stackable { + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot != nil && slot.ItemID == recipe.Output { + return true + } + } + } + return p.FirstFreeSlot() >= 0 +} + +func (g *Game) handleRecipeChoice(sess *net.Session, input string) { + input = strings.TrimSpace(input) + + menuData := sess.PendingMenu + if len(menuData) == 0 { + sess.State = net.StateGame + g.reprompt(sess) + return + } + + if input == "" { + sess.PendingMenu = nil + sess.State = net.StateGame + sess.WriteLine("Never mind.") + g.reprompt(sess) + return + } + + if choice, err := strconv.Atoi(input); err == nil { + sess.PendingMenu = nil + sess.State = net.StateGame + if choice <= 0 || choice > len(menuData) { + sess.WriteLine("Never mind.") + g.reprompt(sess) + return + } + g.dispatchMenuEntry(sess, menuData[choice-1]) + return + } + + lower := strings.ToLower(input) + matchIdx := -1 + for i, entry := range menuData { + name := g.menuEntryName(entry) + if name == "" { + continue + } + if strings.ToLower(name) == lower || action.WordPrefixMatch(input, name) { + if matchIdx >= 0 { + sess.WriteLine("That's ambiguous.") + return + } + matchIdx = i + } + } + if matchIdx >= 0 { + sess.PendingMenu = nil + sess.State = net.StateGame + g.dispatchMenuEntry(sess, menuData[matchIdx]) + return + } + + sess.PendingMenu = nil + sess.State = net.StateGame + sess.WriteLine("Never mind.") + g.reprompt(sess) +} + +func (g *Game) menuEntryName(entry map[string]string) string { + if rid, ok := entry["recipe_id"]; ok { + all, _ := g.RecipeStore.LoadAll() + for _, r := range all { + if r.ID == rid { + if def, err := g.ItemStore.Load(r.Output); err == nil { + return def.Name + } + return r.Output + } + } + } + if rid, ok := entry["fletch_recipe_id"]; ok { + all, _ := g.RecipeStore.LoadAll() + for _, r := range all { + if r.ID == rid { + if def, err := g.ItemStore.Load(r.Output); err == nil { + return def.Name + } + return r.Output + } + } + } + if barID, ok := entry["bar_id"]; ok { + if def, _ := g.ItemStore.Load(barID); def != nil { + return def.Name + } + return barID + } + if cid, ok := entry["combine_item"]; ok { + if def, _ := g.ItemStore.Load(cid); def != nil { + return def.Name + } + return cid + } + return "" +} + +func (g *Game) dispatchMenuEntry(sess *net.Session, entry map[string]string) { + if cid, ok := entry["combine_item"]; ok { + g.promptHowMany(sess, "combine_"+cid) + return + } + if barID, ok := entry["bar_id"]; ok { + p := sess.Player + allRecipes, _ := g.RecipeStore.LoadAll() + g.showSmithTable(sess, p, barID, allRecipes) + return + } + if rid, ok := entry["fletch_recipe_id"]; ok { + p := sess.Player + allRecipes, _ := g.RecipeStore.LoadAll() + for _, r := range allRecipes { + if r.ID == rid { + g.startFletchAction(sess, p, &r, 0) + return + } + } + g.reprompt(sess) + return + } + if rid, ok := entry["recipe_id"]; ok { + g.promptHowMany(sess, rid) + return + } + g.reprompt(sess) +} + +func (g *Game) formatRecipeMaterials(r *action.RecipeDef) string { + var parts []string + for _, e := range r.Consume { + itemName := e.Items[0] + if def, err := g.ItemStore.Load(e.Items[0]); err == nil { + itemName = def.Name + } + if e.Quantity > 1 { + parts = append(parts, fmt.Sprintf("%s x%d", itemName, e.Quantity)) + } else { + parts = append(parts, itemName) + } + } + return strings.Join(parts, ", ") +} + +func (g *Game) showProductionTable(sess *net.Session, p *player.Player, recipes []action.RecipeDef, title, skill, lastFlagKey, promptVerb string, background bool) { + sort.Slice(recipes, func(i, j int) bool { + return recipes[i].Level < recipes[j].Level + }) + + mode := g.colorMode(sess) + skillLevel := p.Level(player.SkillName(skill)) + dimSpec := color.Parse("240") + + tbl := &Table{ + Title: title, + Columns: []string{"#", "Product", "Materials", "Level"}, + } + + for i, r := range recipes { + outDef, _ := g.ItemStore.Load(r.Output) + productName := r.Output + if outDef != nil { + productName = outDef.Name + } + + outputQty := r.OutputQty + if outputQty <= 0 { + outputQty = 1 + } + if outDef != nil && outDef.Stackable && outputQty > 1 { + productName = fmt.Sprintf("%s x%d", productName, outputQty) + } + + matStr := g.formatRecipeMaterials(&r) + levelStr := fmt.Sprint(r.Level) + numStr := fmt.Sprintf("%d", i+1) + + canMake := skillLevel >= r.Level && r.HasAllItemsQty(p.CountItem) + if canMake { + productName = g.itemColorize(sess, outDef, productName) + } else { + productName = color.Render(mode, dimSpec, productName) + matStr = color.Render(mode, dimSpec, matStr) + levelStr = color.Render(mode, dimSpec, levelStr) + numStr = color.Render(mode, dimSpec, numStr) + } + + tbl.Rows = append(tbl.Rows, []string{numStr, productName, matStr, levelStr}) + } + + unicode := p.OptionBool("unicode") + sess.WriteLine("") + for _, line := range tbl.Render(unicode) { + sess.WriteLine(line) + } + + lastRecipeID, _ := p.Flags[lastFlagKey].(string) + hint := "" + if lastRecipeID != "" { + for _, r := range recipes { + if r.ID == lastRecipeID { + if outDef, err := g.ItemStore.Load(r.Output); err == nil { + hint = outDef.Name + } + break + } + } + } + + if hint != "" { + sess.Write(fmt.Sprintf("%s what (enter for all %s): ", promptVerb, hint)) + } else { + sess.Write(fmt.Sprintf("%s what: ", promptVerb)) + } + + sess.PendingMenu = recipeMenuData(recipeDefIDs(recipes)) + sess.PendingSkill = skill + sess.PendingLastFlag = lastFlagKey + sess.PendingBackground = background + sess.State = net.StateProductChoice +} + +func (g *Game) handleProductChoice(sess *net.Session, input string) { + p := sess.Player + menuData := sess.PendingMenu + skill := sess.PendingSkill + lastFlagKey := sess.PendingLastFlag + background := sess.PendingBackground + sess.PendingMenu = nil + sess.PendingSkill = "" + sess.PendingLastFlag = "" + sess.PendingBackground = false + sess.State = net.StateGame + + input = strings.TrimSpace(input) + + allRecipes, err := g.RecipeStore.LoadAll() + if err != nil { + g.reprompt(sess) + return + } + + var recipes []action.RecipeDef + for _, entry := range menuData { + rid := entry["recipe_id"] + for _, r := range allRecipes { + if r.ID == rid { + recipes = append(recipes, r) + break + } + } + } + + if len(recipes) == 0 { + g.reprompt(sess) + return + } + + sort.Slice(recipes, func(i, j int) bool { + return recipes[i].Level < recipes[j].Level + }) + + lastRecipeID, _ := p.Flags[lastFlagKey].(string) + + sel, errMsg := g.resolveRecipeByInput(input, recipes, lastRecipeID) + switch errMsg { + case "ambiguous": + sess.WriteLine("That's ambiguous.") + g.reprompt(sess) + return + case "never_mind": + sess.WriteLine("Never mind.") + g.reprompt(sess) + return + } + + skillLevel := p.Level(player.SkillName(skill)) + if skillLevel < sel.Recipe.Level { + sess.WriteLine(fmt.Sprintf("You need level %d %s to make that.", sel.Recipe.Level, skill)) + g.reprompt(sess) + return + } + if !sel.Recipe.HasAllItemsQty(p.CountItem) { + sess.WriteLine("You don't have the materials for that.") + g.reprompt(sess) + return + } + + p.EnsureFlags() + p.Flags[lastFlagKey] = sel.Recipe.ID + g.AccountStore.SaveCharacter(p) + + if background { + g.startFletchAction(sess, p, sel.Recipe, sel.Count) + } else { + g.startProductionFromRecipe(sess, p, sel.Recipe, sel.Count) + } +} + +func (g *Game) hasToolType(p *player.Player, toolType string) bool { + if itemID, ok := p.Equipment[object.SlotMainHand]; ok { + if def, err := g.ItemStore.Load(itemID); err == nil && def.ToolType == toolType { + return true + } + } + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot == nil { + continue + } + if def, err := g.ItemStore.Load(slot.ItemID); err == nil && def.ToolType == toolType { + return true + } + } + return false +} +type recipeEntry struct { + ItemName string + Recipe action.RecipeDef +} + +type productionTypeInfo struct { + ActionType string + DisplayVerb string +} + +var productionTypes = map[string]productionTypeInfo{ + "cooking": {"cook", "cooking"}, + "smelting": {"smelt", "smelting"}, + "smithing": {"smith", "smithing"}, + "crafting": {"craft", "crafting"}, + "combine": {"combine", "combining"}, + "fletching": {"fletch", "fletching"}, + "pharmacy": {"mix", "mixing"}, + "construction": {"construct", "constructing"}, +} + +var productionActionTypes map[string]bool + +func init() { + productionActionTypes = make(map[string]bool) + for _, pt := range productionTypes { + productionActionTypes[pt.ActionType] = true + } +} + +func recipeMenuData(recipeIDs []string) []map[string]string { + out := make([]map[string]string, len(recipeIDs)) + for i, id := range recipeIDs { + out[i] = map[string]string{"recipe_id": id} + } + return out +} + +func recipeDefIDs(defs []action.RecipeDef) []string { + ids := make([]string, len(defs)) + for i, d := range defs { + ids[i] = d.ID + } + return ids +} + +func entryMenuData(entries []recipeEntry) []map[string]string { + out := make([]map[string]string, len(entries)) + for i, e := range entries { + out[i] = map[string]string{"recipe_id": e.Recipe.ID} + } + return out +} + +func (g *Game) promptHowMany(sess *net.Session, recipeID string) { + sess.PendingRecipeID = recipeID + sess.State = net.StateHowMany + sess.Write("How many (return for all)?: ") +} + +type recipeSelection struct { + Recipe *action.RecipeDef + Count int +} + +func (g *Game) resolveRecipeByInput(input string, recipes []action.RecipeDef, lastRecipeID string) (sel recipeSelection, errMsg string) { + if input == "" { + if lastRecipeID == "" { + return sel, "never_mind" + } + for i := range recipes { + if recipes[i].ID == lastRecipeID { + sel.Recipe = &recipes[i] + return sel, "" + } + } + return sel, "never_mind" + } + + qty, productName := parseQty(input) + productName = strings.TrimSpace(productName) + + if idx, err := strconv.Atoi(productName); err == nil && idx > 0 && idx <= len(recipes) { + sel.Recipe = &recipes[idx-1] + sel.Count = qty + return sel, "" + } + + matchCount := 0 + for i := range recipes { + outDef, _ := g.ItemStore.Load(recipes[i].Output) + name := recipes[i].Output + if outDef != nil { + name = outDef.Name + } + if action.WordPrefixMatch(productName, name) { + if matchCount == 0 { + sel.Recipe = &recipes[i] + sel.Count = qty + } + matchCount++ + } + } + if matchCount > 1 { + return sel, "ambiguous" + } + if sel.Recipe == nil { + return sel, "never_mind" + } + return sel, "" +} + +func (g *Game) showMenuTable(sess *net.Session, title string, names []string) { + p := sess.Player + tbl := &Table{Title: title} + for i, name := range names { + tbl.Rows = append(tbl.Rows, []string{fmt.Sprintf("%d", i+1), name}) + } + unicode := p.OptionBool("unicode") + sess.WriteLine("") + for _, line := range tbl.Render(unicode) { + sess.WriteLine(line) + } +} + +func (g *Game) handleHowMany(sess *net.Session, input string) { + p := sess.Player + recipeID := sess.PendingRecipeID + sess.PendingRecipeID = "" + sess.State = net.StateGame + + input = strings.TrimSpace(input) + + var count int + if input == "" { + count = 0 + } else if n, err := strconv.Atoi(input); err == nil && n > 0 { + count = n + } else { + sess.WriteLine("Never mind.") + g.reprompt(sess) + return + } + + var recipe *action.RecipeDef + if strings.HasPrefix(recipeID, "combine_") { + itemID := strings.TrimPrefix(recipeID, "combine_") + itemDef, err := g.ItemStore.Load(itemID) + if err != nil || len(itemDef.MadeFrom) == 0 { + g.reprompt(sess) + return + } + recipe = g.buildCombineRecipe(itemDef) + } else { + all, err := g.RecipeStore.LoadAll() + if err != nil { + g.reprompt(sess) + return + } + for _, r := range all { + if r.ID == recipeID { + recipe = &r + break + } + } + } + + if recipe == nil { + g.reprompt(sess) + return + } + + g.startProductionFromRecipe(sess, p, recipe, count) +} + +func (g *Game) buildCombineRecipe(def *object.ItemDef) *action.RecipeDef { + var consume []action.ConsumeEntry + for _, mf := range def.MadeFrom { + consume = append(consume, action.ConsumeEntry{ + Items: mf.Items, + Quantity: mf.Quantity, + Byproducts: mf.Byproducts, + }) + } + wait := def.Ticks + if wait <= 0 { + wait = 2 + } + return &action.RecipeDef{ + ID: "combine_" + def.ID, + Type: "combine", + Wait: wait, + Consume: consume, + Output: def.ID, + Message: fmt.Sprintf("You create %s.", def.Name), + } +} diff --git a/internal/game/core_skill.go b/internal/game/core_skill.go new file mode 100644 index 0000000..c02cdf3 --- /dev/null +++ b/internal/game/core_skill.go @@ -0,0 +1,15 @@ +package game + +import ( + "fmt" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) awardSkillXP(sess *net.Session, p *player.Player, skill player.SkillName, xp int) { + newLevel := p.AddSkillXP(skill, xp) + if newLevel > 0 { + sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, skill))) + } +} diff --git a/internal/game/core_startup.go b/internal/game/core_startup.go new file mode 100644 index 0000000..ab52762 --- /dev/null +++ b/internal/game/core_startup.go @@ -0,0 +1,770 @@ +package game + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "thehouseoficarus/internal/action" +) + +type StartupIssue struct { + Level string + Type string + Message string +} + +func (g *Game) ValidateStartup() []StartupIssue { + var issues []StartupIssue + + issues = append(issues, validateDuplicateIDs(g.DataDir, "items", "item")...) + issues = append(issues, validateDuplicateIDs(g.DataDir, "rooms", "room")...) + issues = append(issues, validateDuplicateIDs(g.DataDir, "mobs", "mob")...) + issues = append(issues, validateDuplicateIDs(g.DataDir, "objects", "object")...) + issues = append(issues, validateDuplicateIDs(g.DataDir, "drops", "drop table")...) + issues = append(issues, validateDuplicateIDs(g.DataDir, "recipes", "recipe")...) + issues = append(issues, validateDuplicateIDs(g.DataDir, "courses", "course")...) + issues = append(issues, validateDuplicateIDs(g.DataDir, "modules", "module")...) + issues = append(issues, validateDuplicateIDs(g.DataDir, "help", "help topic")...) + + issues = append(issues, validateRoomReferences(g)...) + issues = append(issues, validateMobReferences(g)...) + issues = append(issues, validateObjectReferences(g)...) + issues = append(issues, validateItemReferences(g)...) + issues = append(issues, validateRecipeReferences(g)...) + issues = append(issues, validateDropTableReferences(g)...) + issues = append(issues, validateCourseReferences(g)...) + issues = append(issues, validateRoomWiring(g)...) + + return issues +} + +func validateDuplicateIDs(dataDir, subDir, label string) []StartupIssue { + dir := filepath.Join(dataDir, subDir) + dups := action.CheckDuplicateIDs(dir) + var issues []StartupIssue + for _, d := range dups { + short := make([]string, len(d.Paths)) + for i, p := range d.Paths { + short[i] = strings.TrimPrefix(p, dir+"/") + } + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "duplicate", + Message: fmt.Sprintf("Duplicate %s ID %q found in: %s", + label, d.ID, strings.Join(short, ", ")), + }) + } + return issues +} + +func validateRoomReferences(g *Game) []StartupIssue { + var issues []StartupIssue + roomIndex := g.World.RoomIndex() + itemIDs := g.ItemStore.IDSet() + mobIDs := g.MobStore.AllDefIDs() + objIDs := g.ObjectStore.IDSet() + + for id := range roomIndex { + room, err := g.World.LoadRoom(id) + if err != nil { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Room %d: failed to load: %v", id, err), + }) + continue + } + if room.Name == "" { + issues = append(issues, StartupIssue{ + Level: "WARN", + Type: "reference", + Message: fmt.Sprintf("Room %d: has no name", id), + }) + } + + for dir, exit := range room.Exits { + if exit.Room <= 0 { + continue + } + if _, ok := roomIndex[exit.Room]; !ok { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Room %d: exit %q targets nonexistent room %d", + id, dir, exit.Room), + }) + } + } + + for _, rm := range room.Mobs { + if rm.ID == "" { + continue + } + if _, ok := mobIDs[rm.ID]; !ok { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Room %d: references nonexistent mob %q", + id, rm.ID), + }) + } + for _, wr := range rm.WanderRooms { + if _, ok := roomIndex[wr]; !ok { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Room %d: mob %q wander_rooms references nonexistent room %d", + id, rm.ID, wr), + }) + } + } + } + + for _, s := range room.ItemSpawns { + if s.ID != "" && !itemIDs[s.ID] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Room %d: spawns nonexistent item %q", + id, s.ID), + }) + } + } + + for _, robj := range room.Objects { + if robj.ID != "" && !objIDs[robj.ID] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Room %d: references nonexistent object %q", + id, robj.ID), + }) + } + for _, wr := range robj.WanderRooms { + if _, ok := roomIndex[wr]; !ok { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Room %d: object %q wander_rooms references nonexistent room %d", + id, robj.ID, wr), + }) + } + } + } + } + + return issues +} + +func validateMobReferences(g *Game) []StartupIssue { + var issues []StartupIssue + itemIDs := g.ItemStore.IDSet() + dropIDs := dropTableIDSet(g.DataDir) + mobIDs := g.MobStore.AllDefIDs() + + for id := range mobIDs { + def, err := g.MobStore.LoadDef(id) + if err != nil { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Mob %q: failed to load: %v", id, err), + }) + continue + } + if def.Name == "" { + issues = append(issues, StartupIssue{ + Level: "WARN", + Type: "reference", + Message: fmt.Sprintf("Mob %q: has no name", id), + }) + } + + if def.Drops.Remains != "" && !itemIDs[def.Drops.Remains] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Mob %q: drops remains (item %q) does not exist", + id, def.Drops.Remains), + }) + } + + for _, d := range def.Drops.Loot { + if d.ItemID != "" && !itemIDs[d.ItemID] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Mob %q: loot item %q does not exist", + id, d.ItemID), + }) + } + if d.Table != "" && !dropIDs[d.Table] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Mob %q: loot table %q does not exist", + id, d.Table), + }) + } + } + + if def.StealTable != "" && !dropIDs[def.StealTable] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Mob %q: steal_table %q does not exist", + id, def.StealTable), + }) + } + + if def.FinishingBlow != "" && !itemIDs[def.FinishingBlow] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Mob %q: finishing_blow item %q does not exist", + id, def.FinishingBlow), + }) + } + } + + return issues +} + +func validateObjectReferences(g *Game) []StartupIssue { + var issues []StartupIssue + objIDs := g.ObjectStore.IDSet() + itemIDs := g.ItemStore.IDSet() + mobIDs := g.MobStore.AllDefIDs() + dropIDs := dropTableIDSet(g.DataDir) + roomIndex := g.World.RoomIndex() + + for id := range objIDs { + obj, err := g.ObjectStore.Load(id) + if err != nil { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Object %q: failed to load: %v", id, err), + }) + continue + } + if obj.Name == "" { + issues = append(issues, StartupIssue{ + Level: "WARN", + Type: "reference", + Message: fmt.Sprintf("Object %q: has no name", id), + }) + } + + if obj.RemovalItem != "" && !itemIDs[obj.RemovalItem] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Object %q: removal_item %q does not exist", + id, obj.RemovalItem), + }) + } + + if obj.StealTable != "" && !dropIDs[obj.StealTable] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Object %q: steal_table %q does not exist", + id, obj.StealTable), + }) + } + + if obj.GuardMob != "" && !mobIDs[obj.GuardMob] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Object %q: guard_mob %q does not exist", + id, obj.GuardMob), + }) + } + + for _, ui := range obj.UseInteractions { + if ui.Item != "" && !itemIDs[ui.Item] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Object %q: use_interaction item %q does not exist", + id, ui.Item), + }) + } + if ui.Action != nil { + issues = append(issues, validateNodeAction(fmt.Sprintf("Object %q: use_interaction", id), ui.Action, itemIDs, roomIndex)...) + } + } + + if obj.Gather != nil { + issues = append(issues, validateGatherConfig(fmt.Sprintf("Object %q: gather", id), obj.Gather, itemIDs, dropIDs)...) + } + + if obj.Talk != nil { + issues = append(issues, validateTalkConfig(fmt.Sprintf("Object %q: talk", id), obj.Talk, itemIDs, roomIndex)...) + } + + if obj.Use != nil { + issues = append(issues, validateUseConfig(fmt.Sprintf("Object %q: use", id), obj.Use, itemIDs)...) + } + } + + return issues +} + +func validateItemReferences(g *Game) []StartupIssue { + var issues []StartupIssue + itemIDs := g.ItemStore.IDSet() + dropIDs := dropTableIDSet(g.DataDir) + + for id := range itemIDs { + def, err := g.ItemStore.Load(id) + if err != nil { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Item %q: failed to load: %v", id, err), + }) + continue + } + if def.Name == "" { + issues = append(issues, StartupIssue{ + Level: "WARN", + Type: "reference", + Message: fmt.Sprintf("Item %q: has no name", id), + }) + } + + if def.SearchTable != "" && !dropIDs[def.SearchTable] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Item %q: search_table %q does not exist", + id, def.SearchTable), + }) + } + + if def.SearchMiscTable != "" && !dropIDs[def.SearchMiscTable] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Item %q: search_misc_table %q does not exist", + id, def.SearchMiscTable), + }) + } + + for _, mf := range def.MadeFrom { + for _, item := range mf.Items { + if item != "" && !itemIDs[item] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Item %q: made_from item %q does not exist", + id, item), + }) + } + } + for _, bp := range mf.Byproducts { + if bp != "" && !itemIDs[bp] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Item %q: made_from byproduct %q does not exist", + id, bp), + }) + } + } + } + + if def.FarmProduct != "" && !itemIDs[def.FarmProduct] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Item %q (seed): farm_product %q does not exist", + id, def.FarmProduct), + }) + } + } + + return issues +} + +func validateRecipeReferences(g *Game) []StartupIssue { + var issues []StartupIssue + itemIDs := g.ItemStore.IDSet() + + allRecipes, err := g.RecipeStore.LoadAll() + if err != nil { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Failed to load recipes: %v", err), + }) + return issues + } + + recipeIDs := make(map[string]bool) + for _, r := range allRecipes { + if r.ID != "" { + if recipeIDs[r.ID] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "duplicate", + Message: fmt.Sprintf("Recipe %q: duplicate ID field (two recipe files with same ID value)", + r.ID), + }) + } + recipeIDs[r.ID] = true + } + + for _, e := range r.Consume { + for _, item := range e.Items { + if item != "" && !itemIDs[item] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Recipe %q (%s): consumes nonexistent item %q", + r.ID, r.Type, item), + }) + } + } + } + + if r.Output != "" && !itemIDs[r.Output] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Recipe %q (%s): output item %q does not exist", + r.ID, r.Type, r.Output), + }) + } + + if r.Fail != "" && !itemIDs[r.Fail] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Recipe %q (%s): fail product %q does not exist", + r.ID, r.Type, r.Fail), + }) + } + } + + return issues +} + +func validateDropTableReferences(g *Game) []StartupIssue { + var issues []StartupIssue + dropIDs := dropTableIDSet(g.DataDir) + itemIDs := g.ItemStore.IDSet() + + for id := range dropIDs { + dt, err := action.LoadDropTable(g.DataDir, id) + if err != nil { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Drop table %q: failed to load: %v", id, err), + }) + continue + } + for _, d := range dt.Drops { + if d.ItemID != "" && !itemIDs[d.ItemID] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Drop table %q: drops nonexistent item %q", + id, d.ItemID), + }) + } + if d.Table != "" && !dropIDs[d.Table] && d.Table != id { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Drop table %q: references nonexistent sub-table %q", + id, d.Table), + }) + } + } + } + + return issues +} + +func validateCourseReferences(g *Game) []StartupIssue { + var issues []StartupIssue + roomIndex := g.World.RoomIndex() + + courses := g.CourseStore.AllCourses() + for id, cfg := range courses { + if cfg.StartRoom > 0 { + if _, ok := roomIndex[cfg.StartRoom]; !ok { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Course %q: start_room %d does not exist", + id, cfg.StartRoom), + }) + } + } + for _, obs := range cfg.Obstacles { + if _, ok := roomIndex[obs.RoomID]; !ok { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Course %q: obstacle room %d does not exist", + id, obs.RoomID), + }) + } + } + } + + return issues +} + +func validateRoomWiring(g *Game) []StartupIssue { + var issues []StartupIssue + roomIndex := g.World.RoomIndex() + cfg := g.ValidationConfig + + sourceSet := make(map[int]bool) + for _, s := range cfg.CheckSources { + sourceSet[s] = true + } + + ignoreTo := make(map[int]bool) + for _, r := range cfg.IgnoreUnreachable { + ignoreTo[r] = true + } + + hasExitTo := make(map[int]bool) + for id := range roomIndex { + room, err := g.World.LoadRoom(id) + if err != nil { + continue + } + for _, exit := range room.Exits { + if exit.Room > 0 { + hasExitTo[exit.Room] = true + } + } + } + + var orphans []int + for id := range roomIndex { + if ignoreTo[id] { + continue + } + if sourceSet[id] { + continue + } + if !hasExitTo[id] { + orphans = append(orphans, id) + } + } + if len(orphans) > 0 { + sort.Ints(orphans) + var strs []string + for _, id := range orphans { + strs = append(strs, fmt.Sprintf("%d", id)) + } + issues = append(issues, StartupIssue{ + Level: "WARN", + Type: "integrity", + Message: fmt.Sprintf("Orphan rooms (no exit leads to them): %s", + strings.Join(strs, ", ")), + }) + } + + return issues +} + +func validateGatherConfig(prefix string, cfg *action.GatherConfig, itemIDs map[string]bool, dropIDs map[string]bool) []StartupIssue { + var issues []StartupIssue + + if cfg.Bait != "" && !itemIDs[cfg.Bait] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("%s: bait item %q does not exist", + prefix, cfg.Bait), + }) + } + + for _, d := range cfg.Drops { + if d.ItemID != "" && !itemIDs[d.ItemID] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("%s: drop item %q does not exist", + prefix, d.ItemID), + }) + } + if d.Table != "" && !dropIDs[d.Table] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("%s: drop table %q does not exist", + prefix, d.Table), + }) + } + } + + return issues +} + +func validateTalkConfig(prefix string, cfg *action.TalkConfig, itemIDs map[string]bool, roomIndex map[int]bool) []StartupIssue { + var issues []StartupIssue + if cfg == nil { + return issues + } + for nodeID, node := range cfg.Nodes { + nodePrefix := fmt.Sprintf("%s: node %q", prefix, nodeID) + if node.Action != nil { + issues = append(issues, validateNodeAction(nodePrefix, node.Action, itemIDs, roomIndex)...) + } + } + return issues +} + +func validateUseConfig(prefix string, cfg *action.UseConfig, itemIDs map[string]bool) []StartupIssue { + var issues []StartupIssue + if cfg == nil { + return issues + } + + for itemID := range cfg.Consume { + if itemID != "" && !itemIDs[itemID] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("%s: consumes nonexistent item %q", + prefix, itemID), + }) + } + } + + if cfg.Reward.ItemID != "" && !itemIDs[cfg.Reward.ItemID] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("%s: reward item %q does not exist", + prefix, cfg.Reward.ItemID), + }) + } + + return issues +} + +func validateNodeAction(prefix string, na *action.NodeAction, itemIDs map[string]bool, roomIndex map[int]bool) []StartupIssue { + var issues []StartupIssue + if na == nil { + return issues + } + + if na.GiveItem != "" && !itemIDs[na.GiveItem] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("%s: give_item %q does not exist", + prefix, na.GiveItem), + }) + } + + if na.TakeItem != "" && !itemIDs[na.TakeItem] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("%s: take_item %q does not exist", + prefix, na.TakeItem), + }) + } + + if na.Teleport > 0 { + if _, ok := roomIndex[na.Teleport]; !ok { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("%s: teleport to nonexistent room %d", + prefix, na.Teleport), + }) + } + } + + if na.Shop != nil { + for _, si := range na.Shop.Items { + if si.ItemID != "" && !itemIDs[si.ItemID] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("%s: shop item %q does not exist", + prefix, si.ItemID), + }) + } + } + } + + return issues +} + +func dropTableIDSet(dataDir string) map[string]bool { + dir := filepath.Join(dataDir, "drops") + ids := make(map[string]bool) + action.WalkYAMLDir(dir, func(path, id string, data []byte) error { + ids[id] = true + return nil + }) + return ids +} + +func (cs *CourseStore) AllCourses() map[string]*CourseConfig { + cs.mu.Lock() + defer cs.mu.Unlock() + if !cs.loaded { + cs.loadAllLocked() + } + return cs.courses +} + +func (g *Game) ValidateAndLog() { + LogStartupIssues(g.ValidateStartup()) +} + +func LogStartupIssues(issues []StartupIssue) { + if len(issues) == 0 { + fmt.Fprintf(os.Stderr, "Startup validation: OK\n") + return + } + + errors := 0 + warns := 0 + for _, issue := range issues { + if issue.Level == "ERROR" { + errors++ + } else { + warns++ + } + } + + fmt.Fprintf(os.Stderr, "\n=== Startup Validation: %d error(s), %d warning(s) ===\n\n", errors, warns) + + sort.Slice(issues, func(i, j int) bool { + if issues[i].Level != issues[j].Level { + return issues[i].Level == "ERROR" + } + return issues[i].Message < issues[j].Message + }) + + for _, issue := range issues { + tag := fmt.Sprintf("[%s]", issue.Level) + fmt.Fprintf(os.Stderr, " %-7s %s\n", tag, issue.Message) + } + fmt.Fprintln(os.Stderr) + + if errors > 0 { + fmt.Fprintf(os.Stderr, "*** %d startup errors detected. The server may behave unexpectedly. ***\n", errors) + } +} \ No newline at end of file diff --git a/internal/game/core_stations.go b/internal/game/core_stations.go new file mode 100644 index 0000000..d30a35c --- /dev/null +++ b/internal/game/core_stations.go @@ -0,0 +1,28 @@ +package game + +func (g *Game) findStation(roomID int, stationIDs []string) (defID string, displayName string) { + for _, obj := range g.World.AllObjInstances(roomID) { + if obj.Depleted { + continue + } + for _, sid := range stationIDs { + if obj.DefID == sid { + name := sid + if def, err := g.ObjectStore.Load(sid); err == nil { + name = def.Name + } + return sid, name + } + } + } + return "", "" +} + +func stationMatch(defID string, recipeStations []string) bool { + for _, rs := range recipeStations { + if rs == defID { + return true + } + } + return false +} diff --git a/internal/game/core_types.go b/internal/game/core_types.go new file mode 100644 index 0000000..d41b18a --- /dev/null +++ b/internal/game/core_types.go @@ -0,0 +1,29 @@ +package game + +import "thehouseoficarus/internal/object" + +var EquipSlots = []object.EquipSlot{ + object.SlotHead, object.SlotNeck, object.SlotTorso, object.SlotLegs, + object.SlotHands, object.SlotFeet, object.SlotBack, object.SlotAmmo, + object.SlotMainHand, object.SlotOffHand, object.SlotRing, +} + +type itemMatch struct { + ID string + Name string + Slot int +} + +type xpGain struct { + Skill string + XP int +} + +type deathDrop struct { + itemID string + quantity int + totalVal int + isEquip bool + equipSlot object.EquipSlot + invSlot int +} diff --git a/internal/game/core_utils.go b/internal/game/core_utils.go new file mode 100644 index 0000000..409422e --- /dev/null +++ b/internal/game/core_utils.go @@ -0,0 +1,116 @@ +package game + +import ( + "fmt" + "math/rand" + "strconv" + "strings" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func plural(n int) string { + if n == 1 { + return "" + } + return "s" +} + +func parseQty(input string) (int, string) { + parts := strings.Fields(input) + if len(parts) > 1 { + if n, err := strconv.Atoi(parts[0]); err == nil && n > 0 { + return n, strings.Join(parts[1:], " ") + } + } + return 0, input +} + +func parseChoiceIndex(s string) (int, error) { + var idx int + _, err := fmt.Sscanf(s, "%d", &idx) + return idx, err +} + +func mobDisplayName(m *world.MobInstance, definite bool) string { + if m.Unique { + return m.Name + } + if definite { + return "the " + m.Name + } + return "a " + m.Name +} + +func mobCombatLevel(m *world.MobInstance) int { + base := float64(m.Defense+m.MaxHP) / 4.0 + melee := float64(m.Attack+m.Strength) / 4.0 + ranged := float64(m.Ranged) * 3.0 / 8.0 + science := float64(m.Science) * 3.0 / 8.0 + best := melee + if ranged > best { + best = ranged + } + if science > best { + best = science + } + return int(base + best + 0.5) +} + +func uniqueItemNames(matches []itemMatch) []string { + seen := make(map[string]bool) + var out []string + for _, m := range matches { + if !seen[m.Name] { + seen[m.Name] = true + out = append(out, m.Name) + } + } + return out +} + +func (g *Game) showWhichOne(sess *net.Session, matches []itemMatch) { + sess.WriteLine("Which one?") + seen := make(map[string]bool) + for _, m := range matches { + if seen[m.Name] { + continue + } + seen[m.Name] = true + def, _ := g.ItemStore.Load(m.ID) + coloredName := g.itemColorize(sess, def, m.Name) + sess.WriteLine(fmt.Sprintf(" - %s", coloredName)) + } +} + +func randInt(max int) int { + if max <= 0 { + return 0 + } + return rand.Intn(max) +} + +func formatPickupList(sess *net.Session, picked []string) { + for i, name := range picked { + if i == len(picked)-1 { + sess.WriteLine(name) + } else if i == len(picked)-2 { + sess.Write(name + " and ") + } else { + sess.Write(name + ", ") + } + } +} + +func (g *Game) newInventorySlot(itemID string, qty int) *player.InventorySlot { + slot := &player.InventorySlot{ItemID: itemID, Quantity: qty} + if def, err := g.ItemStore.Load(itemID); err == nil && def.MaxQuality > 0 { + slot.Quality = def.Quality + slot.MaxQuality = def.MaxQuality + } + return slot +} + + diff --git a/internal/game/course.go b/internal/game/course.go deleted file mode 100644 index 335c97f..0000000 --- a/internal/game/course.go +++ /dev/null @@ -1,141 +0,0 @@ -package game - -import ( - "os" - "path/filepath" - "sync" - - "gopkg.in/yaml.v3" -) - -type ObstacleDef struct { - RoomID int `yaml:"room_id"` - Verb string `yaml:"verb"` - TicksPerPhase float64 `yaml:"ticks_per_phase"` - XP int `yaml:"xp"` - FailDamage [2]int `yaml:"fail_damage"` - Messages []string `yaml:"messages"` -} - -type CourseConfig struct { - ID string `yaml:"id"` - Name string `yaml:"name"` - RequiredLevel int `yaml:"required_level"` - StartRoom int `yaml:"start_room"` - CompletionXP int `yaml:"completion_xp"` - Obstacles []ObstacleDef `yaml:"obstacles"` -} - -type ObstacleInfo struct { - CourseID string - CourseName string - ObstacleIndex int - TotalObstacles int - Verb string - Messages []string - TicksPerPhase float64 - ObstacleXP int - CompletionXP int - FailDamage [2]int - NextRoom int - StartRoom int - RequiredLevel int -} - -var obstacleVerbs = map[string]bool{} - -var verbGerund = map[string]string{ - "scramble": "scrambling", - "jump": "jumping", - "swing": "swinging", - "balance": "balancing", - "climb": "climbing", - "crawl": "crawling", - "vault": "vaulting", - "leap": "leaping", - "slide": "sliding", -} - -type CourseStore struct { - dataDir string - mu sync.Mutex - courses map[string]*CourseConfig - roomToObstacle map[int]*ObstacleInfo - loaded bool -} - -func NewCourseStore(dataDir string) *CourseStore { - return &CourseStore{ - dataDir: dataDir, - courses: make(map[string]*CourseConfig), - } -} - -func (cs *CourseStore) LoadAll() { - cs.mu.Lock() - defer cs.mu.Unlock() - cs.loadAllLocked() -} - -func (cs *CourseStore) GetObstacle(roomID int) *ObstacleInfo { - cs.mu.Lock() - defer cs.mu.Unlock() - if !cs.loaded { - cs.loadAllLocked() - } - return cs.roomToObstacle[roomID] -} - -func (cs *CourseStore) loadAllLocked() { - cs.loaded = true - cs.roomToObstacle = make(map[int]*ObstacleInfo) - localVerbs := make(map[string]bool) - - pattern := filepath.Join(cs.dataDir, "courses", "*.yaml") - files, _ := filepath.Glob(pattern) - - for _, f := range files { - data, err := os.ReadFile(f) - if err != nil { - continue - } - var cfg CourseConfig - if err := yaml.Unmarshal(data, &cfg); err != nil { - continue - } - cs.courses[cfg.ID] = &cfg - - totalObstacles := len(cfg.Obstacles) - for i, obs := range cfg.Obstacles { - nextRoom := 0 - if i < totalObstacles-1 { - nextRoom = cfg.Obstacles[i+1].RoomID - } - - completionXP := 0 - if i == totalObstacles-1 { - completionXP = cfg.CompletionXP - } - - info := &ObstacleInfo{ - CourseID: cfg.ID, - CourseName: cfg.Name, - ObstacleIndex: i, - TotalObstacles: totalObstacles, - Verb: obs.Verb, - Messages: obs.Messages, - TicksPerPhase: obs.TicksPerPhase, - ObstacleXP: obs.XP, - CompletionXP: completionXP, - FailDamage: obs.FailDamage, - NextRoom: nextRoom, - StartRoom: cfg.StartRoom, - RequiredLevel: cfg.RequiredLevel, - } - cs.roomToObstacle[obs.RoomID] = info - localVerbs[obs.Verb] = true - } - } - - obstacleVerbs = localVerbs -} diff --git a/internal/game/death.go b/internal/game/death.go deleted file mode 100644 index d2c1b73..0000000 --- a/internal/game/death.go +++ /dev/null @@ -1,66 +0,0 @@ -package game - -import ( - "sort" - - "thehouseoficarus/internal/player" -) - -func (g *Game) dropItemsOnDeath(p *player.Player) { - roomID := p.RoomID - - if p.Credits > 0 { - g.World.AddGroundItem(roomID, "credits", p.Credits) - p.Credits = 0 - } - - var items []deathDrop - - for slot, inv := range p.Inventory { - if inv == nil || inv.Quantity <= 0 { - continue - } - val := 0 - if def, err := g.ItemStore.Load(inv.ItemID); err == nil { - val = def.Value * inv.Quantity - } - items = append(items, deathDrop{ - itemID: inv.ItemID, - quantity: inv.Quantity, - totalVal: val, - invSlot: slot, - }) - } - - for eqSlot, itemID := range p.Equipment { - val := 0 - if def, err := g.ItemStore.Load(itemID); err == nil { - val = def.Value - } - items = append(items, deathDrop{ - itemID: itemID, - quantity: 1, - totalVal: val, - isEquip: true, - equipSlot: eqSlot, - }) - } - - if len(items) <= 3 { - return - } - - sort.Slice(items, func(i, j int) bool { - return items[i].totalVal > items[j].totalVal - }) - - for i := 3; i < len(items); i++ { - it := items[i] - if it.isEquip { - delete(p.Equipment, it.equipSlot) - } else { - p.SetInvSlot(it.invSlot, nil) - } - g.World.AddGroundItem(roomID, it.itemID, it.quantity) - } -} diff --git a/internal/game/equip_stats.go b/internal/game/equip_stats.go deleted file mode 100644 index 02b8348..0000000 --- a/internal/game/equip_stats.go +++ /dev/null @@ -1,41 +0,0 @@ -package game - -import ( - "thehouseoficarus/internal/object" - "thehouseoficarus/internal/player" -) - -func (g *Game) playerEquipBonuses(p *player.Player) object.ItemStats { - var totals object.ItemStats - for _, itemID := range p.Equipment { - def, err := g.ItemStore.Load(itemID) - if err != nil { - continue - } - totals.StabAttack += def.Stats.StabAttack - totals.SlashAttack += def.Stats.SlashAttack - totals.CrushAttack += def.Stats.CrushAttack - totals.ScienceAttack += def.Stats.ScienceAttack - totals.RangedAttack += def.Stats.RangedAttack - totals.StabDefense += def.Stats.StabDefense - totals.SlashDefense += def.Stats.SlashDefense - totals.CrushDefense += def.Stats.CrushDefense - totals.ScienceDefense += def.Stats.ScienceDefense - totals.RangedDefense += def.Stats.RangedDefense - totals.StrengthBonus += def.Stats.StrengthBonus - totals.RangedStrength += def.Stats.RangedStrength - totals.ScienceDamage += def.Stats.ScienceDamage - totals.TechnologyBonus += def.Stats.TechnologyBonus - } - return totals -} - -func (g *Game) playerWeaponSpeed(p *player.Player) float64 { - if itemID, ok := p.Equipment[object.SlotMainHand]; ok { - def, err := g.ItemStore.Load(itemID) - if err == nil && def.Speed > 0 { - return def.Speed - } - } - return 5.0 -} diff --git a/internal/game/game.go b/internal/game/game.go index b8eadce..5d6e21e 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -35,18 +35,19 @@ type QueuedCommand struct { } type Game struct { - World *world.World - ObjectStore *object.ObjectStore - ItemStore *object.ItemStore - AccountStore *player.AccountStore - MobStore *world.MobStore - RecipeStore *action.RecipeStore - CourseStore *CourseStore - Hub *net.Hub - Ticks *engine.Engine - WorldFlags map[string]any - ColorConfig *config.ColorsConfig - DataDir string + World *world.World + ObjectStore *object.ObjectStore + ItemStore *object.ItemStore + AccountStore *player.AccountStore + MobStore *world.MobStore + RecipeStore *action.RecipeStore + CourseStore *CourseStore + Hub *net.Hub + Ticks *engine.Engine + WorldFlags map[string]any + ColorConfig *config.ColorsConfig + DataDir string + ValidationConfig config.StartupValidationConfig restTimers map[string]uint64 charsMu sync.Mutex loggedInChars map[string]*net.Session @@ -59,7 +60,7 @@ type Game struct { hackingStates map[string]*hacking.Session } -func New(dataDir string, colorConfig *config.ColorsConfig) *Game { +func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.StartupValidationConfig) *Game { g := &Game{ World: world.New(dataDir), ObjectStore: object.NewObjectStore(dataDir), @@ -72,6 +73,7 @@ func New(dataDir string, colorConfig *config.ColorsConfig) *Game { WorldFlags: make(map[string]any), ColorConfig: colorConfig, DataDir: dataDir, + ValidationConfig: valConfig, restTimers: make(map[string]uint64), loggedInChars: make(map[string]*net.Session), freeQueue: make(map[string][]QueuedCommand), @@ -83,6 +85,7 @@ func New(dataDir string, colorConfig *config.ColorsConfig) *Game { } g.CourseStore.LoadAll() g.LoadMods() + g.ValidateAndLog() return g } diff --git a/internal/game/hacking.go b/internal/game/hacking.go deleted file mode 100644 index 3457728..0000000 --- a/internal/game/hacking.go +++ /dev/null @@ -1,93 +0,0 @@ -package game - -import ( - "fmt" - "strings" - - "thehouseoficarus/internal/game/hacking" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" -) - -func (g *Game) handleHackingInput(sess *net.Session, input string) { - p := sess.Player - if p == nil { - sess.State = net.StateGame - return - } - - hs, ok := g.hackingStates[p.Name] - if !ok { - sess.State = net.StateGame - g.writePrompt(sess) - return - } - - input = strings.TrimSpace(input) - lower := strings.ToLower(input) - - if lower == "jack out" || lower == "quit" || lower == "disconnect" { - g.endHacking(sess, p, false, false) - return - } - - hs.Started = true - output, done, won := hs.Minigame.HandleInput(input) - - sess.WriteLine(output) - - if done { - g.endHacking(sess, p, true, won) - return - } - - sess.Write("\nhack> ") -} - -func (g *Game) endHacking(sess *net.Session, p *player.Player, completed bool, won bool) { - hs, ok := g.hackingStates[p.Name] - if !ok { - sess.State = net.StateGame - g.writePrompt(sess) - return - } - - tDef := hacking.Terminals[hs.TerminalID] - var xp int - - if completed && won { - xp = hacking.CalcXP(tDef.BaseXPWin, hs.Level, hs.ReqLevel) - sess.WriteLine("\nConnection terminated. Contract complete.") - } else if completed { - xp = hacking.CalcXP(tDef.BaseXPLose, hs.Level, hs.ReqLevel) - sess.WriteLine("\nConnection lost.") - } else if hs.Started { - xp = hacking.CalcXP(tDef.BaseXPLose, hs.Level, hs.ReqLevel) - sess.WriteLine("\nYou jack out of the terminal.") - } else { - sess.WriteLine("\nYou jack out of the terminal.") - } - - if won { - if b, ok := hs.Minigame.(hacking.XPBonuser); ok { - bonus := b.BonusXP() - if bonus > 0 { - xp += hacking.CalcXP(bonus, hs.Level, hs.ReqLevel) - } - } - } - - if xp > 0 { - g.awardSkillXP(sess, p, player.Hacking, xp) - if p.OptionBool("xp_drops") { - sess.WriteLine(g.colorize(sess, "xp", - fmt.Sprintf("(+%dxp %s)", xp, player.SkillAbbr[player.Hacking]))) - } - g.AccountStore.SaveCharacter(p) - } - - delete(g.hackingStates, p.Name) - p.ActionState = nil - sess.State = net.StateGame - g.writePrompt(sess) -} diff --git a/internal/game/help.go b/internal/game/help.go deleted file mode 100644 index 03e2b8c..0000000 --- a/internal/game/help.go +++ /dev/null @@ -1,155 +0,0 @@ -package game - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "thehouseoficarus/internal/net" - "gopkg.in/yaml.v3" -) - -type HelpDef struct { - Name string `yaml:"name"` - Category string `yaml:"category"` - Content string `yaml:"description"` -} - -type cmdEntry struct { - Name string - Type string - Desc string -} - -var commandList = []cmdEntry{ - {"alias", "Instant", "Create command shortcuts"}, - {"attack / kill", "Active", "Attack a mob"}, - {"autotrigger / auto", "Instant", "Set a module to auto-trigger"}, - {"bank", "Active", "Open bank interface"}, - {"burn", "Active", "Start a fire"}, - {"chop / cut", "Active", "Chop trees (Woodcutting)"}, - {"clean", "Free", "Clean grimy herbs (Pharmacy)"}, - {"color / colors", "Instant", "Customize display colors"}, - {"colortable", "Instant", "Display color reference chart"}, - {"construct / make", "Active", "Build furniture (Construction)"}, - {"cook", "Active", "Cook raw food on a fire or range"}, - {"craft", "Active", "Craft jewelry (Crafting)"}, - {"description / desc", "Instant", "Set your character description"}, - {"drink", "Free", "Drink a potion"}, - {"drop", "Active", "Drop items to the ground"}, - {"eat", "Free", "Eat food to restore hitpoints"}, - {"equipment / eq", "Instant", "Show equipped items"}, - {"exits", "Instant", "List available exits"}, - {"farm", "Active", "Farm crops (Farming)"}, - {"fish", "Active", "Fish at fishing spots (Fishing)"}, - {"fletch", "Free", "Fletch logs into bows (Fletching)"}, - {"get / take / pick", "Active", "Pick up items from the ground"}, - {"help", "Instant", "Show help topics"}, - {"id / identify", "Active", "Identify herbs (Pharmacy)"}, - {"inventory / i / inv", "Instant", "Show your inventory"}, - {"jack / jackin", "Active", "Jack into a terminal (Hacking)"}, - {"look / l", "Instant", "Look around or examine things"}, - {"map", "Instant", "Display an ASCII map of the area"}, - {"mine", "Active", "Mine rocks (Mining)"}, - {"mix", "Active", "Mix potions (Pharmacy)"}, - {"mods / modlist", "Instant", "List available science modules"}, - {"north / south / east / west / up / down", "Active", "Move in a direction"}, - {"option / options", "Instant", "View or change settings"}, - {"prompt", "Instant", "Set custom command prompt"}, - {"pull / push", "Active", "Interact with objects"}, - {"queued", "Instant", "Show pending tick actions"}, - {"quit", "Active", "Rest and disconnect"}, - {"remove / unwear / unwield", "Free", "Unequip items"}, - {"say", "Instant", "Chat with players in your room"}, - {"score / sc", "Instant", "View your stats and skills"}, - {"search", "Active", "Search items for loot"}, - {"smelt", "Active", "Smelt ore into bars (Smithing)"}, - {"smith", "Active", "Smith bars into items (Smithing)"}, - {"sneak", "Instant", "Toggle sneak mode"}, - {"steal", "Active", "Steal from mobs (Thieving)"}, - {"stoke", "Active", "Add logs to a fire"}, - {"style", "Instant", "Change combat style"}, - {"talk / speak / ask", "Active", "Talk to NPCs"}, - {"tech", "Instant", "Toggle technology abilities"}, - {"trigger", "Active", "Trigger a science module"}, - {"unalias", "Instant", "Remove command shortcuts"}, - {"use", "Active", "Use an object (crafting)"}, - {"walk", "Active", "Pathfind to a room or multi-step walk"}, - {"wear / wield", "Free", "Equip items"}, -} - -func LoadHelp(dataDir string) ([]HelpDef, error) { - dir := filepath.Join(dataDir, "help") - entries, err := os.ReadDir(dir) - if err != nil { - return nil, err - } - - var helps []HelpDef - for _, entry := range entries { - if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" { - continue - } - data, err := os.ReadFile(filepath.Join(dir, entry.Name())) - if err != nil { - continue - } - var h HelpDef - if err := yaml.Unmarshal(data, &h); err != nil { - continue - } - helps = append(helps, h) - } - return helps, nil -} - -func (g *Game) doHelp(sess *net.Session, topic string) { - if topic == "" { - unicode := true - if p := sess.Player; p != nil { - unicode = p.OptionBool("unicode") - } - - sess.WriteLine("") - t := &Table{ - Title: "Commands", - Columns: []string{"Command", "Type", "Description"}, - } - for _, c := range commandList { - t.Rows = append(t.Rows, []string{c.Name, c.Type, c.Desc}) - } - for _, line := range t.Render(unicode) { - sess.WriteLine(line) - } - sess.WriteLine("") - sess.WriteLine("Command types: Instant (runs immediately), Free (queued before active),") - sess.WriteLine("Active (replaces current action, queued per tick).") - sess.WriteLine("") - sess.WriteLine("Use 'help ' for detailed usage of a specific command.") - sess.WriteLine("Use 'option' to view and change settings, 'color' to customize colors.") - return - } - - helps, err := LoadHelp(g.DataDir) - if err != nil || len(helps) == 0 { - sess.WriteLine("No help available.") - return - } - - topic = strings.ToLower(topic) - for _, h := range helps { - if strings.ToLower(h.Name) == topic { - sess.WriteLine(fmt.Sprintf("\n %s", h.Content)) - return - } - } - sess.WriteLine(fmt.Sprintf("No help found for '%s'.", topic)) -} - -func firstLine(s string) string { - if idx := strings.Index(s, "\n"); idx >= 0 { - return s[:idx] - } - return s -} diff --git a/internal/game/login_account.go b/internal/game/login_account.go deleted file mode 100644 index 87f8c62..0000000 --- a/internal/game/login_account.go +++ /dev/null @@ -1,351 +0,0 @@ -package game - -import ( - "fmt" - "os" - "strings" - - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" -) - -func (g *Game) handleAccountName(sess *net.Session, input string) { - name := strings.TrimSpace(input) - if name == "" { - sess.Write("Account name: ") - return - } - actualName, err := g.AccountStore.FindAccount(name) - if err == nil { - sess.Account = &player.Account{Name: actualName} - sess.State = net.StatePassword - sess.Conn.SetEcho(false) - sess.Write("Password: ") - } else { - if verr := player.ValidName(name); verr != nil { - sess.WriteLine(verr.Error()) - sess.Write("Account name: ") - return - } - sess.Account = &player.Account{Name: name} - sess.State = net.StateNewAccountPass - sess.Conn.SetEcho(false) - sess.WriteLine("\r\nOh, a new visitor to this rock!") - sess.Write("Choose password: ") - } -} - -func (g *Game) handlePassword(sess *net.Session, input string) { - if input == "" { - sess.Write("Password: ") - return - } - if len(input) > 128 { - sess.Conn.SetEcho(true) - sess.WriteLine("Wrong password.") - sess.State = net.StateAccountName - sess.Write("Account name: ") - return - } - acc, err := g.AccountStore.LoadAccount(sess.Account.Name) - if err != nil { - sess.Conn.SetEcho(true) - sess.WriteLine("Error loading account.") - sess.State = net.StateAccountName - sess.Write("Account name: ") - return - } - if !player.CheckPassword(input, acc.PasswordHash) { - sess.Conn.SetEcho(true) - sess.WriteLine("Wrong password.") - sess.State = net.StateAccountName - sess.Write("Account name: ") - return - } - sess.Conn.SetEcho(true) - sess.Account = &player.Account{ - Name: acc.Name, - PasswordHash: acc.PasswordHash, - Characters: acc.Characters, - Aliases: acc.Aliases, - Colors: acc.Colors, - Options: acc.Options, - } - if sess.Account.Aliases == nil { - sess.Account.Aliases = make(map[string]string) - } - if sess.Account.Colors == nil { - sess.Account.Colors = make(map[string]string) - } - if sess.Account.Options == nil { - sess.Account.Options = make(map[string]any) - } - g.showMenu(sess) -} - -func (g *Game) handleNewAccountPass(sess *net.Session, input string) { - input = strings.TrimSpace(input) - if input == "" { - sess.Conn.SetEcho(true) - sess.Account = nil - sess.State = net.StateAccountName - sess.Write("Account name: ") - return - } - if verr := player.ValidPassword(input); verr != nil { - sess.WriteLine(verr.Error()) - sess.Write("Choose password: ") - return - } - sess.PendingPass = input - sess.State = net.StateNewAccountConfirm - sess.Write("Confirm password: ") -} - -func (g *Game) handleNewAccountConfirm(sess *net.Session, input string) { - input = strings.TrimSpace(input) - if input == "" { - sess.Conn.SetEcho(true) - sess.Account = nil - sess.State = net.StateAccountName - sess.Write("Account name: ") - return - } - if input != sess.PendingPass { - sess.Conn.SetEcho(true) - sess.WriteLine("Passwords do not match.") - sess.Account = nil - sess.State = net.StateAccountName - sess.Write("Account name: ") - return - } - - hash, err := player.HashPassword(input) - if err != nil { - sess.Conn.SetEcho(true) - sess.WriteLine(fmt.Sprintf("Error creating account: %v", err)) - sess.Account = nil - sess.State = net.StateAccountName - sess.Write("Account name: ") - return - } - - acc := &player.Account{ - Name: sess.Account.Name, - PasswordHash: hash, - } - if err := g.AccountStore.SaveAccount(acc); err != nil { - sess.Conn.SetEcho(true) - sess.WriteLine(fmt.Sprintf("Error creating account: %v", err)) - sess.Account = nil - sess.State = net.StateAccountName - sess.Write("Account name: ") - return - } - - sess.Conn.SetEcho(true) - sess.Account = &player.Account{ - Name: acc.Name, - PasswordHash: acc.PasswordHash, - Aliases: make(map[string]string), - Colors: make(map[string]string), - Options: make(map[string]any), - } - sess.PendingPass = "" - sess.State = net.StateColorChoice - sess.Write("\nShould I disable color? [y/N] ") -} - -func (g *Game) handleColorChoice(sess *net.Session, input string) { - input = strings.ToLower(strings.TrimSpace(input)) - if input == "y" || input == "yes" { - sess.Account.Options["color"] = "none" - acc, err := g.AccountStore.LoadAccount(sess.Account.Name) - if err == nil { - if acc.Options == nil { - acc.Options = make(map[string]any) - } - acc.Options["color"] = "none" - g.AccountStore.SaveAccount(acc) - } - } - g.showMenu(sess) -} - -func (g *Game) showMenu(sess *net.Session) { - sess.State = net.StateMenu - lines := []string{ - "", - fmt.Sprintf("Welcome back to Gaia 04, %s.", sess.Account.Name), - "", - } - if len(sess.Account.Characters) > 0 { - lines = append(lines, - " (C)onnect character to THOI", - "", - " (L)ist characters", - " (R)ename character", - " (D)elete character", - ) - } - lines = append(lines, - " (N)ew character", - " (P)urge account", - " (A)ccount rename", - " (Q)uit", - "", - ) - sess.WriteLines(lines...) - sess.Write("> ") -} - -func (g *Game) handleMenu(sess *net.Session, input string) { - switch strings.ToLower(strings.TrimSpace(input)) { - case "": - sess.Write("> ") - case "c": - if len(sess.Account.Characters) == 0 { - sess.WriteLine("\nHey, make a character with 'N' first!") - sess.Write("\n> ") - return - } - if len(sess.Account.Characters) == 1 { - g.connectCharacter(sess, sess.Account.Characters[0]) - return - } - sess.WriteLine("\nSelect character:") - for i, name := range sess.Account.Characters { - sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name)) - } - sess.State = net.StateNewCharName - sess.PendingChar = "connect" - sess.Write("\n> ") - - case "l": - if len(sess.Account.Characters) == 0 { - sess.WriteLine("\nYou don't have any characters! Make one with 'N'!") - } else { - sess.WriteLine("\nCharacters:") - for _, name := range sess.Account.Characters { - sess.WriteLine(fmt.Sprintf(" - %s", name)) - } - } - sess.Write("\n> ") - - case "n": - sess.State = net.StateNewCharName - sess.PendingChar = "" - sess.Write("What's your character name?: ") - - case "a": - sess.State = net.StateRenameAccount - sess.Write("New account name: ") - - case "r": - if len(sess.Account.Characters) == 0 { - sess.WriteLine("\nRename who? You don't have any characters!") - sess.Write("\n> ") - return - } - if len(sess.Account.Characters) == 1 { - sess.PendingChar = sess.Account.Characters[0] - sess.State = net.StateRenameCharName - sess.WriteLine(fmt.Sprintf("\nRenaming %s.", sess.PendingChar)) - sess.Write("New name: ") - return - } - sess.State = net.StateRenameChar - sess.WriteLine("\nRename which character?") - for i, name := range sess.Account.Characters { - sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name)) - } - sess.Write("\n> ") - - case "d": - if len(sess.Account.Characters) == 0 { - sess.WriteLine("\nNo characters to delete.") - sess.Write("\n> ") - return - } - if len(sess.Account.Characters) == 1 { - sess.PendingChar = sess.Account.Characters[0] - } else { - sess.State = net.StateDeleteChar - sess.WriteLine("\nDelete which character?") - for i, name := range sess.Account.Characters { - sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name)) - } - sess.Write("\n> ") - return - } - g.showDeleteConfirm(sess) - - case "p": - sess.State = net.StatePurgeAccount - sess.WriteLine(fmt.Sprintf("\nPurge account %s and all characters?\nTo be clear you are about to PERMANENTLY DELETE EVERYTHING!\nType PURGE %s to confirm, or anything else to cancel.", sess.Account.Name, sess.Account.Name)) - sess.Write("\n> ") - - case "q": - sess.WriteLine("Later.") - sess.Close() - return - - default: - sess.Write("> ") - } -} - -func (g *Game) handleRenameAccount(sess *net.Session, input string) { - newName := strings.TrimSpace(input) - if newName == "" { - sess.Write("New account name: ") - return - } - if verr := player.ValidName(newName); verr != nil { - sess.WriteLine(verr.Error()) - sess.Write("New account name: ") - return - } - oldName := sess.Account.Name - if newName == oldName { - sess.WriteLine("That's already your account name.") - g.showMenu(sess) - return - } - if g.AccountStore.AccountExists(newName) { - sess.WriteLine("An account with that name already exists.") - sess.Write("New account name: ") - return - } - - oldPath := g.AccountStore.AccountPath(oldName) - newPath := g.AccountStore.AccountPath(newName) - if err := os.Rename(oldPath, newPath); err != nil { - sess.WriteLine(fmt.Sprintf("Error renaming account: %v", err)) - g.showMenu(sess) - return - } - - sess.Account.Name = newName - sess.WriteLine(fmt.Sprintf("Account renamed to %s.", newName)) - g.showMenu(sess) -} - -func (g *Game) handlePurgeAccount(sess *net.Session, input string) { - input = strings.TrimSpace(input) - expected := "PURGE " + sess.Account.Name - if strings.ToUpper(input) != strings.ToUpper(expected) { - sess.WriteLine("Purge cancelled.") - g.showMenu(sess) - return - } - - for _, name := range sess.Account.Characters { - os.Remove(g.AccountStore.CharPath(name)) - } - - os.Remove(g.AccountStore.AccountPath(sess.Account.Name)) - - sess.WriteLine(fmt.Sprintf("Account %s has been purged. Thanks for playing.", sess.Account.Name)) - sess.Close() -} diff --git a/internal/game/login_char.go b/internal/game/login_char.go deleted file mode 100644 index 84f146b..0000000 --- a/internal/game/login_char.go +++ /dev/null @@ -1,244 +0,0 @@ -package game - -import ( - "fmt" - "os" - "strings" - - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" -) - -func (g *Game) handleNewCharName(sess *net.Session, input string) { - name := strings.TrimSpace(input) - - if sess.PendingChar == "connect" && len(sess.Account.Characters) > 0 { - if idx, err := parseChoiceIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { - sess.PendingChar = "" - g.connectCharacter(sess, sess.Account.Characters[idx-1]) - return - } - sess.WriteLine("Invalid choice.") - sess.Write("\n> ") - return - } - - if name == "" { - sess.Write("Character name: ") - return - } - - if verr := player.ValidName(name); verr != nil { - sess.WriteLine(verr.Error()) - sess.Write("Character name: ") - return - } - - if g.AccountStore.CharacterExists(name) { - sess.WriteLine("A character with that name already exists.") - sess.Write("Character name: ") - return - } - - p := player.New(name) - p.RoomID = 1 - - if err := g.AccountStore.SaveCharacter(p); err != nil { - sess.WriteLine(fmt.Sprintf("Error creating character: %v", err)) - sess.State = net.StateMenu - g.showMenu(sess) - return - } - - acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) - acc.Characters = append(acc.Characters, name) - g.AccountStore.SaveAccount(acc) - sess.Account.Characters = acc.Characters - - g.connectCharacter(sess, name) -} - -func (g *Game) connectCharacter(sess *net.Session, name string) { - g.charsMu.Lock() - if existing := g.loggedInChars[name]; existing != nil { - g.charsMu.Unlock() - sess.WriteLine("This character is logged in elsewhere.") - sess.State = net.StateMenu - g.showMenu(sess) - return - } - - p, err := g.AccountStore.LoadCharacter(name) - if err != nil { - g.charsMu.Unlock() - sess.WriteLine(fmt.Sprintf("Error loading character: %v", err)) - sess.State = net.StateMenu - g.showMenu(sess) - return - } - - sess.Player = p - sess.State = net.StateGame - g.loggedInChars[name] = sess - g.charsMu.Unlock() - - if sess.Account != nil && sess.Account.Options != nil { - p.Options = make(map[string]any) - for k, v := range sess.Account.Options { - p.Options[k] = v - } - } - - g.World.SeedGroundItems(p.RoomID) - g.seedRoomMobs(p.RoomID) - g.seedRoomObjects(p.RoomID) - - if g.Hub != nil { - g.Hub.EnterRoom(sess, p.RoomID) - } - - g.doLook(sess) - g.checkAggro(sess) - sess.Write("\r\n> ") -} - -func (g *Game) handleRenameChar(sess *net.Session, input string) { - name := strings.TrimSpace(input) - if idx, err := parseChoiceIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { - sess.PendingChar = sess.Account.Characters[idx-1] - } else { - sess.PendingChar = name - } - - found := false - for _, c := range sess.Account.Characters { - if c == sess.PendingChar { - found = true - break - } - } - if !found { - sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar)) - g.showMenu(sess) - return - } - - sess.State = net.StateRenameCharName - sess.WriteLine(fmt.Sprintf("Renaming %s.", sess.PendingChar)) - sess.Write("New name: ") -} - -func (g *Game) handleRenameCharName(sess *net.Session, input string) { - newName := strings.TrimSpace(input) - oldName := sess.PendingChar - if newName == "" { - sess.Write("New name: ") - return - } - if verr := player.ValidName(newName); verr != nil { - sess.WriteLine(verr.Error()) - sess.Write("New name: ") - return - } - if newName == oldName { - sess.WriteLine("That's already the character's name.") - g.showMenu(sess) - return - } - if g.AccountStore.CharacterExists(newName) { - sess.WriteLine("A character with that name already exists.") - sess.Write("New name: ") - return - } - - oldPath := g.AccountStore.CharPath(oldName) - newPath := g.AccountStore.CharPath(newName) - if err := os.Rename(oldPath, newPath); err != nil { - sess.WriteLine(fmt.Sprintf("Error renaming: %v", err)) - g.showMenu(sess) - return - } - - p, _ := g.AccountStore.LoadCharacter(newName) - if p != nil { - p.Name = newName - g.AccountStore.SaveCharacter(p) - } - - acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) - for i, c := range acc.Characters { - if c == oldName { - acc.Characters[i] = newName - break - } - } - g.AccountStore.SaveAccount(acc) - sess.Account.Characters = acc.Characters - - sess.PendingChar = "" - sess.WriteLine(fmt.Sprintf("%s renamed to %s.", oldName, newName)) - g.showMenu(sess) -} - -func (g *Game) showDeleteConfirm(sess *net.Session) { - sess.State = net.StateDeleteChar - sess.WriteLine(fmt.Sprintf("\nDelete %s? Type DELETE %s to confirm, or anything else to cancel.", sess.PendingChar, sess.PendingChar)) -} - -func (g *Game) handleDeleteChar(sess *net.Session, input string) { - if sess.PendingChar == "" { - name := strings.TrimSpace(input) - if idx, err := parseChoiceIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { - sess.PendingChar = sess.Account.Characters[idx-1] - } else { - sess.PendingChar = name - } - found := false - for _, c := range sess.Account.Characters { - if c == sess.PendingChar { - found = true - break - } - } - if !found { - sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar)) - sess.PendingChar = "" - g.showMenu(sess) - return - } - g.showDeleteConfirm(sess) - return - } - - input = strings.TrimSpace(input) - expected := "DELETE " + sess.PendingChar - if strings.ToUpper(input) != strings.ToUpper(expected) { - sess.WriteLine("Delete cancelled.") - sess.PendingChar = "" - g.showMenu(sess) - return - } - - charPath := g.AccountStore.CharPath(sess.PendingChar) - if err := os.Remove(charPath); err != nil { - sess.WriteLine(fmt.Sprintf("Error deleting: %v", err)) - sess.PendingChar = "" - g.showMenu(sess) - return - } - - acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) - var newChars []string - for _, c := range acc.Characters { - if c != sess.PendingChar { - newChars = append(newChars, c) - } - } - acc.Characters = newChars - g.AccountStore.SaveAccount(acc) - sess.Account.Characters = newChars - - sess.WriteLine(fmt.Sprintf("%s has been deleted.", sess.PendingChar)) - sess.PendingChar = "" - g.showMenu(sess) -} diff --git a/internal/game/map.go b/internal/game/map.go deleted file mode 100644 index 32d3d7c..0000000 --- a/internal/game/map.go +++ /dev/null @@ -1,317 +0,0 @@ -package game - -import ( - "strings" - - "thehouseoficarus/internal/world" -) - -type mapGlyphs struct { - topLeft, topRight rune - bottomLeft, bottomRight rune - side rune - topFill rune - connectorH, connectorV rune - upArrow, downArrow rune -} - -func mapGlyphsForPlayer(unicode bool) mapGlyphs { - if unicode { - return mapGlyphs{ - topLeft: '╔', topRight: '╗', bottomLeft: '╚', bottomRight: '╝', - side: '║', topFill: '═', connectorH: '─', connectorV: '│', - upArrow: '↑', downArrow: '↓', - } - } - return mapGlyphs{ - topLeft: '.', topRight: '.', bottomLeft: ':', bottomRight: ':', - side: ':', topFill: '.', connectorH: '-', connectorV: '|', - upArrow: '^', downArrow: 'v', - } -} - -type mapGraph struct { - posToRoom map[[2]int]int - roomToPos map[int][2]int -} - -var bfsDirs = []struct { - dir world.ExitDir - dx, dy int -}{ - {world.North, 0, -1}, - {world.South, 0, 1}, - {world.East, 1, 0}, - {world.West, -1, 0}, -} - -func buildGraph(g *Game, startRoomID int) *mapGraph { - mg := &mapGraph{ - posToRoom: make(map[[2]int]int), - roomToPos: make(map[int][2]int), - } - - type node struct { - roomID int - x, y int - } - queue := []node{{startRoomID, 0, 0}} - mg.posToRoom[[2]int{0, 0}] = startRoomID - mg.roomToPos[startRoomID] = [2]int{0, 0} - - for len(queue) > 0 { - n := queue[0] - queue = queue[1:] - - room, ok := loadRoom(g, n.roomID) - if !ok { - continue - } - - for _, d := range bfsDirs { - targetID, ok := exitTarget(room, d.dir) - if !ok { - continue - } - if _, visited := mg.roomToPos[targetID]; visited { - continue - } - nx, ny := n.x + d.dx, n.y + d.dy - mg.posToRoom[[2]int{nx, ny}] = targetID - mg.roomToPos[targetID] = [2]int{nx, ny} - queue = append(queue, node{targetID, nx, ny}) - } - } - - return mg -} - -func buildTinyMap(g *Game, roomID int, mg mapGlyphs) []string { - bg := buildGraph(g, roomID) - - grid := make([][]rune, 5) - for i := range grid { - grid[i] = make([]rune, 5) - for j := range grid[i] { - grid[i][j] = ' ' - } - } - - for y := -1; y <= 1; y++ { - for x := -1; x <= 1; x++ { - pos := [2]int{x, y} - rid, ok := bg.posToRoom[pos] - if !ok { - continue - } - gr := (y + 1) * 2 - gc := (x + 1) * 2 - if rid == roomID { - grid[gr][gc] = '@' - } else { - grid[gr][gc] = roomMapSymbol(g, rid) - } - } - } - - for y := -1; y <= 1; y++ { - for x := -1; x <= 0; x++ { - leftPos := [2]int{x, y} - rightPos := [2]int{x + 1, y} - leftRoom, leftOK := bg.posToRoom[leftPos] - rightRoom, rightOK := bg.posToRoom[rightPos] - if !leftOK || !rightOK { - continue - } - if exitsConnect(g, leftRoom, rightRoom, world.East, world.West) { - grid[(y+1)*2][(x+1)*2+1] = mg.connectorH - } - } - } - - for y := -1; y <= 0; y++ { - for x := -1; x <= 1; x++ { - topPos := [2]int{x, y} - bottomPos := [2]int{x, y + 1} - topRoom, topOK := bg.posToRoom[topPos] - bottomRoom, bottomOK := bg.posToRoom[bottomPos] - if !topOK || !bottomOK { - continue - } - if exitsConnect(g, topRoom, bottomRoom, world.South, world.North) { - grid[(y+1)*2+1][(x+1)*2] = mg.connectorV - } - } - } - - cur, _ := loadRoom(g, roomID) - if cur != nil { - if _, ok := exitTarget(cur, world.Up); ok { - grid[1][3] = mg.upArrow - } - if _, ok := exitTarget(cur, world.Down); ok { - grid[3][1] = mg.downArrow - } - } - - topFill := strings.Repeat(string(mg.topFill), 5) - lines := make([]string, 7) - lines[0] = string(mg.topLeft) + topFill + string(mg.topRight) - for row := 0; row < 5; row++ { - lines[row+1] = string(mg.side) + string(grid[row]) + string(mg.side) - } - botFill := strings.Repeat(string(mg.topFill), 5) - lines[6] = string(mg.bottomLeft) + botFill + string(mg.bottomRight) - - return lines -} - -func exitsConnect(g *Game, room1, room2 int, dir12, dir21 world.ExitDir) bool { - r1, ok := loadRoom(g, room1) - if !ok { - return false - } - if id, ok := exitTarget(r1, dir12); ok && id == room2 { - return true - } - r2, ok := loadRoom(g, room2) - if !ok { - return false - } - id, ok := exitTarget(r2, dir21) - return ok && id == room1 -} - -func exitTarget(room *world.Room, dir world.ExitDir) (int, bool) { - if room == nil { - return 0, false - } - exit, ok := room.Exits[dir] - if !ok { - return 0, false - } - return exit.Room, true -} - -func loadRoom(g *Game, roomID int) (*world.Room, bool) { - if roomID == 0 { - return nil, false - } - room, err := g.World.LoadRoom(roomID) - if err != nil { - return nil, false - } - return room, true -} - -func roomMapSymbol(g *Game, roomID int) rune { - room, ok := loadRoom(g, roomID) - if !ok { - return '?' - } - if room.MapSymbol != "" { - runes := []rune(room.MapSymbol) - return runes[0] - } - return 'o' -} - -func stripBlankRows(lines []string) []string { - var out []string - for _, line := range lines { - if strings.TrimSpace(line) != "" { - out = append(out, line) - } - } - return out -} - -func leftTrimCommon(lines []string) []string { - min := -1 - for _, line := range lines { - if strings.TrimSpace(line) == "" { - continue - } - n := 0 - for _, r := range line { - if r == ' ' { - n++ - } else { - break - } - } - if min < 0 || n < min { - min = n - } - } - if min <= 0 { - return lines - } - result := make([]string, len(lines)) - for i, line := range lines { - if len(line) <= min { - result[i] = "" - } else { - result[i] = line[min:] - } - } - return result -} - -func buildFullMap(g *Game, roomID, mapWidth, mapHeight int, mg mapGlyphs) []string { - bg := buildGraph(g, roomID) - - grid := make([][]rune, mapHeight) - for i := range grid { - grid[i] = make([]rune, mapWidth) - for j := range grid[i] { - grid[i][j] = ' ' - } - } - - cx := mapWidth / 2 - cy := mapHeight / 2 - - for pos, rid := range bg.posToRoom { - gr := cy + pos[1]*2 - gc := cx + pos[0]*2 - if gr < 0 || gr >= mapHeight || gc < 0 || gc >= mapWidth { - continue - } - if rid == roomID { - grid[gr][gc] = '@' - } else { - grid[gr][gc] = roomMapSymbol(g, rid) - } - } - - for pos, rid := range bg.posToRoom { - x, y := pos[0], pos[1] - - if rightID, ok := bg.posToRoom[[2]int{x + 1, y}]; ok { - if exitsConnect(g, rid, rightID, world.East, world.West) { - gr := cy + y*2 - gc := cx + x*2 + 1 - if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth { - grid[gr][gc] = mg.connectorH - } - } - } - - if bottomID, ok := bg.posToRoom[[2]int{x, y + 1}]; ok { - if exitsConnect(g, rid, bottomID, world.South, world.North) { - gr := cy + y*2 + 1 - gc := cx + x*2 - if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth { - grid[gr][gc] = mg.connectorV - } - } - } - } - - lines := make([]string, mapHeight) - for i := range grid { - lines[i] = string(grid[i]) - } - return lines -} diff --git a/internal/game/messages.go b/internal/game/messages.go deleted file mode 100644 index 1c9e85a..0000000 --- a/internal/game/messages.go +++ /dev/null @@ -1,13 +0,0 @@ -package game - -const ( - MsgErrLoadRecipes = "Error loading recipes." - MsgNoCombat = "You can't do that during combat!" - MsgNoDontHave = "You don't have any '%s'." - MsgNeverMind = "Never mind." - MsgSomethingWrongObject = "Something is wrong with this object." - MsgSomethingWrongThat = "Something is wrong with that." - MsgInventoryFull = "Your inventory is too full!" - MsgAmbiguous = "That's ambiguous, which one?" - MsgWhichOne = "Which one?" -) diff --git a/internal/game/player_flags.go b/internal/game/player_flags.go deleted file mode 100644 index 6595cc4..0000000 --- a/internal/game/player_flags.go +++ /dev/null @@ -1,55 +0,0 @@ -package game - -import "thehouseoficarus/internal/player" - -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) { - p.EnsureFlags() - p.Flags[key] = val -} - -func intFromFlag(flags map[string]any, key string) int { - val, ok := 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 -} diff --git a/internal/game/production_core.go b/internal/game/production_core.go deleted file mode 100644 index 42f64e1..0000000 --- a/internal/game/production_core.go +++ /dev/null @@ -1,606 +0,0 @@ -package game - -import ( - "fmt" - "math/rand" - "sort" - "strconv" - "strings" - - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/color" - "thehouseoficarus/internal/engine" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" - "thehouseoficarus/internal/player" - -) - -func (g *Game) startProduction(sess *net.Session, p *player.Player, recipe *action.RecipeDef, actionType, displayVerb, startMsg, endMsg string, count int) { - g.cancelAction(p) - g.cancelBackgroundAction(p) - - skill := recipe.EffectiveSkill() - if skill != "" { - skillLevel := p.Level(player.SkillName(skill)) - if skillLevel < recipe.Level { - sess.WriteLine(fmt.Sprintf("You need level %d %s to do that.", recipe.Level, skill)) - g.reprompt(sess) - return - } - } - - if !recipe.HasAllItemsQty(p.CountItem) { - sess.WriteLine("You don't have the required materials.") - g.reprompt(sess) - return - } - - outDef, _ := g.ItemStore.Load(recipe.Output) - - wait := recipe.Wait - if wait <= 0 { - wait = 4 - } - - outputName := recipe.Output - if outDef != nil { - outputName = outDef.Name - } - - p.Action = &action.Action{ - Type: actionType, - TargetID: recipe.ID, - TargetName: outputName, - Data: map[string]any{ - "recipe_id": recipe.ID, - "phase": 0, - "wait": wait, - "start_msg": startMsg, - "end_msg": endMsg, - "remaining": count, - }, - WaitLeft: engine.ToTicks(1), - } - - p.ActionState = &ActionState{Type: ActionProducing, TargetName: outputName, Verb: displayVerb} - - if g.Hub != nil { - for _, other := range g.Hub.PlayersInRoom(p.RoomID) { - if other != sess && other.Player != nil { - other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s starts %s.", p.Name, displayVerb))) - } - } - } -} - -func (g *Game) startProductionFromRecipe(sess *net.Session, p *player.Player, recipe *action.RecipeDef, count int) { - _, stationName := g.findStation(p.RoomID, recipe.Station) - if stationName == "" { - stationName = "inventory" - } - - firstItem := recipe.FirstItemName(func(id string) (string, bool) { - def, err := g.ItemStore.Load(id) - if err != nil { - return id, false - } - return def.Name, true - }) - - info, ok := productionTypes[recipe.Type] - if !ok { - info = productionTypeInfo{recipe.Type, recipe.Type} - } - actionType := info.ActionType - displayVerb := info.DisplayVerb - - var startMsg, endMsg string - if stationName != "inventory" { - startMsg = fmt.Sprintf("You start %s %s on the %s.", displayVerb, firstItem, stationName) - } else { - startMsg = fmt.Sprintf("You start %s %s.", displayVerb, firstItem) - } - endMsg = fmt.Sprintf("You've finished %s.", displayVerb) - - g.startProduction(sess, p, recipe, actionType, displayVerb, startMsg, endMsg, count) -} - -func (g *Game) loadProductionRecipe(recipeID string) *action.RecipeDef { - all, err := g.RecipeStore.LoadAll() - if err == nil { - for _, r := range all { - if r.ID == recipeID { - return &r - } - } - } - if strings.HasPrefix(recipeID, "combine_") { - itemID := strings.TrimPrefix(recipeID, "combine_") - if itemDef, err := g.ItemStore.Load(itemID); err == nil && len(itemDef.MadeFrom) > 0 { - return g.buildCombineRecipe(itemDef) - } - } - return nil -} - -func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool { - recipeID := p.Action.Data["recipe_id"].(string) - phase := p.Action.Data["phase"].(int) - wait := p.Action.Data["wait"].(float64) - startMsg, _ := p.Action.Data["start_msg"].(string) - endMsg, _ := p.Action.Data["end_msg"].(string) - remaining, _ := p.Action.Data["remaining"].(int) - - recipe := g.loadProductionRecipe(recipeID) - if recipe == nil { - g.cancelAction(p) - return false - } - - if phase == 0 { - if startMsg != "" { - sess.WriteLine(fmt.Sprintf("\n%s", startMsg)) - } - p.Action.Data["phase"] = 1 - p.Action.WaitLeft = engine.ToTicks(wait) - return true - } - - skill := recipe.EffectiveSkill() - skillLevel := p.Level(player.SkillName(skill)) - chance := 1.0 - if recipe.Success != nil { - chance = action.SuccessChance(*recipe.Success, skillLevel, recipe.Level) - } - - if rand.Float64() < chance { - outputQty := recipe.OutputQty - if outputQty <= 0 { - outputQty = 1 - } - - byproducts := g.collectByproducts(p, recipe) - - placed := false - outDef, _ := g.ItemStore.Load(recipe.Output) - if outDef != nil && outDef.Stackable { - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == recipe.Output { - recipe.ConsumeAll(p.HasItem, p.RemoveItem) - slot.Quantity += outputQty - placed = true - break - } - } - } - if !placed { - recipe.ConsumeAll(p.HasItem, p.RemoveItem) - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - sess.WriteLine("Your inventory is too full!") - g.cancelAction(p) - return false - } - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: recipe.Output, Quantity: outputQty}) - } - - for _, bp := range byproducts { - if slot := p.FirstFreeSlot(); slot >= 0 { - p.SetInvSlot(slot, &player.InventorySlot{ItemID: bp, Quantity: 1}) - } - } - - if recipe.XP > 0 { - if newLevel := p.AddSkillXP(player.SkillName(skill), recipe.XP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, skill))) - } - } - g.AccountStore.SaveCharacter(p) - - msg := recipe.Message - if msg == "" { - outputName := recipe.Output - if outDef != nil { - outputName = outDef.Name - } - msg = fmt.Sprintf("You produce %s.", outputName) - } - if p.OptionBool("xp_drops") && recipe.XP > 0 { - abbr := player.SkillAbbr[player.SkillName(skill)] - msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp %s)", recipe.XP, abbr)) - } - sess.WriteLine(msg) - } else { - recipe.ConsumeAll(p.HasItem, p.RemoveItem) - - if recipe.Fail != "" { - freeSlot := p.FirstFreeSlot() - if freeSlot >= 0 { - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: recipe.Fail, Quantity: 1}) - } - } - g.AccountStore.SaveCharacter(p) - - msg := recipe.FailMessage - if msg == "" { - msg = "You fail and the materials are lost." - } - sess.WriteLine(g.colorize(sess, "damage", msg)) - } - - if remaining > 0 { - remaining-- - p.Action.Data["remaining"] = remaining - if remaining <= 0 { - if endMsg != "" { - sess.WriteLine(fmt.Sprintf("\n%s", endMsg)) - } - g.cancelAction(p) - return false - } - } - - if g.canContinueProduction(p, recipe) { - p.Action.WaitLeft = engine.ToTicks(wait) - return true - } - - if endMsg != "" { - sess.WriteLine(fmt.Sprintf("\n%s", endMsg)) - } - g.cancelAction(p) - return false -} - -func (g *Game) collectByproducts(p *player.Player, recipe *action.RecipeDef) []string { - var byproducts []string - for _, e := range recipe.Consume { - if len(e.Byproducts) == 0 { - continue - } - for i, id := range e.Items { - if p.HasItem(id) && i < len(e.Byproducts) && e.Byproducts[i] != "" { - byproducts = append(byproducts, e.Byproducts[i]) - break - } - } - } - return byproducts -} - -func (g *Game) canContinueProduction(p *player.Player, recipe *action.RecipeDef) bool { - if !recipe.HasAllItemsQty(p.CountItem) { - return false - } - outputQty := recipe.OutputQty - if outputQty <= 0 { - outputQty = 1 - } - outDef, _ := g.ItemStore.Load(recipe.Output) - if outDef != nil && outDef.Stackable { - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == recipe.Output { - return true - } - } - } - return p.FirstFreeSlot() >= 0 -} - -func (g *Game) handleRecipeChoice(sess *net.Session, input string) { - input = strings.TrimSpace(input) - - menuData := sess.PendingMenu - if len(menuData) == 0 { - sess.State = net.StateGame - g.reprompt(sess) - return - } - - if input == "" { - sess.PendingMenu = nil - sess.State = net.StateGame - sess.WriteLine("Never mind.") - g.reprompt(sess) - return - } - - if choice, err := strconv.Atoi(input); err == nil { - sess.PendingMenu = nil - sess.State = net.StateGame - if choice <= 0 || choice > len(menuData) { - sess.WriteLine("Never mind.") - g.reprompt(sess) - return - } - g.dispatchMenuEntry(sess, menuData[choice-1]) - return - } - - lower := strings.ToLower(input) - matchIdx := -1 - for i, entry := range menuData { - name := g.menuEntryName(entry) - if name == "" { - continue - } - if strings.ToLower(name) == lower || action.WordPrefixMatch(input, name) { - if matchIdx >= 0 { - sess.WriteLine("That's ambiguous.") - return - } - matchIdx = i - } - } - if matchIdx >= 0 { - sess.PendingMenu = nil - sess.State = net.StateGame - g.dispatchMenuEntry(sess, menuData[matchIdx]) - return - } - - sess.PendingMenu = nil - sess.State = net.StateGame - sess.WriteLine("Never mind.") - g.reprompt(sess) -} - -func (g *Game) menuEntryName(entry map[string]string) string { - if rid, ok := entry["recipe_id"]; ok { - all, _ := g.RecipeStore.LoadAll() - for _, r := range all { - if r.ID == rid { - if def, err := g.ItemStore.Load(r.Output); err == nil { - return def.Name - } - return r.Output - } - } - } - if rid, ok := entry["fletch_recipe_id"]; ok { - all, _ := g.RecipeStore.LoadAll() - for _, r := range all { - if r.ID == rid { - if def, err := g.ItemStore.Load(r.Output); err == nil { - return def.Name - } - return r.Output - } - } - } - if barID, ok := entry["bar_id"]; ok { - if def, _ := g.ItemStore.Load(barID); def != nil { - return def.Name - } - return barID - } - if cid, ok := entry["combine_item"]; ok { - if def, _ := g.ItemStore.Load(cid); def != nil { - return def.Name - } - return cid - } - return "" -} - -func (g *Game) dispatchMenuEntry(sess *net.Session, entry map[string]string) { - if cid, ok := entry["combine_item"]; ok { - g.promptHowMany(sess, "combine_"+cid) - return - } - if barID, ok := entry["bar_id"]; ok { - p := sess.Player - allRecipes, _ := g.RecipeStore.LoadAll() - g.showSmithTable(sess, p, barID, allRecipes) - return - } - if rid, ok := entry["fletch_recipe_id"]; ok { - p := sess.Player - allRecipes, _ := g.RecipeStore.LoadAll() - for _, r := range allRecipes { - if r.ID == rid { - g.startFletchAction(sess, p, &r, 0) - return - } - } - g.reprompt(sess) - return - } - if rid, ok := entry["recipe_id"]; ok { - g.promptHowMany(sess, rid) - return - } - g.reprompt(sess) -} - -func (g *Game) formatRecipeMaterials(r *action.RecipeDef) string { - var parts []string - for _, e := range r.Consume { - itemName := e.Items[0] - if def, err := g.ItemStore.Load(e.Items[0]); err == nil { - itemName = def.Name - } - if e.Qty > 1 { - parts = append(parts, fmt.Sprintf("%s x%d", itemName, e.Qty)) - } else { - parts = append(parts, itemName) - } - } - return strings.Join(parts, ", ") -} - -func (g *Game) showProductionTable(sess *net.Session, p *player.Player, recipes []action.RecipeDef, title, skill, lastFlagKey, promptVerb string, background bool) { - sort.Slice(recipes, func(i, j int) bool { - return recipes[i].Level < recipes[j].Level - }) - - mode := g.colorMode(sess) - skillLevel := p.Level(player.SkillName(skill)) - dimSpec := color.Parse("240") - - tbl := &Table{ - Title: title, - Columns: []string{"#", "Product", "Materials", "Level"}, - } - - for i, r := range recipes { - outDef, _ := g.ItemStore.Load(r.Output) - productName := r.Output - if outDef != nil { - productName = outDef.Name - } - - outputQty := r.OutputQty - if outputQty <= 0 { - outputQty = 1 - } - if outDef != nil && outDef.Stackable && outputQty > 1 { - productName = fmt.Sprintf("%s x%d", productName, outputQty) - } - - matStr := g.formatRecipeMaterials(&r) - levelStr := fmt.Sprint(r.Level) - numStr := fmt.Sprintf("%d", i+1) - - canMake := skillLevel >= r.Level && r.HasAllItemsQty(p.CountItem) - if canMake { - productName = g.itemColorize(sess, outDef, productName) - } else { - productName = color.Render(mode, dimSpec, productName) - matStr = color.Render(mode, dimSpec, matStr) - levelStr = color.Render(mode, dimSpec, levelStr) - numStr = color.Render(mode, dimSpec, numStr) - } - - tbl.Rows = append(tbl.Rows, []string{numStr, productName, matStr, levelStr}) - } - - unicode := p.OptionBool("unicode") - sess.WriteLine("") - for _, line := range tbl.Render(unicode) { - sess.WriteLine(line) - } - - lastRecipeID, _ := p.Flags[lastFlagKey].(string) - hint := "" - if lastRecipeID != "" { - for _, r := range recipes { - if r.ID == lastRecipeID { - if outDef, err := g.ItemStore.Load(r.Output); err == nil { - hint = outDef.Name - } - break - } - } - } - - if hint != "" { - sess.Write(fmt.Sprintf("%s what (enter for all %s): ", promptVerb, hint)) - } else { - sess.Write(fmt.Sprintf("%s what: ", promptVerb)) - } - - sess.PendingMenu = recipeMenuData(recipeDefIDs(recipes)) - sess.PendingSkill = skill - sess.PendingLastFlag = lastFlagKey - sess.PendingBackground = background - sess.State = net.StateProductChoice -} - -func (g *Game) handleProductChoice(sess *net.Session, input string) { - p := sess.Player - menuData := sess.PendingMenu - skill := sess.PendingSkill - lastFlagKey := sess.PendingLastFlag - background := sess.PendingBackground - sess.PendingMenu = nil - sess.PendingSkill = "" - sess.PendingLastFlag = "" - sess.PendingBackground = false - sess.State = net.StateGame - - input = strings.TrimSpace(input) - - allRecipes, err := g.RecipeStore.LoadAll() - if err != nil { - g.reprompt(sess) - return - } - - var recipes []action.RecipeDef - for _, entry := range menuData { - rid := entry["recipe_id"] - for _, r := range allRecipes { - if r.ID == rid { - recipes = append(recipes, r) - break - } - } - } - - if len(recipes) == 0 { - g.reprompt(sess) - return - } - - sort.Slice(recipes, func(i, j int) bool { - return recipes[i].Level < recipes[j].Level - }) - - lastRecipeID, _ := p.Flags[lastFlagKey].(string) - - sel, errMsg := g.resolveRecipeByInput(input, recipes, lastRecipeID) - switch errMsg { - case "ambiguous": - sess.WriteLine("That's ambiguous.") - g.reprompt(sess) - return - case "never_mind": - sess.WriteLine("Never mind.") - g.reprompt(sess) - return - } - - skillLevel := p.Level(player.SkillName(skill)) - if skillLevel < sel.Recipe.Level { - sess.WriteLine(fmt.Sprintf("You need level %d %s to make that.", sel.Recipe.Level, skill)) - g.reprompt(sess) - return - } - if !sel.Recipe.HasAllItemsQty(p.CountItem) { - sess.WriteLine("You don't have the materials for that.") - g.reprompt(sess) - return - } - - p.EnsureFlags() - p.Flags[lastFlagKey] = sel.Recipe.ID - g.AccountStore.SaveCharacter(p) - - if background { - g.startFletchAction(sess, p, sel.Recipe, sel.Count) - } else { - g.startProductionFromRecipe(sess, p, sel.Recipe, sel.Count) - } -} - -func (g *Game) hasToolType(p *player.Player, toolType string) bool { - if itemID, ok := p.Equipment[object.SlotMainHand]; ok { - if def, err := g.ItemStore.Load(itemID); err == nil && def.ToolType == toolType { - return true - } - } - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot == nil { - continue - } - if def, err := g.ItemStore.Load(slot.ItemID); err == nil && def.ToolType == toolType { - return true - } - } - return false -} diff --git a/internal/game/production_menu.go b/internal/game/production_menu.go deleted file mode 100644 index 329110b..0000000 --- a/internal/game/production_menu.go +++ /dev/null @@ -1,209 +0,0 @@ -package game - -import ( - "fmt" - "strconv" - "strings" - - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" -) - -type recipeEntry struct { - ItemName string - Recipe action.RecipeDef -} - -type productionTypeInfo struct { - ActionType string - DisplayVerb string -} - -var productionTypes = map[string]productionTypeInfo{ - "cooking": {"cook", "cooking"}, - "smelting": {"smelt", "smelting"}, - "smithing": {"smith", "smithing"}, - "crafting": {"craft", "crafting"}, - "combine": {"combine", "combining"}, - "fletching": {"fletch", "fletching"}, - "pharmacy": {"mix", "mixing"}, - "construction": {"construct", "constructing"}, -} - -var productionActionTypes map[string]bool - -func init() { - productionActionTypes = make(map[string]bool) - for _, pt := range productionTypes { - productionActionTypes[pt.ActionType] = true - } -} - -func recipeMenuData(recipeIDs []string) []map[string]string { - out := make([]map[string]string, len(recipeIDs)) - for i, id := range recipeIDs { - out[i] = map[string]string{"recipe_id": id} - } - return out -} - -func recipeDefIDs(defs []action.RecipeDef) []string { - ids := make([]string, len(defs)) - for i, d := range defs { - ids[i] = d.ID - } - return ids -} - -func entryMenuData(entries []recipeEntry) []map[string]string { - out := make([]map[string]string, len(entries)) - for i, e := range entries { - out[i] = map[string]string{"recipe_id": e.Recipe.ID} - } - return out -} - -func (g *Game) promptHowMany(sess *net.Session, recipeID string) { - sess.PendingRecipeID = recipeID - sess.State = net.StateHowMany - sess.Write("How many (return for all)?: ") -} - -type recipeSelection struct { - Recipe *action.RecipeDef - Count int -} - -func (g *Game) resolveRecipeByInput(input string, recipes []action.RecipeDef, lastRecipeID string) (sel recipeSelection, errMsg string) { - if input == "" { - if lastRecipeID == "" { - return sel, "never_mind" - } - for i := range recipes { - if recipes[i].ID == lastRecipeID { - sel.Recipe = &recipes[i] - return sel, "" - } - } - return sel, "never_mind" - } - - qty, productName := parseQty(input) - productName = strings.TrimSpace(productName) - - if idx, err := strconv.Atoi(productName); err == nil && idx > 0 && idx <= len(recipes) { - sel.Recipe = &recipes[idx-1] - sel.Count = qty - return sel, "" - } - - matchCount := 0 - for i := range recipes { - outDef, _ := g.ItemStore.Load(recipes[i].Output) - name := recipes[i].Output - if outDef != nil { - name = outDef.Name - } - if action.WordPrefixMatch(productName, name) { - if matchCount == 0 { - sel.Recipe = &recipes[i] - sel.Count = qty - } - matchCount++ - } - } - if matchCount > 1 { - return sel, "ambiguous" - } - if sel.Recipe == nil { - return sel, "never_mind" - } - return sel, "" -} - -func (g *Game) showMenuTable(sess *net.Session, title string, names []string) { - p := sess.Player - tbl := &Table{Title: title} - for i, name := range names { - tbl.Rows = append(tbl.Rows, []string{fmt.Sprintf("%d", i+1), name}) - } - unicode := p.OptionBool("unicode") - sess.WriteLine("") - for _, line := range tbl.Render(unicode) { - sess.WriteLine(line) - } -} - -func (g *Game) handleHowMany(sess *net.Session, input string) { - p := sess.Player - recipeID := sess.PendingRecipeID - sess.PendingRecipeID = "" - sess.State = net.StateGame - - input = strings.TrimSpace(input) - - var count int - if input == "" { - count = 0 - } else if n, err := strconv.Atoi(input); err == nil && n > 0 { - count = n - } else { - sess.WriteLine("Never mind.") - g.reprompt(sess) - return - } - - var recipe *action.RecipeDef - if strings.HasPrefix(recipeID, "combine_") { - itemID := strings.TrimPrefix(recipeID, "combine_") - itemDef, err := g.ItemStore.Load(itemID) - if err != nil || len(itemDef.MadeFrom) == 0 { - g.reprompt(sess) - return - } - recipe = g.buildCombineRecipe(itemDef) - } else { - all, err := g.RecipeStore.LoadAll() - if err != nil { - g.reprompt(sess) - return - } - for _, r := range all { - if r.ID == recipeID { - recipe = &r - break - } - } - } - - if recipe == nil { - g.reprompt(sess) - return - } - - g.startProductionFromRecipe(sess, p, recipe, count) -} - -func (g *Game) buildCombineRecipe(def *object.ItemDef) *action.RecipeDef { - var consume []action.ConsumeEntry - for _, mf := range def.MadeFrom { - consume = append(consume, action.ConsumeEntry{ - Items: mf.Items, - Qty: mf.Qty, - Byproducts: mf.Byproducts, - }) - } - wait := def.Ticks - if wait <= 0 { - wait = 2 - } - return &action.RecipeDef{ - ID: "combine_" + def.ID, - Type: "combine", - Wait: wait, - Consume: consume, - Output: def.ID, - Message: fmt.Sprintf("You create %s.", def.Name), - } -} diff --git a/internal/game/prompt.go b/internal/game/prompt.go deleted file mode 100644 index 2ecf367..0000000 --- a/internal/game/prompt.go +++ /dev/null @@ -1,100 +0,0 @@ -package game - -import ( - "fmt" - "strings" - - "thehouseoficarus/internal/color" - "thehouseoficarus/internal/combat" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" -) - -func (g *Game) promptStr(sess *net.Session) string { - prompt := "> " - if p := sess.Player; p != nil { - if p.Prompt != "" { - prompt = p.Prompt - } - } - - prompt = g.expandPromptColors(sess, prompt) - prompt = g.expandPromptVars(sess, prompt) - return prompt -} - -func (g *Game) expandPromptColors(sess *net.Session, text string) string { - return color.ExpandTags(g.colorMode(sess), text) -} - -func (g *Game) expandPromptVars(sess *net.Session, text string) string { - p := sess.Player - if p == nil { - return text - } - - text = strings.ReplaceAll(text, "%%", "\x00") - text = strings.ReplaceAll(text, "%h", fmt.Sprint(p.HP)) - text = strings.ReplaceAll(text, "%H", fmt.Sprint(p.MaxHP())) - text = strings.ReplaceAll(text, "%b", fmt.Sprintf("%.1f", p.Battery)) - text = strings.ReplaceAll(text, "%B", fmt.Sprintf("%.0f", p.MaxBattery())) - text = strings.ReplaceAll(text, "%c", fmt.Sprint(p.Credits)) - text = strings.ReplaceAll(text, "%i", fmt.Sprint(p.FreeSlots())) - text = strings.ReplaceAll(text, "%s", attackStyleShort(p.AttackStyle)) - text = strings.ReplaceAll(text, "%S", attackStyleLong(p.AttackStyle)) - - mobHP, mobMaxHP := "", "" - if cs := combat.GetCombat(p.Name); cs != nil { - mob := g.MobStore.GetInstance(cs.MobID) - if mob != nil && mob.HP > 0 { - mobHP = fmt.Sprint(mob.HP) - mobMaxHP = fmt.Sprint(mob.MaxHP) - } - } - text = strings.ReplaceAll(text, "%m", mobHP) - text = strings.ReplaceAll(text, "%M", mobMaxHP) - - for _, sk := range player.AllSkills { - tag := string(sk) - text = strings.ReplaceAll(text, "%X_"+tag, fmt.Sprint(p.Skills[sk])) - text = strings.ReplaceAll(text, "%x_"+tag, fmt.Sprint(xpToLevel(p, sk))) - } - - text = strings.ReplaceAll(text, "\x00", "%") - return text -} - -func attackStyleShort(s player.AttackStyle) string { - switch s { - case player.Accurate: - return "acc" - case player.Aggressive: - return "agg" - case player.Defensive: - return "def" - case player.Balanced: - return "bal" - } - return "acc" -} - -func attackStyleLong(s player.AttackStyle) string { - switch s { - case player.Accurate: - return "accurate" - case player.Aggressive: - return "aggressive" - case player.Defensive: - return "defensive" - case player.Balanced: - return "balanced" - } - return "accurate" -} - -func xpToLevel(p *player.Player, sk player.SkillName) int { - currentXP := p.Skills[sk] - currentLevel := p.Level(sk) - nextLevelXP := player.XPForLevel(currentLevel + 1) - return nextLevelXP - currentXP -} diff --git a/internal/game/science.go b/internal/game/science.go deleted file mode 100644 index 8c7f0af..0000000 --- a/internal/game/science.go +++ /dev/null @@ -1,231 +0,0 @@ -package game - -import ( - "fmt" - "os" - "path/filepath" - "sort" - "strings" - - "gopkg.in/yaml.v3" - - "thehouseoficarus/internal/object" - "thehouseoficarus/internal/player" -) - -type ModCategory string - -const ( - ModCombat ModCategory = "combat" - ModUtility ModCategory = "utility" - ModEnchant ModCategory = "enchant" - ModProcessing ModCategory = "processing" - ModTransport ModCategory = "transport" -) - -type ModDef struct { - ID string `yaml:"id"` - Name string `yaml:"name"` - Level int `yaml:"level"` - MaxHit int `yaml:"max_hit"` - BaseXP int `yaml:"base_xp"` - JunkCost map[string]int `yaml:"junk_cost"` - Category ModCategory `yaml:"category"` - Element string `yaml:"element"` - Destination int `yaml:"destination"` -} - -var AllMods []*ModDef - -var modByID map[string]*ModDef - -func (g *Game) LoadMods() error { - dir := filepath.Join(g.DataDir, "modules") - entries, err := os.ReadDir(dir) - if err != nil { - return err - } - AllMods = nil - modByID = make(map[string]*ModDef) - for _, e := range entries { - if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" { - continue - } - data, err := os.ReadFile(filepath.Join(dir, e.Name())) - if err != nil { - continue - } - var m ModDef - if err := yaml.Unmarshal(data, &m); err != nil { - continue - } - AllMods = append(AllMods, &m) - modByID[m.ID] = AllMods[len(AllMods)-1] - } - modByID = make(map[string]*ModDef, len(AllMods)) - for _, m := range AllMods { - modByID[m.ID] = m - } - return nil -} - -func GetMod(id string) *ModDef { - return modByID[id] -} - -func FindMod(input string) *ModDef { - if m, ok := modByID[input]; ok { - return m - } - lower := strings.ToLower(strings.ReplaceAll(input, " ", "_")) - for _, m := range AllMods { - if strings.HasPrefix(m.ID, lower) { - return m - } - } - lowerSpace := strings.ToLower(input) - for _, m := range AllMods { - if strings.HasPrefix(strings.ToLower(m.Name), lowerSpace) { - return m - } - } - return nil -} - -type chipEntry struct { - Input string - Output string - Qty int -} - -var enchantMap = map[string]map[string]string{ - "enchant_1": { - "sapphire_ring": "ring_of_recoil", - "sapphire_necklace": "necklace_of_passage", - "sapphire_bracelet": "bracelet_of_clay", - }, - "enchant_2": { - "emerald_ring": "ring_of_dueling", - "emerald_necklace": "binding_necklace", - "emerald_bracelet": "bracelet_of_slaughter", - }, - "enchant_3": { - "ruby_ring": "ring_of_forging", - "ruby_necklace": "digsite_pendant", - "ruby_bracelet": "inoculation_bracelet", - }, - "enchant_4": { - "diamond_ring": "ring_of_life", - "diamond_necklace": "phoenix_necklace", - "diamond_bracelet": "abyssal_bracelet", - }, -} - -var chipMap = map[string]chipEntry{ - "chip_sapphire": {"sapphire_bolts", "sapphire_bolts_e", 10}, - "chip_emerald": {"emerald_bolts", "emerald_bolts_e", 10}, - "chip_ruby": {"ruby_bolts", "ruby_bolts_e", 10}, - "chip_diamond": {"diamond_bolts", "diamond_bolts_e", 10}, -} - -func (g *Game) hasDeckEquipped(p *player.Player) bool { - itemID, ok := p.Equipment[object.SlotMainHand] - if !ok { - return false - } - def, err := g.ItemStore.Load(itemID) - if err != nil { - return false - } - return def.WeaponType == object.WeaponScience -} - -func (g *Game) equippedProvidesJunk(p *player.Player) string { - itemID, ok := p.Equipment[object.SlotMainHand] - if !ok { - return "" - } - def, err := g.ItemStore.Load(itemID) - if err != nil { - return "" - } - return def.ProvidesJunk -} - -func (g *Game) effectiveJunkCost(p *player.Player, mod *ModDef) map[string]int { - cost := make(map[string]int) - for k, v := range mod.JunkCost { - cost[k] = v - } - - hasDeck := g.hasDeckEquipped(p) - - if hasDeck { - delete(cost, "scrap_metal") - } - - providesJunk := g.equippedProvidesJunk(p) - if providesJunk != "" { - delete(cost, providesJunk) - } - - return cost -} - -func (g *Game) hasJunkCost(p *player.Player, mod *ModDef) bool { - cost := g.effectiveJunkCost(p, mod) - for itemID, qty := range cost { - if p.CountItem(itemID) < qty { - return false - } - } - return true -} - -func (g *Game) consumeJunkCost(p *player.Player, mod *ModDef) bool { - cost := g.effectiveJunkCost(p, mod) - for itemID, qty := range cost { - if !p.RemoveItem(itemID, qty) { - return false - } - } - g.AccountStore.SaveCharacter(p) - return true -} - -func (g *Game) junkCostString(p *player.Player, mod *ModDef) string { - cost := g.effectiveJunkCost(p, mod) - delete(cost, "scrap_metal") - if len(cost) == 0 { - return "free" - } - var parts []string - for itemID, qty := range cost { - def, _ := g.ItemStore.Load(itemID) - name := itemID - if def != nil { - name = def.Name - } - name = strings.TrimSuffix(name, "junk") - if qty > 1 { - parts = append(parts, fmt.Sprintf("%d %s", qty, name)) - } else { - parts = append(parts, name) - } - } - sort.Strings(parts) - return strings.Join(parts, ", ") -} - -func (g *Game) totalEquipScienceAttack(p *player.Player) int { - total := 0 - for _, itemID := range p.Equipment { - def, err := g.ItemStore.Load(itemID) - if err == nil { - total += def.Stats.ScienceAttack - } - } - return total -} - - diff --git a/internal/game/stations.go b/internal/game/stations.go deleted file mode 100644 index d30a35c..0000000 --- a/internal/game/stations.go +++ /dev/null @@ -1,28 +0,0 @@ -package game - -func (g *Game) findStation(roomID int, stationIDs []string) (defID string, displayName string) { - for _, obj := range g.World.AllObjInstances(roomID) { - if obj.Depleted { - continue - } - for _, sid := range stationIDs { - if obj.DefID == sid { - name := sid - if def, err := g.ObjectStore.Load(sid); err == nil { - name = def.Name - } - return sid, name - } - } - } - return "", "" -} - -func stationMatch(defID string, recipeStations []string) bool { - for _, rs := range recipeStations { - if rs == defID { - return true - } - } - return false -} diff --git a/internal/game/sys_assassin.go b/internal/game/sys_assassin.go new file mode 100644 index 0000000..1927399 --- /dev/null +++ b/internal/game/sys_assassin.go @@ -0,0 +1,174 @@ +package game + +import ( + "fmt" + "math/rand" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +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 + g.awardSkillXP(sess, p, player.Assassin, xp) + 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 +} diff --git a/internal/game/sys_buff.go b/internal/game/sys_buff.go new file mode 100644 index 0000000..9449731 --- /dev/null +++ b/internal/game/sys_buff.go @@ -0,0 +1,55 @@ +package game + +import ( + "strings" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) BuffTick() { + if g.Hub == nil { + return + } + for _, sess := range g.Hub.AllSessions() { + if sess.Player == nil { + continue + } + p := sess.Player + remaining := make([]player.PotionBuff, 0, len(p.ActiveBuffs)) + dirty := false + for _, buff := range p.ActiveBuffs { + buff.TicksLeft-- + if buff.TicksLeft > 0 { + remaining = append(remaining, buff) + } else { + dirty = true + } + } + if dirty { + p.ActiveBuffs = remaining + sess.WriteLine(g.colorize(sess, "broadcast", "\nYour potion buff has worn off.")) + } + } +} + +func (g *Game) buffLevelBonus(p *player.Player, stat string) int { + if g.Hub == nil { + return 0 + } + totalPercent := 0 + for _, buff := range p.ActiveBuffs { + if strings.EqualFold(buff.Stat, stat) { + totalPercent += buff.BonusPercent + } + } + if totalPercent == 0 { + return 0 + } + baseLevel := p.Level(player.SkillName(strings.ToLower(stat))) + return baseLevel * totalPercent / 100 +} + +func (g *Game) totalBuffedLevel(sess *net.Session, p *player.Player, stat string) int { + return p.Level(player.SkillName(strings.ToLower(stat))) + g.buffLevelBonus(p, stat) +} diff --git a/internal/game/sys_combat.go b/internal/game/sys_combat.go new file mode 100644 index 0000000..030bed1 --- /dev/null +++ b/internal/game/sys_combat.go @@ -0,0 +1,114 @@ +package game + +import ( + "fmt" + "sort" + + "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) dropItemsOnDeath(p *player.Player) { + roomID := p.RoomID + + if p.Credits > 0 { + g.World.AddGroundItem(roomID, "credits", p.Credits) + p.Credits = 0 + } + + var items []deathDrop + + for slot, inv := range p.Inventory { + if inv == nil || inv.Quantity <= 0 { + continue + } + val := 0 + if def, err := g.ItemStore.Load(inv.ItemID); err == nil { + val = def.Value * inv.Quantity + } + items = append(items, deathDrop{ + itemID: inv.ItemID, + quantity: inv.Quantity, + totalVal: val, + invSlot: slot, + }) + } + + for eqSlot, itemID := range p.Equipment { + val := 0 + if def, err := g.ItemStore.Load(itemID); err == nil { + val = def.Value + } + items = append(items, deathDrop{ + itemID: itemID, + quantity: 1, + totalVal: val, + isEquip: true, + equipSlot: eqSlot, + }) + } + + if len(items) <= 3 { + return + } + + sort.Slice(items, func(i, j int) bool { + return items[i].totalVal > items[j].totalVal + }) + + for i := 3; i < len(items); i++ { + it := items[i] + if it.isEquip { + delete(p.Equipment, it.equipSlot) + } else { + p.SetInvSlot(it.invSlot, nil) + } + g.World.AddGroundItem(roomID, it.itemID, it.quantity) + } +} + +func (g *Game) checkAggro(sess *net.Session) { + p := sess.Player + + if combat.GetCombat(p.Name) != nil { + return + } + + playerLevel := p.CombatLevel() + mobs := g.MobStore.MobsInRoom(p.RoomID) + + for _, mob := range mobs { + if !mob.Aggressive || mob.HP <= 0 || mob.Protected { + continue + } + if combat.IsMobInCombat(mob.InstanceID) { + continue + } + mobLevel := mobCombatLevel(mob) + if playerLevel > mobLevel*2 { + continue + } + aggMob := mob + g.Ticks.Subscribe(engine.ToTicks(1), func() bool { + if combat.GetCombat(p.Name) != nil { + return false + } + if aggMob.HP <= 0 || aggMob.RoomID != p.RoomID { + return false + } + if combat.IsMobInCombat(aggMob.InstanceID) { + return false + } + attacker := aggMob.Name + if !aggMob.Unique { + attacker = "The " + aggMob.Name + } + sess.WriteLine(fmt.Sprintf("\n%s attacks you!", g.colorize(sess, "mob_name", attacker))) + g.startCombat(sess, p, aggMob) + return false + }) + break + } +} diff --git a/internal/game/sys_construction.go b/internal/game/sys_construction.go new file mode 100644 index 0000000..06713cd --- /dev/null +++ b/internal/game/sys_construction.go @@ -0,0 +1,74 @@ +package game + +import ( + "fmt" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) processSawmill(sess *net.Session, p *player.Player) { + logTypes := map[string]struct { + plankID string + cost int + name string + }{ + "logs": {"planks", 5, "regular planks"}, + "oak_logs": {"oak_planks", 10, "oak planks"}, + "teak_logs": {"teak_planks", 20, "teak planks"}, + "mahogany_logs": {"mahogany_planks", 40, "mahogany planks"}, + } + + totalCost := 0 + totalPlanks := 0 + type plankResult struct { + plankID string + qty int + } + + var results []plankResult + + for logID, info := range logTypes { + qty := p.CountItem(logID) + if qty == 0 { + continue + } + cost := qty * info.cost + totalCost += cost + totalPlanks += qty + results = append(results, plankResult{info.plankID, qty}) + p.RemoveItem(logID, qty) + } + + if totalPlanks == 0 { + sess.WriteLine("You don't have any logs to process.") + return + } + + if p.Credits < totalCost { + sess.WriteLine(fmt.Sprintf("You need %d credits to process all your logs (you have %d).", totalCost, p.Credits)) + return + } + + p.Credits -= totalCost + + processed := 0 + for _, r := range results { + for i := 0; i < r.qty; i++ { + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + g.World.AddGroundItem(p.RoomID, r.plankID, r.qty-i) + break + } + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: r.plankID, Quantity: 1}) + processed++ + } + } + + g.AccountStore.SaveCharacter(p) + + sess.WriteLine(fmt.Sprintf("The sawmill operator processes your logs into %d planks for %d credits.", processed, totalCost)) + if processed < totalPlanks { + sess.WriteLine(fmt.Sprintf("Your inventory is full. The remaining %d planks fall to the ground.", totalPlanks-processed)) + } +} diff --git a/internal/game/sys_science.go b/internal/game/sys_science.go new file mode 100644 index 0000000..23aeef4 --- /dev/null +++ b/internal/game/sys_science.go @@ -0,0 +1,220 @@ +package game + +import ( + "fmt" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" + + "thehouseoficarus/internal/action" + "thehouseoficarus/internal/object" + "thehouseoficarus/internal/player" +) + +type ModCategory string + +const ( + ModCombat ModCategory = "combat" + ModUtility ModCategory = "utility" + ModEnchant ModCategory = "enchant" + ModProcessing ModCategory = "processing" + ModTransport ModCategory = "transport" +) + +type ModDef struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Level int `yaml:"level"` + MaxHit int `yaml:"max_hit"` + BaseXP int `yaml:"base_xp"` + JunkCost map[string]int `yaml:"junk_cost"` + Category ModCategory `yaml:"category"` + Element string `yaml:"element"` + Destination int `yaml:"destination"` +} + +var AllMods []*ModDef + +var modByID map[string]*ModDef + +func (g *Game) LoadMods() error { + dir := filepath.Join(g.DataDir, "modules") + AllMods = nil + modByID = make(map[string]*ModDef) + action.WalkYAMLDir(dir, func(path, id string, data []byte) error { + var m ModDef + if err := yaml.Unmarshal(data, &m); err != nil { + return nil + } + AllMods = append(AllMods, &m) + return nil + }) + modByID = make(map[string]*ModDef, len(AllMods)) + for _, m := range AllMods { + modByID[m.ID] = m + } + return nil +} + +func GetMod(id string) *ModDef { + return modByID[id] +} + +func FindMod(input string) *ModDef { + if m, ok := modByID[input]; ok { + return m + } + lower := strings.ToLower(strings.ReplaceAll(input, " ", "_")) + for _, m := range AllMods { + if strings.HasPrefix(m.ID, lower) { + return m + } + } + lowerSpace := strings.ToLower(input) + for _, m := range AllMods { + if strings.HasPrefix(strings.ToLower(m.Name), lowerSpace) { + return m + } + } + return nil +} + +type chipEntry struct { + Input string + Output string + Qty int +} + +var enchantMap = map[string]map[string]string{ + "enchant_1": { + "sapphire_ring": "ring_of_recoil", + "sapphire_necklace": "necklace_of_passage", + "sapphire_bracelet": "bracelet_of_clay", + }, + "enchant_2": { + "emerald_ring": "ring_of_dueling", + "emerald_necklace": "binding_necklace", + "emerald_bracelet": "bracelet_of_slaughter", + }, + "enchant_3": { + "ruby_ring": "ring_of_forging", + "ruby_necklace": "digsite_pendant", + "ruby_bracelet": "inoculation_bracelet", + }, + "enchant_4": { + "diamond_ring": "ring_of_life", + "diamond_necklace": "phoenix_necklace", + "diamond_bracelet": "abyssal_bracelet", + }, +} + +var chipMap = map[string]chipEntry{ + "chip_sapphire": {"sapphire_bolts", "sapphire_bolts_e", 10}, + "chip_emerald": {"emerald_bolts", "emerald_bolts_e", 10}, + "chip_ruby": {"ruby_bolts", "ruby_bolts_e", 10}, + "chip_diamond": {"diamond_bolts", "diamond_bolts_e", 10}, +} + +func (g *Game) hasDeckEquipped(p *player.Player) bool { + itemID, ok := p.Equipment[object.SlotMainHand] + if !ok { + return false + } + def, err := g.ItemStore.Load(itemID) + if err != nil { + return false + } + return def.WeaponType == object.WeaponScience +} + +func (g *Game) equippedProvidesJunk(p *player.Player) string { + itemID, ok := p.Equipment[object.SlotMainHand] + if !ok { + return "" + } + def, err := g.ItemStore.Load(itemID) + if err != nil { + return "" + } + return def.ProvidesJunk +} + +func (g *Game) effectiveJunkCost(p *player.Player, mod *ModDef) map[string]int { + cost := make(map[string]int) + for k, v := range mod.JunkCost { + cost[k] = v + } + + hasDeck := g.hasDeckEquipped(p) + + if hasDeck { + delete(cost, "scrap_metal") + } + + providesJunk := g.equippedProvidesJunk(p) + if providesJunk != "" { + delete(cost, providesJunk) + } + + return cost +} + +func (g *Game) hasJunkCost(p *player.Player, mod *ModDef) bool { + cost := g.effectiveJunkCost(p, mod) + for itemID, qty := range cost { + if p.CountItem(itemID) < qty { + return false + } + } + return true +} + +func (g *Game) consumeJunkCost(p *player.Player, mod *ModDef) bool { + cost := g.effectiveJunkCost(p, mod) + for itemID, qty := range cost { + if !p.RemoveItem(itemID, qty) { + return false + } + } + g.AccountStore.SaveCharacter(p) + return true +} + +func (g *Game) junkCostString(p *player.Player, mod *ModDef) string { + cost := g.effectiveJunkCost(p, mod) + delete(cost, "scrap_metal") + if len(cost) == 0 { + return "free" + } + var parts []string + for itemID, qty := range cost { + def, _ := g.ItemStore.Load(itemID) + name := itemID + if def != nil { + name = def.Name + } + name = strings.TrimSuffix(name, "junk") + if qty > 1 { + parts = append(parts, fmt.Sprintf("%d %s", qty, name)) + } else { + parts = append(parts, name) + } + } + sort.Strings(parts) + return strings.Join(parts, ", ") +} + +func (g *Game) totalEquipScienceAttack(p *player.Player) int { + total := 0 + for _, itemID := range p.Equipment { + def, err := g.ItemStore.Load(itemID) + if err == nil { + total += def.Stats.ScienceAttack + } + } + return total +} + + diff --git a/internal/game/sys_technology.go b/internal/game/sys_technology.go new file mode 100644 index 0000000..a24c66d --- /dev/null +++ b/internal/game/sys_technology.go @@ -0,0 +1,250 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +type TechEffects struct { + AttackPercent int + StrengthPercent int + DefensePercent int + RangedPercent int + SciencePercent int + ProtectMelee bool + ProtectRanged bool + ProtectScience bool + DamageReduction float64 + HPRegenMulti float64 + PreserveDrain float64 + RetributionPct float64 +} + +type TechDef struct { + ID string + Name string + Level int + DrainRate float64 + Category string + Group string + Effects TechEffects +} + +var AllTechs []TechDef +var techByID map[string]*TechDef + +func init() { + AllTechs = []TechDef{ + {ID: "clarity_1", Name: "Clarity", Level: 4, DrainRate: 0.05, Category: "attack", Group: "attack_tier", Effects: TechEffects{AttackPercent: 5}}, + {ID: "clarity_2", Name: "Enhanced Clarity", Level: 16, DrainRate: 0.10, Category: "attack", Group: "attack_tier", Effects: TechEffects{AttackPercent: 10}}, + {ID: "clarity_3", Name: "Superior Clarity", Level: 44, DrainRate: 0.15, Category: "attack", Group: "attack_tier", Effects: TechEffects{AttackPercent: 15}}, + + {ID: "amplifier_1", Name: "Power Amplifier", Level: 7, DrainRate: 0.05, Category: "strength", Group: "strength_tier", Effects: TechEffects{StrengthPercent: 5}}, + {ID: "amplifier_2", Name: "Enhanced Amplifier", Level: 23, DrainRate: 0.10, Category: "strength", Group: "strength_tier", Effects: TechEffects{StrengthPercent: 10}}, + {ID: "amplifier_3", Name: "Superior Amplifier", Level: 49, DrainRate: 0.15, Category: "strength", Group: "strength_tier", Effects: TechEffects{StrengthPercent: 15}}, + + {ID: "shield_1", Name: "Energy Shield", Level: 10, DrainRate: 0.05, Category: "defense", Group: "defense_tier", Effects: TechEffects{DefensePercent: 5}}, + {ID: "shield_2", Name: "Enhanced Shield", Level: 28, DrainRate: 0.10, Category: "defense", Group: "defense_tier", Effects: TechEffects{DefensePercent: 10}}, + {ID: "shield_3", Name: "Superior Shield", Level: 52, DrainRate: 0.15, Category: "defense", Group: "defense_tier", Effects: TechEffects{DefensePercent: 15}}, + + {ID: "targeting_1", Name: "Targeting System", Level: 8, DrainRate: 0.05, Category: "ranged", Group: "ranged_tier", Effects: TechEffects{RangedPercent: 5}}, + {ID: "targeting_2", Name: "Enhanced Targeting", Level: 22, DrainRate: 0.10, Category: "ranged", Group: "ranged_tier", Effects: TechEffects{RangedPercent: 10}}, + {ID: "targeting_3", Name: "Superior Targeting", Level: 46, DrainRate: 0.15, Category: "ranged", Group: "ranged_tier", Effects: TechEffects{RangedPercent: 15}}, + + {ID: "focus_1", Name: "Neural Focus", Level: 9, DrainRate: 0.05, Category: "science", Group: "science_tier", Effects: TechEffects{SciencePercent: 5}}, + {ID: "focus_2", Name: "Enhanced Focus", Level: 27, DrainRate: 0.10, Category: "science", Group: "science_tier", Effects: TechEffects{SciencePercent: 10}}, + {ID: "focus_3", Name: "Superior Focus", Level: 55, DrainRate: 0.15, Category: "science", Group: "science_tier", Effects: TechEffects{SciencePercent: 15}}, + + {ID: "protect_melee", Name: "Kinetic Barrier", Level: 37, DrainRate: 0.20, Category: "protection", Group: "protection", Effects: TechEffects{ProtectMelee: true, DamageReduction: 0.4}}, + {ID: "protect_ranged", Name: "Projectile Screen", Level: 40, DrainRate: 0.20, Category: "protection", Group: "protection", Effects: TechEffects{ProtectRanged: true, DamageReduction: 0.4}}, + {ID: "protect_science", Name: "Neural Firewall", Level: 43, DrainRate: 0.20, Category: "protection", Group: "protection", Effects: TechEffects{ProtectScience: true, DamageReduction: 0.4}}, + + {ID: "regen", Name: "Nano Repair", Level: 22, DrainRate: 0.10, Category: "utility", Group: "regen_tier", Effects: TechEffects{HPRegenMulti: 2.0}}, + {ID: "rapid_heal", Name: "Rapid Repair", Level: 31, DrainRate: 0.15, Category: "utility", Group: "regen_tier", Effects: TechEffects{HPRegenMulti: 4.0}}, + {ID: "preserve", Name: "Power Saver", Level: 55, DrainRate: 0.05, Category: "utility", Group: "", Effects: TechEffects{PreserveDrain: 0.2}}, + {ID: "retribution", Name: "Dead Man's Switch", Level: 46, DrainRate: 0.10, Category: "utility", Group: "", Effects: TechEffects{RetributionPct: 0.25}}, + + {ID: "overclock", Name: "Overclock", Level: 60, DrainRate: 0.25, Category: "combo", Group: "attack_tier", Effects: TechEffects{AttackPercent: 15, StrengthPercent: 15}}, + {ID: "fortify", Name: "Fortify", Level: 65, DrainRate: 0.25, Category: "combo", Group: "defense_tier", Effects: TechEffects{DefensePercent: 15, HPRegenMulti: 2.0}}, + } + + techByID = make(map[string]*TechDef, len(AllTechs)) + for i := range AllTechs { + techByID[AllTechs[i].ID] = &AllTechs[i] + } +} + +func GetTechDef(id string) *TechDef { + return techByID[id] +} + +func TechByPrefixMatch(input string) []*TechDef { + input = strings.ToLower(input) + var exact []*TechDef + var prefix []*TechDef + for i := range AllTechs { + lower := strings.ToLower(AllTechs[i].Name) + lowerID := strings.ToLower(AllTechs[i].ID) + if lower == input || lowerID == input { + exact = append(exact, &AllTechs[i]) + } else if strings.HasPrefix(lower, input) || strings.HasPrefix(lowerID, input) { + prefix = append(prefix, &AllTechs[i]) + } + } + if len(exact) > 0 { + return exact + } + return prefix +} + +func techEffectString(tech TechDef) string { + var parts []string + e := tech.Effects + if e.AttackPercent > 0 { + parts = append(parts, fmt.Sprintf("+%d%% Attack", e.AttackPercent)) + } + if e.StrengthPercent > 0 { + parts = append(parts, fmt.Sprintf("+%d%% Strength", e.StrengthPercent)) + } + if e.DefensePercent > 0 { + parts = append(parts, fmt.Sprintf("+%d%% Defense", e.DefensePercent)) + } + if e.RangedPercent > 0 { + parts = append(parts, fmt.Sprintf("+%d%% Ranged", e.RangedPercent)) + } + if e.SciencePercent > 0 { + parts = append(parts, fmt.Sprintf("+%d%% Science", e.SciencePercent)) + } + if e.ProtectMelee { + parts = append(parts, fmt.Sprintf("%.0f%% melee protection", e.DamageReduction*100)) + } + if e.ProtectRanged { + parts = append(parts, fmt.Sprintf("%.0f%% ranged protection", e.DamageReduction*100)) + } + if e.ProtectScience { + parts = append(parts, fmt.Sprintf("%.0f%% science protection", e.DamageReduction*100)) + } + if e.HPRegenMulti > 0 { + parts = append(parts, fmt.Sprintf("%.0fx HP regen", e.HPRegenMulti)) + } + if e.PreserveDrain > 0 { + parts = append(parts, fmt.Sprintf("%.0f%% drain reduction", e.PreserveDrain*100)) + } + if e.RetributionPct > 0 { + parts = append(parts, fmt.Sprintf("%.0f%% retribution", e.RetributionPct*100)) + } + return strings.Join(parts, ", ") +} + +func (g *Game) totalTechBonus(p *player.Player) int { + total := 0 + for _, itemID := range p.Equipment { + def, err := g.ItemStore.Load(itemID) + if err == nil { + total += def.Stats.TechnologyBonus + } + } + return total +} + +func (g *Game) techLevelBonus(p *player.Player, stat string) int { + if len(p.ActiveTechs) == 0 { + return 0 + } + + totalPercent := 0 + for id := range p.ActiveTechs { + def := GetTechDef(id) + if def == nil { + continue + } + switch stat { + case "attack": + totalPercent += def.Effects.AttackPercent + case "strength": + totalPercent += def.Effects.StrengthPercent + case "defense": + totalPercent += def.Effects.DefensePercent + case "ranged": + totalPercent += def.Effects.RangedPercent + case "science": + totalPercent += def.Effects.SciencePercent + } + } + + if totalPercent == 0 { + return 0 + } + + var baseLevel int + switch stat { + case "attack": + baseLevel = p.Level(player.Attack) + case "strength": + baseLevel = p.Level(player.Strength) + case "defense": + baseLevel = p.Level(player.Defense) + case "ranged": + baseLevel = p.Level(player.Ranged) + case "science": + baseLevel = p.Level(player.Science) + } + + return baseLevel * totalPercent / 100 +} + +func (g *Game) tryRecharge(sess *net.Session, p *player.Player, input string) bool { + lower := strings.ToLower(strings.TrimSpace(input)) + instances := g.World.FindObjInstances(p.RoomID, lower) + for _, st := range instances { + if st.DefID == "charging_station" || st.DefID == "power_conduit" { + if p.Battery >= p.MaxBattery() { + sess.WriteLine("\nYour battery is already full.") + } else { + p.Battery = p.MaxBattery() + g.AccountStore.SaveCharacter(p) + sess.WriteLine(g.colorize(sess, "battery", + "\nYou connect to the charging station. Your battery is fully recharged.")) + } + return true + } + } + return false +} + +func (g *Game) applyTechProtection(p *player.Player, mob *world.MobInstance, dmg int) int { + if len(p.ActiveTechs) == 0 { + return dmg + } + + attackType := mob.AttackType + if attackType == "" { + attackType = "crush" + } + + var protectTechID string + switch attackType { + case "stab", "slash", "crush": + protectTechID = "protect_melee" + case "ranged": + protectTechID = "protect_ranged" + case "science": + protectTechID = "protect_science" + } + + if protectTechID != "" && p.HasActiveTech(protectTechID) { + def := GetTechDef(protectTechID) + if def != nil { + reduced := int(float64(dmg) * (1.0 - def.Effects.DamageReduction)) + if reduced < 0 { + reduced = 0 + } + return reduced + } + } + return dmg +} diff --git a/internal/game/table.go b/internal/game/table.go deleted file mode 100644 index b96dca1..0000000 --- a/internal/game/table.go +++ /dev/null @@ -1,150 +0,0 @@ -package game - -import ( - "strings" - - "thehouseoficarus/internal/color" -) - -type tableGlyphs struct { - topLeft, topSep, topRight rune - side, colSep rune - sepLeft, sepCross, sepRight rune - botLeft, botSep, botRight rune - fillH, fillHSep rune - titleSep rune -} - -func tableGlyphSet(unicode bool) tableGlyphs { - if unicode { - return tableGlyphs{ - topLeft: '╔', topSep: '╤', topRight: '╗', side: '║', colSep: '│', - sepLeft: '╟', sepCross: '┼', sepRight: '╢', - botLeft: '╚', botSep: '╧', botRight: '╝', fillH: '═', fillHSep: '─', - titleSep: '┬', - } - } - return tableGlyphs{ - topLeft: '+', topSep: '+', topRight: '+', side: '|', colSep: '|', - sepLeft: '+', sepCross: '+', sepRight: '+', - botLeft: '+', botSep: '+', botRight: '+', fillH: '-', fillHSep: '-', - titleSep: '+', - } -} - -type Table struct { - Title string - Columns []string - Rows [][]string -} - -func (t *Table) Render(unicode bool) []string { - g := tableGlyphSet(unicode) - - nCols := len(t.Columns) - if nCols == 0 && len(t.Rows) > 0 { - nCols = len(t.Rows[0]) - } - if nCols == 0 { - return nil - } - - colWidths := make([]int, nCols) - for i, h := range t.Columns { - colWidths[i] = color.VisibleLen(h) - } - for _, row := range t.Rows { - for i, cell := range row { - if i >= nCols { - break - } - vl := color.VisibleLen(cell) - if vl > colWidths[i] { - colWidths[i] = vl - } - } - } - - makeSep := func(left, cross, right rune, fill rune) string { - var b strings.Builder - b.WriteRune(left) - for i := 0; i < nCols; i++ { - if i > 0 { - b.WriteRune(cross) - } - b.WriteString(strings.Repeat(string(fill), colWidths[i]+2)) - } - b.WriteRune(right) - return b.String() - } - - makeRow := func(cells []string) string { - var b strings.Builder - b.WriteRune(g.side) - for i := 0; i < nCols; i++ { - if i > 0 { - b.WriteRune(g.colSep) - } - cell := "" - if i < len(cells) { - cell = cells[i] - } - pad := colWidths[i] - color.VisibleLen(cell) - if pad < 0 { - pad = 0 - } - b.WriteString(" " + cell + strings.Repeat(" ", pad) + " ") - } - b.WriteRune(g.side) - return b.String() - } - - hasTitle := t.Title != "" - hasCols := len(t.Columns) > 0 - - var out []string - - if hasTitle { - innerWidth := 0 - for _, w := range colWidths { - innerWidth += w + 2 - } - innerWidth += nCols - 1 - - var tb strings.Builder - tb.WriteRune(g.topLeft) - tb.WriteString(strings.Repeat(string(g.fillH), innerWidth)) - tb.WriteRune(g.topRight) - out = append(out, tb.String()) - - var tr strings.Builder - tr.WriteRune(g.side) - tr.WriteString(" ") - tr.WriteString(t.Title) - padding := innerWidth - 2 - color.VisibleLen(t.Title) - if padding < 0 { - padding = 0 - } - tr.WriteString(strings.Repeat(" ", padding)) - tr.WriteString(" ") - tr.WriteRune(g.side) - out = append(out, tr.String()) - - out = append(out, makeSep(g.sepLeft, g.titleSep, g.sepRight, g.fillHSep)) - } else { - out = append(out, makeSep(g.topLeft, g.topSep, g.topRight, g.fillH)) - } - - if hasCols { - out = append(out, makeRow(t.Columns)) - out = append(out, makeSep(g.sepLeft, g.sepCross, g.sepRight, g.fillHSep)) - } - - for _, row := range t.Rows { - out = append(out, makeRow(row)) - } - - out = append(out, makeSep(g.botLeft, g.botSep, g.botRight, g.fillH)) - - return out -} diff --git a/internal/game/tech.go b/internal/game/tech.go deleted file mode 100644 index a24c66d..0000000 --- a/internal/game/tech.go +++ /dev/null @@ -1,250 +0,0 @@ -package game - -import ( - "fmt" - "strings" - - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" -) - -type TechEffects struct { - AttackPercent int - StrengthPercent int - DefensePercent int - RangedPercent int - SciencePercent int - ProtectMelee bool - ProtectRanged bool - ProtectScience bool - DamageReduction float64 - HPRegenMulti float64 - PreserveDrain float64 - RetributionPct float64 -} - -type TechDef struct { - ID string - Name string - Level int - DrainRate float64 - Category string - Group string - Effects TechEffects -} - -var AllTechs []TechDef -var techByID map[string]*TechDef - -func init() { - AllTechs = []TechDef{ - {ID: "clarity_1", Name: "Clarity", Level: 4, DrainRate: 0.05, Category: "attack", Group: "attack_tier", Effects: TechEffects{AttackPercent: 5}}, - {ID: "clarity_2", Name: "Enhanced Clarity", Level: 16, DrainRate: 0.10, Category: "attack", Group: "attack_tier", Effects: TechEffects{AttackPercent: 10}}, - {ID: "clarity_3", Name: "Superior Clarity", Level: 44, DrainRate: 0.15, Category: "attack", Group: "attack_tier", Effects: TechEffects{AttackPercent: 15}}, - - {ID: "amplifier_1", Name: "Power Amplifier", Level: 7, DrainRate: 0.05, Category: "strength", Group: "strength_tier", Effects: TechEffects{StrengthPercent: 5}}, - {ID: "amplifier_2", Name: "Enhanced Amplifier", Level: 23, DrainRate: 0.10, Category: "strength", Group: "strength_tier", Effects: TechEffects{StrengthPercent: 10}}, - {ID: "amplifier_3", Name: "Superior Amplifier", Level: 49, DrainRate: 0.15, Category: "strength", Group: "strength_tier", Effects: TechEffects{StrengthPercent: 15}}, - - {ID: "shield_1", Name: "Energy Shield", Level: 10, DrainRate: 0.05, Category: "defense", Group: "defense_tier", Effects: TechEffects{DefensePercent: 5}}, - {ID: "shield_2", Name: "Enhanced Shield", Level: 28, DrainRate: 0.10, Category: "defense", Group: "defense_tier", Effects: TechEffects{DefensePercent: 10}}, - {ID: "shield_3", Name: "Superior Shield", Level: 52, DrainRate: 0.15, Category: "defense", Group: "defense_tier", Effects: TechEffects{DefensePercent: 15}}, - - {ID: "targeting_1", Name: "Targeting System", Level: 8, DrainRate: 0.05, Category: "ranged", Group: "ranged_tier", Effects: TechEffects{RangedPercent: 5}}, - {ID: "targeting_2", Name: "Enhanced Targeting", Level: 22, DrainRate: 0.10, Category: "ranged", Group: "ranged_tier", Effects: TechEffects{RangedPercent: 10}}, - {ID: "targeting_3", Name: "Superior Targeting", Level: 46, DrainRate: 0.15, Category: "ranged", Group: "ranged_tier", Effects: TechEffects{RangedPercent: 15}}, - - {ID: "focus_1", Name: "Neural Focus", Level: 9, DrainRate: 0.05, Category: "science", Group: "science_tier", Effects: TechEffects{SciencePercent: 5}}, - {ID: "focus_2", Name: "Enhanced Focus", Level: 27, DrainRate: 0.10, Category: "science", Group: "science_tier", Effects: TechEffects{SciencePercent: 10}}, - {ID: "focus_3", Name: "Superior Focus", Level: 55, DrainRate: 0.15, Category: "science", Group: "science_tier", Effects: TechEffects{SciencePercent: 15}}, - - {ID: "protect_melee", Name: "Kinetic Barrier", Level: 37, DrainRate: 0.20, Category: "protection", Group: "protection", Effects: TechEffects{ProtectMelee: true, DamageReduction: 0.4}}, - {ID: "protect_ranged", Name: "Projectile Screen", Level: 40, DrainRate: 0.20, Category: "protection", Group: "protection", Effects: TechEffects{ProtectRanged: true, DamageReduction: 0.4}}, - {ID: "protect_science", Name: "Neural Firewall", Level: 43, DrainRate: 0.20, Category: "protection", Group: "protection", Effects: TechEffects{ProtectScience: true, DamageReduction: 0.4}}, - - {ID: "regen", Name: "Nano Repair", Level: 22, DrainRate: 0.10, Category: "utility", Group: "regen_tier", Effects: TechEffects{HPRegenMulti: 2.0}}, - {ID: "rapid_heal", Name: "Rapid Repair", Level: 31, DrainRate: 0.15, Category: "utility", Group: "regen_tier", Effects: TechEffects{HPRegenMulti: 4.0}}, - {ID: "preserve", Name: "Power Saver", Level: 55, DrainRate: 0.05, Category: "utility", Group: "", Effects: TechEffects{PreserveDrain: 0.2}}, - {ID: "retribution", Name: "Dead Man's Switch", Level: 46, DrainRate: 0.10, Category: "utility", Group: "", Effects: TechEffects{RetributionPct: 0.25}}, - - {ID: "overclock", Name: "Overclock", Level: 60, DrainRate: 0.25, Category: "combo", Group: "attack_tier", Effects: TechEffects{AttackPercent: 15, StrengthPercent: 15}}, - {ID: "fortify", Name: "Fortify", Level: 65, DrainRate: 0.25, Category: "combo", Group: "defense_tier", Effects: TechEffects{DefensePercent: 15, HPRegenMulti: 2.0}}, - } - - techByID = make(map[string]*TechDef, len(AllTechs)) - for i := range AllTechs { - techByID[AllTechs[i].ID] = &AllTechs[i] - } -} - -func GetTechDef(id string) *TechDef { - return techByID[id] -} - -func TechByPrefixMatch(input string) []*TechDef { - input = strings.ToLower(input) - var exact []*TechDef - var prefix []*TechDef - for i := range AllTechs { - lower := strings.ToLower(AllTechs[i].Name) - lowerID := strings.ToLower(AllTechs[i].ID) - if lower == input || lowerID == input { - exact = append(exact, &AllTechs[i]) - } else if strings.HasPrefix(lower, input) || strings.HasPrefix(lowerID, input) { - prefix = append(prefix, &AllTechs[i]) - } - } - if len(exact) > 0 { - return exact - } - return prefix -} - -func techEffectString(tech TechDef) string { - var parts []string - e := tech.Effects - if e.AttackPercent > 0 { - parts = append(parts, fmt.Sprintf("+%d%% Attack", e.AttackPercent)) - } - if e.StrengthPercent > 0 { - parts = append(parts, fmt.Sprintf("+%d%% Strength", e.StrengthPercent)) - } - if e.DefensePercent > 0 { - parts = append(parts, fmt.Sprintf("+%d%% Defense", e.DefensePercent)) - } - if e.RangedPercent > 0 { - parts = append(parts, fmt.Sprintf("+%d%% Ranged", e.RangedPercent)) - } - if e.SciencePercent > 0 { - parts = append(parts, fmt.Sprintf("+%d%% Science", e.SciencePercent)) - } - if e.ProtectMelee { - parts = append(parts, fmt.Sprintf("%.0f%% melee protection", e.DamageReduction*100)) - } - if e.ProtectRanged { - parts = append(parts, fmt.Sprintf("%.0f%% ranged protection", e.DamageReduction*100)) - } - if e.ProtectScience { - parts = append(parts, fmt.Sprintf("%.0f%% science protection", e.DamageReduction*100)) - } - if e.HPRegenMulti > 0 { - parts = append(parts, fmt.Sprintf("%.0fx HP regen", e.HPRegenMulti)) - } - if e.PreserveDrain > 0 { - parts = append(parts, fmt.Sprintf("%.0f%% drain reduction", e.PreserveDrain*100)) - } - if e.RetributionPct > 0 { - parts = append(parts, fmt.Sprintf("%.0f%% retribution", e.RetributionPct*100)) - } - return strings.Join(parts, ", ") -} - -func (g *Game) totalTechBonus(p *player.Player) int { - total := 0 - for _, itemID := range p.Equipment { - def, err := g.ItemStore.Load(itemID) - if err == nil { - total += def.Stats.TechnologyBonus - } - } - return total -} - -func (g *Game) techLevelBonus(p *player.Player, stat string) int { - if len(p.ActiveTechs) == 0 { - return 0 - } - - totalPercent := 0 - for id := range p.ActiveTechs { - def := GetTechDef(id) - if def == nil { - continue - } - switch stat { - case "attack": - totalPercent += def.Effects.AttackPercent - case "strength": - totalPercent += def.Effects.StrengthPercent - case "defense": - totalPercent += def.Effects.DefensePercent - case "ranged": - totalPercent += def.Effects.RangedPercent - case "science": - totalPercent += def.Effects.SciencePercent - } - } - - if totalPercent == 0 { - return 0 - } - - var baseLevel int - switch stat { - case "attack": - baseLevel = p.Level(player.Attack) - case "strength": - baseLevel = p.Level(player.Strength) - case "defense": - baseLevel = p.Level(player.Defense) - case "ranged": - baseLevel = p.Level(player.Ranged) - case "science": - baseLevel = p.Level(player.Science) - } - - return baseLevel * totalPercent / 100 -} - -func (g *Game) tryRecharge(sess *net.Session, p *player.Player, input string) bool { - lower := strings.ToLower(strings.TrimSpace(input)) - instances := g.World.FindObjInstances(p.RoomID, lower) - for _, st := range instances { - if st.DefID == "charging_station" || st.DefID == "power_conduit" { - if p.Battery >= p.MaxBattery() { - sess.WriteLine("\nYour battery is already full.") - } else { - p.Battery = p.MaxBattery() - g.AccountStore.SaveCharacter(p) - sess.WriteLine(g.colorize(sess, "battery", - "\nYou connect to the charging station. Your battery is fully recharged.")) - } - return true - } - } - return false -} - -func (g *Game) applyTechProtection(p *player.Player, mob *world.MobInstance, dmg int) int { - if len(p.ActiveTechs) == 0 { - return dmg - } - - attackType := mob.AttackType - if attackType == "" { - attackType = "crush" - } - - var protectTechID string - switch attackType { - case "stab", "slash", "crush": - protectTechID = "protect_melee" - case "ranged": - protectTechID = "protect_ranged" - case "science": - protectTechID = "protect_science" - } - - if protectTechID != "" && p.HasActiveTech(protectTechID) { - def := GetTechDef(protectTechID) - if def != nil { - reduced := int(float64(dmg) * (1.0 - def.Effects.DamageReduction)) - if reduced < 0 { - reduced = 0 - } - return reduced - } - } - return dmg -} diff --git a/internal/game/types.go b/internal/game/types.go deleted file mode 100644 index d41b18a..0000000 --- a/internal/game/types.go +++ /dev/null @@ -1,29 +0,0 @@ -package game - -import "thehouseoficarus/internal/object" - -var EquipSlots = []object.EquipSlot{ - object.SlotHead, object.SlotNeck, object.SlotTorso, object.SlotLegs, - object.SlotHands, object.SlotFeet, object.SlotBack, object.SlotAmmo, - object.SlotMainHand, object.SlotOffHand, object.SlotRing, -} - -type itemMatch struct { - ID string - Name string - Slot int -} - -type xpGain struct { - Skill string - XP int -} - -type deathDrop struct { - itemID string - quantity int - totalVal int - isEquip bool - equipSlot object.EquipSlot - invSlot int -} diff --git a/internal/game/ui_color.go b/internal/game/ui_color.go new file mode 100644 index 0000000..962728a --- /dev/null +++ b/internal/game/ui_color.go @@ -0,0 +1,79 @@ +package game + +import ( + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/config" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/object" +) + +func (g *Game) colorMode(sess *net.Session) string { + if p := sess.Player; p != nil { + return p.OptionString("color") + } + return "none" +} + +func (g *Game) resolveColor(sess *net.Session, category string) color.ColorSpec { + if sess.Account != nil && sess.Account.Colors != nil { + if val, ok := sess.Account.Colors[category]; ok { + if val == "off" { + return color.NoColor() + } + if val != "" { + return color.Parse(val) + } + } + } + if g.ColorConfig != nil { + if val, ok := (*g.ColorConfig)[category]; ok && val != "" { + return color.Parse(val) + } + } + if val, ok := config.DefaultColors()[category]; ok && val != "" { + return color.Parse(val) + } + return color.NoColor() +} + +func (g *Game) colorize(sess *net.Session, category, text string) string { + spec := g.resolveColor(sess, category) + return color.Render(g.colorMode(sess), spec, text) +} + +func levelColorSpec(myLevel, theirLevel int) color.ColorSpec { + diff := theirLevel - myLevel + switch { + case diff == 0: + return color.Parse("231") + case diff > 0 && diff < 5: + return color.Parse("208") + case diff >= 5: + return color.Parse("196") + case diff < 0 && diff > -5: + return color.Parse("190") + default: + return color.Parse("34") + } +} + +func (g *Game) levelColorize(sess *net.Session, myLevel, theirLevel int, text string) string { + spec := levelColorSpec(myLevel, theirLevel) + return color.Render(g.colorMode(sess), spec, text) +} + +func (g *Game) objColorize(sess *net.Session, objDef *object.ObjectDef, text string) string { + if objDef != nil && objDef.Color != "" { + spec := color.Parse(objDef.Color) + return color.Render(g.colorMode(sess), spec, text) + } + return text +} + +func (g *Game) itemColorize(sess *net.Session, itemDef *object.ItemDef, text string) string { + if itemDef != nil && itemDef.Color != "" { + spec := color.Parse(itemDef.Color) + return color.Render(g.colorMode(sess), spec, text) + } + return g.colorize(sess, "item", text) +} diff --git a/internal/game/ui_help.go b/internal/game/ui_help.go new file mode 100644 index 0000000..151d1ba --- /dev/null +++ b/internal/game/ui_help.go @@ -0,0 +1,152 @@ +package game + +import ( + "fmt" + "path/filepath" + "strings" + + "thehouseoficarus/internal/action" + "thehouseoficarus/internal/net" + "gopkg.in/yaml.v3" +) + +type HelpDef struct { + Name string `yaml:"name"` + Category string `yaml:"category"` + Content string `yaml:"description"` +} + +type cmdEntry struct { + Name string + Type string + Desc string +} + +var commandList = []cmdEntry{ + {"alias", "Instant", "Create command shortcuts"}, + {"attack / kill", "Active", "Attack a mob"}, + {"autotrigger / auto", "Instant", "Set a module to auto-trigger"}, + {"bank", "Active", "Open bank interface"}, + {"burn", "Active", "Start a fire"}, + {"chop / cut", "Active", "Chop trees (Woodcutting)"}, + {"clean", "Free", "Clean grimy herbs (Pharmacy)"}, + {"color / colors", "Instant", "Customize display colors"}, + {"colortable", "Instant", "Display color reference chart"}, + {"construct / make", "Active", "Build furniture (Construction)"}, + {"cook", "Active", "Cook raw food on a fire or range"}, + {"craft", "Active", "Craft jewelry (Crafting)"}, + {"description / desc", "Instant", "Set your character description"}, + {"drink", "Free", "Drink a potion"}, + {"drop", "Active", "Drop items to the ground"}, + {"eat", "Free", "Eat food to restore hitpoints"}, + {"equipment / eq", "Instant", "Show equipped items"}, + {"exits", "Instant", "List available exits"}, + {"farm", "Active", "Farm crops (Farming)"}, + {"fish", "Active", "Fish at fishing spots (Fishing)"}, + {"fletch", "Free", "Fletch logs into bows (Fletching)"}, + {"get / take / pick", "Active", "Pick up items from the ground"}, + {"help", "Instant", "Show help topics"}, + {"id / identify", "Active", "Identify herbs (Pharmacy)"}, + {"inventory / i / inv", "Instant", "Show your inventory"}, + {"jack / jackin", "Active", "Jack into a terminal (Hacking)"}, + {"look / l", "Instant", "Look around or examine things"}, + {"map", "Instant", "Display an ASCII map of the area"}, + {"mine", "Active", "Mine rocks (Mining)"}, + {"mix", "Active", "Mix potions (Pharmacy)"}, + {"mods / modlist", "Instant", "List available science modules"}, + {"north / south / east / west / up / down", "Active", "Move in a direction"}, + {"option / options", "Instant", "View or change settings"}, + {"prompt", "Instant", "Set custom command prompt"}, + {"pull / push", "Active", "Interact with objects"}, + {"queued", "Instant", "Show pending tick actions"}, + {"quit", "Active", "Rest and disconnect"}, + {"remove / unwear / unwield", "Free", "Unequip items"}, + {"say", "Instant", "Chat with players in your room"}, + {"score / sc", "Instant", "View your stats and skills"}, + {"search", "Active", "Search items for loot"}, + {"smelt", "Active", "Smelt ore into bars (Smithing)"}, + {"smith", "Active", "Smith bars into items (Smithing)"}, + {"sneak", "Instant", "Toggle sneak mode"}, + {"steal", "Active", "Steal from mobs (Thieving)"}, + {"stoke", "Active", "Add logs to a fire"}, + {"style", "Instant", "Change combat style"}, + {"talk / speak / ask", "Active", "Talk to NPCs"}, + {"tech", "Instant", "Toggle technology abilities"}, + {"trigger", "Active", "Trigger a science module"}, + {"unalias", "Instant", "Remove command shortcuts"}, + {"use", "Active", "Use an object (crafting)"}, + {"walk", "Active", "Pathfind to a room or multi-step walk"}, + {"wear / wield", "Free", "Equip items"}, +} + +func LoadHelp(dataDir string) ([]HelpDef, error) { + dir := filepath.Join(dataDir, "help") + var helps []HelpDef + action.WalkYAMLDir(dir, func(path, id string, data []byte) error { + var h HelpDef + if err := yaml.Unmarshal(data, &h); err != nil { + return nil + } + helps = append(helps, h) + return nil + }) + return helps, nil +} + +func (g *Game) doHelp(sess *net.Session, topic string) { + if topic == "" { + unicode := true + if p := sess.Player; p != nil { + unicode = p.OptionBool("unicode") + } + + sess.WriteLine("") + t := &Table{ + Title: "Commands", + Columns: []string{"Command", "Type", "Description"}, + } + for _, c := range commandList { + t.Rows = append(t.Rows, []string{c.Name, c.Type, c.Desc}) + } + for _, line := range t.Render(unicode) { + sess.WriteLine(line) + } + sess.WriteLine("") + sess.WriteLine("Command types: Instant (runs immediately), Free (queued before active),") + sess.WriteLine("Active (replaces current action, queued per tick).") + sess.WriteLine("") + sess.WriteLine("Use 'help ' for detailed usage of a specific command.") + sess.WriteLine("Use 'option' to view and change settings, 'color' to customize colors.") + return + } + + helps, err := LoadHelp(g.DataDir) + if err != nil || len(helps) == 0 { + sess.WriteLine("No help available.") + return + } + + topic = strings.ToLower(topic) + for _, h := range helps { + if strings.ToLower(h.Name) == topic { + sess.WriteLine(fmt.Sprintf("\n %s", h.Content)) + return + } + } + sess.WriteLine(fmt.Sprintf("No help found for '%s'.", topic)) +} + +func firstLine(s string) string { + if idx := strings.Index(s, "\n"); idx >= 0 { + return s[:idx] + } + return s +} + +func (g *Game) executeHelp(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + g.doHelp(sess, "") + } else { + g.doHelp(sess, strings.Join(args, " ")) + } +} diff --git a/internal/game/ui_map.go b/internal/game/ui_map.go new file mode 100644 index 0000000..32d3d7c --- /dev/null +++ b/internal/game/ui_map.go @@ -0,0 +1,317 @@ +package game + +import ( + "strings" + + "thehouseoficarus/internal/world" +) + +type mapGlyphs struct { + topLeft, topRight rune + bottomLeft, bottomRight rune + side rune + topFill rune + connectorH, connectorV rune + upArrow, downArrow rune +} + +func mapGlyphsForPlayer(unicode bool) mapGlyphs { + if unicode { + return mapGlyphs{ + topLeft: '╔', topRight: '╗', bottomLeft: '╚', bottomRight: '╝', + side: '║', topFill: '═', connectorH: '─', connectorV: '│', + upArrow: '↑', downArrow: '↓', + } + } + return mapGlyphs{ + topLeft: '.', topRight: '.', bottomLeft: ':', bottomRight: ':', + side: ':', topFill: '.', connectorH: '-', connectorV: '|', + upArrow: '^', downArrow: 'v', + } +} + +type mapGraph struct { + posToRoom map[[2]int]int + roomToPos map[int][2]int +} + +var bfsDirs = []struct { + dir world.ExitDir + dx, dy int +}{ + {world.North, 0, -1}, + {world.South, 0, 1}, + {world.East, 1, 0}, + {world.West, -1, 0}, +} + +func buildGraph(g *Game, startRoomID int) *mapGraph { + mg := &mapGraph{ + posToRoom: make(map[[2]int]int), + roomToPos: make(map[int][2]int), + } + + type node struct { + roomID int + x, y int + } + queue := []node{{startRoomID, 0, 0}} + mg.posToRoom[[2]int{0, 0}] = startRoomID + mg.roomToPos[startRoomID] = [2]int{0, 0} + + for len(queue) > 0 { + n := queue[0] + queue = queue[1:] + + room, ok := loadRoom(g, n.roomID) + if !ok { + continue + } + + for _, d := range bfsDirs { + targetID, ok := exitTarget(room, d.dir) + if !ok { + continue + } + if _, visited := mg.roomToPos[targetID]; visited { + continue + } + nx, ny := n.x + d.dx, n.y + d.dy + mg.posToRoom[[2]int{nx, ny}] = targetID + mg.roomToPos[targetID] = [2]int{nx, ny} + queue = append(queue, node{targetID, nx, ny}) + } + } + + return mg +} + +func buildTinyMap(g *Game, roomID int, mg mapGlyphs) []string { + bg := buildGraph(g, roomID) + + grid := make([][]rune, 5) + for i := range grid { + grid[i] = make([]rune, 5) + for j := range grid[i] { + grid[i][j] = ' ' + } + } + + for y := -1; y <= 1; y++ { + for x := -1; x <= 1; x++ { + pos := [2]int{x, y} + rid, ok := bg.posToRoom[pos] + if !ok { + continue + } + gr := (y + 1) * 2 + gc := (x + 1) * 2 + if rid == roomID { + grid[gr][gc] = '@' + } else { + grid[gr][gc] = roomMapSymbol(g, rid) + } + } + } + + for y := -1; y <= 1; y++ { + for x := -1; x <= 0; x++ { + leftPos := [2]int{x, y} + rightPos := [2]int{x + 1, y} + leftRoom, leftOK := bg.posToRoom[leftPos] + rightRoom, rightOK := bg.posToRoom[rightPos] + if !leftOK || !rightOK { + continue + } + if exitsConnect(g, leftRoom, rightRoom, world.East, world.West) { + grid[(y+1)*2][(x+1)*2+1] = mg.connectorH + } + } + } + + for y := -1; y <= 0; y++ { + for x := -1; x <= 1; x++ { + topPos := [2]int{x, y} + bottomPos := [2]int{x, y + 1} + topRoom, topOK := bg.posToRoom[topPos] + bottomRoom, bottomOK := bg.posToRoom[bottomPos] + if !topOK || !bottomOK { + continue + } + if exitsConnect(g, topRoom, bottomRoom, world.South, world.North) { + grid[(y+1)*2+1][(x+1)*2] = mg.connectorV + } + } + } + + cur, _ := loadRoom(g, roomID) + if cur != nil { + if _, ok := exitTarget(cur, world.Up); ok { + grid[1][3] = mg.upArrow + } + if _, ok := exitTarget(cur, world.Down); ok { + grid[3][1] = mg.downArrow + } + } + + topFill := strings.Repeat(string(mg.topFill), 5) + lines := make([]string, 7) + lines[0] = string(mg.topLeft) + topFill + string(mg.topRight) + for row := 0; row < 5; row++ { + lines[row+1] = string(mg.side) + string(grid[row]) + string(mg.side) + } + botFill := strings.Repeat(string(mg.topFill), 5) + lines[6] = string(mg.bottomLeft) + botFill + string(mg.bottomRight) + + return lines +} + +func exitsConnect(g *Game, room1, room2 int, dir12, dir21 world.ExitDir) bool { + r1, ok := loadRoom(g, room1) + if !ok { + return false + } + if id, ok := exitTarget(r1, dir12); ok && id == room2 { + return true + } + r2, ok := loadRoom(g, room2) + if !ok { + return false + } + id, ok := exitTarget(r2, dir21) + return ok && id == room1 +} + +func exitTarget(room *world.Room, dir world.ExitDir) (int, bool) { + if room == nil { + return 0, false + } + exit, ok := room.Exits[dir] + if !ok { + return 0, false + } + return exit.Room, true +} + +func loadRoom(g *Game, roomID int) (*world.Room, bool) { + if roomID == 0 { + return nil, false + } + room, err := g.World.LoadRoom(roomID) + if err != nil { + return nil, false + } + return room, true +} + +func roomMapSymbol(g *Game, roomID int) rune { + room, ok := loadRoom(g, roomID) + if !ok { + return '?' + } + if room.MapSymbol != "" { + runes := []rune(room.MapSymbol) + return runes[0] + } + return 'o' +} + +func stripBlankRows(lines []string) []string { + var out []string + for _, line := range lines { + if strings.TrimSpace(line) != "" { + out = append(out, line) + } + } + return out +} + +func leftTrimCommon(lines []string) []string { + min := -1 + for _, line := range lines { + if strings.TrimSpace(line) == "" { + continue + } + n := 0 + for _, r := range line { + if r == ' ' { + n++ + } else { + break + } + } + if min < 0 || n < min { + min = n + } + } + if min <= 0 { + return lines + } + result := make([]string, len(lines)) + for i, line := range lines { + if len(line) <= min { + result[i] = "" + } else { + result[i] = line[min:] + } + } + return result +} + +func buildFullMap(g *Game, roomID, mapWidth, mapHeight int, mg mapGlyphs) []string { + bg := buildGraph(g, roomID) + + grid := make([][]rune, mapHeight) + for i := range grid { + grid[i] = make([]rune, mapWidth) + for j := range grid[i] { + grid[i][j] = ' ' + } + } + + cx := mapWidth / 2 + cy := mapHeight / 2 + + for pos, rid := range bg.posToRoom { + gr := cy + pos[1]*2 + gc := cx + pos[0]*2 + if gr < 0 || gr >= mapHeight || gc < 0 || gc >= mapWidth { + continue + } + if rid == roomID { + grid[gr][gc] = '@' + } else { + grid[gr][gc] = roomMapSymbol(g, rid) + } + } + + for pos, rid := range bg.posToRoom { + x, y := pos[0], pos[1] + + if rightID, ok := bg.posToRoom[[2]int{x + 1, y}]; ok { + if exitsConnect(g, rid, rightID, world.East, world.West) { + gr := cy + y*2 + gc := cx + x*2 + 1 + if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth { + grid[gr][gc] = mg.connectorH + } + } + } + + if bottomID, ok := bg.posToRoom[[2]int{x, y + 1}]; ok { + if exitsConnect(g, rid, bottomID, world.South, world.North) { + gr := cy + y*2 + 1 + gc := cx + x*2 + if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth { + grid[gr][gc] = mg.connectorV + } + } + } + } + + lines := make([]string, mapHeight) + for i := range grid { + lines[i] = string(grid[i]) + } + return lines +} diff --git a/internal/game/ui_prompt.go b/internal/game/ui_prompt.go new file mode 100644 index 0000000..2ecf367 --- /dev/null +++ b/internal/game/ui_prompt.go @@ -0,0 +1,100 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) promptStr(sess *net.Session) string { + prompt := "> " + if p := sess.Player; p != nil { + if p.Prompt != "" { + prompt = p.Prompt + } + } + + prompt = g.expandPromptColors(sess, prompt) + prompt = g.expandPromptVars(sess, prompt) + return prompt +} + +func (g *Game) expandPromptColors(sess *net.Session, text string) string { + return color.ExpandTags(g.colorMode(sess), text) +} + +func (g *Game) expandPromptVars(sess *net.Session, text string) string { + p := sess.Player + if p == nil { + return text + } + + text = strings.ReplaceAll(text, "%%", "\x00") + text = strings.ReplaceAll(text, "%h", fmt.Sprint(p.HP)) + text = strings.ReplaceAll(text, "%H", fmt.Sprint(p.MaxHP())) + text = strings.ReplaceAll(text, "%b", fmt.Sprintf("%.1f", p.Battery)) + text = strings.ReplaceAll(text, "%B", fmt.Sprintf("%.0f", p.MaxBattery())) + text = strings.ReplaceAll(text, "%c", fmt.Sprint(p.Credits)) + text = strings.ReplaceAll(text, "%i", fmt.Sprint(p.FreeSlots())) + text = strings.ReplaceAll(text, "%s", attackStyleShort(p.AttackStyle)) + text = strings.ReplaceAll(text, "%S", attackStyleLong(p.AttackStyle)) + + mobHP, mobMaxHP := "", "" + if cs := combat.GetCombat(p.Name); cs != nil { + mob := g.MobStore.GetInstance(cs.MobID) + if mob != nil && mob.HP > 0 { + mobHP = fmt.Sprint(mob.HP) + mobMaxHP = fmt.Sprint(mob.MaxHP) + } + } + text = strings.ReplaceAll(text, "%m", mobHP) + text = strings.ReplaceAll(text, "%M", mobMaxHP) + + for _, sk := range player.AllSkills { + tag := string(sk) + text = strings.ReplaceAll(text, "%X_"+tag, fmt.Sprint(p.Skills[sk])) + text = strings.ReplaceAll(text, "%x_"+tag, fmt.Sprint(xpToLevel(p, sk))) + } + + text = strings.ReplaceAll(text, "\x00", "%") + return text +} + +func attackStyleShort(s player.AttackStyle) string { + switch s { + case player.Accurate: + return "acc" + case player.Aggressive: + return "agg" + case player.Defensive: + return "def" + case player.Balanced: + return "bal" + } + return "acc" +} + +func attackStyleLong(s player.AttackStyle) string { + switch s { + case player.Accurate: + return "accurate" + case player.Aggressive: + return "aggressive" + case player.Defensive: + return "defensive" + case player.Balanced: + return "balanced" + } + return "accurate" +} + +func xpToLevel(p *player.Player, sk player.SkillName) int { + currentXP := p.Skills[sk] + currentLevel := p.Level(sk) + nextLevelXP := player.XPForLevel(currentLevel + 1) + return nextLevelXP - currentXP +} diff --git a/internal/game/ui_table.go b/internal/game/ui_table.go new file mode 100644 index 0000000..b96dca1 --- /dev/null +++ b/internal/game/ui_table.go @@ -0,0 +1,150 @@ +package game + +import ( + "strings" + + "thehouseoficarus/internal/color" +) + +type tableGlyphs struct { + topLeft, topSep, topRight rune + side, colSep rune + sepLeft, sepCross, sepRight rune + botLeft, botSep, botRight rune + fillH, fillHSep rune + titleSep rune +} + +func tableGlyphSet(unicode bool) tableGlyphs { + if unicode { + return tableGlyphs{ + topLeft: '╔', topSep: '╤', topRight: '╗', side: '║', colSep: '│', + sepLeft: '╟', sepCross: '┼', sepRight: '╢', + botLeft: '╚', botSep: '╧', botRight: '╝', fillH: '═', fillHSep: '─', + titleSep: '┬', + } + } + return tableGlyphs{ + topLeft: '+', topSep: '+', topRight: '+', side: '|', colSep: '|', + sepLeft: '+', sepCross: '+', sepRight: '+', + botLeft: '+', botSep: '+', botRight: '+', fillH: '-', fillHSep: '-', + titleSep: '+', + } +} + +type Table struct { + Title string + Columns []string + Rows [][]string +} + +func (t *Table) Render(unicode bool) []string { + g := tableGlyphSet(unicode) + + nCols := len(t.Columns) + if nCols == 0 && len(t.Rows) > 0 { + nCols = len(t.Rows[0]) + } + if nCols == 0 { + return nil + } + + colWidths := make([]int, nCols) + for i, h := range t.Columns { + colWidths[i] = color.VisibleLen(h) + } + for _, row := range t.Rows { + for i, cell := range row { + if i >= nCols { + break + } + vl := color.VisibleLen(cell) + if vl > colWidths[i] { + colWidths[i] = vl + } + } + } + + makeSep := func(left, cross, right rune, fill rune) string { + var b strings.Builder + b.WriteRune(left) + for i := 0; i < nCols; i++ { + if i > 0 { + b.WriteRune(cross) + } + b.WriteString(strings.Repeat(string(fill), colWidths[i]+2)) + } + b.WriteRune(right) + return b.String() + } + + makeRow := func(cells []string) string { + var b strings.Builder + b.WriteRune(g.side) + for i := 0; i < nCols; i++ { + if i > 0 { + b.WriteRune(g.colSep) + } + cell := "" + if i < len(cells) { + cell = cells[i] + } + pad := colWidths[i] - color.VisibleLen(cell) + if pad < 0 { + pad = 0 + } + b.WriteString(" " + cell + strings.Repeat(" ", pad) + " ") + } + b.WriteRune(g.side) + return b.String() + } + + hasTitle := t.Title != "" + hasCols := len(t.Columns) > 0 + + var out []string + + if hasTitle { + innerWidth := 0 + for _, w := range colWidths { + innerWidth += w + 2 + } + innerWidth += nCols - 1 + + var tb strings.Builder + tb.WriteRune(g.topLeft) + tb.WriteString(strings.Repeat(string(g.fillH), innerWidth)) + tb.WriteRune(g.topRight) + out = append(out, tb.String()) + + var tr strings.Builder + tr.WriteRune(g.side) + tr.WriteString(" ") + tr.WriteString(t.Title) + padding := innerWidth - 2 - color.VisibleLen(t.Title) + if padding < 0 { + padding = 0 + } + tr.WriteString(strings.Repeat(" ", padding)) + tr.WriteString(" ") + tr.WriteRune(g.side) + out = append(out, tr.String()) + + out = append(out, makeSep(g.sepLeft, g.titleSep, g.sepRight, g.fillHSep)) + } else { + out = append(out, makeSep(g.topLeft, g.topSep, g.topRight, g.fillH)) + } + + if hasCols { + out = append(out, makeRow(t.Columns)) + out = append(out, makeSep(g.sepLeft, g.sepCross, g.sepRight, g.fillHSep)) + } + + for _, row := range t.Rows { + out = append(out, makeRow(row)) + } + + out = append(out, makeSep(g.botLeft, g.botSep, g.botRight, g.fillH)) + + return out +} diff --git a/internal/game/utils.go b/internal/game/utils.go deleted file mode 100644 index 9148ea3..0000000 --- a/internal/game/utils.go +++ /dev/null @@ -1,121 +0,0 @@ -package game - -import ( - "fmt" - "math/rand" - "strconv" - "strings" - - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" -) - -func plural(n int) string { - if n == 1 { - return "" - } - return "s" -} - -func parseQty(input string) (int, string) { - parts := strings.Fields(input) - if len(parts) > 1 { - if n, err := strconv.Atoi(parts[0]); err == nil && n > 0 { - return n, strings.Join(parts[1:], " ") - } - } - return 0, input -} - -func parseChoiceIndex(s string) (int, error) { - var idx int - _, err := fmt.Sscanf(s, "%d", &idx) - return idx, err -} - -func mobDisplayName(m *world.MobInstance, definite bool) string { - if m.Unique { - return m.Name - } - if definite { - return "the " + m.Name - } - return "a " + m.Name -} - -func mobCombatLevel(m *world.MobInstance) int { - base := float64(m.Defense+m.MaxHP) / 4.0 - melee := float64(m.Attack+m.Strength) / 4.0 - ranged := float64(m.Ranged) * 3.0 / 8.0 - science := float64(m.Science) * 3.0 / 8.0 - best := melee - if ranged > best { - best = ranged - } - if science > best { - best = science - } - return int(base + best + 0.5) -} - -func uniqueItemNames(matches []itemMatch) []string { - seen := make(map[string]bool) - var out []string - for _, m := range matches { - if !seen[m.Name] { - seen[m.Name] = true - out = append(out, m.Name) - } - } - return out -} - -func (g *Game) showWhichOne(sess *net.Session, matches []itemMatch) { - sess.WriteLine("Which one?") - seen := make(map[string]bool) - for _, m := range matches { - if seen[m.Name] { - continue - } - seen[m.Name] = true - def, _ := g.ItemStore.Load(m.ID) - coloredName := g.itemColorize(sess, def, m.Name) - sess.WriteLine(fmt.Sprintf(" - %s", coloredName)) - } -} - -func randInt(max int) int { - if max <= 0 { - return 0 - } - return rand.Intn(max) -} - -func formatPickupList(sess *net.Session, picked []string) { - for i, name := range picked { - if i == len(picked)-1 { - sess.WriteLine(name) - } else if i == len(picked)-2 { - sess.Write(name + " and ") - } else { - sess.Write(name + ", ") - } - } -} - -func (g *Game) newInventorySlot(itemID string, qty int) *player.InventorySlot { - slot := &player.InventorySlot{ItemID: itemID, Quantity: qty} - if def, err := g.ItemStore.Load(itemID); err == nil && def.MaxQuality > 0 { - slot.Quality = def.Quality - slot.MaxQuality = def.MaxQuality - } - return slot -} - -func (g *Game) awardSkillXP(sess *net.Session, p *player.Player, skill player.SkillName, xp int) { - newLevel := p.AddSkillXP(skill, xp) - if newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, skill))) - } -} -- cgit v1.2.3