From abd612c15799f604e671e83dc7c410ed2b44185f Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Thu, 25 Jun 2026 15:40:48 -0400 Subject: slop refactor --- internal/action/action.go | 188 ------- internal/action/behavior.go | 94 ---- internal/action/store.go | 164 ------ internal/behavior/behavior.go | 94 ++++ internal/behavior/desc.go | 27 + internal/behavior/doc.go | 4 + internal/behavior/store.go | 164 ++++++ internal/behavior/types.go | 188 +++++++ internal/combat/doc.go | 4 + internal/combat/state.go | 79 +-- internal/config/config.go | 76 +-- internal/game/act.go | 376 ++++++++++++++ internal/game/act_agility.go | 162 ++++++ internal/game/act_burn.go | 532 ++++++++++++++++++++ internal/game/act_clean.go | 105 ++++ internal/game/act_default_target.go | 61 +++ internal/game/act_farm.go | 644 ++++++++++++++++++++++++ internal/game/act_finishing_blow.go | 77 +++ internal/game/act_fletch.go | 214 ++++++++ internal/game/act_gather.go | 405 +++++++++++++++ internal/game/act_identify.go | 83 +++ internal/game/act_room.go | 220 ++++++++ internal/game/act_search.go | 126 +++++ internal/game/act_state.go | 97 ++++ internal/game/act_steal.go | 476 ++++++++++++++++++ internal/game/act_talk.go | 277 ++++++++++ internal/game/act_use.go | 123 +++++ internal/game/action.go | 377 -------------- internal/game/action_agility.go | 162 ------ internal/game/action_burn.go | 532 -------------------- internal/game/action_clean.go | 105 ---- internal/game/action_default_target.go | 61 --- internal/game/action_farm.go | 645 ------------------------ internal/game/action_finishing_blow.go | 78 --- internal/game/action_fletch.go | 214 -------- internal/game/action_gather.go | 405 --------------- internal/game/action_identify.go | 83 --- internal/game/action_room.go | 220 -------- internal/game/action_search.go | 126 ----- internal/game/action_state.go | 98 ---- internal/game/action_steal.go | 477 ------------------ internal/game/action_talk.go | 277 ---------- internal/game/action_use.go | 123 ----- internal/game/cmd_agility.go | 3 +- internal/game/cmd_aps.go | 3 +- internal/game/cmd_attack.go | 730 --------------------------- internal/game/cmd_bank.go | 7 +- internal/game/cmd_clean.go | 13 +- internal/game/cmd_color.go | 3 +- internal/game/cmd_consume.go | 6 +- internal/game/cmd_cook.go | 8 +- internal/game/cmd_craft.go | 51 +- internal/game/cmd_equipment.go | 4 +- internal/game/cmd_estate_directory.go | 6 +- internal/game/cmd_farm.go | 22 +- internal/game/cmd_fletch.go | 48 +- internal/game/cmd_get.go | 6 +- internal/game/cmd_hide.go | 3 +- internal/game/cmd_id.go | 6 +- internal/game/cmd_jack.go | 3 +- internal/game/cmd_look.go | 770 ---------------------------- internal/game/cmd_mods.go | 3 +- internal/game/cmd_move.go | 9 +- internal/game/cmd_option.go | 3 +- internal/game/cmd_quit.go | 7 +- internal/game/cmd_registry.go | 232 ++++----- internal/game/cmd_remove.go | 20 +- internal/game/cmd_score.go | 17 +- internal/game/cmd_shop.go | 23 +- internal/game/cmd_skills.go | 3 +- internal/game/cmd_smelt.go | 8 +- internal/game/cmd_smith.go | 12 +- internal/game/cmd_stats.go | 14 +- internal/game/cmd_stop.go | 3 +- internal/game/cmd_tech.go | 3 +- internal/game/cmd_trigger.go | 5 +- internal/game/cmd_trigger_transport.go | 3 +- internal/game/cmd_trigger_utility.go | 35 +- internal/game/cmd_use.go | 22 +- internal/game/cmd_verbs.go | 4 +- internal/game/cmd_wear.go | 30 +- internal/game/combat_attack.go | 449 +++++++++++++++++ internal/game/combat_mob.go | 301 +++++++++++ internal/game/condition_test.go | 38 +- internal/game/core_course.go | 14 +- internal/game/core_equip.go | 8 +- internal/game/core_production.go | 779 +---------------------------- internal/game/core_startup.go | 887 --------------------------------- internal/game/core_types.go | 20 +- internal/game/core_utils.go | 2 - internal/game/craft_index.go | 56 +-- internal/game/game.go | 99 ++-- internal/game/hacking/liars_dice.go | 20 +- internal/game/look_entities.go | 347 +++++++++++++ internal/game/look_room.go | 107 ++++ internal/game/look_target.go | 343 +++++++++++++ internal/game/map_test.go | 4 +- internal/game/production_engine.go | 359 +++++++++++++ internal/game/production_menu.go | 416 ++++++++++++++++ internal/game/render_color.go | 80 +++ internal/game/render_combat.go | 41 ++ internal/game/render_help.go | 156 ++++++ internal/game/render_map.go | 428 ++++++++++++++++ internal/game/render_prompt.go | 97 ++++ internal/game/render_xpdrop.go | 31 ++ internal/game/sys_combat.go | 23 +- internal/game/sys_science.go | 14 +- internal/game/sys_technology.go | 20 +- internal/game/tick.go | 39 +- internal/game/ui_bar.go | 104 ---- internal/game/ui_color.go | 79 --- internal/game/ui_combat.go | 38 -- internal/game/ui_help.go | 155 ------ internal/game/ui_map.go | 428 ---------------- internal/game/ui_prompt.go | 98 ---- internal/game/ui_table.go | 150 ------ internal/game/ui_xpdrop.go | 31 -- internal/game/validate_source.go | 53 ++ internal/item/doc.go | 4 + internal/item/item.go | 191 +++++++ internal/item/store.go | 78 +++ internal/net/server.go | 22 +- internal/net/server_test.go | 8 +- internal/object/doc.go | 5 + internal/object/item.go | 211 -------- internal/object/item_store.go | 78 --- internal/object/object.go | 68 ++- internal/object/store.go | 4 +- internal/player/player.go | 94 ++-- internal/player/store.go | 4 +- internal/ui/bar.go | 110 ++++ internal/ui/doc.go | 5 + internal/ui/table.go | 150 ++++++ internal/validate/checks.go | 751 ++++++++++++++++++++++++++++ internal/validate/doc.go | 6 + internal/validate/validate.go | 162 ++++++ internal/world/hazard.go | 8 +- internal/world/mob.go | 161 +++--- internal/world/objects.go | 10 +- internal/world/room.go | 52 +- internal/world/world.go | 18 +- 141 files changed, 9956 insertions(+), 9749 deletions(-) delete mode 100644 internal/action/action.go delete mode 100644 internal/action/behavior.go delete mode 100644 internal/action/store.go create mode 100644 internal/behavior/behavior.go create mode 100644 internal/behavior/desc.go create mode 100644 internal/behavior/doc.go create mode 100644 internal/behavior/store.go create mode 100644 internal/behavior/types.go create mode 100644 internal/combat/doc.go create mode 100644 internal/game/act.go create mode 100644 internal/game/act_agility.go create mode 100644 internal/game/act_burn.go create mode 100644 internal/game/act_clean.go create mode 100644 internal/game/act_default_target.go create mode 100644 internal/game/act_farm.go create mode 100644 internal/game/act_finishing_blow.go create mode 100644 internal/game/act_fletch.go create mode 100644 internal/game/act_gather.go create mode 100644 internal/game/act_identify.go create mode 100644 internal/game/act_room.go create mode 100644 internal/game/act_search.go create mode 100644 internal/game/act_state.go create mode 100644 internal/game/act_steal.go create mode 100644 internal/game/act_talk.go create mode 100644 internal/game/act_use.go delete mode 100644 internal/game/action.go delete mode 100644 internal/game/action_agility.go delete mode 100644 internal/game/action_burn.go delete mode 100644 internal/game/action_clean.go delete mode 100644 internal/game/action_default_target.go delete mode 100644 internal/game/action_farm.go delete mode 100644 internal/game/action_finishing_blow.go delete mode 100644 internal/game/action_fletch.go delete mode 100644 internal/game/action_gather.go delete mode 100644 internal/game/action_identify.go delete mode 100644 internal/game/action_room.go delete mode 100644 internal/game/action_search.go delete mode 100644 internal/game/action_state.go delete mode 100644 internal/game/action_steal.go delete mode 100644 internal/game/action_talk.go delete mode 100644 internal/game/action_use.go create mode 100644 internal/game/combat_attack.go create mode 100644 internal/game/combat_mob.go delete mode 100644 internal/game/core_startup.go create mode 100644 internal/game/look_entities.go create mode 100644 internal/game/look_room.go create mode 100644 internal/game/look_target.go create mode 100644 internal/game/production_engine.go create mode 100644 internal/game/production_menu.go create mode 100644 internal/game/render_color.go create mode 100644 internal/game/render_combat.go create mode 100644 internal/game/render_help.go create mode 100644 internal/game/render_map.go create mode 100644 internal/game/render_prompt.go create mode 100644 internal/game/render_xpdrop.go delete mode 100644 internal/game/ui_bar.go delete mode 100644 internal/game/ui_color.go delete mode 100644 internal/game/ui_combat.go delete mode 100644 internal/game/ui_help.go delete mode 100644 internal/game/ui_map.go delete mode 100644 internal/game/ui_prompt.go delete mode 100644 internal/game/ui_table.go delete mode 100644 internal/game/ui_xpdrop.go create mode 100644 internal/game/validate_source.go create mode 100644 internal/item/doc.go create mode 100644 internal/item/item.go create mode 100644 internal/item/store.go create mode 100644 internal/object/doc.go delete mode 100644 internal/object/item.go delete mode 100644 internal/object/item_store.go create mode 100644 internal/ui/bar.go create mode 100644 internal/ui/doc.go create mode 100644 internal/ui/table.go create mode 100644 internal/validate/checks.go create mode 100644 internal/validate/doc.go create mode 100644 internal/validate/validate.go (limited to 'internal') diff --git a/internal/action/action.go b/internal/action/action.go deleted file mode 100644 index d66d88e..0000000 --- a/internal/action/action.go +++ /dev/null @@ -1,188 +0,0 @@ -package action - -import "strings" - -func WordPrefixMatch(input, name string) bool { - inputWords := strings.Fields(strings.ToLower(input)) - if len(inputWords) == 0 { - return false - } - nameWords := strings.Fields(strings.ToLower(name)) - for _, iw := range inputWords { - found := false - for _, nw := range nameWords { - if strings.HasPrefix(nw, iw) || strings.HasPrefix(iw, nw) { - found = true - break - } - } - if !found { - return false - } - } - return true -} - -type ActionType string - -const ( - TypeGather ActionType = "gather" - TypeSteal ActionType = "steal" - TypeTalk ActionType = "talk" - TypeUse ActionType = "use" - TypeBurn ActionType = "burn" - TypeStoke ActionType = "stoke" - TypeSearch ActionType = "search" - TypeIdentify ActionType = "identify" - TypePlant ActionType = "plant" - TypeHarvest ActionType = "harvest" - TypeRake ActionType = "rake" - TypeWater ActionType = "water" - TypeCure ActionType = "cure" - TypeObstacle ActionType = "obstacle" - TypeFletch ActionType = "fletch" - TypeClean ActionType = "clean" - TypeCook ActionType = "cook" - TypeSmelt ActionType = "smelt" - TypeSmith ActionType = "smith" - TypeCraft ActionType = "craft" - TypeCombine ActionType = "combine" - TypeMix ActionType = "mix" - TypeConstruct ActionType = "construct" -) - -type Action struct { - Type ActionType - TargetID string - TargetName string - WaitLeft int - Data any -} - -func (a *Action) Advance() bool { - if a.WaitLeft > 0 { - a.WaitLeft-- - return false - } - return true -} - -type GatherData struct { - ObjDefID string - InstanceKey string - InstanceIdx int - EffectiveWait float64 - DepleteTimer bool - Step int - Verb string - ToolName string -} - -type ProductionData struct { - ItemID string - Phase int - Wait float64 - StartMsg string - EndMsg string - Remaining int - NextStepIndex int - StepsAccumulatedTicks float64 -} - -type StealData struct { - TargetType string - StealTable string - StealLevel int - StealXP int - StealSpeed float64 - TargetName string - MobInstanceID string - ObjDefID string - GuardMob string - GuardWatching bool -} - -type TalkData struct { - Node string - Cfg *TalkConfig - EstateDirectory bool - StealGuard bool -} - -type UseData struct { - Cfg *UseConfig - Step int -} - -type BurnData struct { - ItemID string - ToolSpeed float64 - Level int - XP int - BurnTicks float64 - Phase int -} - -type IdentifyData struct { - JunkID string - XPPer int -} - -type ObstacleData struct { - CourseID string - Phase int - ObstacleIndex int - TotalObstacles int - NextRoom int - StartRoom int - ObstacleXP int - CompletionXP int - FailChance float64 - FailDamageMin int - FailDamageMax int - Messages []any - TicksPerPhase float64 - RequiredLevel int -} - -type FarmData struct { - Prefix string - SeedID string - XP float64 - Product string - MinYield float64 - MaxYield float64 -} - -type SearchData struct { - ItemID string - SlotIdx int - Started bool -} - -type FletchData struct { - ItemID string - Phase int - Wait float64 - Remaining int -} - -type CleanData struct { - Phase int - Filter string -} - -type DropEntry struct { - ItemID string `yaml:"item_id"` - Table string `yaml:"table"` - Weight int `yaml:"weight"` - Quantity int `yaml:"quantity"` - Depletes bool `yaml:"depletes"` - Message string `yaml:"message"` - Level int `yaml:"level"` - XP int `yaml:"xp"` -} - -type DropTableDef struct { - Drops []DropEntry `yaml:"drops"` -} diff --git a/internal/action/behavior.go b/internal/action/behavior.go deleted file mode 100644 index 1fdc8df..0000000 --- a/internal/action/behavior.go +++ /dev/null @@ -1,94 +0,0 @@ -package action - -type GatherConfig struct { - Skill string `yaml:"skill"` - Level int `yaml:"level"` - XP int `yaml:"xp"` - BaseWait float64 `yaml:"base_wait"` - Tools []string `yaml:"tools"` - Bait string `yaml:"bait"` - Success SuccessFormula `yaml:"success"` - GatherMsg string `yaml:"gather_message"` - DepletedMessage string `yaml:"depleted_message"` - ExhaustedMessage string `yaml:"exhausted_message"` - FailMsg string `yaml:"fail_message"` - Drops []DropEntry `yaml:"drops"` - RespawnTimer float64 `yaml:"respawn_timer"` - RespawnBroadcast string `yaml:"respawn_broadcast"` - DepleteTimer float64 `yaml:"deplete_timer"` - NestChance int `yaml:"nest_chance"` -} - -type SuccessFormula struct { - Base float64 `yaml:"base"` - PerLevel float64 `yaml:"per_level"` - Cap float64 `yaml:"cap"` -} - -type TalkConfig struct { - Nodes map[string]TalkNode `yaml:"nodes"` -} - -type TalkNode struct { - Message string `yaml:"message"` - Options []TalkOption `yaml:"options"` - Action *NodeAction `yaml:"action"` -} - -type TalkOption struct { - Text string `yaml:"text"` - Goto string `yaml:"goto"` - End bool `yaml:"end"` - Condition *Condition `yaml:"condition"` -} - -type NodeAction struct { - SetFlags map[string]any `yaml:"set_flags"` - SetPlayerFlags map[string]any `yaml:"set_player_flags"` - GiveItem string `yaml:"give_item"` - TakeItem string `yaml:"take_item"` - Teleport int `yaml:"teleport"` - Heal int `yaml:"heal"` - Cost int `yaml:"cost"` - Shop *ShopConfig `yaml:"shop"` - AssignTask bool `yaml:"assign_task"` - SkipTask bool `yaml:"skip_task"` - ExtendTask bool `yaml:"extend_task"` - ReputationCost int `yaml:"reputation_cost"` - Sawmill bool `yaml:"sawmill"` - ApsNode bool `yaml:"aps_node"` -} - -type ShopConfig struct { - Message string `yaml:"message"` - Items []ShopItem `yaml:"items"` -} - -type ShopItem struct { - ItemID string `yaml:"item_id"` - BuyPrice int `yaml:"buy_price"` - SellPrice int `yaml:"sell_price"` -} - -type Condition struct { - Flag string `yaml:"flag"` - Value any `yaml:"value"` - Not bool `yaml:"not"` - PlayerFlag string `yaml:"player_flag"` - HasItem string `yaml:"has_item"` - MinCredits int `yaml:"min_credits"` - AllOf []Condition `yaml:"all_of"` - AnyOf []Condition `yaml:"any_of"` -} - -type UseConfig struct { - Message string `yaml:"message"` - Wait float64 `yaml:"wait"` - Consume map[string]int `yaml:"consume"` - Reward DropEntry `yaml:"reward"` - FailMsg string `yaml:"fail_message"` - Success *SuccessFormula `yaml:"success"` - Skill string `yaml:"skill"` - Level int `yaml:"level"` - XP int `yaml:"xp"` -} diff --git a/internal/action/store.go b/internal/action/store.go deleted file mode 100644 index 6cb2e39..0000000 --- a/internal/action/store.go +++ /dev/null @@ -1,164 +0,0 @@ -package action - -import ( - "fmt" - "math/rand" - "os" - "path/filepath" - "strings" - "sync" - - "gopkg.in/yaml.v3" -) - -func BuildPathIndex(dir string) map[string]string { - index := make(map[string]string) - filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { - if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") { - return nil - } - id := strings.TrimSuffix(d.Name(), ".yaml") - index[id] = path - return nil - }) - return index -} - -func BuildRoomIndex(dir string) map[int]string { - index := make(map[int]string) - filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { - if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") { - return nil - } - id := strings.TrimSuffix(d.Name(), ".yaml") - var n int - if _, scanErr := fmt.Sscanf(id, "%d", &n); scanErr == nil { - index[n] = path - } - return nil - }) - return index -} - -func WalkYAMLDir(dir string, fn func(path, id string, data []byte) error) error { - return filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { - if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") { - return nil - } - data, readErr := os.ReadFile(path) - if readErr != nil { - return nil - } - id := strings.TrimSuffix(d.Name(), ".yaml") - return fn(path, id, data) - }) -} - -type Duplicate struct { - ID string - Paths []string -} - -func CheckDuplicateIDs(dir string) []Duplicate { - seen := make(map[string][]string) - filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { - if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") { - return nil - } - id := strings.TrimSuffix(d.Name(), ".yaml") - seen[id] = append(seen[id], path) - return nil - }) - var dups []Duplicate - for id, paths := range seen { - if len(paths) > 1 { - dups = append(dups, Duplicate{ID: id, Paths: paths}) - } - } - return dups -} - -func SuccessChance(cfg SuccessFormula, level int, requiredLevel int) float64 { - chance := cfg.Base + float64(level-requiredLevel)*cfg.PerLevel - if chance > cfg.Cap { - chance = cfg.Cap - } - if chance < 0 { - chance = 0 - } - return chance -} - -var ( - dropIndex map[string]string - dropIndexMu sync.Mutex -) - -func loadDropIndex(dataDir string) map[string]string { - dropIndexMu.Lock() - defer dropIndexMu.Unlock() - if dropIndex == nil { - dropIndex = BuildPathIndex(filepath.Join(dataDir, "drops")) - } - return dropIndex -} - -func LoadDropTable(dataDir, id string) (*DropTableDef, error) { - index := loadDropIndex(dataDir) - path, ok := index[id] - if !ok { - path = filepath.Join(dataDir, "drops", id+".yaml") - } - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read drop table %s: %w", id, err) - } - var dt DropTableDef - if err := yaml.Unmarshal(data, &dt); err != nil { - return nil, fmt.Errorf("parse drop table %s: %w", id, err) - } - return &dt, nil -} - -func ResolveDrop(dataDir string, drops []DropEntry) *DropEntry { - if len(drops) == 0 { - return nil - } - total := 0 - for _, d := range drops { - total += d.Weight - } - if total <= 0 { - return nil - } - roll := rand.Intn(total) - cumulative := 0 - for i := range drops { - cumulative += drops[i].Weight - if roll < cumulative { - if drops[i].Table != "" { - sub, err := LoadDropTable(dataDir, drops[i].Table) - if err == nil { - if resolved := ResolveDrop(dataDir, sub.Drops); resolved != nil { - qty := drops[i].Quantity - if qty <= 0 { - qty = resolved.Quantity - } - if qty <= 0 { - qty = 1 - } - return &DropEntry{ - ItemID: resolved.ItemID, - Quantity: qty, - Depletes: drops[i].Depletes, - Message: drops[i].Message, - } - } - } - return nil - } - return &drops[i] - } - } - return &drops[0] -} diff --git a/internal/behavior/behavior.go b/internal/behavior/behavior.go new file mode 100644 index 0000000..57b2a7b --- /dev/null +++ b/internal/behavior/behavior.go @@ -0,0 +1,94 @@ +package behavior + +type GatherConfig struct { + Skill string `yaml:"skill"` + Level int `yaml:"level"` + XP int `yaml:"xp"` + BaseWait float64 `yaml:"base_wait"` + Tools []string `yaml:"tools"` + Bait string `yaml:"bait"` + Success SuccessFormula `yaml:"success"` + GatherMsg string `yaml:"gather_message"` + DepletedMessage string `yaml:"depleted_message"` + ExhaustedMessage string `yaml:"exhausted_message"` + FailMsg string `yaml:"fail_message"` + Drops []DropEntry `yaml:"drops"` + RespawnTimer float64 `yaml:"respawn_timer"` + RespawnBroadcast string `yaml:"respawn_broadcast"` + DepleteTimer float64 `yaml:"deplete_timer"` + NestChance int `yaml:"nest_chance"` +} + +type SuccessFormula struct { + Base float64 `yaml:"base"` + PerLevel float64 `yaml:"per_level"` + Cap float64 `yaml:"cap"` +} + +type TalkConfig struct { + Nodes map[string]TalkNode `yaml:"nodes"` +} + +type TalkNode struct { + Message string `yaml:"message"` + Options []TalkOption `yaml:"options"` + Action *NodeAction `yaml:"action"` +} + +type TalkOption struct { + Text string `yaml:"text"` + Goto string `yaml:"goto"` + End bool `yaml:"end"` + Condition *Condition `yaml:"condition"` +} + +type NodeAction struct { + SetFlags map[string]any `yaml:"set_flags"` + SetPlayerFlags map[string]any `yaml:"set_player_flags"` + GiveItem string `yaml:"give_item"` + TakeItem string `yaml:"take_item"` + Teleport int `yaml:"teleport"` + Heal int `yaml:"heal"` + Cost int `yaml:"cost"` + Shop *ShopConfig `yaml:"shop"` + AssignTask bool `yaml:"assign_task"` + SkipTask bool `yaml:"skip_task"` + ExtendTask bool `yaml:"extend_task"` + ReputationCost int `yaml:"reputation_cost"` + Sawmill bool `yaml:"sawmill"` + ApsNode bool `yaml:"aps_node"` +} + +type ShopConfig struct { + Message string `yaml:"message"` + Items []ShopItem `yaml:"items"` +} + +type ShopItem struct { + ItemID string `yaml:"item_id"` + BuyPrice int `yaml:"buy_price"` + SellPrice int `yaml:"sell_price"` +} + +type Condition struct { + Flag string `yaml:"flag"` + Value any `yaml:"value"` + Not bool `yaml:"not"` + PlayerFlag string `yaml:"player_flag"` + HasItem string `yaml:"has_item"` + MinCredits int `yaml:"min_credits"` + AllOf []Condition `yaml:"all_of"` + AnyOf []Condition `yaml:"any_of"` +} + +type UseConfig struct { + Message string `yaml:"message"` + Wait float64 `yaml:"wait"` + Consume map[string]int `yaml:"consume"` + Reward DropEntry `yaml:"reward"` + FailMsg string `yaml:"fail_message"` + Success *SuccessFormula `yaml:"success"` + Skill string `yaml:"skill"` + Level int `yaml:"level"` + XP int `yaml:"xp"` +} diff --git a/internal/behavior/desc.go b/internal/behavior/desc.go new file mode 100644 index 0000000..ca4bf69 --- /dev/null +++ b/internal/behavior/desc.go @@ -0,0 +1,27 @@ +package behavior + +import "gopkg.in/yaml.v3" + +type DescVariant struct { + Text string `yaml:"text"` + Condition *Condition `yaml:"condition,omitempty"` +} + +type DescList []DescVariant + +func (dl *DescList) UnmarshalYAML(value *yaml.Node) error { + if value.Kind == yaml.ScalarNode { + var s string + if err := value.Decode(&s); err != nil { + return err + } + *dl = DescList{{Text: s}} + return nil + } + var entries []DescVariant + if err := value.Decode(&entries); err != nil { + return err + } + *dl = entries + return nil +} diff --git a/internal/behavior/doc.go b/internal/behavior/doc.go new file mode 100644 index 0000000..a1ea45f --- /dev/null +++ b/internal/behavior/doc.go @@ -0,0 +1,4 @@ +// Package behavior defines YAML-driven interactive behaviors for objects and +// mobs: gathering, talking, using, and the shared condition system. These are +// data structures — the runtime action lifecycle lives in the game package. +package behavior diff --git a/internal/behavior/store.go b/internal/behavior/store.go new file mode 100644 index 0000000..7695b8d --- /dev/null +++ b/internal/behavior/store.go @@ -0,0 +1,164 @@ +package behavior + +import ( + "fmt" + "math/rand" + "os" + "path/filepath" + "strings" + "sync" + + "gopkg.in/yaml.v3" +) + +func BuildPathIndex(dir string) map[string]string { + index := make(map[string]string) + filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") { + return nil + } + id := strings.TrimSuffix(d.Name(), ".yaml") + index[id] = path + return nil + }) + return index +} + +func BuildRoomIndex(dir string) map[int]string { + index := make(map[int]string) + filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") { + return nil + } + id := strings.TrimSuffix(d.Name(), ".yaml") + var n int + if _, scanErr := fmt.Sscanf(id, "%d", &n); scanErr == nil { + index[n] = path + } + return nil + }) + return index +} + +func WalkYAMLDir(dir string, fn func(path, id string, data []byte) error) error { + return filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") { + return nil + } + data, readErr := os.ReadFile(path) + if readErr != nil { + return nil + } + id := strings.TrimSuffix(d.Name(), ".yaml") + return fn(path, id, data) + }) +} + +type Duplicate struct { + ID string + Paths []string +} + +func CheckDuplicateIDs(dir string) []Duplicate { + seen := make(map[string][]string) + filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") { + return nil + } + id := strings.TrimSuffix(d.Name(), ".yaml") + seen[id] = append(seen[id], path) + return nil + }) + var dups []Duplicate + for id, paths := range seen { + if len(paths) > 1 { + dups = append(dups, Duplicate{ID: id, Paths: paths}) + } + } + return dups +} + +func SuccessChance(cfg SuccessFormula, level int, requiredLevel int) float64 { + chance := cfg.Base + float64(level-requiredLevel)*cfg.PerLevel + if chance > cfg.Cap { + chance = cfg.Cap + } + if chance < 0 { + chance = 0 + } + return chance +} + +var ( + dropIndex map[string]string + dropIndexMu sync.Mutex +) + +func loadDropIndex(dataDir string) map[string]string { + dropIndexMu.Lock() + defer dropIndexMu.Unlock() + if dropIndex == nil { + dropIndex = BuildPathIndex(filepath.Join(dataDir, "drops")) + } + return dropIndex +} + +func LoadDropTable(dataDir, id string) (*DropTableDef, error) { + index := loadDropIndex(dataDir) + path, ok := index[id] + if !ok { + path = filepath.Join(dataDir, "drops", id+".yaml") + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read drop table %s: %w", id, err) + } + var dt DropTableDef + if err := yaml.Unmarshal(data, &dt); err != nil { + return nil, fmt.Errorf("parse drop table %s: %w", id, err) + } + return &dt, nil +} + +func ResolveDrop(dataDir string, drops []DropEntry) *DropEntry { + if len(drops) == 0 { + return nil + } + total := 0 + for _, d := range drops { + total += d.Weight + } + if total <= 0 { + return nil + } + roll := rand.Intn(total) + cumulative := 0 + for i := range drops { + cumulative += drops[i].Weight + if roll < cumulative { + if drops[i].Table != "" { + sub, err := LoadDropTable(dataDir, drops[i].Table) + if err == nil { + if resolved := ResolveDrop(dataDir, sub.Drops); resolved != nil { + qty := drops[i].Quantity + if qty <= 0 { + qty = resolved.Quantity + } + if qty <= 0 { + qty = 1 + } + return &DropEntry{ + ItemID: resolved.ItemID, + Quantity: qty, + Depletes: drops[i].Depletes, + Message: drops[i].Message, + } + } + } + return nil + } + return &drops[i] + } + } + return &drops[0] +} diff --git a/internal/behavior/types.go b/internal/behavior/types.go new file mode 100644 index 0000000..3c6e290 --- /dev/null +++ b/internal/behavior/types.go @@ -0,0 +1,188 @@ +package behavior + +import "strings" + +func WordPrefixMatch(input, name string) bool { + inputWords := strings.Fields(strings.ToLower(input)) + if len(inputWords) == 0 { + return false + } + nameWords := strings.Fields(strings.ToLower(name)) + for _, iw := range inputWords { + found := false + for _, nw := range nameWords { + if strings.HasPrefix(nw, iw) || strings.HasPrefix(iw, nw) { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +type ActionType string + +const ( + TypeGather ActionType = "gather" + TypeSteal ActionType = "steal" + TypeTalk ActionType = "talk" + TypeUse ActionType = "use" + TypeBurn ActionType = "burn" + TypeStoke ActionType = "stoke" + TypeSearch ActionType = "search" + TypeIdentify ActionType = "identify" + TypePlant ActionType = "plant" + TypeHarvest ActionType = "harvest" + TypeRake ActionType = "rake" + TypeWater ActionType = "water" + TypeCure ActionType = "cure" + TypeObstacle ActionType = "obstacle" + TypeFletch ActionType = "fletch" + TypeClean ActionType = "clean" + TypeCook ActionType = "cook" + TypeSmelt ActionType = "smelt" + TypeSmith ActionType = "smith" + TypeCraft ActionType = "craft" + TypeCombine ActionType = "combine" + TypeMix ActionType = "mix" + TypeConstruct ActionType = "construct" +) + +type Action struct { + Type ActionType + TargetID string + TargetName string + WaitLeft int + Data any +} + +func (a *Action) Advance() bool { + if a.WaitLeft > 0 { + a.WaitLeft-- + return false + } + return true +} + +type GatherData struct { + ObjDefID string + InstanceKey string + InstanceIdx int + EffectiveWait float64 + DepleteTimer bool + Step int + Verb string + ToolName string +} + +type ProductionData struct { + ItemID string + Phase int + Wait float64 + StartMsg string + EndMsg string + Remaining int + NextStepIndex int + StepsAccumulatedTicks float64 +} + +type StealData struct { + TargetType string + StealTable string + StealLevel int + StealXP int + StealSpeed float64 + TargetName string + MobInstanceID string + ObjDefID string + GuardMob string + GuardWatching bool +} + +type TalkData struct { + Node string + Cfg *TalkConfig + EstateDirectory bool + StealGuard bool +} + +type UseData struct { + Cfg *UseConfig + Step int +} + +type BurnData struct { + ItemID string + ToolSpeed float64 + Level int + XP int + BurnTicks float64 + Phase int +} + +type IdentifyData struct { + JunkID string + XPPer int +} + +type ObstacleData struct { + CourseID string + Phase int + ObstacleIndex int + TotalObstacles int + NextRoom int + StartRoom int + ObstacleXP int + CompletionXP int + FailChance float64 + FailDamageMin int + FailDamageMax int + Messages []any + TicksPerPhase float64 + RequiredLevel int +} + +type FarmData struct { + Prefix string + SeedID string + XP float64 + Product string + MinYield float64 + MaxYield float64 +} + +type SearchData struct { + ItemID string + SlotIdx int + Started bool +} + +type FletchData struct { + ItemID string + Phase int + Wait float64 + Remaining int +} + +type CleanData struct { + Phase int + Filter string +} + +type DropEntry struct { + ItemID string `yaml:"item_id"` + Table string `yaml:"table"` + Weight int `yaml:"weight"` + Quantity int `yaml:"quantity"` + Depletes bool `yaml:"depletes"` + Message string `yaml:"message"` + Level int `yaml:"level"` + XP int `yaml:"xp"` +} + +type DropTableDef struct { + Drops []DropEntry `yaml:"drops"` +} diff --git a/internal/combat/doc.go b/internal/combat/doc.go new file mode 100644 index 0000000..aac6f32 --- /dev/null +++ b/internal/combat/doc.go @@ -0,0 +1,4 @@ +// Package combat tracks in-progress combat engagements (one player per mob) +// and provides the classic attack-roll, hit-chance, and damage formulas. The +// Tracker struct replaces the previous package-level globals. +package combat diff --git a/internal/combat/state.go b/internal/combat/state.go index 7a4f24c..8e4c68d 100644 --- a/internal/combat/state.go +++ b/internal/combat/state.go @@ -2,56 +2,73 @@ package combat import "sync" +// State captures a single player's active combat engagement. type State struct { - PlayerName string - MobID string - Active bool + PlayerName string + MobID string + Active bool DamageWarningShown bool } -var ( - mu sync.Mutex - combatants = make(map[string]*State) // player name -> state - mobTargets = make(map[string]string) // mob ID -> player name -) +// Tracker holds all in-progress combat engagements. One player fights one mob +// at a time; one mob is fought by one player at a time (the IsMobInCombat lock +// is reused by task/labor mobs too). Safe for concurrent use. +type Tracker struct { + mu sync.Mutex + combatants map[string]*State // player name -> state + mobTargets map[string]string // mob instance ID -> player name +} + +// NewTracker returns an empty combat tracker. +func NewTracker() *Tracker { + return &Tracker{ + combatants: make(map[string]*State), + mobTargets: make(map[string]string), + } +} -func EnterCombat(playerName, mobID string) { - mu.Lock() - defer mu.Unlock() - combatants[playerName] = &State{ +// Enter records that playerName is now fighting mobID. +func (t *Tracker) Enter(playerName, mobID string) { + t.mu.Lock() + defer t.mu.Unlock() + t.combatants[playerName] = &State{ PlayerName: playerName, MobID: mobID, Active: true, } - mobTargets[mobID] = playerName + t.mobTargets[mobID] = playerName } -func LeaveCombat(playerName string) { - mu.Lock() - defer mu.Unlock() - state, ok := combatants[playerName] +// Leave ends playerName's combat, freeing their target mob. +func (t *Tracker) Leave(playerName string) { + t.mu.Lock() + defer t.mu.Unlock() + state, ok := t.combatants[playerName] if !ok { return } - delete(mobTargets, state.MobID) - delete(combatants, playerName) + delete(t.mobTargets, state.MobID) + delete(t.combatants, playerName) } -func GetCombat(playerName string) *State { - mu.Lock() - defer mu.Unlock() - return combatants[playerName] +// Get returns the player's combat state, or nil if not in combat. +func (t *Tracker) Get(playerName string) *State { + t.mu.Lock() + defer t.mu.Unlock() + return t.combatants[playerName] } -func IsMobInCombat(mobID string) bool { - mu.Lock() - defer mu.Unlock() - _, ok := mobTargets[mobID] +// IsMobInCombat reports whether the mob instance is currently engaged. +func (t *Tracker) IsMobInCombat(mobID string) bool { + t.mu.Lock() + defer t.mu.Unlock() + _, ok := t.mobTargets[mobID] return ok } -func GetMobTarget(mobID string) string { - mu.Lock() - defer mu.Unlock() - return mobTargets[mobID] +// MobTarget returns the name of the player fighting the mob, or "". +func (t *Tracker) MobTarget(mobID string) string { + t.mu.Lock() + defer t.mu.Unlock() + return t.mobTargets[mobID] } diff --git a/internal/config/config.go b/internal/config/config.go index b01997c..28093fc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,50 +19,50 @@ type ColorsConfig map[string]string func DefaultColors() ColorsConfig { return ColorsConfig{ - "room_name": "81 bold", - "room_number": "240", - "room_desc": "252", - "direction": "75", - "exit_direction": "75", - "exit_name": "114", - "protected_mob": "off", - "mob": "off", - "damage_dealt": "33", - "damage_taken": "196", - "damage": "196", + "room_name": "81 bold", + "room_number": "240", + "room_desc": "252", + "direction": "75", + "exit_direction": "75", + "exit_name": "114", + "protected_mob": "off", + "mob": "off", + "damage_dealt": "33", + "damage_taken": "196", + "damage": "196", - "xp": "222", - "level_up": "226 bold", - "item": "223", - "player_name": "189", - "death": "196 bold", - "victory": "83", - "miss": "243", - "error": "209", - "say": "230", - "global": "45", - "dialog": "117", - "broadcast": "215", - "fire": "208", - "eat_food": "156", - "drop_message": "186", - "credits_pickup": "220", - "visual_tick": "33 dim", + "xp": "222", + "level_up": "226 bold", + "item": "223", + "player_name": "189", + "death": "196 bold", + "victory": "83", + "miss": "243", + "error": "209", + "say": "230", + "global": "45", + "dialog": "117", + "broadcast": "215", + "fire": "208", + "eat_food": "156", + "drop_message": "186", + "credits_pickup": "220", + "visual_tick": "33 dim", "visual_first_tick": "196 bold", - "battery": "45", - "tech_depleted": "196", - "science_mod": "99", - "assassin_task": "219", - "dim": "243 dim", - "warning": "208", - "farm_grow": "34", - "farm_disease": "196", - "map_at": "15", + "battery": "45", + "tech_depleted": "196", + "science_mod": "99", + "assassin_task": "219", + "dim": "243 dim", + "warning": "208", + "farm_grow": "34", + "farm_disease": "196", + "map_at": "15", } } type GameConfig struct { - TickLength int `yaml:"tick_length"` + TickLength int `yaml:"tick_length"` StartupValidation ValidationConfig `yaml:"startup_validation"` } diff --git a/internal/game/act.go b/internal/game/act.go new file mode 100644 index 0000000..8920d0c --- /dev/null +++ b/internal/game/act.go @@ -0,0 +1,376 @@ +package game + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/object" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +var verbAliases = map[string]string{ + "mine": "gather", + "chop": "gather", + "fish": "gather", + "cut": "gather", + "shear": "gather", + "talk": "talk", + "speak": "talk", + "ask": "talk", + "steal": "steal", + "thieve": "steal", + "hide": "safespot", +} + +var verbSkill = map[string]string{ + "mine": "mining", + "chop": "woodcutting", + "cut": "woodcutting", + "fish": "fishing", + "shear": "crafting", + "steal": "thieving", + "thieve": "thieving", +} + +func normalizeVerb(v string) string { + if a, ok := verbAliases[v]; ok { + return a + } + return v +} + +func (g *Game) startAction(sess *net.Session, verb, target string) { + p := sess.Player + + verb = normalizeVerb(verb) + + if g.Combat.Get(p.Name) != nil { + sess.WriteLine("You can't do that during combat!") + return + } + + if p.Action != nil { + g.cancelAction(p) + } + g.cancelBgAction(p) + + lower := strings.ToLower(target) + instanceIdx := -1 + if dotPos := strings.Index(lower, "."); dotPos > 0 { + if n, err := strconv.Atoi(lower[:dotPos]); err == nil && n > 0 { + instanceIdx = n - 1 + lower = lower[dotPos+1:] + } + } + + instances := g.World.FindObjInstances(p.RoomID, lower) + sort.Slice(instances, func(i, j int) bool { + return instances[i].Index < instances[j].Index + }) + + var obj *object.ObjectDef + var mob *world.MobInstance + var chosen *world.ObjState + + if len(instances) > 0 { + uniqueDefs := make(map[string]bool) + for _, ist := range instances { + uniqueDefs[ist.DefID] = true + } + if len(uniqueDefs) > 1 { + sess.WriteLine("That's ambiguous, which one?") + return + } + + if instanceIdx >= 0 && instanceIdx < len(instances) { + chosen = &instances[instanceIdx] + } else { + for i := range instances { + if !instances[i].Depleted { + chosen = &instances[i] + break + } + } + if chosen == nil { + chosen = &instances[0] + } + } + if chosen == nil { + sess.WriteLine(fmt.Sprintf("There's nothing here to %s.", verb)) + return + } + var err error + obj, err = g.ObjectStore.Load(chosen.DefID) + if err != nil || !obj.IsInteractable() { + sess.WriteLine(fmt.Sprintf("You can't %s the %s.", verb, obj.Name)) + return + } + } else { + mobs := g.MobStore.MobsInRoom(p.RoomID) + var candidates []*world.MobInstance + for _, m := range mobs { + if !m.IsTalkable() { + continue + } + if q := m.MatchQuality(lower); q >= world.MatchPrefix { + candidates = append(candidates, m) + } + } + if len(candidates) == 0 { + sess.WriteLine(fmt.Sprintf("There's nothing here to %s.", verb)) + return + } + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].InstanceID < candidates[j].InstanceID + }) + if instanceIdx >= 0 && instanceIdx < len(candidates) { + mob = candidates[instanceIdx] + } else if len(candidates) == 1 { + mob = candidates[0] + } else { + sess.WriteLine("Which one?") + return + } + } + + if obj != nil && obj.ID == "estate_directory" { + g.startEstateDirectory(sess, verb) + return + } + + bhType := "" + if obj != nil { + bhType = obj.BehaviorType() + } else if mob != nil { + if mob.IsTalkable() { + bhType = "talk" + } + } + + if bhType != verb { + if obj != nil { + sess.WriteLine(fmt.Sprintf("You can't %s the %s.", verb, obj.Name)) + } else { + sess.WriteLine(fmt.Sprintf("You can't %s that.", verb)) + } + return + } + + switch bhType { + case "gather": + if obj == nil || chosen == nil { + sess.WriteLine(fmt.Sprintf("You can't %s that.", verb)) + return + } + g.startGather(sess, p, obj, chosen) + case "talk": + if mob != nil { + g.startMobTalk(sess, p, mob) + } else { + g.startTalk(sess, p, obj) + } + case "use": + g.startUse(sess, p, obj) + default: + sess.WriteLine(fmt.Sprintf("You can't %s that.", verb)) + } +} + +func (g *Game) cancelAction(p *player.Player) { + p.Action = nil + p.WalkSequence = nil + p.ClearMoveState() +} + +func (g *Game) cancelBgAction(p *player.Player) { + p.BackgroundAction = nil +} + +func (g *Game) AdvanceActions() { + if g.Hub == nil { + return + } + for _, sess := range g.Hub.AllSessions() { + p := sess.Player + if p == nil || p.Action == nil { + continue + } + if !p.Action.Advance() { + continue + } + switch p.Action.Type { + case "gather": + g.advanceGather(sess, p) + case "use": + g.advanceUse(sess, p) + case "burn": + g.advanceBurn(sess, p) + case "stoke": + g.advanceStoke(sess, p) + case "search": + g.advanceSearch(sess, p) + case "identify": + g.advanceIdentify(sess, p) + case "steal": + g.advanceSteal(sess, p) + case "plant": + g.advancePlant(sess, p) + case "harvest": + g.advanceHarvest(sess, p) + case "rake": + g.advanceRake(sess, p) + case "water": + g.advanceWater(sess, p) + case "cure": + g.advanceCure(sess, p) + case "obstacle": + g.advanceObstacle(sess, p) + default: + if productionActionTypes[string(p.Action.Type)] { + g.advanceProduction(sess, p) + } + } + if p.Action == nil { + g.writePrompt(sess) + } + } + + for _, sess := range g.Hub.AllSessions() { + p := sess.Player + if p == nil || p.BackgroundAction == nil { + continue + } + if !p.BackgroundAction.Advance() { + continue + } + switch p.BackgroundAction.Type { + case "fletch": + g.advanceFletch(sess, p) + case "clean": + g.advanceClean(sess, p) + } + if p.BackgroundAction == nil { + g.writePrompt(sess) + } + } +} + +func (g *Game) checkCondition(sess *net.Session, c *behavior.Condition) bool { + p := sess.Player + + if c.AllOf != nil { + for _, sub := range c.AllOf { + if !g.checkCondition(sess, &sub) { + return false + } + } + return true + } + if c.AnyOf != nil { + for _, sub := range c.AnyOf { + if g.checkCondition(sess, &sub) { + return true + } + } + return false + } + + if c.PlayerFlag != "" { + var val any + present := false + if p != nil && p.Flags != nil { + val, present = p.Flags[c.PlayerFlag] + } + return flagMatches(present, val, c.Value, c.Not) + } + + if c.Flag != "" { + val, present := g.Flags.Get(c.Flag) + return flagMatches(present, val, c.Value, c.Not) + } + if c.HasItem != "" { + has := p != nil && p.HasItem(c.HasItem) + if c.Not { + return !has + } + return has + } + if c.MinCredits > 0 { + has := p != nil && p.Credits >= c.MinCredits + if c.Not { + return !has + } + return has + } + return true +} + +// flagMatches evaluates a flag/player_flag condition. +// +// If the condition specifies no `value`, the flag matches when it is present +// and truthy (so `{player_flag: x}` means "x is set" and +// `{player_flag: x, not: true}` means "x is not set"). If a `value` is given, +// the flag must be present and equal to it. `not` inverts the result. Numeric +// values are compared by coercing both sides through a common numeric type so +// that an int flag set in code matches an int64/float64 decoded from YAML. +func flagMatches(present bool, val, want any, not bool) bool { + var match bool + if want == nil { + match = present && isTruthy(val) + } else { + match = present && valuesEqual(val, want) + } + if not { + return !match + } + return match +} + +// valuesEqual compares two any-typed values, normalizing numeric types (int, +// int64, float64) so that e.g. int(3) == int64(3) == float64(3). Non-numeric +// types fall back to ==. +func valuesEqual(a, b any) bool { + ai, aok := numericValue(a) + bi, bok := numericValue(b) + if aok && bok { + return ai == bi + } + return a == b +} + +// numericValue returns the value as a float64 if it is a numeric type. +func numericValue(v any) (float64, bool) { + switch x := v.(type) { + case int: + return float64(x), true + case int64: + return float64(x), true + case float64: + return x, true + } + return 0, false +} + +func isTruthy(v any) bool { + switch x := v.(type) { + case nil: + return false + case bool: + return x + case int: + return x != 0 + case int64: + return x != 0 + case float64: + return x != 0 + case string: + return x != "" + default: + return true + } +} diff --git a/internal/game/act_agility.go b/internal/game/act_agility.go new file mode 100644 index 0000000..2136699 --- /dev/null +++ b/internal/game/act_agility.go @@ -0,0 +1,162 @@ +package game + +import ( + "fmt" + "math/rand" + + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) startObstacle(sess *net.Session, p *player.Player, info *ObstacleInfo) { + failChance := g.calcFailChance(p, info) + + msgs := make([]any, len(info.Messages)) + for i, m := range info.Messages { + msgs[i] = m + } + + gerund := info.Verb + "ing" + if g, ok := verbGerund[info.Verb]; ok { + gerund = g + } + + p.Action = &behavior.Action{ + Type: behavior.TypeObstacle, + TargetID: info.CourseID, + TargetName: info.CourseName, + WaitLeft: 0, + Data: &behavior.ObstacleData{ + CourseID: info.CourseID, + Phase: 0, + ObstacleIndex: info.ObstacleIndex, + TotalObstacles: info.TotalObstacles, + NextRoom: info.NextRoom, + StartRoom: info.StartRoom, + ObstacleXP: info.ObstacleXP, + CompletionXP: info.CompletionXP, + FailChance: failChance, + FailDamageMin: info.FailDamage[0], + FailDamageMax: info.FailDamage[1], + Messages: msgs, + TicksPerPhase: info.TicksPerPhase, + RequiredLevel: info.RequiredLevel, + }, + } + + g.broadcastAction(sess, "\n%s starts %s on the %s.", p.Name, gerund, info.CourseName) +} + +func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) { + d := p.Action.Data.(*behavior.ObstacleData) + phase := d.Phase + messages := d.Messages + ticksPerPhase := d.TicksPerPhase + + if phase >= len(messages) { + g.cancelAction(p) + return + } + + msg := messages[phase].(string) + sess.WriteLine(g.colorize(sess, "broadcast", msg)) + + if phase == 1 { + failChance := d.FailChance + if rand.Float64() < failChance { + g.obstacleFail(sess, p, d) + return + } + } + + nextPhase := phase + 1 + + if nextPhase >= len(messages) { + obstacleXP := d.ObstacleXP + completionXP := d.CompletionXP + nextRoom := d.NextRoom + startRoom := d.StartRoom + obstacleIndex := d.ObstacleIndex + totalObstacles := d.TotalObstacles + courseID := d.CourseID + + if obstacleXP > 0 { + g.awardSkillXP(sess, p, player.Agility, obstacleXP) + if p.OptionBool("xp_drops") { + sess.WriteLine(g.colorize(sess, "xp", + fmt.Sprintf("(+%dxp %s)", obstacleXP, player.SkillAbbr[player.Agility]))) + } + } + + isLastObstacle := obstacleIndex == totalObstacles-1 + + if isLastObstacle { + if completionXP > 0 { + g.awardSkillXP(sess, p, player.Agility, completionXP) + if p.OptionBool("xp_drops") { + sess.WriteLine(g.colorize(sess, "xp", + fmt.Sprintf("(+%dxp %s course bonus)", completionXP, player.SkillAbbr[player.Agility]))) + } + } + + lapKey := "agility_laps_" + courseID + laps := getPlayerFlagInt(p, lapKey) + 1 + setPlayerFlag(p, lapKey, laps) + + courseName := p.Action.TargetName + sess.WriteLine(g.colorize(sess, "broadcast", + fmt.Sprintf("Course complete! %s lap %d finished.", courseName, laps))) + + g.cancelAction(p) + g.teleportPlayer(sess, p, startRoom) + } else { + g.cancelAction(p) + g.teleportPlayer(sess, p, nextRoom) + } + + g.AccountStore.SaveCharacter(p) + return + } + + d.Phase = nextPhase + p.Action.WaitLeft = engine.ToTicks(ticksPerPhase) +} + +func (g *Game) obstacleFail(sess *net.Session, p *player.Player, d *behavior.ObstacleData) { + startRoom := d.StartRoom + failMin := d.FailDamageMin + failMax := d.FailDamageMax + + damage := failMin + if failMax > failMin { + damage = failMin + rand.Intn(failMax-failMin+1) + } + + sess.WriteLine(g.colorize(sess, "damage_taken", "You slip and fall!")) + + p.HP -= damage + if p.HP < 1 { + p.HP = 1 + } + sess.WriteLine(g.colorize(sess, "damage_taken", + fmt.Sprintf("You take %d damage. HP: %d/%d", damage, p.HP, p.MaxHP()))) + + g.AccountStore.SaveCharacter(p) + g.cancelAction(p) + g.teleportPlayer(sess, p, startRoom) +} + +func (g *Game) calcFailChance(p *player.Player, info *ObstacleInfo) float64 { + level := p.Level(player.Agility) + required := info.RequiredLevel + chance := 0.30 - float64(level-required)*0.01 + if chance < 0.05 { + chance = 0.05 + } + if chance > 0.60 { + chance = 0.60 + } + return chance +} diff --git a/internal/game/act_burn.go b/internal/game/act_burn.go new file mode 100644 index 0000000..861ff93 --- /dev/null +++ b/internal/game/act_burn.go @@ -0,0 +1,532 @@ +package game + +import ( + "fmt" + "math/rand" + "strings" + + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/item" + "thehouseoficarus/internal/net" + "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) + } + g.cancelBgAction(p) + + itemID, fromGround, ambiguous := g.resolveBurnTarget(p, input) + if ambiguous { + sess.WriteLine("Burn what?") + return + } + if itemID == "" { + sess.WriteLine("Burn what?") + return + } + + g.startBurn(sess, p, itemID, fromGround) +} + +func (g *Game) doStoke(sess *net.Session, p *player.Player, input string) { + if p.Action != nil { + g.cancelAction(p) + } + g.cancelBgAction(p) + + if !g.hasFireObject(p.RoomID) { + sess.WriteLine("There's no fire here to stoke.") + return + } + + itemID, ambiguous := g.resolveStokeTarget(p, input) + if ambiguous { + sess.WriteLine("Stoke what?") + return + } + if itemID == "" { + sess.WriteLine("You have no logs to stoke with!") + return + } + + g.startStoke(sess, p, itemID) +} + +func (g *Game) startBurn(sess *net.Session, p *player.Player, itemID string, fromGround bool) { + if p.FirstFreeSlot() == -1 && !fromGround { + sess.WriteLine("Your inventory is too full!") + return + } + + if g.hasFireObject(p.RoomID) { + sess.WriteLine("There is already a fire burning here.") + return + } + + logDef, err := g.ItemStore.Load(itemID) + if err != nil { + sess.WriteLine("Something is wrong with that item.") + return + } + + if logDef.BurnTicks <= 0 { + sess.WriteLine(fmt.Sprintf("You can't burn the %s.", g.itemColorize(sess, logDef, logDef.Name))) + return + } + + skillLevel := p.Level(player.Firemaking) + if logDef.FireLevel > 0 && skillLevel < logDef.FireLevel { + sess.WriteLine(fmt.Sprintf("You need level %d firemaking to burn %s.", logDef.FireLevel, g.itemColorize(sess, logDef, logDef.Name))) + return + } + + toolSpeed, ok := g.findFireTool(sess, p) + if !ok { + return + } + + if !fromGround { + if !p.HasItem(itemID) { + sess.WriteLine(fmt.Sprintf("You don't have any %s.", g.itemColorize(sess, logDef, logDef.Name))) + return + } + } else { + ground := g.World.GroundItems(p.RoomID) + if ground[itemID] <= 0 { + sess.WriteLine(fmt.Sprintf("There are no %s on the ground here.", g.itemColorize(sess, logDef, logDef.Name))) + return + } + } + + phase := 1 + if !fromGround { + phase = 0 + } + + g.broadcastAction(sess, "\n%s starts building a fire.", p.Name) + + p.Action = &behavior.Action{ + Type: behavior.TypeBurn, + TargetID: itemID, + TargetName: logDef.Name, + WaitLeft: 0, + Data: &behavior.BurnData{ + ItemID: itemID, + ToolSpeed: toolSpeed, + Level: logDef.FireLevel, + XP: logDef.FireXP, + BurnTicks: logDef.BurnTicks, + Phase: phase, + }, + } +} + +func (g *Game) startStoke(sess *net.Session, p *player.Player, itemID string) { + fireKey := g.findFireObjectKey(p.RoomID) + if fireKey == "" { + sess.WriteLine("There's no fire here to stoke.") + return + } + + logDef, err := g.ItemStore.Load(itemID) + if err != nil || logDef.BurnTicks <= 0 { + sess.WriteLine(fmt.Sprintf("You can't stoke the fire with that.")) + return + } + + if !p.HasItem(itemID) { + sess.WriteLine(fmt.Sprintf("You don't have any %s.", g.itemColorize(sess, logDef, logDef.Name))) + return + } + + sess.WriteLine("You begin to tend to the fire.") + + g.broadcastAction(sess, "\n%s tends to the fire.", p.Name) + + p.Action = &behavior.Action{ + Type: behavior.TypeStoke, + TargetID: itemID, + TargetName: logDef.Name, + WaitLeft: engine.ToTicks(10), + Data: &behavior.BurnData{ + ItemID: itemID, + BurnTicks: logDef.BurnTicks, + XP: logDef.FireXP, + }, + } +} + +func (g *Game) advanceBurn(sess *net.Session, p *player.Player) { + d := p.Action.Data.(*behavior.BurnData) + itemID := d.ItemID + toolSpeed := d.ToolSpeed + phase := d.Phase + + logDef, err := g.ItemStore.Load(itemID) + if err != nil { + g.cancelAction(p) + return + } + + if phase == 0 { + if !p.HasItem(itemID) { + sess.WriteLine(fmt.Sprintf("You're out of %s.", g.itemColorize(sess, logDef, logDef.Name))) + g.cancelAction(p) + return + } + p.RemoveItem(itemID, 1) + g.World.AddGroundItem(p.RoomID, itemID, 1) + g.AccountStore.SaveCharacter(p) + + sess.WriteLine(g.colorize(sess, "fire", fmt.Sprintf("You drop the %s and begin to make a fire...", g.itemColorize(sess, logDef, logDef.Name)))) + g.broadcastDrop(p, itemID, logDef.Name) + + d.Phase = 1 + p.Action.WaitLeft = engine.ToTicks(toolSpeed) + return + } + + ground := g.World.GroundItems(p.RoomID) + if ground[itemID] <= 0 { + sess.WriteLine("The thing you were trying to burn is gone.") + g.cancelAction(p) + return + } + + if phase == 1 { + sess.WriteLine(g.colorize(sess, "fire", fmt.Sprintf("You try burning the %s on the ground...", g.itemColorize(sess, logDef, logDef.Name)))) + d.Phase = 2 + p.Action.WaitLeft = engine.ToTicks(toolSpeed) + return + } + + if phase == 2 { + skillLevel := p.Level(player.Firemaking) + chance := 0.5 + float64(skillLevel-logDef.FireLevel)*0.02 + if chance > 0.95 { + chance = 0.95 + } + if chance < 0.05 { + chance = 0.05 + } + + if rand.Float64() < chance { + g.World.RemoveGroundItem(p.RoomID, itemID, 1) + g.World.AddObjInstance(p.RoomID, "fire", logDef.BurnTicks) + + xp := logDef.FireXP + if xp > 0 { + g.awardSkillXP(sess, p, player.Firemaking, xp) + } + + g.AccountStore.SaveCharacter(p) + g.cancelAction(p) + + msg := g.colorize(sess, "fire", "You manage to get a fire going!") + if xp > 0 && p.OptionBool("xp_drops") { + msg += g.formatXpDropSingle(sess, p, player.Firemaking, xp) + } + sess.WriteLine(msg) + + g.broadcastAction(sess, "\n%s started a fire!", p.Name) + return + } + + sess.WriteLine("You can't seem to get a fire going.") + d.Phase = 1 + p.Action.WaitLeft = engine.ToTicks(toolSpeed) + } +} + +func (g *Game) advanceStoke(sess *net.Session, p *player.Player) { + d := p.Action.Data.(*behavior.BurnData) + itemID := d.ItemID + fireKey := g.findFireObjectKey(p.RoomID) + burnTicks := d.BurnTicks + xp := d.XP + + st := g.World.GetObjStateByKey(fireKey) + if st == nil || st.Quality <= 0 { + sess.WriteLine("The fire went out!") + g.cancelAction(p) + return + } + + logDef, _ := g.ItemStore.Load(itemID) + name := itemID + if logDef != nil { + name = logDef.Name + } + + if !p.HasItem(itemID) { + sess.WriteLine(g.colorize(sess, "fire", fmt.Sprintf("You're out of %s and the fire is roaring.", g.itemColorize(sess, logDef, name)))) + g.cancelAction(p) + return + } + + p.RemoveItem(itemID, 1) + st.Quality += burnTicks / 2 + + if xp > 0 { + g.awardSkillXP(sess, p, player.Firemaking, xp) + } + + g.AccountStore.SaveCharacter(p) + + msg := g.colorize(sess, "fire", fmt.Sprintf("You throw %s onto the fire.", g.itemColorize(sess, logDef, name))) + if xp > 0 && p.OptionBool("xp_drops") { + msg += g.formatXpDropSingle(sess, p, player.Firemaking, xp) + } + sess.WriteLine(msg) + + if !p.HasItem(itemID) { + sess.WriteLine(g.colorize(sess, "fire", fmt.Sprintf("You're out of %s and the fire is roaring.", g.itemColorize(sess, logDef, name)))) + g.cancelAction(p) + return + } + + p.Action.WaitLeft = engine.ToTicks(10.0) +} + +func (g *Game) findFireTool(sess *net.Session, p *player.Player) (toolSpeed float64, ok bool) { + toolSpeed = -1 + var toolItemID string + + if itemID, equipped := p.Equipment[item.SlotMainHand]; equipped { + def, err := g.ItemStore.Load(itemID) + if err == nil && def.ToolType == "fire" { + toolSpeed = def.ToolSpeed + toolItemID = itemID + } + } + + if toolSpeed < 0 { + for _, slot := range p.Inventory { + if slot == nil || slot.Quantity <= 0 { + continue + } + def, err := g.ItemStore.Load(slot.ItemID) + if err != nil || def.ToolType != "fire" { + continue + } + toolSpeed = def.ToolSpeed + toolItemID = slot.ItemID + break + } + } + + if toolSpeed < 0 { + sess.WriteLine("You need a fire starter to do that.") + return 0, false + } + + if !g.consumeFireTool(sess, p, toolItemID) { + return 0, false + } + + return toolSpeed, true +} + +func (g *Game) consumeFireTool(sess *net.Session, p *player.Player, toolItemID string) bool { + def, _ := g.ItemStore.Load(toolItemID) + + if def != nil && def.MaxQuality > 0 { + for _, slot := range p.Inventory { + if slot != nil && slot.ItemID == toolItemID && slot.MaxQuality > 0 { + if slot.Quality <= 0 { + sess.WriteLine("The lighter is out of butane!") + return false + } + slot.Quality-- + g.AccountStore.SaveCharacter(p) + return true + } + } + if q, ok := p.EquipQuality[toolItemID]; ok { + if q <= 0 { + sess.WriteLine("The lighter is out of butane!") + return false + } + p.EquipQuality[toolItemID] = q - 1 + g.AccountStore.SaveCharacter(p) + return true + } + } + + if def != nil && def.Stackable { + if p.HasItem(toolItemID) { + p.RemoveItem(toolItemID, 1) + g.AccountStore.SaveCharacter(p) + return true + } + for slot, itemID := range p.Equipment { + if itemID == toolItemID { + delete(p.Equipment, slot) + g.AccountStore.SaveCharacter(p) + return true + } + } + sess.WriteLine("You need a fire starter to do that.") + return false + } + + return true +} + +func (g *Game) findFireObjectKey(roomID int) string { + objs := g.World.AllObjInstances(roomID) + for _, st := range objs { + if st.DefID == "fire" && st.Quality > 0 { + return g.World.ObjStateKey(st.RoomID, st.DefID, st.Index) + } + } + return "" +} + +func (g *Game) hasFireObject(roomID int) bool { + return g.findFireObjectKey(roomID) != "" +} + +func (g *Game) broadcastDrop(p *player.Player, itemID, itemName string) { + itemDef, _ := g.ItemStore.Load(itemID) + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + op := other.Player + if op != nil && op.Name != p.Name { + colored := itemName + if itemDef != nil { + colored = g.itemColorize(other, itemDef, itemName) + } + other.WriteLine(fmt.Sprintf("%s drops a %s.", p.Name, colored)) + } + } +} + +func (g *Game) FireTick() { + for _, st := range g.World.AllFireStates() { + st.Quality-- + if st.Quality <= 0 { + g.World.RemoveObjInstance(st.RoomID, st.DefID) + g.cancelStokers(st.RoomID) + if def, err := g.ObjectStore.Load(st.DefID); err == nil && def.RemovalItem != "" { + g.World.AddGroundItem(st.RoomID, def.RemovalItem, 1) + } + } + } +} + +func (g *Game) cancelStokers(roomID int) { + for _, other := range g.Hub.PlayersInRoom(roomID) { + op := other.Player + if op != nil && op.Action != nil && op.Action.Type == behavior.TypeStoke { + other.WriteLine("The fire went out!") + g.cancelAction(op) + g.writePrompt(other) + } + } +} + +func (g *Game) resolveBurnTarget(p *player.Player, input string) (itemID string, fromGround bool, ambiguous bool) { + if input != "" { + return g.namedBurnTarget(p, input) + } + return g.defaultBurnTarget(p) +} + +func (g *Game) burnableGround(roomID int, name string) []string { + var out []string + for itemID := range g.World.GroundItems(roomID) { + def, err := g.ItemStore.Load(itemID) + if err != nil || def.BurnTicks <= 0 { + continue + } + if name == "" || def.MatchesName(name) { + out = append(out, itemID) + } + } + return out +} + +func (g *Game) burnableInv(p *player.Player, name string) []string { + var out []string + seen := make(map[string]bool) + for _, slot := range p.Inventory { + if slot == nil || slot.Quantity <= 0 { + continue + } + def, err := g.ItemStore.Load(slot.ItemID) + if err != nil || def.BurnTicks <= 0 || seen[slot.ItemID] { + continue + } + if name == "" || def.MatchesName(name) { + seen[slot.ItemID] = true + out = append(out, slot.ItemID) + } + } + return out +} + +func (g *Game) namedBurnTarget(p *player.Player, input string) (itemID string, fromGround bool, ambiguous bool) { + groundMatches := g.burnableGround(p.RoomID, input) + if len(groundMatches) > 1 { + return "", false, true + } + if len(groundMatches) == 1 { + return groundMatches[0], true, false + } + + invMatches := g.burnableInv(p, input) + if len(invMatches) > 1 { + return "", false, true + } + if len(invMatches) == 1 { + return invMatches[0], false, false + } + + return "", false, false +} + +func (g *Game) defaultBurnTarget(p *player.Player) (itemID string, fromGround bool, ambiguous bool) { + groundBurnable := g.burnableGround(p.RoomID, "") + if len(groundBurnable) == 1 { + return groundBurnable[0], true, false + } + if len(groundBurnable) > 1 { + return "", false, true + } + + invBurnable := g.burnableInv(p, "") + if len(invBurnable) == 1 { + return invBurnable[0], false, false + } + if len(invBurnable) > 1 { + return "", false, true + } + + return "", false, false +} + +func (g *Game) resolveStokeTarget(p *player.Player, input string) (string, bool) { + invBurnable := g.burnableInv(p, input) + if len(invBurnable) == 1 { + return invBurnable[0], false + } + if len(invBurnable) > 1 { + return "", true + } + return "", false +} diff --git a/internal/game/act_clean.go b/internal/game/act_clean.go new file mode 100644 index 0000000..0c128a1 --- /dev/null +++ b/internal/game/act_clean.go @@ -0,0 +1,105 @@ +package game + +import ( + "strings" + + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/item" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) advanceClean(sess *net.Session, p *player.Player) { + data, ok := p.BackgroundAction.Data.(*behavior.CleanData) + if !ok { + g.cancelBgAction(p) + return + } + phase := data.Phase + filter := data.Filter + + if phase == 0 { + data.Phase = 1 + p.BackgroundAction.WaitLeft = engine.ToTicks(2) + return + } + + items := g.CraftIndex.ByType("clean") + + var matchItem *item.ItemDef + for _, item := range items { + c := item.FirstCraft() + if filter != "" { + if len(c.Consume) == 0 || len(c.Consume[0].Items) == 0 { + continue + } + if !strings.Contains(c.Consume[0].Items[0], filter) && + !strings.Contains(item.ID, filter) { + continue + } + } + if p.Level(player.Pharmacy) < c.Level { + continue + } + if !craftHasAllItems(c, p.HasItem) { + continue + } + matchItem = item + break + } + + if matchItem == nil { + sess.WriteLine("You've finished cleaning herbs.") + g.cancelBgAction(p) + return + } + + craft := matchItem.FirstCraft() + consumeID := craft.Consume[0].Items[0] + + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot != nil && slot.ItemID == consumeID { + slot.ItemID = matchItem.ID + break + } + } + + if craft.XP > 0 { + g.awardSkillXP(sess, p, player.Pharmacy, craft.XP) + } + g.AccountStore.SaveCharacter(p) + + msg := g.resolveCraftMessage(sess, craft.Message, "You clean the %i1.", craft, matchItem.ID, nil, p.HasItem) + if p.OptionBool("xp_drops") && craft.XP > 0 { + msg += g.formatXpDropSingle(sess, p, player.Pharmacy, craft.XP) + } + sess.WriteLine(msg) + + hasMore := false + for _, item := range items { + c := item.FirstCraft() + if filter != "" { + if len(c.Consume) == 0 || len(c.Consume[0].Items) == 0 { + continue + } + if !strings.Contains(c.Consume[0].Items[0], filter) && + !strings.Contains(item.ID, filter) { + continue + } + } + if p.Level(player.Pharmacy) >= c.Level && craftHasAllItems(c, p.HasItem) { + hasMore = true + break + } + } + + if !hasMore { + sess.WriteLine("You've finished cleaning herbs.") + g.cancelBgAction(p) + return + } + + p.BackgroundAction.WaitLeft = engine.ToTicks(2) +} diff --git a/internal/game/act_default_target.go b/internal/game/act_default_target.go new file mode 100644 index 0000000..a184f32 --- /dev/null +++ b/internal/game/act_default_target.go @@ -0,0 +1,61 @@ +package game + +import ( + "strings" +) + +func verbToSkill(verb string) string { + return verbSkill[verb] +} + +func (g *Game) defaultTarget(roomID int, verb string) string { + skill := verbToSkill(verb) + if skill == "" { + return "" + } + + objStates := g.World.AllObjInstances(roomID) + + type candidate struct { + defID string + name string + } + var candidates []candidate + seen := make(map[string]bool) + + for _, st := range objStates { + if seen[st.DefID] { + continue + } + seen[st.DefID] = true + + objDef, err := g.ObjectStore.Load(st.DefID) + if err != nil || !objDef.IsGatherable() { + continue + } + + if strings.EqualFold(objDef.Gather.Skill, skill) { + candidates = append(candidates, candidate{defID: st.DefID, name: objDef.Name}) + } + } + + if len(candidates) == 1 { + return candidates[0].name + } + return "" +} + +func (g *Game) resolveDefaultMob(roomID int) string { + mobs := g.MobStore.MobsInRoom(roomID) + if len(mobs) == 0 { + return "" + } + + firstDef := mobs[0].DefID + for _, m := range mobs[1:] { + if m.DefID != firstDef { + return "" + } + } + return mobs[0].Name +} diff --git a/internal/game/act_farm.go b/internal/game/act_farm.go new file mode 100644 index 0000000..62e08fd --- /dev/null +++ b/internal/game/act_farm.go @@ -0,0 +1,644 @@ +package game + +import ( + "fmt" + "math/rand" + "sort" + "strings" + + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +const FarmTickInterval = 500 + +var farmPatchDefIDs = map[string]bool{ + "herb_patch": true, + "allotment_patch": true, + "flower_patch": true, + "bush_patch": true, + "tree_patch": true, +} + +func farmFlagPrefix(defID string, index int) string { + switch defID { + case "herb_patch": + return fmt.Sprintf("farm_herb_%d", index+1) + case "allotment_patch": + return fmt.Sprintf("farm_allot_%d", index+1) + case "flower_patch": + return fmt.Sprintf("farm_flower_%d", index+1) + case "bush_patch": + return fmt.Sprintf("farm_bush_%d", index+1) + case "tree_patch": + return fmt.Sprintf("farm_tree_%d", index+1) + } + return "" +} + +func (g *Game) ensureFarmState(p *player.Player, prefix string) { + p.EnsureFlags() + if _, exists := p.Flags[prefix+"_seed"]; !exists { + p.Flags[prefix+"_weeds"] = true + p.Flags[prefix+"_seed"] = "" + p.Flags[prefix+"_stage"] = 0 + p.Flags[prefix+"_watered"] = false + p.Flags[prefix+"_diseased"] = false + p.Flags[prefix+"_dead"] = false + p.Flags[prefix+"_ready"] = false + } +} + +func (g *Game) findFarmPrefixes(p *player.Player) []string { + seen := make(map[string]bool) + var prefixes []string + for key := range p.Flags { + if !strings.HasPrefix(key, "farm_") { + continue + } + if !strings.HasSuffix(key, "_seed") { + continue + } + prefix := strings.TrimSuffix(key, "_seed") + if !seen[prefix] { + seen[prefix] = true + if seedID, ok := p.Flags[key].(string); ok && seedID != "" { + prefixes = append(prefixes, prefix) + } + } + } + sort.Strings(prefixes) + return prefixes +} + +func (g *Game) FarmTick() { + g.farmTickCounter++ + if g.farmTickCounter < FarmTickInterval { + return + } + g.farmTickCounter = 0 + + if g.Hub == nil { + return + } + + for _, sess := range g.Hub.AllSessions() { + p := sess.Player + if p == nil || p.Flags == nil { + continue + } + g.advanceFarmGrowth(sess, p) + } +} + +func (g *Game) advanceFarmGrowth(sess *net.Session, p *player.Player) { + prefixes := g.findFarmPrefixes(p) + for _, prefix := range prefixes { + seedID, _ := p.Flags[prefix+"_seed"].(string) + if seedID == "" { + continue + } + dead, _ := p.Flags[prefix+"_dead"].(bool) + if dead { + continue + } + diseased, _ := p.Flags[prefix+"_diseased"].(bool) + ready, _ := p.Flags[prefix+"_ready"].(bool) + if ready { + continue + } + + if diseased { + p.Flags[prefix+"_dead"] = true + p.Flags[prefix+"_diseased"] = false + sess.WriteLine(g.colorize(sess, "farm_disease", + fmt.Sprintf("\nYour %s has died from disease!", g.seedDisplayName(sess, seedID)))) + g.AccountStore.SaveCharacter(p) + continue + } + + stage := intFromFlag(p.Flags, prefix+"_stage") + watered, _ := p.Flags[prefix+"_watered"].(bool) + + seedDef, err := g.ItemStore.Load(seedID) + if err != nil { + continue + } + + maxStages := seedDef.FarmStages + if maxStages <= 0 { + maxStages = 4 + } + + stage++ + + if stage >= maxStages { + p.Flags[prefix+"_stage"] = stage + p.Flags[prefix+"_ready"] = true + p.Flags[prefix+"_watered"] = false + sess.WriteLine(g.colorize(sess, "farm_grow", + fmt.Sprintf("\nYour %s is fully grown and ready to harvest!", + g.seedDisplayName(sess, seedID)))) + } else { + if !watered && rand.Float64() < 0.10 { + p.Flags[prefix+"_stage"] = stage + p.Flags[prefix+"_diseased"] = true + p.Flags[prefix+"_watered"] = false + sess.WriteLine(g.colorize(sess, "farm_disease", + fmt.Sprintf("\nYour %s has become diseased!", + g.seedDisplayName(sess, seedID)))) + } else { + p.Flags[prefix+"_stage"] = stage + p.Flags[prefix+"_watered"] = false + sess.WriteLine(g.colorize(sess, "farm_grow", + fmt.Sprintf("\nYour %s has grown to stage %d/%d.", + g.seedDisplayName(sess, seedID), stage, maxStages))) + } + } + g.AccountStore.SaveCharacter(p) + } +} + +func (g *Game) seedDisplayName(sess *net.Session, seedID string) string { + if def, err := g.ItemStore.Load(seedID); err == nil { + return g.itemColorize(sess, def, def.Name) + } + return seedID +} + +func (g *Game) advancePlant(sess *net.Session, p *player.Player) { + if p.Action == nil || p.Action.Data == nil { + return + } + data, ok := p.Action.Data.(*behavior.FarmData) + if !ok { + return + } + prefix := data.Prefix + seedID := data.SeedID + xp := data.XP + + g.ensureFarmState(p, prefix) + + p.Flags[prefix+"_weeds"] = false + p.Flags[prefix+"_seed"] = seedID + p.Flags[prefix+"_stage"] = 0 + p.Flags[prefix+"_watered"] = false + p.Flags[prefix+"_diseased"] = false + p.Flags[prefix+"_dead"] = false + p.Flags[prefix+"_ready"] = false + + if intXP := int(xp); intXP > 0 { + g.awardSkillXP(sess, p, player.Farming, intXP) + } + + g.AccountStore.SaveCharacter(p) + + seedName := seedID + if def, err := g.ItemStore.Load(seedID); err == nil { + seedName = g.itemColorize(sess, def, def.Name) + } + patchName := patchDisplayName(prefix) + msg := fmt.Sprintf("You plant a %s in the %s.", seedName, patchName) + if int(xp) > 0 && p.OptionBool("xp_drops") { + msg += g.formatXpDropSingle(sess, p, player.Farming, int(xp)) + } + sess.WriteLine(msg) + + p.Action = nil +} + +func (g *Game) advanceHarvest(sess *net.Session, p *player.Player) { + if p.Action == nil || p.Action.Data == nil { + return + } + data, ok := p.Action.Data.(*behavior.FarmData) + if !ok { + return + } + prefix := data.Prefix + product := data.Product + minYield := data.MinYield + maxYield := data.MaxYield + xp := data.XP + + ready, _ := p.Flags[prefix+"_ready"].(bool) + if !ready { + sess.WriteLine("There's nothing ready to harvest here.") + p.Action = nil + return + } + + yield := int(minYield) + rand.Intn(int(maxYield)-int(minYield)+1) + bonus := p.Level(player.Farming) / 20 + yield += bonus + + freeSlots := p.FreeSlots() + if yield > freeSlots { + productDef, _ := g.ItemStore.Load(product) + if productDef == nil || !productDef.Stackable { + yield = freeSlots + } + } + if yield <= 0 { + sess.WriteLine("Your inventory is too full!") + p.Action = nil + return + } + + productDef, _ := g.ItemStore.Load(product) + if productDef != nil && productDef.Stackable { + added := 0 + for i := 0; i < 28 && added < yield; i++ { + slot := p.InvSlot(i) + if slot != nil && slot.ItemID == product { + slot.Quantity += yield + added = yield + break + } + } + if added == 0 { + freeSlot := p.FirstFreeSlot() + if freeSlot >= 0 { + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: product, Quantity: yield}) + } + } + } else { + for i := 0; i < yield; i++ { + freeSlot := p.FirstFreeSlot() + if freeSlot < 0 { + break + } + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: product, Quantity: 1}) + } + } + + if intXP := int(xp); intXP > 0 { + g.awardSkillXP(sess, p, player.Farming, intXP) + } + + g.ensureFarmState(p, prefix) + p.Flags[prefix+"_seed"] = "" + p.Flags[prefix+"_stage"] = 0 + p.Flags[prefix+"_ready"] = false + p.Flags[prefix+"_watered"] = false + p.Flags[prefix+"_weeds"] = true + p.Flags[prefix+"_diseased"] = false + p.Flags[prefix+"_dead"] = false + + g.AccountStore.SaveCharacter(p) + + productName := product + if productDef != nil { + productName = g.itemColorize(sess, productDef, productDef.Name) + } + patchName := patchDisplayName(prefix) + msg := fmt.Sprintf("You harvest %d %s from the %s.", yield, productName, patchName) + if int(xp) > 0 && p.OptionBool("xp_drops") { + msg += g.formatXpDropSingle(sess, p, player.Farming, int(xp)) + } + sess.WriteLine(msg) + + p.Action = nil +} + +func (g *Game) advanceRake(sess *net.Session, p *player.Player) { + if p.Action == nil || p.Action.Data == nil { + return + } + data, ok := p.Action.Data.(*behavior.FarmData) + if !ok { + return + } + prefix := data.Prefix + + p.Flags[prefix+"_weeds"] = false + p.Flags[prefix+"_dead"] = false + p.Flags[prefix+"_seed"] = "" + p.Flags[prefix+"_stage"] = 0 + p.Flags[prefix+"_ready"] = false + p.Flags[prefix+"_diseased"] = false + p.Flags[prefix+"_watered"] = false + + g.AccountStore.SaveCharacter(p) + + patchName := patchDisplayName(prefix) + sess.WriteLine(fmt.Sprintf("You rake the %s clean.", patchName)) + + p.Action = nil +} + +func (g *Game) advanceWater(sess *net.Session, p *player.Player) { + if p.Action == nil || p.Action.Data == nil { + return + } + data, ok := p.Action.Data.(*behavior.FarmData) + if !ok { + return + } + prefix := data.Prefix + + p.Flags[prefix+"_watered"] = true + + g.AccountStore.SaveCharacter(p) + + patchName := patchDisplayName(prefix) + sess.WriteLine(fmt.Sprintf("You water the %s.", patchName)) + + p.Action = nil +} + +func (g *Game) advanceCure(sess *net.Session, p *player.Player) { + if p.Action == nil || p.Action.Data == nil { + return + } + data, ok := p.Action.Data.(*behavior.FarmData) + if !ok { + return + } + prefix := data.Prefix + + p.RemoveItem("plant_cure", 1) + p.Flags[prefix+"_diseased"] = false + + g.AccountStore.SaveCharacter(p) + + sess.WriteLine("You apply the plant cure. The patch looks healthy again.") + + p.Action = nil +} + +func patchDisplayName(prefix string) string { + switch { + case strings.Contains(prefix, "herb"): + return "herb patch" + case strings.Contains(prefix, "allot"): + return "allotment patch" + case strings.Contains(prefix, "flower"): + return "flower patch" + case strings.Contains(prefix, "bush"): + return "bush patch" + case strings.Contains(prefix, "tree"): + return "tree patch" + } + return "patch" +} + +func (g *Game) farmPatchLookSuffix(p *player.Player, defID string, index int) string { + prefix := farmFlagPrefix(defID, index) + if prefix == "" { + return "" + } + g.ensureFarmState(p, prefix) + + weeds, _ := p.Flags[prefix+"_weeds"].(bool) + if weeds { + return "\nIt is overgrown with weeds." + } + + seedID, _ := p.Flags[prefix+"_seed"].(string) + if seedID == "" { + return "\nThe patch is empty and ready for planting." + } + + dead, _ := p.Flags[prefix+"_dead"].(bool) + if dead { + return "\nThe plant has died. You need to rake it clean." + } + + diseased, _ := p.Flags[prefix+"_diseased"].(bool) + if diseased { + return "\nThe plant looks diseased! Use plant cure to save it." + } + + ready, _ := p.Flags[prefix+"_ready"].(bool) + if ready { + name := seedID + if def, err := g.ItemStore.Load(seedID); err == nil { + name = def.Name + } + return fmt.Sprintf("\nA fully grown %s is ready to harvest!", name) + } + + stage := intFromFlag(p.Flags, prefix+"_stage") + maxStages := 4 + if def, err := g.ItemStore.Load(seedID); err == nil && def.FarmStages > 0 { + maxStages = def.FarmStages + } + name := seedID + if def, err := g.ItemStore.Load(seedID); err == nil { + name = def.Name + } + watered, _ := p.Flags[prefix+"_watered"].(bool) + suffix := fmt.Sprintf("\nA %s is growing (stage %d/%d).", name, stage, maxStages) + if watered { + suffix += " It has been watered." + } + return suffix +} + +func (g *Game) toolShedLookSuffix(p *player.Player) string { + var stored []string + if v, _ := p.Flags["tool_shed_rake"].(bool); v { + stored = append(stored, "a rake") + } + if v, _ := p.Flags["tool_shed_spade"].(bool); v { + stored = append(stored, "a spade") + } + if v, _ := p.Flags["tool_shed_watering_can"].(bool); v { + stored = append(stored, "a watering can") + } + if len(stored) == 0 { + return "\nThe shed is empty." + } + return fmt.Sprintf("\nInside: %s.", strings.Join(stored, ", ")) +} + +func (g *Game) farmMatchPrefix(p *player.Player, input string) (prefix string, defID string, index int) { + objInstances := g.World.AllObjInstances(p.RoomID) + if len(objInstances) == 0 { + return "", "", -1 + } + + type indexedFarm struct { + prefix string + defID string + index int + name string + } + var farms []indexedFarm + + for _, obj := range objInstances { + if !farmPatchDefIDs[obj.DefID] { + continue + } + pref := farmFlagPrefix(obj.DefID, obj.Index) + if pref == "" { + continue + } + var name string + if def, err := g.ObjectStore.Load(obj.DefID); err == nil { + name = def.Name + } else { + name = obj.DefID + } + farms = append(farms, indexedFarm{prefix: pref, defID: obj.DefID, index: obj.Index, name: name}) + } + + if len(farms) == 0 { + return "", "", -1 + } + + if input == "" { + return farms[0].prefix, farms[0].defID, farms[0].index + } + + lower := strings.ToLower(input) + for _, f := range farms { + if strings.Contains(lower, strings.ToLower(f.name)) || behavior.WordPrefixMatch(lower, f.name) { + return f.prefix, f.defID, f.index + } + } + + return "", "", -1 +} + +func (g *Game) farmObjSuffix(p *player.Player, defID string, index int) string { + if defID == "tool_shed" { + return g.toolShedLookSuffix(p) + } + if farmPatchDefIDs[defID] { + return g.farmPatchLookSuffix(p, defID, index) + } + return "" +} + +func (g *Game) showFarmPatches(sess *net.Session, p *player.Player) []string { + objInstances := g.World.AllObjInstances(p.RoomID) + hasFarmPatches := false + for _, obj := range objInstances { + if farmPatchDefIDs[obj.DefID] { + hasFarmPatches = true + break + } + } + if !hasFarmPatches { + return nil + } + + type farmDisplay struct { + name string + line string + } + var displays []farmDisplay + + for _, obj := range objInstances { + if !farmPatchDefIDs[obj.DefID] { + continue + } + def, _ := g.ObjectStore.Load(obj.DefID) + patchName := obj.DefID + if def != nil { + patchName = def.Name + } + + prefix := farmFlagPrefix(obj.DefID, obj.Index) + if prefix == "" { + continue + } + g.ensureFarmState(p, prefix) + + idxStr := "" + if obj.Index > 0 || g.countPatchesByType(objInstances, obj.DefID) > 1 { + idxStr = fmt.Sprintf(" %d", obj.Index+1) + } + + weeds, _ := p.Flags[prefix+"_weeds"].(bool) + if weeds { + displays = append(displays, farmDisplay{ + name: fmt.Sprintf("%s%s", patchName, idxStr), + line: fmt.Sprintf("overgrown with weeds"), + }) + continue + } + + seedID, _ := p.Flags[prefix+"_seed"].(string) + if seedID == "" { + displays = append(displays, farmDisplay{ + name: fmt.Sprintf("%s%s", patchName, idxStr), + line: fmt.Sprintf("empty and ready for planting"), + }) + continue + } + + seedName := seedID + if sd, err := g.ItemStore.Load(seedID); err == nil { + seedName = g.itemColorize(sess, sd, sd.Name) + } + + dead, _ := p.Flags[prefix+"_dead"].(bool) + if dead { + displays = append(displays, farmDisplay{ + name: fmt.Sprintf("%s%s", patchName, idxStr), + line: fmt.Sprintf("contains a dead plant"), + }) + continue + } + + diseased, _ := p.Flags[prefix+"_diseased"].(bool) + if diseased { + displays = append(displays, farmDisplay{ + name: fmt.Sprintf("%s%s", patchName, idxStr), + line: fmt.Sprintf("has a sickly-looking %s plant", seedName), + }) + continue + } + + ready, _ := p.Flags[prefix+"_ready"].(bool) + if ready { + displays = append(displays, farmDisplay{ + name: fmt.Sprintf("%s%s", patchName, idxStr), + line: fmt.Sprintf("has a fully grown %s {ready to harvest}", seedName), + }) + continue + } + + stage := intFromFlag(p.Flags, prefix+"_stage") + maxStages := 4 + if sd, err := g.ItemStore.Load(seedID); err == nil && sd.FarmStages > 0 { + maxStages = sd.FarmStages + } + watered, _ := p.Flags[prefix+"_watered"].(bool) + waterStr := "" + if watered { + waterStr = ", watered" + } + displays = append(displays, farmDisplay{ + name: fmt.Sprintf("%s%s", patchName, idxStr), + line: fmt.Sprintf("has a growing %s (stage %d/%d%s)", seedName, stage, maxStages, waterStr), + }) + } + + if len(displays) == 0 { + return nil + } + + var lines []string + lines = append(lines, "") + for _, d := range displays { + lines = append(lines, fmt.Sprintf("A %s: %s.", d.name, d.line)) + } + return lines +} + +func (g *Game) countPatchesByType(instances []world.ObjState, defID string) int { + count := 0 + for _, obj := range instances { + if obj.DefID == defID && farmPatchDefIDs[obj.DefID] { + count++ + } + } + return count +} diff --git a/internal/game/act_finishing_blow.go b/internal/game/act_finishing_blow.go new file mode 100644 index 0000000..fbd9781 --- /dev/null +++ b/internal/game/act_finishing_blow.go @@ -0,0 +1,77 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func (g *Game) tryFinishingBlow(sess *net.Session, input string) bool { + p := sess.Player + cs := g.Combat.Get(p.Name) + if cs == nil { + return false + } + mob := g.MobStore.GetInstance(cs.MobID) + if mob == nil || mob.FinishingBlow == "" || mob.HP != 1 { + return false + } + lower := strings.ToLower(input) + var itemPart, targetPart string + for _, sep := range []string{" on ", " with "} { + if idx := strings.Index(lower, sep); idx > 0 { + itemPart = strings.TrimSpace(input[:idx]) + targetPart = strings.TrimSpace(input[idx+len(sep):]) + break + } + } + if targetPart == "" { + itemPart = strings.TrimSpace(input) + if mob.MatchQuality(itemPart) != world.MatchNone { + targetPart = itemPart + itemPart = strings.TrimSpace(mob.FinishingBlow) + } + } + if targetPart == "" { + return false + } + if mob.MatchQuality(targetPart) == world.MatchNone { + return false + } + g.doFinishingBlow(sess, p, mob, itemPart) + return true +} + +func (g *Game) doFinishingBlow(sess *net.Session, p *player.Player, mob *world.MobInstance, itemInput string) { + fbDef, err := g.ItemStore.Load(mob.FinishingBlow) + if err != nil { + sess.WriteLine("Something went wrong.") + return + } + if !fbDef.MatchesName(itemInput) && !strings.EqualFold(itemInput, fbDef.ID) { + sess.WriteLine(fmt.Sprintf("That won't work on %s. You need %s.", mobDisplayName(mob, true), g.itemColorize(sess, fbDef, fbDef.Name))) + return + } + if !p.HasItem(mob.FinishingBlow) { + sess.WriteLine(fmt.Sprintf("You don't have any %s.", g.itemColorize(sess, fbDef, fbDef.Name))) + return + } + autoKey := "assassin_unlocked_auto_" + mob.FinishingBlow + consumed := true + if p.Flags != nil { + if val, ok := p.Flags[autoKey]; ok { + if b, ok := val.(bool); ok && b { + consumed = false + } + } + } + if consumed { + p.RemoveItem(mob.FinishingBlow, 1) + } + sess.WriteLine(fmt.Sprintf("You use the %s on %s!", g.itemColorize(sess, fbDef, fbDef.Name), g.colorize(sess, "mob", mobDisplayName(mob, true)))) + mob.HP = 0 + g.endCombat(sess, p, mob) +} diff --git a/internal/game/act_fletch.go b/internal/game/act_fletch.go new file mode 100644 index 0000000..78a19ad --- /dev/null +++ b/internal/game/act_fletch.go @@ -0,0 +1,214 @@ +package game + +import ( + "fmt" + + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/item" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) startFletchAction(sess *net.Session, p *player.Player, def *item.ItemDef, count int) { + p.EnsureFlags() + p.Flags["last_fletch"] = def.ID + g.AccountStore.SaveCharacter(p) + + if def.FirstCraft().Wait <= 0 { + g.doInstantFletch(sess, p, def, count) + return + } + + g.startTimedFletch(sess, p, def, count) +} + +func (g *Game) doInstantFletch(sess *net.Session, p *player.Player, def *item.ItemDef, count int) { + c := def.FirstCraft() + outputQty := c.OutputQty + if outputQty <= 0 { + outputQty = 1 + } + + batches := 0 + totalOutput := 0 + skill := player.SkillName(c.EffectiveSkill()) + tmpItem := &item.ItemDef{Craft: item.CraftList{*c}} + for { + if !g.canDoFletchItem(p, tmpItem) { + break + } + + placed := false + if def.Stackable { + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot != nil && slot.ItemID == def.ID { + craftConsumeAll(c, p.HasItem, p.RemoveItem) + slot.Quantity += outputQty + placed = true + break + } + } + } + if !placed { + craftConsumeAll(c, p.HasItem, p.RemoveItem) + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + break + } + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: def.ID, Quantity: outputQty}) + } + + if c.XP > 0 { + g.awardSkillXP(sess, p, skill, c.XP) + } + + batches++ + totalOutput += outputQty + + if count > 0 { + count-- + if count <= 0 { + break + } + } + } + + if batches == 0 { + sess.WriteLine("You don't have the materials for that.") + return + } + + g.AccountStore.SaveCharacter(p) + + outDef, _ := g.ItemStore.Load(def.ID) + outputName := def.Name + if outDef != nil { + outputName = g.itemColorize(sess, outDef, outDef.Name) + } + msg := fmt.Sprintf("You fletch %d %s.", totalOutput, outputName) + if p.OptionBool("xp_drops") && c.XP > 0 { + msg += g.formatXpDropSingle(sess, p, skill, c.XP*batches) + } + sess.WriteLine(msg) +} + +func (g *Game) startTimedFletch(sess *net.Session, p *player.Player, def *item.ItemDef, count int) { + craft := def.FirstCraft() + + startMsg := g.resolveCraftMessage(sess, craft.StartMessage, "You begin fletching %i1.", craft, def.ID, nil, p.HasItem) + + sess.WriteLine(startMsg) + + broadcastName := def.Name + if dd, _ := g.ItemStore.Load(def.ID); dd != nil { + broadcastName = dd.Name + } + g.broadcastAction(sess, "%s starts fletching.", p.Name) + _ = broadcastName + + p.BackgroundAction = &behavior.Action{ + Type: "fletch", + TargetID: def.ID, + TargetName: def.Name, + Data: &behavior.FletchData{ + ItemID: def.ID, + Phase: 0, + Wait: craft.Wait, + Remaining: count, + }, + WaitLeft: engine.ToTicks(1), + } +} + +func (g *Game) advanceFletch(sess *net.Session, p *player.Player) { + data, ok := p.BackgroundAction.Data.(*behavior.FletchData) + if !ok { + g.cancelBgAction(p) + return + } + itemID := data.ItemID + phase := data.Phase + wait := data.Wait + remaining := data.Remaining + + def := g.loadCraftItem(itemID) + if def == nil { + g.cancelBgAction(p) + return + } + c := def.FirstCraft() + + if phase == 0 { + data.Phase = 1 + p.BackgroundAction.WaitLeft = engine.ToTicks(wait) + return + } + + tmpItem := &item.ItemDef{Craft: item.CraftList{*c}} + if !g.canDoFletchItem(p, tmpItem) { + sess.WriteLine("You've run out of fletching materials.") + g.cancelBgAction(p) + return + } + + outputQty := c.OutputQty + if outputQty <= 0 { + outputQty = 1 + } + + skill := player.SkillName(c.EffectiveSkill()) + + placed := false + if def.Stackable { + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot != nil && slot.ItemID == def.ID { + craftConsumeAll(c, p.HasItem, p.RemoveItem) + slot.Quantity += outputQty + placed = true + break + } + } + } + if !placed { + craftConsumeAll(c, p.HasItem, p.RemoveItem) + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + sess.WriteLine("Your inventory is too full!") + g.cancelBgAction(p) + return + } + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: def.ID, Quantity: outputQty}) + } + + if c.XP > 0 { + g.awardSkillXP(sess, p, skill, c.XP) + } + g.AccountStore.SaveCharacter(p) + + msg := g.resolveCraftMessage(sess, c.Message, "You fletch a %n.", c, def.ID, nil, p.HasItem) + if p.OptionBool("xp_drops") && c.XP > 0 { + msg += g.formatXpDropSingle(sess, p, skill, c.XP) + } + sess.WriteLine(msg) + + if remaining > 0 { + remaining-- + data.Remaining = remaining + if remaining <= 0 { + sess.WriteLine("You've finished fletching.") + g.cancelBgAction(p) + return + } + } + + if !g.canContinueProduction(p, def) { + sess.WriteLine("You've run out of fletching materials.") + g.cancelBgAction(p) + return + } + + p.BackgroundAction.WaitLeft = engine.ToTicks(wait) +} diff --git a/internal/game/act_gather.go b/internal/game/act_gather.go new file mode 100644 index 0000000..0c743e7 --- /dev/null +++ b/internal/game/act_gather.go @@ -0,0 +1,405 @@ +package game + +import ( + "fmt" + "math/rand" + "strings" + + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/object" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +const birdsNestItemID = "birds_nest" + +func loadGatherConfig(g *Game, objDefID string) *behavior.GatherConfig { + def, err := g.ObjectStore.Load(objDefID) + if err != nil { + return nil + } + return def.Gather +} + +func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.ObjectDef, st *world.ObjState) { + cfg := obj.Gather + if cfg == nil { + sess.WriteLine("Something is wrong with this object.") + return + } + + skillLevel := p.Level(player.SkillName(cfg.Skill)) + if cfg.Level > 0 && skillLevel < cfg.Level { + sess.WriteLine(fmt.Sprintf("You need level %d %s to do that.", cfg.Level, cfg.Skill)) + return + } + + if st.Depleted { + if cfg.DepletedMessage != "" { + msg := cfg.DepletedMessage + if p.OptionBool("depletion") { + msg += fmt.Sprintf(" (%d ticks to respawn)", int(st.DepleteTimer)) + } + sess.WriteLine(msg) + } else if cfg.DepleteTimer > 0 { + sess.WriteLine(fmt.Sprintf("The %s has been cut down for %d more ticks.", obj.Name, int(st.DepleteTimer))) + } else { + sess.WriteLine(fmt.Sprintf("The %s is depleted for %d more ticks.", obj.Name, int(st.DepleteTimer))) + } + return + } + + wait := cfg.BaseWait + var toolName string + + if len(cfg.Tools) > 0 { + toolSpeed, name, found := g.findTool(p, cfg.Tools) + if !found { + names := make([]string, len(cfg.Tools)) + for i, t := range cfg.Tools { + names[i] = strings.ReplaceAll(t, "_", " ") + } + toolList := strings.Join(names, " or ") + sess.WriteLine(fmt.Sprintf("You need a %s to do that.", toolList)) + return + } + toolName = name + wait = cfg.BaseWait - toolSpeed + if wait < 1 { + wait = 1 + } + } + + if cfg.Bait != "" { + if !p.HasItem(cfg.Bait) { + baitName := cfg.Bait + if def, err := g.ItemStore.Load(cfg.Bait); err == nil { + baitName = def.Name + } + sess.WriteLine(fmt.Sprintf("You need %s to fish here.", baitName)) + return + } + } + + if p.FirstFreeSlot() == -1 { + sess.WriteLine("Your inventory is too full!") + return + } + verb := cfg.Skill + switch cfg.Skill { + case "woodcutting": + verb = "chopping" + } + + g.broadcastAction(sess, "\n%s starts %s the %s.", p.Name, verb, obj.Name) + + d := &behavior.GatherData{ + ObjDefID: obj.ID, + InstanceKey: g.World.ObjStateKey(st.RoomID, st.DefID, st.Index), + InstanceIdx: st.Index + 1, + EffectiveWait: wait, + Verb: verb, + ToolName: toolName, + } + if cfg.DepleteTimer > 0 { + d.DepleteTimer = true + } + p.Action = &behavior.Action{ + Type: behavior.TypeGather, + TargetID: st.DefID, + TargetName: obj.Name, + WaitLeft: 0, + Data: d, + } +} + +func (g *Game) advanceGather(sess *net.Session, p *player.Player) { + d := p.Action.Data.(*behavior.GatherData) + cfg := loadGatherConfig(g, d.ObjDefID) + if cfg == nil { + g.cancelAction(p) + return + } + + step := d.Step + wait := d.EffectiveWait + instanceKey := d.InstanceKey + shared := d.DepleteTimer + + if step == 0 { + if p.FirstFreeSlot() == -1 { + sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!")) + g.cancelAction(p) + return + } + if cfg.Bait != "" { + if !p.HasItem(cfg.Bait) { + baitName := cfg.Bait + if def, err := g.ItemStore.Load(cfg.Bait); err == nil { + baitName = def.Name + } + sess.WriteLine(g.colorize(sess, "error", fmt.Sprintf("You've run out of %s.", baitName))) + g.cancelAction(p) + return + } + } + sess.WriteLine(fmt.Sprintf("%s", cfg.GatherMsg)) + d.Step = 1 + p.Action.WaitLeft = engine.ToTicks(wait) + return + } + + if step == 2 { + st := g.World.GetObjStateByKey(instanceKey) + if st != nil && st.Depleted { + p.Action.WaitLeft = engine.ToTicks(3) + return + } + d.Step = 0 + p.Action.WaitLeft = engine.ToTicks(wait) + return + } + + if cfg.Bait != "" { + if !p.HasItem(cfg.Bait) { + baitName := cfg.Bait + if def, err := g.ItemStore.Load(cfg.Bait); err == nil { + baitName = def.Name + } + sess.WriteLine(g.colorize(sess, "error", fmt.Sprintf("You've run out of %s.", baitName))) + g.cancelAction(p) + return + } + p.RemoveItem(cfg.Bait, 1) + } + + skillLevel := p.Level(player.SkillName(cfg.Skill)) + chance := behavior.SuccessChance(cfg.Success, skillLevel, cfg.Level) + + if rand.Float64() < chance { + eligible := filterDropsByLevel(cfg.Drops, skillLevel) + drop := behavior.ResolveDrop(g.DataDir, eligible) + if drop != nil { + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!")) + g.cancelAction(p) + return + } + + qty := drop.Quantity + if qty <= 0 { + qty = 1 + } + + itemName := drop.ItemID + itemDef, _ := g.ItemStore.Load(drop.ItemID) + if itemDef != nil { + itemName = itemDef.Name + } + + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty}) + xp := drop.XP + if xp <= 0 { + xp = cfg.XP + } + if xp > 0 { + g.awardSkillXP(sess, p, player.SkillName(cfg.Skill), xp) + } + g.AccountStore.SaveCharacter(p) + + msg := drop.Message + if msg == "" { + msg = fmt.Sprintf("You manage to get some %s.", g.itemColorize(sess, itemDef, itemName)) + } else { + msg = color.ExpandTags(g.colorMode(sess), msg) + } + if xp > 0 && p.OptionBool("xp_drops") { + msg += g.formatXpDropSingle(sess, p, player.SkillName(cfg.Skill), xp) + } + sess.WriteLine(msg) + + g.checkBirdNest(sess, p, cfg, freeSlot) + + if shared { + st := g.World.GetObjStateByKey(instanceKey) + if st != nil && st.SharedTimer <= 0 { + g.pendingDepletions = append(g.pendingDepletions, pendingDepletion{ + instanceKey: instanceKey, + objDefID: d.ObjDefID, + targetName: p.Action.TargetName, + playerNames: []string{p.Name}, + }) + d.Step = 2 + p.Action.WaitLeft = engine.ToTicks(1) + return + } + } + + if !shared && drop.Depletes { + delay := engine.ToTicks(cfg.RespawnTimer) + if delay <= 0 { + delay = 10 + } + st := g.World.GetObjStateByKey(instanceKey) + if st != nil { + st.Depleted = true + st.DepleteTimer = float64(delay) + } + + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other == sess { + continue + } + op := other.Player + if op == nil || op.Action == nil || op.Action.Type != behavior.TypeGather { + continue + } + if gd, ok := op.Action.Data.(*behavior.GatherData); ok && gd.InstanceKey == instanceKey { + other.WriteLine(fmt.Sprintf("\nThe %s was depleted by %s!", g.colorize(sess, "item", p.Action.TargetName), g.colorize(sess, "player_name", p.Name))) + g.cancelAction(op) + g.writePrompt(other) + } + } + + d.Step = 2 + p.Action.WaitLeft = engine.ToTicks(1) + return + } + + if p.FirstFreeSlot() == -1 { + sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!")) + g.cancelAction(p) + return + } + d.Step = 0 + p.Action.WaitLeft = engine.ToTicks(wait) + return + } + } + + sess.WriteLine(cfg.FailMsg) + if p.FirstFreeSlot() == -1 { + sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!")) + g.cancelAction(p) + return + } + d.Step = 0 + p.Action.WaitLeft = engine.ToTicks(wait) +} + +func filterDropsByLevel(drops []behavior.DropEntry, level int) []behavior.DropEntry { + var eligible []behavior.DropEntry + for _, d := range drops { + if level >= d.Level { + eligible = append(eligible, d) + } + } + if len(eligible) == 0 { + return drops + } + return eligible +} + +func (g *Game) depleteSharedTree(st *world.ObjState, cfg *behavior.GatherConfig, targetName string, playerNames []string) { + st.Depleted = true + st.DepleteTimer = float64(engine.ToTicks(cfg.RespawnTimer)) + st.SharedTimer = 0 + + msg := cfg.ExhaustedMessage + if msg == "" { + msg = fmt.Sprintf("The %s falls to the ground!", targetName) + } + + for _, sess := range g.Hub.PlayersInRoom(st.RoomID) { + op := sess.Player + if op == nil || op.Action == nil || op.Action.Type != behavior.TypeGather { + continue + } + if gd, ok := op.Action.Data.(*behavior.GatherData); !ok || gd.InstanceKey != g.World.ObjStateKey(st.RoomID, st.DefID, st.Index) { + continue + } + isDepleter := false + for _, name := range playerNames { + if op.Name == name { + isDepleter = true + break + } + } + if isDepleter { + sess.WriteLine(fmt.Sprintf("\n%s", msg)) + continue + } + sess.WriteLine(fmt.Sprintf("\n%s", msg)) + g.cancelAction(op) + g.writePrompt(sess) + } +} + +func (g *Game) flushPendingDepletions() { + if len(g.pendingDepletions) == 0 { + return + } + + merged := make(map[string]*pendingDepletion) + for _, pd := range g.pendingDepletions { + if existing, ok := merged[pd.instanceKey]; ok { + existing.playerNames = append(existing.playerNames, pd.playerNames...) + } else { + copy := pd + merged[pd.instanceKey] = © + } + } + + for _, pd := range merged { + st := g.World.GetObjStateByKey(pd.instanceKey) + if st == nil || st.Depleted { + continue + } + cfg := loadGatherConfig(g, pd.objDefID) + if cfg == nil { + continue + } + g.depleteSharedTree(st, cfg, pd.targetName, pd.playerNames) + } + + g.pendingDepletions = nil +} + +func (g *Game) checkBirdNest(sess *net.Session, p *player.Player, cfg *behavior.GatherConfig, currentDropSlot int) { + if cfg.NestChance <= 0 { + return + } + if rand.Intn(cfg.NestChance) != 0 { + return + } + + nestName := "bird's nest" + nestDef, _ := g.ItemStore.Load(birdsNestItemID) + if nestDef != nil { + nestName = g.itemColorize(sess, nestDef, nestDef.Name) + } + + freeSlot := -1 + for i := 0; i < 28; i++ { + if i == currentDropSlot { + continue + } + if p.InvSlot(i) == nil { + freeSlot = i + break + } + } + if freeSlot == -1 { + g.World.AddGroundItem(p.RoomID, birdsNestItemID, 1) + sess.WriteLine(fmt.Sprintf("A %s falls to the ground.", nestName)) + return + } + + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: birdsNestItemID, Quantity: 1}) + g.AccountStore.SaveCharacter(p) + sess.WriteLine(fmt.Sprintf("A %s falls out of the tree!", nestName)) +} diff --git a/internal/game/act_identify.go b/internal/game/act_identify.go new file mode 100644 index 0000000..b905494 --- /dev/null +++ b/internal/game/act_identify.go @@ -0,0 +1,83 @@ +package game + +import ( + "fmt" + + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) advanceIdentify(sess *net.Session, p *player.Player) { + data, ok := p.Action.Data.(*behavior.IdentifyData) + if !ok { + g.cancelAction(p) + return + } + junkID := data.JunkID + xpPer := data.XPPer + + scrapCount := p.CountItem("scrap") + if scrapCount == 0 { + sess.WriteLine("You don't have any scrap.") + g.cancelAction(p) + return + } + + scavLevel := p.Level(player.Scavenging) + multiplier := junkMultiplier(junkID, scavLevel) + totalJunk := scrapCount * multiplier + + hasExistingStack := false + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot != nil && slot.ItemID == junkID { + hasExistingStack = true + break + } + } + if !hasExistingStack && p.FirstFreeSlot() == -1 { + sess.WriteLine("Your inventory is too full!") + g.cancelAction(p) + return + } + + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot != nil && slot.ItemID == "scrap" { + p.SetInvSlot(i, nil) + } + } + + placed := false + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot != nil && slot.ItemID == junkID { + slot.Quantity += totalJunk + placed = true + break + } + } + if !placed { + freeSlot := p.FirstFreeSlot() + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: junkID, Quantity: totalJunk}) + } + + totalXP := scrapCount * xpPer + junkName := junkID + if def, err := g.ItemStore.Load(junkID); err == nil { + junkName = g.itemColorize(sess, def, def.Name) + } + + msg := fmt.Sprintf("The altar hums. You identify %d scrap into %d %s.", scrapCount, totalJunk, junkName) + + g.awardSkillXP(sess, p, player.Scavenging, totalXP) + + if p.OptionBool("xp_drops") && totalXP > 0 { + msg += g.formatXpDropSingle(sess, p, player.Scavenging, totalXP) + } + sess.WriteLine(msg) + + g.AccountStore.SaveCharacter(p) + g.cancelAction(p) +} diff --git a/internal/game/act_room.go b/internal/game/act_room.go new file mode 100644 index 0000000..86c1737 --- /dev/null +++ b/internal/game/act_room.go @@ -0,0 +1,220 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +// enterSeq is an in-flight on_enter cutscene for a single player. It is driven +// one step at a time by EnterSeqTick. +type enterSeq struct { + sess *net.Session + playerName string + roomID int + steps []world.EnterStep + wait int + started bool +} + +// runEnterSteps evaluates a room's on_enter script for the player. Steps whose +// condition fails are dropped. If none of the surviving steps are "timed" +// (no delay, no flag mutation) the messages are printed synchronously, exactly +// as before. Otherwise the surviving steps become a scheduled cutscene driven +// by EnterSeqTick. +func (g *Game) runEnterSteps(sess *net.Session, roomID int) { + room, err := g.World.LoadRoom(roomID) + if err != nil || len(room.OnEnter) == 0 { + return + } + p := sess.Player + if p == nil { + return + } + + var steps []world.EnterStep + cutscene := false + for _, step := range room.OnEnter { + if step.Condition != nil && !g.checkCondition(sess, step.Condition) { + continue + } + steps = append(steps, step) + if step.IsTimed() { + cutscene = true + } + } + if len(steps) == 0 { + return + } + + if !cutscene { + for _, step := range steps { + if step.Message != "" { + sess.WriteLine(step.Message) + } + } + return + } + + g.startEnterCutscene(sess, p, roomID, steps) +} + +// startEnterCutscene registers a scheduled on_enter sequence and persists a +// marker on the player so the cutscene can be resumed on reconnect if it is +// interrupted (disconnect, server restart). +func (g *Game) startEnterCutscene(sess *net.Session, p *player.Player, roomID int, steps []world.EnterStep) { + g.cancelEnterSeq(p.Name) + if p.CutsceneRoom != roomID { + p.CutsceneRoom = roomID + g.AccountStore.SaveCharacter(p) + } + g.enterMu.Lock() + g.enterSeqs[p.Name] = &enterSeq{ + sess: sess, + playerName: p.Name, + roomID: roomID, + steps: steps, + wait: steps[0].Delay, + } + g.enterMu.Unlock() +} + +// EnterSeqTick advances every in-flight cutscene by one tick. Sequences for a +// player who is no longer connected are paused (left for resume); sequences are +// removed from the map on completion or cancellation. +func (g *Game) EnterSeqTick() { + g.enterMu.Lock() + seqs := make([]*enterSeq, 0, len(g.enterSeqs)) + for _, s := range g.enterSeqs { + seqs = append(seqs, s) + } + g.enterMu.Unlock() + + for _, seq := range seqs { + if !g.isCharLive(seq.playerName, seq.sess) { + continue + } + + var jobs []world.EnterStep + done := false + + g.enterMu.Lock() + if g.enterSeqs[seq.playerName] != seq { + g.enterMu.Unlock() + continue + } + seq.wait-- + for seq.wait <= 0 && len(seq.steps) > 0 { + step := seq.steps[0] + seq.steps = seq.steps[1:] + jobs = append(jobs, step) + if len(seq.steps) > 0 { + seq.wait = seq.steps[0].Delay + } else { + seq.wait = 0 + } + } + if len(seq.steps) == 0 { + done = true + delete(g.enterSeqs, seq.playerName) + } + g.enterMu.Unlock() + + for _, step := range jobs { + g.fireEnterStep(seq, step) + } + if done { + g.completeEnterSeq(seq) + } + } +} + +func (g *Game) fireEnterStep(seq *enterSeq, step world.EnterStep) { + p := seq.sess.Player + if p == nil || p.RoomID != seq.roomID { + return + } + if step.Message != "" { + // Break from the prompt on the first line only, so a multi-line + // cutscene reads as tight consecutive lines. + if !seq.started { + seq.sess.WriteLine("\n" + step.Message) + seq.started = true + } else { + seq.sess.WriteLine(step.Message) + } + } + if len(step.SetFlags) > 0 || len(step.SetPlayerFlags) > 0 { + g.applyFlagMutations(p, step.SetFlags, step.SetPlayerFlags) + g.AccountStore.SaveCharacter(p) + } +} + +func (g *Game) completeEnterSeq(seq *enterSeq) { + if !g.isCharLive(seq.playerName, seq.sess) { + return + } + p := seq.sess.Player + if p == nil { + return + } + if p.CutsceneRoom == seq.roomID { + p.CutsceneRoom = 0 + g.AccountStore.SaveCharacter(p) + } + g.writePrompt(seq.sess) +} + +// cancelEnterSeq drops any in-flight cutscene for the player. It does not touch +// the persisted CutsceneRoom marker, so a cutscene cancelled by disconnect can +// still be resumed on reconnect. +func (g *Game) cancelEnterSeq(name string) { + g.enterMu.Lock() + delete(g.enterSeqs, name) + g.enterMu.Unlock() +} + +func (g *Game) isCharLive(name string, sess *net.Session) bool { + g.charsMu.Lock() + defer g.charsMu.Unlock() + return g.loggedInChars[name] == sess +} + +// applyFlagMutations sets world and player flags, shared by on_enter steps and +// exit traversal. +func (g *Game) applyFlagMutations(p *player.Player, setFlags, setPlayerFlags map[string]any) { + g.Flags.SetAll(setFlags) + if len(setPlayerFlags) > 0 { + p.EnsureFlags() + for k, v := range setPlayerFlags { + p.Flags[k] = v + } + } +} + +func (g *Game) BroadcastRespawns() { + respawns := g.World.FlushObjRespawns() + for _, st := range respawns { + def, err := g.ObjectStore.Load(st.DefID) + if err != nil || !def.IsGatherable() { + continue + } + cfg := def.Gather + if cfg.RespawnBroadcast == "" { + continue + } + name := def.Name + if idx := st.Index + 1; len(g.World.FindObjInstances(st.RoomID, st.DefID)) > 1 { + name += fmt.Sprintf(" [%d]", idx) + } + msg := strings.ReplaceAll(cfg.RespawnBroadcast, "{name}", name) + if g.Hub != nil { + for _, sess := range g.Hub.PlayersInRoom(st.RoomID) { + sess.WriteLine(g.colorize(sess, "broadcast", fmt.Sprintf("\n%s", msg))) + } + } + } +} diff --git a/internal/game/act_search.go b/internal/game/act_search.go new file mode 100644 index 0000000..6af15ca --- /dev/null +++ b/internal/game/act_search.go @@ -0,0 +1,126 @@ +package game + +import ( + "fmt" + + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) startSearch(sess *net.Session, p *player.Player, itemID string, slotIdx int) { + def, err := g.ItemStore.Load(itemID) + if err != nil || def.SearchTable == "" { + sess.WriteLine("You can't search that.") + return + } + + ticks := def.SearchTicks + if ticks <= 0 { + ticks = 3 + } + + p.Action = &behavior.Action{ + Type: "search", + TargetID: itemID, + TargetName: def.Name, + WaitLeft: engine.ToTicks(ticks), + Data: &behavior.SearchData{ + ItemID: itemID, + SlotIdx: slotIdx, + Started: false, + }, + } + + g.broadcastAction(sess, "\n%s searches a %s.", p.Name, def.Name) +} + +func (g *Game) advanceSearch(sess *net.Session, p *player.Player) { + d, ok := p.Action.Data.(*behavior.SearchData) + if !ok || d == nil { + sess.WriteLine("Something went wrong.") + g.cancelAction(p) + return + } + + def, err := g.ItemStore.Load(d.ItemID) + if err != nil { + sess.WriteLine("Something went wrong.") + g.cancelAction(p) + return + } + + if !d.Started { + msg := def.SearchMessage + if msg == "" { + msg = fmt.Sprintf("digging through the %s", def.Name) + } + sess.WriteLine(fmt.Sprintf("You begin %s.", msg)) + d.Started = true + return + } + + slot := p.InvSlot(d.SlotIdx) + if slot == nil || slot.ItemID != d.ItemID || slot.Quantity <= 0 { + sess.WriteLine("The item is gone.") + g.cancelAction(p) + return + } + + if slot.Quantity > 1 { + slot.Quantity-- + } else { + p.SetInvSlot(d.SlotIdx, nil) + } + + dt, err := behavior.LoadDropTable(g.DataDir, def.SearchTable) + if err == nil && len(dt.Drops) > 0 { + drop := behavior.ResolveDrop(g.DataDir, dt.Drops) + if drop != nil && drop.ItemID != "" { + g.giveSearchLoot(sess, p, drop) + } + } + + if def.SearchMiscTable != "" { + dt2, err := behavior.LoadDropTable(g.DataDir, def.SearchMiscTable) + if err == nil && len(dt2.Drops) > 0 { + drop := behavior.ResolveDrop(g.DataDir, dt2.Drops) + if drop != nil && drop.ItemID != "" { + g.giveSearchLoot(sess, p, drop) + } + } + } + + g.AccountStore.SaveCharacter(p) + g.cancelAction(p) +} + +func (g *Game) giveSearchLoot(sess *net.Session, p *player.Player, drop *behavior.DropEntry) { + qty := drop.Quantity + if qty <= 0 { + qty = 1 + } + + name := drop.ItemID + lootDef, _ := g.ItemStore.Load(drop.ItemID) + if lootDef != nil { + name = lootDef.Name + } + + if drop.ItemID == "credits" { + p.Credits += qty + sess.WriteLine(g.colorize(sess, "credits_pickup", fmt.Sprintf("You find %d credits.", qty))) + return + } + + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + g.World.AddGroundItem(p.RoomID, drop.ItemID, qty) + sess.WriteLine(fmt.Sprintf("You find %s. It falls to the ground.", g.itemColorize(sess, lootDef, name))) + return + } + + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty}) + sess.WriteLine(fmt.Sprintf("You find %s.", g.itemColorize(sess, lootDef, name))) +} diff --git a/internal/game/act_state.go b/internal/game/act_state.go new file mode 100644 index 0000000..a219875 --- /dev/null +++ b/internal/game/act_state.go @@ -0,0 +1,97 @@ +package game + +import ( + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/player" +) + +func (g *Game) playerActionDisplay(p *player.Player) string { + if cs := g.Combat.Get(p.Name); cs != nil { + mob := g.MobStore.GetInstance(cs.MobID) + if mob != nil { + if mob.IsTask() { + return "working on a " + mob.Name + } + return "fighting a " + mob.Name + } + } + + if p.MoveTicks > 0 { + return "heading " + p.MoveDirection + } + if len(p.WalkSequence) > 0 { + return "walking somewhere with a purpose!" + } + + if ss, ok := g.safespot.Get(p.Name); ok && ss.Active { + obj, err := g.ObjectStore.Load(ss.ObjectDefID) + if err == nil { + return "positioning behind a " + obj.Name + } + return "hiding" + } + + if a := p.Action; a != nil { + switch a.Type { + case behavior.TypeGather: + if d, ok := a.Data.(*behavior.GatherData); ok { + desc := d.Verb + " a " + a.TargetName + if d.ToolName != "" { + desc += " with a " + d.ToolName + } + return desc + } + case behavior.TypeSteal: + return "stealing from " + a.TargetName + case behavior.TypeTalk: + return "talking to " + a.TargetName + case behavior.TypeUse: + return "using a " + a.TargetName + case behavior.TypeBurn: + return "trying to start a fire" + case behavior.TypeStoke: + return "tending to a fire" + case behavior.TypeSearch: + return "digging through a " + a.TargetName + case behavior.TypeIdentify: + return "identifying scrap at " + a.TargetName + case behavior.TypePlant: + return "planting " + a.TargetName + case behavior.TypeHarvest: + return "harvesting " + a.TargetName + case behavior.TypeRake: + return "raking a " + a.TargetName + case behavior.TypeWater: + return "watering a " + a.TargetName + case behavior.TypeCure: + return "curing a " + a.TargetName + case behavior.TypeObstacle: + return "traversing across " + a.TargetName + case behavior.TypeCook: + return "cooking some " + a.TargetName + case behavior.TypeSmelt: + return "smelting some " + a.TargetName + case behavior.TypeSmith: + return "smithing some " + a.TargetName + case behavior.TypeCraft: + return "crafting some " + a.TargetName + case behavior.TypeCombine: + return "combining some " + a.TargetName + case behavior.TypeMix: + return "mixing some " + a.TargetName + case behavior.TypeConstruct: + return "constructing some " + a.TargetName + } + } + + if a := p.BackgroundAction; a != nil { + switch a.Type { + case behavior.TypeFletch: + return "fletching " + a.TargetName + case behavior.TypeClean: + return "cleaning herbs" + } + } + + return "" +} diff --git a/internal/game/act_steal.go b/internal/game/act_steal.go new file mode 100644 index 0000000..6f67944 --- /dev/null +++ b/internal/game/act_steal.go @@ -0,0 +1,476 @@ +package game + +import ( + "fmt" + "math/rand" + "sort" + "strconv" + "strings" + + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/object" + "thehouseoficarus/internal/player" + "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 = &behavior.TalkConfig{ + Nodes: map[string]behavior.TalkNode{ + "start": { + Message: "Caught you red-handed! You have three options, thief.", + Options: []behavior.TalkOption{ + {Text: "\"I'll pay a fine. (500 credits)\"", Goto: "bribe", Condition: &behavior.Condition{MinCredits: 500}}, + {Text: "\"Take me to jail.\"", Goto: "jail"}, + {Text: "\"You'll have to catch me first!\"", Goto: "fight"}, + }, + }, + "bribe": { + Message: "Smart choice. Hand over 500 credits and we'll forget this happened.", + Action: &behavior.NodeAction{Cost: 500}, + Options: []behavior.TalkOption{{Text: "\"Fine, take it.\"", End: true}}, + }, + "jail": { + Message: "Off to the detention cell with you!", + Action: &behavior.NodeAction{Teleport: 162}, + Options: []behavior.TalkOption{{Text: "(You are dragged away)", End: true}}, + }, + "fight": { + Message: "Then defend yourself!", + Action: &behavior.NodeAction{SetFlags: map[string]any{"guard_hostile": true}}, + Options: []behavior.TalkOption{{Text: "(The guard attacks!)", End: true}}, + }, + }, +} + +var stealSuccess = behavior.SuccessFormula{ + Base: 0.5, + PerLevel: 0.03, + Cap: 0.95, +} + +func (g *Game) doSteal(sess *net.Session, input string) { + p := sess.Player + + if g.Combat.Get(p.Name) != nil { + sess.WriteLine("You can't do that during combat!") + return + } + + g.cancelAction(p) + + targetType, mob, objDef, errMsg := g.resolveStealTarget(sess, p, input) + if errMsg != "" { + sess.WriteLine(errMsg) + return + } + + g.startSteal(sess, p, targetType, mob, objDef) +} + +func (g *Game) resolveStealTarget(sess *net.Session, p *player.Player, input string) (targetType string, mob *world.MobInstance, objDef *object.ObjectDef, errMsg string) { + input = strings.ToLower(strings.TrimSpace(input)) + instanceIdx := -1 + searchName := input + + if dotPos := strings.Index(input, "."); dotPos > 0 { + if n, err := strconv.Atoi(input[:dotPos]); err == nil && n > 0 { + instanceIdx = n - 1 + searchName = input[dotPos+1:] + } + } + + roomMobs := g.MobStore.MobsInRoom(p.RoomID) + var stealMobs []*world.MobInstance + for _, m := range roomMobs { + if m.StealTable != "" { + stealMobs = append(stealMobs, m) + } + } + + roomObjs := g.World.AllObjInstances(p.RoomID) + var stealObjs []*object.ObjectDef + for _, st := range roomObjs { + def, err := g.ObjectStore.Load(st.DefID) + if err != nil || def.StealTable == "" { + continue + } + stealObjs = append(stealObjs, def) + } + + if input == "" { + if len(stealMobs) == 0 && len(stealObjs) == 0 { + return "", nil, nil, "There's nothing here to steal from." + } + + uniqueMobDefs := make(map[string]bool) + for _, m := range stealMobs { + uniqueMobDefs[m.DefID] = true + } + + if len(stealMobs) > 0 && len(stealObjs) == 0 { + if len(uniqueMobDefs) <= 1 { + return "mob", stealMobs[0], nil, "" + } + } + if len(stealObjs) > 0 && len(stealMobs) == 0 { + uniqueObjDefs := make(map[string]bool) + for _, o := range stealObjs { + uniqueObjDefs[o.ID] = true + } + if len(uniqueObjDefs) <= 1 { + return "object", nil, stealObjs[0], "" + } + } + return "", nil, nil, "Steal from what?" + } + + var matchingMobs []*world.MobInstance + for _, m := range stealMobs { + if m.MatchQuality(searchName) >= world.MatchPrefix { + matchingMobs = append(matchingMobs, m) + } + } + sort.Slice(matchingMobs, func(i, j int) bool { + return matchingMobs[i].InstanceID < matchingMobs[j].InstanceID + }) + + if len(matchingMobs) > 0 { + uniqueDefs := make(map[string]bool) + for _, m := range matchingMobs { + uniqueDefs[m.DefID] = true + } + if len(uniqueDefs) > 1 && instanceIdx < 0 { + return "", nil, nil, "Which one?" + } + if instanceIdx >= 0 && instanceIdx < len(matchingMobs) { + return "mob", matchingMobs[instanceIdx], nil, "" + } + if len(matchingMobs) == 1 { + return "mob", matchingMobs[0], nil, "" + } + for i := range matchingMobs { + if instanceIdx == i || instanceIdx < 0 { + return "mob", matchingMobs[i], nil, "" + } + } + } + + var matchingObjs []*object.ObjectDef + for _, o := range stealObjs { + if behavior.WordPrefixMatch(searchName, o.Name) { + matchingObjs = append(matchingObjs, o) + } + } + if len(matchingObjs) > 1 { + return "", nil, nil, "Which one?" + } + if len(matchingObjs) == 1 { + return "object", nil, matchingObjs[0], "" + } + + return "", nil, nil, "There's nothing here to steal from." +} + +func (g *Game) startSteal(sess *net.Session, p *player.Player, targetType string, mob *world.MobInstance, objDef *object.ObjectDef) { + var stealTable string + var stealLevel int + var stealXP int + var stealSpeed float64 + var targetName string + var targetID string + var mobInstanceID string + var objDefID string + var guardMob string + guardWatching := false + + if targetType == "mob" { + stealTable = mob.StealTable + stealLevel = mob.StealLevel + stealXP = mob.StealXP + stealSpeed = mob.StealSpeed + targetName = mob.Name + targetID = mob.InstanceID + mobInstanceID = mob.InstanceID + + if stealTable == "" { + sess.WriteLine(fmt.Sprintf("You can't steal from the %s.", targetName)) + return + } + if thievingLevel := p.Level(player.Thieving); stealLevel > 0 && thievingLevel < stealLevel { + sess.WriteLine(fmt.Sprintf("You need level %d thieving to steal from the %s.", stealLevel, targetName)) + return + } + if mob.HP <= 0 { + sess.WriteLine("That is already dead.") + return + } + if g.Combat.IsMobInCombat(mob.InstanceID) { + sess.WriteLine(fmt.Sprintf("The %s is busy.", targetName)) + return + } + } else { + stealTable = objDef.StealTable + stealLevel = objDef.StealLevel + stealXP = objDef.StealXP + stealSpeed = objDef.StealSpeed + targetName = objDef.Name + targetID = objDef.ID + objDefID = objDef.ID + guardMob = objDef.GuardMob + + if stealTable == "" { + sess.WriteLine(fmt.Sprintf("You can't steal from the %s.", targetName)) + return + } + if thievingLevel := p.Level(player.Thieving); stealLevel > 0 && thievingLevel < stealLevel { + sess.WriteLine(fmt.Sprintf("You need level %d thieving to steal from the %s.", stealLevel, targetName)) + return + } + + if guardMob != "" { + guard := g.findGuardInRoom(p.RoomID, guardMob) + if guard != nil { + timerKey := fmt.Sprintf("%d:%s", p.RoomID, objDefID) + if _, exists := g.guardWatchTimers[timerKey]; !exists { + g.guardWatchTimers[timerKey] = 0 + } + guardWatching = g.isGuardWatching(p.RoomID, objDefID) + } + } + } + + if p.FirstFreeSlot() == -1 { + sess.WriteLine("Your inventory is too full!") + return + } + + sess.WriteLine(fmt.Sprintf("You attempt to steal from the %s...", targetName)) + + g.broadcastAction(sess, "\n%s glances around the room.", p.Name) + + p.Action = &behavior.Action{ + Type: behavior.TypeSteal, + TargetID: targetID, + TargetName: targetName, + WaitLeft: engine.ToTicks(stealSpeed), + Data: &behavior.StealData{ + TargetType: targetType, + StealTable: stealTable, + StealLevel: stealLevel, + StealXP: stealXP, + StealSpeed: stealSpeed, + TargetName: targetName, + MobInstanceID: mobInstanceID, + ObjDefID: objDefID, + GuardMob: guardMob, + GuardWatching: guardWatching, + }, + } +} + +func (g *Game) advanceSteal(sess *net.Session, p *player.Player) { + d := p.Action.Data.(*behavior.StealData) + targetType := d.TargetType + stealTable := d.StealTable + stealLevel := d.StealLevel + stealXP := d.StealXP + stealSpeed := d.StealSpeed + targetName := d.TargetName + guardMob := d.GuardMob + guardWatching := d.GuardWatching + + if targetType == "mob" { + mobInstanceID := d.MobInstanceID + mob := g.MobStore.GetInstance(mobInstanceID) + if mob == nil || mob.HP <= 0 || mob.RoomID != p.RoomID { + sess.WriteLine("Your target is gone.") + g.cancelAction(p) + return + } + if g.Combat.IsMobInCombat(mob.InstanceID) { + sess.WriteLine(fmt.Sprintf("The %s is busy.", targetName)) + g.cancelAction(p) + return + } + d.StealLevel = mob.StealLevel + d.StealXP = mob.StealXP + d.StealSpeed = mob.StealSpeed + stealLevel = mob.StealLevel + stealXP = mob.StealXP + stealSpeed = mob.StealSpeed + } else { + objDefID := d.ObjDefID + found := false + for _, st := range g.World.AllObjInstances(p.RoomID) { + if st.DefID == objDefID && !st.Depleted { + found = true + break + } + } + if !found { + sess.WriteLine("Your target is gone.") + g.cancelAction(p) + return + } + objDef, err := g.ObjectStore.Load(objDefID) + if err == nil { + d.StealLevel = objDef.StealLevel + d.StealXP = objDef.StealXP + d.StealSpeed = objDef.StealSpeed + stealLevel = objDef.StealLevel + stealXP = objDef.StealXP + stealSpeed = objDef.StealSpeed + d.GuardMob = objDef.GuardMob + guardMob = objDef.GuardMob + + if guardMob != "" { + guard := g.findGuardInRoom(p.RoomID, guardMob) + if guard != nil { + timerKey := fmt.Sprintf("%d:%s", p.RoomID, objDefID) + if _, exists := g.guardWatchTimers[timerKey]; !exists { + g.guardWatchTimers[timerKey] = 0 + } + guardWatching = g.isGuardWatching(p.RoomID, objDefID) + d.GuardWatching = guardWatching + } + } + } + } + + level := p.Level(player.Thieving) + chance := behavior.SuccessChance(stealSuccess, level, stealLevel) + if guardWatching { + chance *= 0.5 + if chance < 0.05 { + chance = 0.05 + } + } + + if rand.Float64() < chance { + dt, err := behavior.LoadDropTable(g.DataDir, stealTable) + if err != nil || len(dt.Drops) == 0 { + sess.WriteLine("You steal nothing of value.") + } else { + drop := behavior.ResolveDrop(g.DataDir, dt.Drops) + if drop == nil || drop.ItemID == "" { + sess.WriteLine("You steal nothing of value.") + } else { + g.giveStealLoot(sess, p, drop) + } + } + + if stealXP > 0 { + g.awardSkillXP(sess, p, player.Thieving, stealXP) + } + + g.AccountStore.SaveCharacter(p) + + if p.FirstFreeSlot() == -1 { + sess.WriteLine("Your inventory is full.") + g.cancelAction(p) + return + } + + p.Action.WaitLeft = engine.ToTicks(stealSpeed) + } else { + if targetType == "mob" { + mobInstanceID := d.MobInstanceID + mob := g.MobStore.GetInstance(mobInstanceID) + sess.WriteLine(fmt.Sprintf("The %s notices you! They attack!", targetName)) + g.cancelAction(p) + if mob != nil && mob.HP > 0 { + g.startCombat(sess, p, mob) + } + } else { + if guardMob != "" && guardWatching { + guard := g.findGuardInRoom(p.RoomID, guardMob) + if guard != nil { + sess.WriteLine("You fumble and the Guard spots you!") + g.cancelAction(p) + g.startStealGuardTalk(sess, p, guard) + return + } + } + sess.WriteLine("You fail to steal anything.") + p.Action.WaitLeft = engine.ToTicks(stealSpeed) + } + } +} + +func (g *Game) giveStealLoot(sess *net.Session, p *player.Player, drop *behavior.DropEntry) { + qty := drop.Quantity + if qty <= 0 { + qty = 1 + } + + if drop.ItemID == "credits" { + p.Credits += qty + sess.WriteLine(g.colorize(sess, "credits_pickup", fmt.Sprintf("You steal %d credits.", qty))) + return + } + + name := drop.ItemID + lootDef, _ := g.ItemStore.Load(drop.ItemID) + if lootDef != nil { + name = lootDef.Name + } + + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + g.World.AddGroundItem(p.RoomID, drop.ItemID, qty) + sess.WriteLine(fmt.Sprintf("You steal %s but your inventory is full. It falls to the ground.", g.itemColorize(sess, lootDef, name))) + return + } + + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty}) + sess.WriteLine(fmt.Sprintf("You steal %s.", g.itemColorize(sess, lootDef, name))) +} + +func (g *Game) findGuardInRoom(roomID int, guardDefID string) *world.MobInstance { + mobs := g.MobStore.MobsInRoom(roomID) + for _, m := range mobs { + if m.DefID == guardDefID && m.HP > 0 { + return m + } + } + return nil +} + +const guardWatchDuration = 8 +const guardLookAwayDuration = 4 +const guardCycleLength = guardWatchDuration + guardLookAwayDuration + +func (g *Game) isGuardWatching(roomID int, objDefID string) bool { + key := fmt.Sprintf("%d:%s", roomID, objDefID) + counter := g.guardWatchTimers[key] + return counter%guardCycleLength < guardWatchDuration +} + +func (g *Game) startStealGuardTalk(sess *net.Session, p *player.Player, guardMob *world.MobInstance) { + cfg := stallGuardTalk + node, ok := cfg.Nodes["start"] + if !ok { + sess.WriteLine("The Guard glares at you but says nothing.") + return + } + + p.Action = &behavior.Action{ + Type: "talk", + TargetID: guardMob.DefID, + TargetName: guardMob.Name, + Data: &behavior.TalkData{Node: "start", Cfg: cfg, StealGuard: true}, + } + + g.showTalkNode(sess, node) +} diff --git a/internal/game/act_talk.go b/internal/game/act_talk.go new file mode 100644 index 0000000..e9047b2 --- /dev/null +++ b/internal/game/act_talk.go @@ -0,0 +1,277 @@ +package game + +import ( + "fmt" + "strconv" + "strings" + + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/object" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func (g *Game) startMobTalk(sess *net.Session, p *player.Player, mob *world.MobInstance) { + cfg := mob.TalkConfig + if cfg == nil { + sess.WriteLine("This person has nothing to say.") + return + } + + if node, ok := cfg.Nodes["start"]; ok { + p.Action = &behavior.Action{ + Type: behavior.TypeTalk, + TargetID: mob.DefID, + TargetName: mob.Name, + Data: &behavior.TalkData{Node: "start", Cfg: cfg}, + } + g.showTalkNode(sess, node) + } else { + sess.WriteLine("This person has nothing to say.") + } +} + +func (g *Game) startTalk(sess *net.Session, p *player.Player, obj *object.ObjectDef) { + cfg := obj.Talk + if cfg == nil { + sess.WriteLine("This person has nothing to say.") + return + } + + if node, ok := cfg.Nodes["start"]; ok { + p.Action = &behavior.Action{ + Type: behavior.TypeTalk, + TargetID: obj.ID, + TargetName: obj.Name, + Data: &behavior.TalkData{Node: "start", Cfg: cfg}, + } + g.showTalkNode(sess, node) + } else { + sess.WriteLine("This person has nothing to say.") + } +} + +func (g *Game) showTalkNode(sess *net.Session, node behavior.TalkNode) { + sess.WriteLine(fmt.Sprintf("%s", g.colorize(sess, "dialog", node.Message))) + + if node.Action != nil { + if node.Action.Shop != nil { + g.applyNodeAction(sess, node.Action) + g.enterShop(sess, node.Action.Shop) + return + } + g.applyNodeAction(sess, node.Action) + + if v, ok := g.Flags.Get("guard_hostile"); ok && v != nil { + g.Flags.Delete("guard_hostile") + p := sess.Player + if p != nil && p.Action != nil { + if td, ok := p.Action.Data.(*behavior.TalkData); ok && td.StealGuard { + guardMob := g.findGuardInRoom(p.RoomID, "guard") + if guardMob != nil { + sess.State = net.StateGame + g.cancelAction(p) + guardMob.Protected = false + g.startCombat(sess, p, guardMob) + } + return + } + } + } + } + + if len(node.Options) == 0 { + sess.State = net.StateGame + g.writePrompt(sess) + return + } + + visible := 0 + for _, opt := range node.Options { + if opt.Condition != nil && !g.checkCondition(sess, opt.Condition) { + continue + } + visible++ + sess.WriteLine(fmt.Sprintf(" %d. %s", visible, opt.Text)) + } + sess.State = net.StateTalk + sess.Write("\nChoice: ") +} + +func (g *Game) handleTalkInput(sess *net.Session, input string) { + p := sess.Player + if p == nil || p.Action == nil || p.Action.Type != behavior.TypeTalk { + sess.State = net.StateGame + g.writePrompt(sess) + return + } + + input = strings.TrimSpace(input) + lower := strings.ToLower(input) + if lower == "bye" || lower == "exit" || lower == "end" || lower == "quit" { + g.cancelAction(p) + sess.State = net.StateGame + g.writePrompt(sess) + return + } + + if td, ok := p.Action.Data.(*behavior.TalkData); ok && td.EstateDirectory { + g.handleEstateDirectoryTalk(sess, input) + return + } + + td, ok := p.Action.Data.(*behavior.TalkData) + if !ok || td.Cfg == nil { + g.cancelAction(p) + sess.State = net.StateGame + g.writePrompt(sess) + return + } + cfg := td.Cfg + + nodeKey := td.Node + node, ok := cfg.Nodes[nodeKey] + if !ok { + g.cancelAction(p) + sess.State = net.StateGame + g.writePrompt(sess) + return + } + + idx, err := parseChoiceIndex(input) + if err != nil || idx <= 0 { + g.cancelAction(p) + sess.State = net.StateGame + g.writePrompt(sess) + return + } + + validIdx := 0 + var chosen *behavior.TalkOption + for i := range node.Options { + opt := &node.Options[i] + if opt.Condition != nil && !g.checkCondition(sess, opt.Condition) { + continue + } + validIdx++ + if validIdx == idx { + chosen = opt + break + } + } + + if chosen == nil { + g.cancelAction(p) + sess.State = net.StateGame + g.writePrompt(sess) + return + } + + if chosen.End { + g.cancelAction(p) + sess.State = net.StateGame + g.writePrompt(sess) + return + } + + if chosen.Goto != "" { + if nextNode, ok := cfg.Nodes[chosen.Goto]; ok { + td.Node = chosen.Goto + g.showTalkNode(sess, nextNode) + return + } + } + + g.cancelAction(p) + sess.State = net.StateGame + g.writePrompt(sess) +} + +func (g *Game) enterShop(sess *net.Session, cfg *behavior.ShopConfig) { + sess.Shop = cfg + sess.State = net.StateShop + g.showShopBrowse(sess) + g.writeShopPrompt(sess) +} + +func (g *Game) applyNodeAction(sess *net.Session, na *behavior.NodeAction) { + p := sess.Player + if p == nil { + return + } + + if na.AssignTask { + g.assignAssassinTask(sess, p) + } + if na.SkipTask { + g.skipAssassinTask(sess, p) + } + if na.ExtendTask { + g.extendAssassinTask(sess, p) + } + if na.ReputationCost > 0 { + rep := getPlayerFlagInt(p, "assassin_reputation") + if rep < na.ReputationCost { + sess.WriteLine(fmt.Sprintf("You don't have enough Reputation. (Need %d, have %d)", na.ReputationCost, rep)) + return + } + setPlayerFlag(p, "assassin_reputation", rep-na.ReputationCost) + g.AccountStore.SaveCharacter(p) + } + + for k, v := range na.SetFlags { + g.Flags.Set(k, v) + } + for k, v := range na.SetPlayerFlags { + p.EnsureFlags() + p.Flags[k] = v + } + if na.TakeItem != "" { + if p.HasItem(na.TakeItem) { + p.RemoveItem(na.TakeItem, 1) + } + } + if na.GiveItem != "" { + slot := p.FirstFreeSlot() + if slot == -1 { + sess.WriteLine("Your inventory is too full to receive that.") + } else { + p.SetInvSlot(slot, &player.InventorySlot{ItemID: na.GiveItem, Quantity: 1}) + g.AccountStore.SaveCharacter(p) + } + } + if na.Teleport > 0 { + p.RoomID = na.Teleport + p.Stats.RecordRoomVisit(na.Teleport) + g.AccountStore.SaveCharacter(p) + 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.runEnterSteps(sess, p.RoomID) + } + if na.Heal > 0 { + p.HP += na.Heal + if maxHP := p.MaxHP(); p.HP > maxHP { + p.HP = maxHP + } + g.AccountStore.SaveCharacter(p) + sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", na.Heal)) + } + if na.Cost > 0 { + if p.Credits >= na.Cost { + p.Credits -= na.Cost + g.AccountStore.SaveCharacter(p) + } + } + if na.Sawmill { + g.processSawmill(sess, p) + } + if na.ApsNode { + setPlayerFlag(p, "aps_node_"+strconv.Itoa(p.RoomID), true) + } +} diff --git a/internal/game/act_use.go b/internal/game/act_use.go new file mode 100644 index 0000000..34f28c3 --- /dev/null +++ b/internal/game/act_use.go @@ -0,0 +1,123 @@ +package game + +import ( + "fmt" + "math/rand" + + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/object" + "thehouseoficarus/internal/player" +) + +func (g *Game) startUse(sess *net.Session, p *player.Player, obj *object.ObjectDef) { + cfg := obj.Use + if cfg == nil { + sess.WriteLine("You can't use this.") + return + } + + for itemID := range cfg.Consume { + if !p.HasItem(itemID) { + defName := itemID + if def, err := g.ItemStore.Load(itemID); err == nil { + defName = g.itemColorize(sess, def, def.Name) + } + sess.WriteLine(fmt.Sprintf("You need %s to use this.", defName)) + return + } + } + + if cfg.Success == nil && p.FirstFreeSlot() == -1 { + sess.WriteLine("Your inventory is full.") + return + } + + g.broadcastAction(sess, "\n%s uses the %s.", p.Name, obj.Name) + + p.Action = &behavior.Action{ + Type: "use", + TargetID: obj.ID, + TargetName: obj.Name, + WaitLeft: engine.ToTicks(1), + Data: &behavior.UseData{Cfg: cfg, Step: 0}, + } + + sess.WriteLine(fmt.Sprintf("%s", cfg.Message)) +} + +func (g *Game) advanceUse(sess *net.Session, p *player.Player) { + d, ok := p.Action.Data.(*behavior.UseData) + if !ok || d == nil || d.Cfg == nil { + g.cancelAction(p) + return + } + cfg := d.Cfg + + for itemID, qty := range cfg.Consume { + if !p.HasItem(itemID) { + defName := itemID + if def, err := g.ItemStore.Load(itemID); err == nil { + defName = g.itemColorize(sess, def, def.Name) + } + sess.WriteLine(fmt.Sprintf("You've run out of %s.", defName)) + g.cancelAction(p) + return + } + if !p.RemoveItem(itemID, qty) { + sess.WriteLine(fmt.Sprintf("You need %d of %s.", qty, itemID)) + g.cancelAction(p) + return + } + } + + if cfg.Success != nil { + skillLevel := p.Level(player.SkillName(cfg.Skill)) + chance := behavior.SuccessChance(*cfg.Success, skillLevel, cfg.Level) + if rand.Float64() >= chance { + if cfg.FailMsg != "" { + sess.WriteLine(cfg.FailMsg) + } + p.Action.WaitLeft = engine.ToTicks(cfg.Wait) + return + } + } + + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + sess.WriteLine("Your inventory is full.") + g.cancelAction(p) + return + } + + qty := cfg.Reward.Quantity + if qty <= 0 { + qty = 1 + } + + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: cfg.Reward.ItemID, Quantity: qty}) + if cfg.XP > 0 { + g.awardSkillXP(sess, p, player.SkillName(cfg.Skill), cfg.XP) + } + g.AccountStore.SaveCharacter(p) + + itemName := cfg.Reward.ItemID + rewardDef, _ := g.ItemStore.Load(cfg.Reward.ItemID) + if rewardDef != nil { + itemName = rewardDef.Name + } + line := fmt.Sprintf("You make a %s.", g.itemColorize(sess, rewardDef, itemName)) + if cfg.XP > 0 && p.OptionBool("xp_drops") { + line += g.formatXpDropSingle(sess, p, player.SkillName(cfg.Skill), cfg.XP) + } + sess.WriteLine(line) + + if p.FirstFreeSlot() == -1 { + sess.WriteLine("Your inventory is full.") + g.cancelAction(p) + return + } + + p.Action.WaitLeft = engine.ToTicks(cfg.Wait) +} diff --git a/internal/game/action.go b/internal/game/action.go deleted file mode 100644 index 19e79cf..0000000 --- a/internal/game/action.go +++ /dev/null @@ -1,377 +0,0 @@ -package game - -import ( - "fmt" - "sort" - "strconv" - "strings" - - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/combat" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" - "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" -) - -var verbAliases = map[string]string{ - "mine": "gather", - "chop": "gather", - "fish": "gather", - "cut": "gather", - "shear": "gather", - "talk": "talk", - "speak": "talk", - "ask": "talk", - "steal": "steal", - "thieve": "steal", - "hide": "safespot", -} - -var verbSkill = map[string]string{ - "mine": "mining", - "chop": "woodcutting", - "cut": "woodcutting", - "fish": "fishing", - "shear": "crafting", - "steal": "thieving", - "thieve": "thieving", -} - -func normalizeVerb(v string) string { - if a, ok := verbAliases[v]; ok { - return a - } - return v -} - -func (g *Game) startAction(sess *net.Session, verb, target string) { - p := sess.Player - - verb = normalizeVerb(verb) - - if combat.GetCombat(p.Name) != nil { - sess.WriteLine("You can't do that during combat!") - return - } - - if p.Action != nil { - g.cancelAction(p) - } - g.cancelBgAction(p) - - lower := strings.ToLower(target) - instanceIdx := -1 - if dotPos := strings.Index(lower, "."); dotPos > 0 { - if n, err := strconv.Atoi(lower[:dotPos]); err == nil && n > 0 { - instanceIdx = n - 1 - lower = lower[dotPos+1:] - } - } - - instances := g.World.FindObjInstances(p.RoomID, lower) - sort.Slice(instances, func(i, j int) bool { - return instances[i].Index < instances[j].Index - }) - - var obj *object.ObjectDef - var mob *world.MobInstance - var chosen *world.ObjState - - if len(instances) > 0 { - uniqueDefs := make(map[string]bool) - for _, ist := range instances { - uniqueDefs[ist.DefID] = true - } - if len(uniqueDefs) > 1 { - sess.WriteLine("That's ambiguous, which one?") - return - } - - if instanceIdx >= 0 && instanceIdx < len(instances) { - chosen = &instances[instanceIdx] - } else { - for i := range instances { - if !instances[i].Depleted { - chosen = &instances[i] - break - } - } - if chosen == nil { - chosen = &instances[0] - } - } - if chosen == nil { - sess.WriteLine(fmt.Sprintf("There's nothing here to %s.", verb)) - return - } - var err error - obj, err = g.ObjectStore.Load(chosen.DefID) - if err != nil || !obj.IsInteractable() { - sess.WriteLine(fmt.Sprintf("You can't %s the %s.", verb, obj.Name)) - return - } - } else { - mobs := g.MobStore.MobsInRoom(p.RoomID) - var candidates []*world.MobInstance - for _, m := range mobs { - if !m.IsTalkable() { - continue - } - if q := m.MatchQuality(lower); q >= world.MatchPrefix { - candidates = append(candidates, m) - } - } - if len(candidates) == 0 { - sess.WriteLine(fmt.Sprintf("There's nothing here to %s.", verb)) - return - } - sort.Slice(candidates, func(i, j int) bool { - return candidates[i].InstanceID < candidates[j].InstanceID - }) - if instanceIdx >= 0 && instanceIdx < len(candidates) { - mob = candidates[instanceIdx] - } else if len(candidates) == 1 { - mob = candidates[0] - } else { - sess.WriteLine("Which one?") - return - } - } - - if obj != nil && obj.ID == "estate_directory" { - g.startEstateDirectory(sess, verb) - return - } - - bhType := "" - if obj != nil { - bhType = obj.BehaviorType() - } else if mob != nil { - if mob.IsTalkable() { - bhType = "talk" - } - } - - if bhType != verb { - if obj != nil { - sess.WriteLine(fmt.Sprintf("You can't %s the %s.", verb, obj.Name)) - } else { - sess.WriteLine(fmt.Sprintf("You can't %s that.", verb)) - } - return - } - - switch bhType { - case "gather": - if obj == nil || chosen == nil { - sess.WriteLine(fmt.Sprintf("You can't %s that.", verb)) - return - } - g.startGather(sess, p, obj, chosen) - case "talk": - if mob != nil { - g.startMobTalk(sess, p, mob) - } else { - g.startTalk(sess, p, obj) - } - case "use": - g.startUse(sess, p, obj) - default: - sess.WriteLine(fmt.Sprintf("You can't %s that.", verb)) - } -} - -func (g *Game) cancelAction(p *player.Player) { - p.Action = nil - p.WalkSequence = nil - p.ClearMoveState() -} - -func (g *Game) cancelBgAction(p *player.Player) { - p.BackgroundAction = nil -} - -func (g *Game) AdvanceActions() { - if g.Hub == nil { - return - } - for _, sess := range g.Hub.AllSessions() { - p := sess.Player - if p == nil || p.Action == nil { - continue - } - if !p.Action.Advance() { - continue - } - switch p.Action.Type { - case "gather": - g.advanceGather(sess, p) - case "use": - g.advanceUse(sess, p) - case "burn": - g.advanceBurn(sess, p) - case "stoke": - g.advanceStoke(sess, p) - case "search": - g.advanceSearch(sess, p) - case "identify": - g.advanceIdentify(sess, p) - case "steal": - g.advanceSteal(sess, p) - case "plant": - g.advancePlant(sess, p) - case "harvest": - g.advanceHarvest(sess, p) - case "rake": - g.advanceRake(sess, p) - case "water": - g.advanceWater(sess, p) - case "cure": - g.advanceCure(sess, p) - case "obstacle": - g.advanceObstacle(sess, p) - default: - if productionActionTypes[string(p.Action.Type)] { - g.advanceProduction(sess, p) - } - } - if p.Action == nil { - g.writePrompt(sess) - } - } - - for _, sess := range g.Hub.AllSessions() { - p := sess.Player - if p == nil || p.BackgroundAction == nil { - continue - } - if !p.BackgroundAction.Advance() { - continue - } - switch p.BackgroundAction.Type { - case "fletch": - g.advanceFletch(sess, p) - case "clean": - g.advanceClean(sess, p) - } - if p.BackgroundAction == nil { - g.writePrompt(sess) - } - } -} - -func (g *Game) checkCondition(sess *net.Session, c *action.Condition) bool { - p := sess.Player - - if c.AllOf != nil { - for _, sub := range c.AllOf { - if !g.checkCondition(sess, &sub) { - return false - } - } - return true - } - if c.AnyOf != nil { - for _, sub := range c.AnyOf { - if g.checkCondition(sess, &sub) { - return true - } - } - return false - } - - if c.PlayerFlag != "" { - var val any - present := false - if p != nil && p.Flags != nil { - val, present = p.Flags[c.PlayerFlag] - } - return flagMatches(present, val, c.Value, c.Not) - } - - if c.Flag != "" { - val, present := g.Flags.Get(c.Flag) - return flagMatches(present, val, c.Value, c.Not) - } - if c.HasItem != "" { - has := p != nil && p.HasItem(c.HasItem) - if c.Not { - return !has - } - return has - } - if c.MinCredits > 0 { - has := p != nil && p.Credits >= c.MinCredits - if c.Not { - return !has - } - return has - } - return true -} - -// flagMatches evaluates a flag/player_flag condition. -// -// If the condition specifies no `value`, the flag matches when it is present -// and truthy (so `{player_flag: x}` means "x is set" and -// `{player_flag: x, not: true}` means "x is not set"). If a `value` is given, -// the flag must be present and equal to it. `not` inverts the result. Numeric -// values are compared by coercing both sides through a common numeric type so -// that an int flag set in code matches an int64/float64 decoded from YAML. -func flagMatches(present bool, val, want any, not bool) bool { - var match bool - if want == nil { - match = present && isTruthy(val) - } else { - match = present && valuesEqual(val, want) - } - if not { - return !match - } - return match -} - -// valuesEqual compares two any-typed values, normalizing numeric types (int, -// int64, float64) so that e.g. int(3) == int64(3) == float64(3). Non-numeric -// types fall back to ==. -func valuesEqual(a, b any) bool { - ai, aok := numericValue(a) - bi, bok := numericValue(b) - if aok && bok { - return ai == bi - } - return a == b -} - -// numericValue returns the value as a float64 if it is a numeric type. -func numericValue(v any) (float64, bool) { - switch x := v.(type) { - case int: - return float64(x), true - case int64: - return float64(x), true - case float64: - return x, true - } - return 0, false -} - -func isTruthy(v any) bool { - switch x := v.(type) { - case nil: - return false - case bool: - return x - case int: - return x != 0 - case int64: - return x != 0 - case float64: - return x != 0 - case string: - return x != "" - default: - return true - } -} diff --git a/internal/game/action_agility.go b/internal/game/action_agility.go deleted file mode 100644 index 116e888..0000000 --- a/internal/game/action_agility.go +++ /dev/null @@ -1,162 +0,0 @@ -package game - -import ( - "fmt" - "math/rand" - - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/engine" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" -) - -func (g *Game) startObstacle(sess *net.Session, p *player.Player, info *ObstacleInfo) { - failChance := g.calcFailChance(p, info) - - msgs := make([]any, len(info.Messages)) - for i, m := range info.Messages { - msgs[i] = m - } - - gerund := info.Verb + "ing" - if g, ok := verbGerund[info.Verb]; ok { - gerund = g - } - - p.Action = &action.Action{ - Type: action.TypeObstacle, - TargetID: info.CourseID, - TargetName: info.CourseName, - WaitLeft: 0, - Data: &action.ObstacleData{ - CourseID: info.CourseID, - Phase: 0, - ObstacleIndex: info.ObstacleIndex, - TotalObstacles: info.TotalObstacles, - NextRoom: info.NextRoom, - StartRoom: info.StartRoom, - ObstacleXP: info.ObstacleXP, - CompletionXP: info.CompletionXP, - FailChance: failChance, - FailDamageMin: info.FailDamage[0], - FailDamageMax: info.FailDamage[1], - Messages: msgs, - TicksPerPhase: info.TicksPerPhase, - RequiredLevel: info.RequiredLevel, - }, - } - - g.broadcastAction(sess, "\n%s starts %s on the %s.", p.Name, gerund, info.CourseName) -} - -func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) { - d := p.Action.Data.(*action.ObstacleData) - phase := d.Phase - messages := d.Messages - ticksPerPhase := d.TicksPerPhase - - if phase >= len(messages) { - g.cancelAction(p) - return - } - - msg := messages[phase].(string) - sess.WriteLine(g.colorize(sess, "broadcast", msg)) - - if phase == 1 { - failChance := d.FailChance - if rand.Float64() < failChance { - g.obstacleFail(sess, p, d) - return - } - } - - nextPhase := phase + 1 - - if nextPhase >= len(messages) { - obstacleXP := d.ObstacleXP - completionXP := d.CompletionXP - nextRoom := d.NextRoom - startRoom := d.StartRoom - obstacleIndex := d.ObstacleIndex - totalObstacles := d.TotalObstacles - courseID := d.CourseID - - if obstacleXP > 0 { - g.awardSkillXP(sess, p, player.Agility, obstacleXP) - if p.OptionBool("xp_drops") { - sess.WriteLine(g.colorize(sess, "xp", - fmt.Sprintf("(+%dxp %s)", obstacleXP, player.SkillAbbr[player.Agility]))) - } - } - - isLastObstacle := obstacleIndex == totalObstacles-1 - - if isLastObstacle { - if completionXP > 0 { - g.awardSkillXP(sess, p, player.Agility, completionXP) - if p.OptionBool("xp_drops") { - sess.WriteLine(g.colorize(sess, "xp", - fmt.Sprintf("(+%dxp %s course bonus)", completionXP, player.SkillAbbr[player.Agility]))) - } - } - - lapKey := "agility_laps_" + courseID - laps := getPlayerFlagInt(p, lapKey) + 1 - setPlayerFlag(p, lapKey, laps) - - courseName := p.Action.TargetName - sess.WriteLine(g.colorize(sess, "broadcast", - fmt.Sprintf("Course complete! %s lap %d finished.", courseName, laps))) - - g.cancelAction(p) - g.teleportPlayer(sess, p, startRoom) - } else { - g.cancelAction(p) - g.teleportPlayer(sess, p, nextRoom) - } - - g.AccountStore.SaveCharacter(p) - return - } - - d.Phase = nextPhase - p.Action.WaitLeft = engine.ToTicks(ticksPerPhase) -} - -func (g *Game) obstacleFail(sess *net.Session, p *player.Player, d *action.ObstacleData) { - startRoom := d.StartRoom - failMin := d.FailDamageMin - failMax := d.FailDamageMax - - damage := failMin - if failMax > failMin { - damage = failMin + rand.Intn(failMax-failMin+1) - } - - sess.WriteLine(g.colorize(sess, "damage_taken", "You slip and fall!")) - - p.HP -= damage - if p.HP < 1 { - p.HP = 1 - } - sess.WriteLine(g.colorize(sess, "damage_taken", - fmt.Sprintf("You take %d damage. HP: %d/%d", damage, p.HP, p.MaxHP()))) - - g.AccountStore.SaveCharacter(p) - g.cancelAction(p) - g.teleportPlayer(sess, p, startRoom) -} - -func (g *Game) calcFailChance(p *player.Player, info *ObstacleInfo) float64 { - level := p.Level(player.Agility) - required := info.RequiredLevel - chance := 0.30 - float64(level-required)*0.01 - if chance < 0.05 { - chance = 0.05 - } - if chance > 0.60 { - chance = 0.60 - } - return chance -} diff --git a/internal/game/action_burn.go b/internal/game/action_burn.go deleted file mode 100644 index e1872ed..0000000 --- a/internal/game/action_burn.go +++ /dev/null @@ -1,532 +0,0 @@ -package game - -import ( - "fmt" - "math/rand" - "strings" - - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/engine" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" - "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) - } - g.cancelBgAction(p) - - itemID, fromGround, ambiguous := g.resolveBurnTarget(p, input) - if ambiguous { - sess.WriteLine("Burn what?") - return - } - if itemID == "" { - sess.WriteLine("Burn what?") - return - } - - g.startBurn(sess, p, itemID, fromGround) -} - -func (g *Game) doStoke(sess *net.Session, p *player.Player, input string) { - if p.Action != nil { - g.cancelAction(p) - } - g.cancelBgAction(p) - - if !g.hasFireObject(p.RoomID) { - sess.WriteLine("There's no fire here to stoke.") - return - } - - itemID, ambiguous := g.resolveStokeTarget(p, input) - if ambiguous { - sess.WriteLine("Stoke what?") - return - } - if itemID == "" { - sess.WriteLine("You have no logs to stoke with!") - return - } - - g.startStoke(sess, p, itemID) -} - -func (g *Game) startBurn(sess *net.Session, p *player.Player, itemID string, fromGround bool) { - if p.FirstFreeSlot() == -1 && !fromGround { - sess.WriteLine("Your inventory is too full!") - return - } - - if g.hasFireObject(p.RoomID) { - sess.WriteLine("There is already a fire burning here.") - return - } - - logDef, err := g.ItemStore.Load(itemID) - if err != nil { - sess.WriteLine("Something is wrong with that item.") - return - } - - if logDef.BurnTicks <= 0 { - sess.WriteLine(fmt.Sprintf("You can't burn the %s.", g.itemColorize(sess, logDef, logDef.Name))) - return - } - - skillLevel := p.Level(player.Firemaking) - if logDef.FireLevel > 0 && skillLevel < logDef.FireLevel { - sess.WriteLine(fmt.Sprintf("You need level %d firemaking to burn %s.", logDef.FireLevel, g.itemColorize(sess, logDef, logDef.Name))) - return - } - - toolSpeed, ok := g.findFireTool(sess, p) - if !ok { - return - } - - if !fromGround { - if !p.HasItem(itemID) { - sess.WriteLine(fmt.Sprintf("You don't have any %s.", g.itemColorize(sess, logDef, logDef.Name))) - return - } - } else { - ground := g.World.GroundItems(p.RoomID) - if ground[itemID] <= 0 { - sess.WriteLine(fmt.Sprintf("There are no %s on the ground here.", g.itemColorize(sess, logDef, logDef.Name))) - return - } - } - - phase := 1 - if !fromGround { - phase = 0 - } - - g.broadcastAction(sess, "\n%s starts building a fire.", p.Name) - - p.Action = &action.Action{ - Type: action.TypeBurn, - TargetID: itemID, - TargetName: logDef.Name, - WaitLeft: 0, - Data: &action.BurnData{ - ItemID: itemID, - ToolSpeed: toolSpeed, - Level: logDef.FireLevel, - XP: logDef.FireXP, - BurnTicks: logDef.BurnTicks, - Phase: phase, - }, - } -} - -func (g *Game) startStoke(sess *net.Session, p *player.Player, itemID string) { - fireKey := g.findFireObjectKey(p.RoomID) - if fireKey == "" { - sess.WriteLine("There's no fire here to stoke.") - return - } - - logDef, err := g.ItemStore.Load(itemID) - if err != nil || logDef.BurnTicks <= 0 { - sess.WriteLine(fmt.Sprintf("You can't stoke the fire with that.")) - return - } - - if !p.HasItem(itemID) { - sess.WriteLine(fmt.Sprintf("You don't have any %s.", g.itemColorize(sess, logDef, logDef.Name))) - return - } - - sess.WriteLine("You begin to tend to the fire.") - - g.broadcastAction(sess, "\n%s tends to the fire.", p.Name) - - p.Action = &action.Action{ - Type: action.TypeStoke, - TargetID: itemID, - TargetName: logDef.Name, - WaitLeft: engine.ToTicks(10), - Data: &action.BurnData{ - ItemID: itemID, - BurnTicks: logDef.BurnTicks, - XP: logDef.FireXP, - }, - } -} - -func (g *Game) advanceBurn(sess *net.Session, p *player.Player) { - d := p.Action.Data.(*action.BurnData) - itemID := d.ItemID - toolSpeed := d.ToolSpeed - phase := d.Phase - - logDef, err := g.ItemStore.Load(itemID) - if err != nil { - g.cancelAction(p) - return - } - - if phase == 0 { - if !p.HasItem(itemID) { - sess.WriteLine(fmt.Sprintf("You're out of %s.", g.itemColorize(sess, logDef, logDef.Name))) - g.cancelAction(p) - return - } - p.RemoveItem(itemID, 1) - g.World.AddGroundItem(p.RoomID, itemID, 1) - g.AccountStore.SaveCharacter(p) - - sess.WriteLine(g.colorize(sess, "fire", fmt.Sprintf("You drop the %s and begin to make a fire...", g.itemColorize(sess, logDef, logDef.Name)))) - g.broadcastDrop(p, itemID, logDef.Name) - - d.Phase = 1 - p.Action.WaitLeft = engine.ToTicks(toolSpeed) - return - } - - ground := g.World.GroundItems(p.RoomID) - if ground[itemID] <= 0 { - sess.WriteLine("The thing you were trying to burn is gone.") - g.cancelAction(p) - return - } - - if phase == 1 { - sess.WriteLine(g.colorize(sess, "fire", fmt.Sprintf("You try burning the %s on the ground...", g.itemColorize(sess, logDef, logDef.Name)))) - d.Phase = 2 - p.Action.WaitLeft = engine.ToTicks(toolSpeed) - return - } - - if phase == 2 { - skillLevel := p.Level(player.Firemaking) - chance := 0.5 + float64(skillLevel-logDef.FireLevel)*0.02 - if chance > 0.95 { - chance = 0.95 - } - if chance < 0.05 { - chance = 0.05 - } - - if rand.Float64() < chance { - g.World.RemoveGroundItem(p.RoomID, itemID, 1) - g.World.AddObjInstance(p.RoomID, "fire", logDef.BurnTicks) - - xp := logDef.FireXP - if xp > 0 { - g.awardSkillXP(sess, p, player.Firemaking, xp) - } - - g.AccountStore.SaveCharacter(p) - g.cancelAction(p) - - msg := g.colorize(sess, "fire", "You manage to get a fire going!") - if xp > 0 && p.OptionBool("xp_drops") { - msg += g.formatXpDropSingle(sess, p, player.Firemaking, xp) - } - sess.WriteLine(msg) - - g.broadcastAction(sess, "\n%s started a fire!", p.Name) - return - } - - sess.WriteLine("You can't seem to get a fire going.") - d.Phase = 1 - p.Action.WaitLeft = engine.ToTicks(toolSpeed) - } -} - -func (g *Game) advanceStoke(sess *net.Session, p *player.Player) { - d := p.Action.Data.(*action.BurnData) - itemID := d.ItemID - fireKey := g.findFireObjectKey(p.RoomID) - burnTicks := d.BurnTicks - xp := d.XP - - st := g.World.GetObjStateByKey(fireKey) - if st == nil || st.Quality <= 0 { - sess.WriteLine("The fire went out!") - g.cancelAction(p) - return - } - - logDef, _ := g.ItemStore.Load(itemID) - name := itemID - if logDef != nil { - name = logDef.Name - } - - if !p.HasItem(itemID) { - sess.WriteLine(g.colorize(sess, "fire", fmt.Sprintf("You're out of %s and the fire is roaring.", g.itemColorize(sess, logDef, name)))) - g.cancelAction(p) - return - } - - p.RemoveItem(itemID, 1) - st.Quality += burnTicks / 2 - - if xp > 0 { - g.awardSkillXP(sess, p, player.Firemaking, xp) - } - - g.AccountStore.SaveCharacter(p) - - msg := g.colorize(sess, "fire", fmt.Sprintf("You throw %s onto the fire.", g.itemColorize(sess, logDef, name))) - if xp > 0 && p.OptionBool("xp_drops") { - msg += g.formatXpDropSingle(sess, p, player.Firemaking, xp) - } - sess.WriteLine(msg) - - if !p.HasItem(itemID) { - sess.WriteLine(g.colorize(sess, "fire", fmt.Sprintf("You're out of %s and the fire is roaring.", g.itemColorize(sess, logDef, name)))) - g.cancelAction(p) - return - } - - p.Action.WaitLeft = engine.ToTicks(10.0) -} - -func (g *Game) findFireTool(sess *net.Session, p *player.Player) (toolSpeed float64, ok bool) { - toolSpeed = -1 - var toolItemID string - - if itemID, equipped := p.Equipment[object.SlotMainHand]; equipped { - def, err := g.ItemStore.Load(itemID) - if err == nil && def.ToolType == "fire" { - toolSpeed = def.ToolSpeed - toolItemID = itemID - } - } - - if toolSpeed < 0 { - for _, slot := range p.Inventory { - if slot == nil || slot.Quantity <= 0 { - continue - } - def, err := g.ItemStore.Load(slot.ItemID) - if err != nil || def.ToolType != "fire" { - continue - } - toolSpeed = def.ToolSpeed - toolItemID = slot.ItemID - break - } - } - - if toolSpeed < 0 { - sess.WriteLine("You need a fire starter to do that.") - return 0, false - } - - if !g.consumeFireTool(sess, p, toolItemID) { - return 0, false - } - - return toolSpeed, true -} - -func (g *Game) consumeFireTool(sess *net.Session, p *player.Player, toolItemID string) bool { - def, _ := g.ItemStore.Load(toolItemID) - - if def != nil && def.MaxQuality > 0 { - for _, slot := range p.Inventory { - if slot != nil && slot.ItemID == toolItemID && slot.MaxQuality > 0 { - if slot.Quality <= 0 { - sess.WriteLine("The lighter is out of butane!") - return false - } - slot.Quality-- - g.AccountStore.SaveCharacter(p) - return true - } - } - if q, ok := p.EquipQuality[toolItemID]; ok { - if q <= 0 { - sess.WriteLine("The lighter is out of butane!") - return false - } - p.EquipQuality[toolItemID] = q - 1 - g.AccountStore.SaveCharacter(p) - return true - } - } - - if def != nil && def.Stackable { - if p.HasItem(toolItemID) { - p.RemoveItem(toolItemID, 1) - g.AccountStore.SaveCharacter(p) - return true - } - for slot, itemID := range p.Equipment { - if itemID == toolItemID { - delete(p.Equipment, slot) - g.AccountStore.SaveCharacter(p) - return true - } - } - sess.WriteLine("You need a fire starter to do that.") - return false - } - - return true -} - -func (g *Game) findFireObjectKey(roomID int) string { - objs := g.World.AllObjInstances(roomID) - for _, st := range objs { - if st.DefID == "fire" && st.Quality > 0 { - return g.World.ObjStateKey(st.RoomID, st.DefID, st.Index) - } - } - return "" -} - -func (g *Game) hasFireObject(roomID int) bool { - return g.findFireObjectKey(roomID) != "" -} - -func (g *Game) broadcastDrop(p *player.Player, itemID, itemName string) { - itemDef, _ := g.ItemStore.Load(itemID) - for _, other := range g.Hub.PlayersInRoom(p.RoomID) { - op := other.Player - if op != nil && op.Name != p.Name { - colored := itemName - if itemDef != nil { - colored = g.itemColorize(other, itemDef, itemName) - } - other.WriteLine(fmt.Sprintf("%s drops a %s.", p.Name, colored)) - } - } -} - -func (g *Game) FireTick() { - for _, st := range g.World.AllFireStates() { - st.Quality-- - if st.Quality <= 0 { - g.World.RemoveObjInstance(st.RoomID, st.DefID) - g.cancelStokers(st.RoomID) - if def, err := g.ObjectStore.Load(st.DefID); err == nil && def.RemovalItem != "" { - g.World.AddGroundItem(st.RoomID, def.RemovalItem, 1) - } - } - } -} - -func (g *Game) cancelStokers(roomID int) { - for _, other := range g.Hub.PlayersInRoom(roomID) { - op := other.Player - if op != nil && op.Action != nil && op.Action.Type == action.TypeStoke { - other.WriteLine("The fire went out!") - g.cancelAction(op) - g.writePrompt(other) - } - } -} - -func (g *Game) resolveBurnTarget(p *player.Player, input string) (itemID string, fromGround bool, ambiguous bool) { - if input != "" { - return g.namedBurnTarget(p, input) - } - return g.defaultBurnTarget(p) -} - -func (g *Game) burnableGround(roomID int, name string) []string { - var out []string - for itemID := range g.World.GroundItems(roomID) { - def, err := g.ItemStore.Load(itemID) - if err != nil || def.BurnTicks <= 0 { - continue - } - if name == "" || def.MatchesName(name) { - out = append(out, itemID) - } - } - return out -} - -func (g *Game) burnableInv(p *player.Player, name string) []string { - var out []string - seen := make(map[string]bool) - for _, slot := range p.Inventory { - if slot == nil || slot.Quantity <= 0 { - continue - } - def, err := g.ItemStore.Load(slot.ItemID) - if err != nil || def.BurnTicks <= 0 || seen[slot.ItemID] { - continue - } - if name == "" || def.MatchesName(name) { - seen[slot.ItemID] = true - out = append(out, slot.ItemID) - } - } - return out -} - -func (g *Game) namedBurnTarget(p *player.Player, input string) (itemID string, fromGround bool, ambiguous bool) { - groundMatches := g.burnableGround(p.RoomID, input) - if len(groundMatches) > 1 { - return "", false, true - } - if len(groundMatches) == 1 { - return groundMatches[0], true, false - } - - invMatches := g.burnableInv(p, input) - if len(invMatches) > 1 { - return "", false, true - } - if len(invMatches) == 1 { - return invMatches[0], false, false - } - - return "", false, false -} - -func (g *Game) defaultBurnTarget(p *player.Player) (itemID string, fromGround bool, ambiguous bool) { - groundBurnable := g.burnableGround(p.RoomID, "") - if len(groundBurnable) == 1 { - return groundBurnable[0], true, false - } - if len(groundBurnable) > 1 { - return "", false, true - } - - invBurnable := g.burnableInv(p, "") - if len(invBurnable) == 1 { - return invBurnable[0], false, false - } - if len(invBurnable) > 1 { - return "", false, true - } - - return "", false, false -} - -func (g *Game) resolveStokeTarget(p *player.Player, input string) (string, bool) { - invBurnable := g.burnableInv(p, input) - if len(invBurnable) == 1 { - return invBurnable[0], false - } - if len(invBurnable) > 1 { - return "", true - } - return "", false -} diff --git a/internal/game/action_clean.go b/internal/game/action_clean.go deleted file mode 100644 index d41746e..0000000 --- a/internal/game/action_clean.go +++ /dev/null @@ -1,105 +0,0 @@ -package game - -import ( - "strings" - - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/engine" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" - "thehouseoficarus/internal/player" -) - -func (g *Game) advanceClean(sess *net.Session, p *player.Player) { - data, ok := p.BackgroundAction.Data.(*action.CleanData) - if !ok { - g.cancelBgAction(p) - return - } - phase := data.Phase - filter := data.Filter - - if phase == 0 { - data.Phase = 1 - p.BackgroundAction.WaitLeft = engine.ToTicks(2) - return - } - - items := g.CraftIndex.ByType("clean") - - var matchItem *object.ItemDef - for _, item := range items { - c := item.FirstCraft() - if filter != "" { - if len(c.Consume) == 0 || len(c.Consume[0].Items) == 0 { - continue - } - if !strings.Contains(c.Consume[0].Items[0], filter) && - !strings.Contains(item.ID, filter) { - continue - } - } - if p.Level(player.Pharmacy) < c.Level { - continue - } - if !craftHasAllItems(c, p.HasItem) { - continue - } - matchItem = item - break - } - - if matchItem == nil { - sess.WriteLine("You've finished cleaning herbs.") - g.cancelBgAction(p) - return - } - - craft := matchItem.FirstCraft() - consumeID := craft.Consume[0].Items[0] - - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == consumeID { - slot.ItemID = matchItem.ID - break - } - } - - if craft.XP > 0 { - g.awardSkillXP(sess, p, player.Pharmacy, craft.XP) - } - g.AccountStore.SaveCharacter(p) - - msg := g.resolveCraftMessage(sess, craft.Message, "You clean the %i1.", craft, matchItem.ID, nil, p.HasItem) - if p.OptionBool("xp_drops") && craft.XP > 0 { - msg += g.formatXpDropSingle(sess, p, player.Pharmacy, craft.XP) - } - sess.WriteLine(msg) - - hasMore := false - for _, item := range items { - c := item.FirstCraft() - if filter != "" { - if len(c.Consume) == 0 || len(c.Consume[0].Items) == 0 { - continue - } - if !strings.Contains(c.Consume[0].Items[0], filter) && - !strings.Contains(item.ID, filter) { - continue - } - } - if p.Level(player.Pharmacy) >= c.Level && craftHasAllItems(c, p.HasItem) { - hasMore = true - break - } - } - - if !hasMore { - sess.WriteLine("You've finished cleaning herbs.") - g.cancelBgAction(p) - return - } - - p.BackgroundAction.WaitLeft = engine.ToTicks(2) -} diff --git a/internal/game/action_default_target.go b/internal/game/action_default_target.go deleted file mode 100644 index a184f32..0000000 --- a/internal/game/action_default_target.go +++ /dev/null @@ -1,61 +0,0 @@ -package game - -import ( - "strings" -) - -func verbToSkill(verb string) string { - return verbSkill[verb] -} - -func (g *Game) defaultTarget(roomID int, verb string) string { - skill := verbToSkill(verb) - if skill == "" { - return "" - } - - objStates := g.World.AllObjInstances(roomID) - - type candidate struct { - defID string - name string - } - var candidates []candidate - seen := make(map[string]bool) - - for _, st := range objStates { - if seen[st.DefID] { - continue - } - seen[st.DefID] = true - - objDef, err := g.ObjectStore.Load(st.DefID) - if err != nil || !objDef.IsGatherable() { - continue - } - - if strings.EqualFold(objDef.Gather.Skill, skill) { - candidates = append(candidates, candidate{defID: st.DefID, name: objDef.Name}) - } - } - - if len(candidates) == 1 { - return candidates[0].name - } - return "" -} - -func (g *Game) resolveDefaultMob(roomID int) string { - mobs := g.MobStore.MobsInRoom(roomID) - if len(mobs) == 0 { - return "" - } - - firstDef := mobs[0].DefID - for _, m := range mobs[1:] { - if m.DefID != firstDef { - return "" - } - } - return mobs[0].Name -} diff --git a/internal/game/action_farm.go b/internal/game/action_farm.go deleted file mode 100644 index 61a66cc..0000000 --- a/internal/game/action_farm.go +++ /dev/null @@ -1,645 +0,0 @@ -package game - -import ( - "fmt" - "math/rand" - "sort" - "strings" - - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" - "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" -) - -const FarmTickInterval = 500 - -var farmPatchDefIDs = map[string]bool{ - "herb_patch": true, - "allotment_patch": true, - "flower_patch": true, - "bush_patch": true, - "tree_patch": true, -} - -func farmFlagPrefix(defID string, index int) string { - switch defID { - case "herb_patch": - return fmt.Sprintf("farm_herb_%d", index+1) - case "allotment_patch": - return fmt.Sprintf("farm_allot_%d", index+1) - case "flower_patch": - return fmt.Sprintf("farm_flower_%d", index+1) - case "bush_patch": - return fmt.Sprintf("farm_bush_%d", index+1) - case "tree_patch": - return fmt.Sprintf("farm_tree_%d", index+1) - } - return "" -} - -func (g *Game) ensureFarmState(p *player.Player, prefix string) { - p.EnsureFlags() - if _, exists := p.Flags[prefix+"_seed"]; !exists { - p.Flags[prefix+"_weeds"] = true - p.Flags[prefix+"_seed"] = "" - p.Flags[prefix+"_stage"] = 0 - p.Flags[prefix+"_watered"] = false - p.Flags[prefix+"_diseased"] = false - p.Flags[prefix+"_dead"] = false - p.Flags[prefix+"_ready"] = false - } -} - -func (g *Game) findFarmPrefixes(p *player.Player) []string { - seen := make(map[string]bool) - var prefixes []string - for key := range p.Flags { - if !strings.HasPrefix(key, "farm_") { - continue - } - if !strings.HasSuffix(key, "_seed") { - continue - } - prefix := strings.TrimSuffix(key, "_seed") - if !seen[prefix] { - seen[prefix] = true - if seedID, ok := p.Flags[key].(string); ok && seedID != "" { - prefixes = append(prefixes, prefix) - } - } - } - sort.Strings(prefixes) - return prefixes -} - -func (g *Game) FarmTick() { - g.farmTickCounter++ - if g.farmTickCounter < FarmTickInterval { - return - } - g.farmTickCounter = 0 - - if g.Hub == nil { - return - } - - for _, sess := range g.Hub.AllSessions() { - p := sess.Player - if p == nil || p.Flags == nil { - continue - } - g.advanceFarmGrowth(sess, p) - } -} - -func (g *Game) advanceFarmGrowth(sess *net.Session, p *player.Player) { - prefixes := g.findFarmPrefixes(p) - for _, prefix := range prefixes { - seedID, _ := p.Flags[prefix+"_seed"].(string) - if seedID == "" { - continue - } - dead, _ := p.Flags[prefix+"_dead"].(bool) - if dead { - continue - } - diseased, _ := p.Flags[prefix+"_diseased"].(bool) - ready, _ := p.Flags[prefix+"_ready"].(bool) - if ready { - continue - } - - if diseased { - p.Flags[prefix+"_dead"] = true - p.Flags[prefix+"_diseased"] = false - sess.WriteLine(g.colorize(sess, "farm_disease", - fmt.Sprintf("\nYour %s has died from disease!", g.seedDisplayName(sess, seedID)))) - g.AccountStore.SaveCharacter(p) - continue - } - - stage := intFromFlag(p.Flags, prefix+"_stage") - watered, _ := p.Flags[prefix+"_watered"].(bool) - - seedDef, err := g.ItemStore.Load(seedID) - if err != nil { - continue - } - - maxStages := seedDef.FarmStages - if maxStages <= 0 { - maxStages = 4 - } - - stage++ - - if stage >= maxStages { - p.Flags[prefix+"_stage"] = stage - p.Flags[prefix+"_ready"] = true - p.Flags[prefix+"_watered"] = false - sess.WriteLine(g.colorize(sess, "farm_grow", - fmt.Sprintf("\nYour %s is fully grown and ready to harvest!", - g.seedDisplayName(sess, seedID)))) - } else { - if !watered && rand.Float64() < 0.10 { - p.Flags[prefix+"_stage"] = stage - p.Flags[prefix+"_diseased"] = true - p.Flags[prefix+"_watered"] = false - sess.WriteLine(g.colorize(sess, "farm_disease", - fmt.Sprintf("\nYour %s has become diseased!", - g.seedDisplayName(sess, seedID)))) - } else { - p.Flags[prefix+"_stage"] = stage - p.Flags[prefix+"_watered"] = false - sess.WriteLine(g.colorize(sess, "farm_grow", - fmt.Sprintf("\nYour %s has grown to stage %d/%d.", - g.seedDisplayName(sess, seedID), stage, maxStages))) - } - } - g.AccountStore.SaveCharacter(p) - } -} - -func (g *Game) seedDisplayName(sess *net.Session, seedID string) string { - if def, err := g.ItemStore.Load(seedID); err == nil { - return g.itemColorize(sess, def, def.Name) - } - return seedID -} - -func (g *Game) advancePlant(sess *net.Session, p *player.Player) { - if p.Action == nil || p.Action.Data == nil { - return - } - data, ok := p.Action.Data.(*action.FarmData) - if !ok { - return - } - prefix := data.Prefix - seedID := data.SeedID - xp := data.XP - - g.ensureFarmState(p, prefix) - - p.Flags[prefix+"_weeds"] = false - p.Flags[prefix+"_seed"] = seedID - p.Flags[prefix+"_stage"] = 0 - p.Flags[prefix+"_watered"] = false - p.Flags[prefix+"_diseased"] = false - p.Flags[prefix+"_dead"] = false - p.Flags[prefix+"_ready"] = false - - if intXP := int(xp); intXP > 0 { - g.awardSkillXP(sess, p, player.Farming, intXP) - } - - g.AccountStore.SaveCharacter(p) - - seedName := seedID - if def, err := g.ItemStore.Load(seedID); err == nil { - seedName = g.itemColorize(sess, def, def.Name) - } - patchName := patchDisplayName(prefix) - msg := fmt.Sprintf("You plant a %s in the %s.", seedName, patchName) - if int(xp) > 0 && p.OptionBool("xp_drops") { - msg += g.formatXpDropSingle(sess, p, player.Farming, int(xp)) - } - sess.WriteLine(msg) - - p.Action = nil -} - -func (g *Game) advanceHarvest(sess *net.Session, p *player.Player) { - if p.Action == nil || p.Action.Data == nil { - return - } - data, ok := p.Action.Data.(*action.FarmData) - if !ok { - return - } - prefix := data.Prefix - product := data.Product - minYield := data.MinYield - maxYield := data.MaxYield - xp := data.XP - - ready, _ := p.Flags[prefix+"_ready"].(bool) - if !ready { - sess.WriteLine("There's nothing ready to harvest here.") - p.Action = nil - return - } - - yield := int(minYield) + rand.Intn(int(maxYield)-int(minYield)+1) - bonus := p.Level(player.Farming) / 20 - yield += bonus - - freeSlots := p.FreeSlots() - if yield > freeSlots { - productDef, _ := g.ItemStore.Load(product) - if productDef == nil || !productDef.Stackable { - yield = freeSlots - } - } - if yield <= 0 { - sess.WriteLine("Your inventory is too full!") - p.Action = nil - return - } - - productDef, _ := g.ItemStore.Load(product) - if productDef != nil && productDef.Stackable { - added := 0 - for i := 0; i < 28 && added < yield; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == product { - slot.Quantity += yield - added = yield - break - } - } - if added == 0 { - freeSlot := p.FirstFreeSlot() - if freeSlot >= 0 { - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: product, Quantity: yield}) - } - } - } else { - for i := 0; i < yield; i++ { - freeSlot := p.FirstFreeSlot() - if freeSlot < 0 { - break - } - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: product, Quantity: 1}) - } - } - - if intXP := int(xp); intXP > 0 { - g.awardSkillXP(sess, p, player.Farming, intXP) - } - - g.ensureFarmState(p, prefix) - p.Flags[prefix+"_seed"] = "" - p.Flags[prefix+"_stage"] = 0 - p.Flags[prefix+"_ready"] = false - p.Flags[prefix+"_watered"] = false - p.Flags[prefix+"_weeds"] = true - p.Flags[prefix+"_diseased"] = false - p.Flags[prefix+"_dead"] = false - - g.AccountStore.SaveCharacter(p) - - productName := product - if productDef != nil { - productName = g.itemColorize(sess, productDef, productDef.Name) - } - patchName := patchDisplayName(prefix) - msg := fmt.Sprintf("You harvest %d %s from the %s.", yield, productName, patchName) - if int(xp) > 0 && p.OptionBool("xp_drops") { - msg += g.formatXpDropSingle(sess, p, player.Farming, int(xp)) - } - sess.WriteLine(msg) - - p.Action = nil -} - -func (g *Game) advanceRake(sess *net.Session, p *player.Player) { - if p.Action == nil || p.Action.Data == nil { - return - } - data, ok := p.Action.Data.(*action.FarmData) - if !ok { - return - } - prefix := data.Prefix - - p.Flags[prefix+"_weeds"] = false - p.Flags[prefix+"_dead"] = false - p.Flags[prefix+"_seed"] = "" - p.Flags[prefix+"_stage"] = 0 - p.Flags[prefix+"_ready"] = false - p.Flags[prefix+"_diseased"] = false - p.Flags[prefix+"_watered"] = false - - g.AccountStore.SaveCharacter(p) - - patchName := patchDisplayName(prefix) - sess.WriteLine(fmt.Sprintf("You rake the %s clean.", patchName)) - - p.Action = nil -} - -func (g *Game) advanceWater(sess *net.Session, p *player.Player) { - if p.Action == nil || p.Action.Data == nil { - return - } - data, ok := p.Action.Data.(*action.FarmData) - if !ok { - return - } - prefix := data.Prefix - - p.Flags[prefix+"_watered"] = true - - g.AccountStore.SaveCharacter(p) - - patchName := patchDisplayName(prefix) - sess.WriteLine(fmt.Sprintf("You water the %s.", patchName)) - - p.Action = nil -} - -func (g *Game) advanceCure(sess *net.Session, p *player.Player) { - if p.Action == nil || p.Action.Data == nil { - return - } - data, ok := p.Action.Data.(*action.FarmData) - if !ok { - return - } - prefix := data.Prefix - - p.RemoveItem("plant_cure", 1) - p.Flags[prefix+"_diseased"] = false - - g.AccountStore.SaveCharacter(p) - - sess.WriteLine("You apply the plant cure. The patch looks healthy again.") - - p.Action = nil -} - -func patchDisplayName(prefix string) string { - switch { - case strings.Contains(prefix, "herb"): - return "herb patch" - case strings.Contains(prefix, "allot"): - return "allotment patch" - case strings.Contains(prefix, "flower"): - return "flower patch" - case strings.Contains(prefix, "bush"): - return "bush patch" - case strings.Contains(prefix, "tree"): - return "tree patch" - } - return "patch" -} - -func (g *Game) farmPatchLookSuffix(p *player.Player, defID string, index int) string { - prefix := farmFlagPrefix(defID, index) - if prefix == "" { - return "" - } - g.ensureFarmState(p, prefix) - - weeds, _ := p.Flags[prefix+"_weeds"].(bool) - if weeds { - return "\nIt is overgrown with weeds." - } - - seedID, _ := p.Flags[prefix+"_seed"].(string) - if seedID == "" { - return "\nThe patch is empty and ready for planting." - } - - dead, _ := p.Flags[prefix+"_dead"].(bool) - if dead { - return "\nThe plant has died. You need to rake it clean." - } - - diseased, _ := p.Flags[prefix+"_diseased"].(bool) - if diseased { - return "\nThe plant looks diseased! Use plant cure to save it." - } - - ready, _ := p.Flags[prefix+"_ready"].(bool) - if ready { - name := seedID - if def, err := g.ItemStore.Load(seedID); err == nil { - name = def.Name - } - return fmt.Sprintf("\nA fully grown %s is ready to harvest!", name) - } - - stage := intFromFlag(p.Flags, prefix+"_stage") - maxStages := 4 - if def, err := g.ItemStore.Load(seedID); err == nil && def.FarmStages > 0 { - maxStages = def.FarmStages - } - name := seedID - if def, err := g.ItemStore.Load(seedID); err == nil { - name = def.Name - } - watered, _ := p.Flags[prefix+"_watered"].(bool) - suffix := fmt.Sprintf("\nA %s is growing (stage %d/%d).", name, stage, maxStages) - if watered { - suffix += " It has been watered." - } - return suffix -} - -func (g *Game) toolShedLookSuffix(p *player.Player) string { - var stored []string - if v, _ := p.Flags["tool_shed_rake"].(bool); v { - stored = append(stored, "a rake") - } - if v, _ := p.Flags["tool_shed_spade"].(bool); v { - stored = append(stored, "a spade") - } - if v, _ := p.Flags["tool_shed_watering_can"].(bool); v { - stored = append(stored, "a watering can") - } - if len(stored) == 0 { - return "\nThe shed is empty." - } - return fmt.Sprintf("\nInside: %s.", strings.Join(stored, ", ")) -} - -func (g *Game) farmMatchPrefix(p *player.Player, input string) (prefix string, defID string, index int) { - objInstances := g.World.AllObjInstances(p.RoomID) - if len(objInstances) == 0 { - return "", "", -1 - } - - type indexedFarm struct { - prefix string - defID string - index int - name string - } - var farms []indexedFarm - - for _, obj := range objInstances { - if !farmPatchDefIDs[obj.DefID] { - continue - } - pref := farmFlagPrefix(obj.DefID, obj.Index) - if pref == "" { - continue - } - var name string - if def, err := g.ObjectStore.Load(obj.DefID); err == nil { - name = def.Name - } else { - name = obj.DefID - } - farms = append(farms, indexedFarm{prefix: pref, defID: obj.DefID, index: obj.Index, name: name}) - } - - if len(farms) == 0 { - return "", "", -1 - } - - if input == "" { - return farms[0].prefix, farms[0].defID, farms[0].index - } - - lower := strings.ToLower(input) - for _, f := range farms { - if strings.Contains(lower, strings.ToLower(f.name)) || object.WordPrefixMatch(lower, f.name) { - return f.prefix, f.defID, f.index - } - } - - return "", "", -1 -} - -func (g *Game) farmObjSuffix(p *player.Player, defID string, index int) string { - if defID == "tool_shed" { - return g.toolShedLookSuffix(p) - } - if farmPatchDefIDs[defID] { - return g.farmPatchLookSuffix(p, defID, index) - } - return "" -} - -func (g *Game) showFarmPatches(sess *net.Session, p *player.Player) []string { - objInstances := g.World.AllObjInstances(p.RoomID) - hasFarmPatches := false - for _, obj := range objInstances { - if farmPatchDefIDs[obj.DefID] { - hasFarmPatches = true - break - } - } - if !hasFarmPatches { - return nil - } - - type farmDisplay struct { - name string - line string - } - var displays []farmDisplay - - for _, obj := range objInstances { - if !farmPatchDefIDs[obj.DefID] { - continue - } - def, _ := g.ObjectStore.Load(obj.DefID) - patchName := obj.DefID - if def != nil { - patchName = def.Name - } - - prefix := farmFlagPrefix(obj.DefID, obj.Index) - if prefix == "" { - continue - } - g.ensureFarmState(p, prefix) - - idxStr := "" - if obj.Index > 0 || g.countPatchesByType(objInstances, obj.DefID) > 1 { - idxStr = fmt.Sprintf(" %d", obj.Index+1) - } - - weeds, _ := p.Flags[prefix+"_weeds"].(bool) - if weeds { - displays = append(displays, farmDisplay{ - name: fmt.Sprintf("%s%s", patchName, idxStr), - line: fmt.Sprintf("overgrown with weeds"), - }) - continue - } - - seedID, _ := p.Flags[prefix+"_seed"].(string) - if seedID == "" { - displays = append(displays, farmDisplay{ - name: fmt.Sprintf("%s%s", patchName, idxStr), - line: fmt.Sprintf("empty and ready for planting"), - }) - continue - } - - seedName := seedID - if sd, err := g.ItemStore.Load(seedID); err == nil { - seedName = g.itemColorize(sess, sd, sd.Name) - } - - dead, _ := p.Flags[prefix+"_dead"].(bool) - if dead { - displays = append(displays, farmDisplay{ - name: fmt.Sprintf("%s%s", patchName, idxStr), - line: fmt.Sprintf("contains a dead plant"), - }) - continue - } - - diseased, _ := p.Flags[prefix+"_diseased"].(bool) - if diseased { - displays = append(displays, farmDisplay{ - name: fmt.Sprintf("%s%s", patchName, idxStr), - line: fmt.Sprintf("has a sickly-looking %s plant", seedName), - }) - continue - } - - ready, _ := p.Flags[prefix+"_ready"].(bool) - if ready { - displays = append(displays, farmDisplay{ - name: fmt.Sprintf("%s%s", patchName, idxStr), - line: fmt.Sprintf("has a fully grown %s {ready to harvest}", seedName), - }) - continue - } - - stage := intFromFlag(p.Flags, prefix+"_stage") - maxStages := 4 - if sd, err := g.ItemStore.Load(seedID); err == nil && sd.FarmStages > 0 { - maxStages = sd.FarmStages - } - watered, _ := p.Flags[prefix+"_watered"].(bool) - waterStr := "" - if watered { - waterStr = ", watered" - } - displays = append(displays, farmDisplay{ - name: fmt.Sprintf("%s%s", patchName, idxStr), - line: fmt.Sprintf("has a growing %s (stage %d/%d%s)", seedName, stage, maxStages, waterStr), - }) - } - - if len(displays) == 0 { - return nil - } - - var lines []string - lines = append(lines, "") - for _, d := range displays { - lines = append(lines, fmt.Sprintf("A %s: %s.", d.name, d.line)) - } - return lines -} - -func (g *Game) countPatchesByType(instances []world.ObjState, defID string) int { - count := 0 - for _, obj := range instances { - if obj.DefID == defID && farmPatchDefIDs[obj.DefID] { - count++ - } - } - return count -} diff --git a/internal/game/action_finishing_blow.go b/internal/game/action_finishing_blow.go deleted file mode 100644 index d404fb3..0000000 --- a/internal/game/action_finishing_blow.go +++ /dev/null @@ -1,78 +0,0 @@ -package game - -import ( - "fmt" - "strings" - - "thehouseoficarus/internal/combat" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" -) - -func (g *Game) tryFinishingBlow(sess *net.Session, input string) bool { - p := sess.Player - cs := combat.GetCombat(p.Name) - if cs == nil { - return false - } - mob := g.MobStore.GetInstance(cs.MobID) - if mob == nil || mob.FinishingBlow == "" || mob.HP != 1 { - return false - } - lower := strings.ToLower(input) - var itemPart, targetPart string - for _, sep := range []string{" on ", " with "} { - if idx := strings.Index(lower, sep); idx > 0 { - itemPart = strings.TrimSpace(input[:idx]) - targetPart = strings.TrimSpace(input[idx+len(sep):]) - break - } - } - if targetPart == "" { - itemPart = strings.TrimSpace(input) - if mob.MatchQuality(itemPart) != world.MatchNone { - targetPart = itemPart - itemPart = strings.TrimSpace(mob.FinishingBlow) - } - } - if targetPart == "" { - return false - } - if mob.MatchQuality(targetPart) == world.MatchNone { - return false - } - g.doFinishingBlow(sess, p, mob, itemPart) - return true -} - -func (g *Game) doFinishingBlow(sess *net.Session, p *player.Player, mob *world.MobInstance, itemInput string) { - fbDef, err := g.ItemStore.Load(mob.FinishingBlow) - if err != nil { - sess.WriteLine("Something went wrong.") - return - } - if !fbDef.MatchesName(itemInput) && !strings.EqualFold(itemInput, fbDef.ID) { - sess.WriteLine(fmt.Sprintf("That won't work on %s. You need %s.", mobDisplayName(mob, true), g.itemColorize(sess, fbDef, fbDef.Name))) - return - } - if !p.HasItem(mob.FinishingBlow) { - sess.WriteLine(fmt.Sprintf("You don't have any %s.", g.itemColorize(sess, fbDef, fbDef.Name))) - return - } - autoKey := "assassin_unlocked_auto_" + mob.FinishingBlow - consumed := true - if p.Flags != nil { - if val, ok := p.Flags[autoKey]; ok { - if b, ok := val.(bool); ok && b { - consumed = false - } - } - } - if consumed { - p.RemoveItem(mob.FinishingBlow, 1) - } - sess.WriteLine(fmt.Sprintf("You use the %s on %s!", g.itemColorize(sess, fbDef, fbDef.Name), g.colorize(sess, "mob", mobDisplayName(mob, true)))) - mob.HP = 0 - g.endCombat(sess, p, mob) -} diff --git a/internal/game/action_fletch.go b/internal/game/action_fletch.go deleted file mode 100644 index 53346a2..0000000 --- a/internal/game/action_fletch.go +++ /dev/null @@ -1,214 +0,0 @@ -package game - -import ( - "fmt" - - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/engine" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" - "thehouseoficarus/internal/player" -) - -func (g *Game) startFletchAction(sess *net.Session, p *player.Player, item *object.ItemDef, count int) { - p.EnsureFlags() - p.Flags["last_fletch"] = item.ID - g.AccountStore.SaveCharacter(p) - - if item.FirstCraft().Wait <= 0 { - g.doInstantFletch(sess, p, item, count) - return - } - - g.startTimedFletch(sess, p, item, count) -} - -func (g *Game) doInstantFletch(sess *net.Session, p *player.Player, item *object.ItemDef, count int) { - c := item.FirstCraft() - outputQty := c.OutputQty - if outputQty <= 0 { - outputQty = 1 - } - - batches := 0 - totalOutput := 0 - skill := player.SkillName(c.EffectiveSkill()) - tmpItem := &object.ItemDef{Craft: object.CraftList{*c}} - for { - if !g.canDoFletchItem(p, tmpItem) { - break - } - - placed := false - if item.Stackable { - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == item.ID { - craftConsumeAll(c, p.HasItem, p.RemoveItem) - slot.Quantity += outputQty - placed = true - break - } - } - } - if !placed { - craftConsumeAll(c, p.HasItem, p.RemoveItem) - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - break - } - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: item.ID, Quantity: outputQty}) - } - - if c.XP > 0 { - g.awardSkillXP(sess, p, skill, c.XP) - } - - batches++ - totalOutput += outputQty - - if count > 0 { - count-- - if count <= 0 { - break - } - } - } - - if batches == 0 { - sess.WriteLine("You don't have the materials for that.") - return - } - - g.AccountStore.SaveCharacter(p) - - outDef, _ := g.ItemStore.Load(item.ID) - outputName := item.Name - if outDef != nil { - outputName = g.itemColorize(sess, outDef, outDef.Name) - } - msg := fmt.Sprintf("You fletch %d %s.", totalOutput, outputName) - if p.OptionBool("xp_drops") && c.XP > 0 { - msg += g.formatXpDropSingle(sess, p, skill, c.XP*batches) - } - sess.WriteLine(msg) -} - -func (g *Game) startTimedFletch(sess *net.Session, p *player.Player, item *object.ItemDef, count int) { - craft := item.FirstCraft() - - startMsg := g.resolveCraftMessage(sess, craft.StartMessage, "You begin fletching %i1.", craft, item.ID, nil, p.HasItem) - - sess.WriteLine(startMsg) - - broadcastName := item.Name - if def, _ := g.ItemStore.Load(item.ID); def != nil { - broadcastName = def.Name - } - g.broadcastAction(sess, "%s starts fletching.", p.Name) - _ = broadcastName - - p.BackgroundAction = &action.Action{ - Type: "fletch", - TargetID: item.ID, - TargetName: item.Name, - Data: &action.FletchData{ - ItemID: item.ID, - Phase: 0, - Wait: craft.Wait, - Remaining: count, - }, - WaitLeft: engine.ToTicks(1), - } -} - -func (g *Game) advanceFletch(sess *net.Session, p *player.Player) { - data, ok := p.BackgroundAction.Data.(*action.FletchData) - if !ok { - g.cancelBgAction(p) - return - } - itemID := data.ItemID - phase := data.Phase - wait := data.Wait - remaining := data.Remaining - - item := g.loadCraftItem(itemID) - if item == nil { - g.cancelBgAction(p) - return - } - c := item.FirstCraft() - - if phase == 0 { - data.Phase = 1 - p.BackgroundAction.WaitLeft = engine.ToTicks(wait) - return - } - - tmpItem := &object.ItemDef{Craft: object.CraftList{*c}} - if !g.canDoFletchItem(p, tmpItem) { - sess.WriteLine("You've run out of fletching materials.") - g.cancelBgAction(p) - return - } - - outputQty := c.OutputQty - if outputQty <= 0 { - outputQty = 1 - } - - skill := player.SkillName(c.EffectiveSkill()) - - placed := false - if item.Stackable { - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == item.ID { - craftConsumeAll(c, p.HasItem, p.RemoveItem) - slot.Quantity += outputQty - placed = true - break - } - } - } - if !placed { - craftConsumeAll(c, p.HasItem, p.RemoveItem) - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - sess.WriteLine("Your inventory is too full!") - g.cancelBgAction(p) - return - } - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: item.ID, Quantity: outputQty}) - } - - if c.XP > 0 { - g.awardSkillXP(sess, p, skill, c.XP) - } - g.AccountStore.SaveCharacter(p) - - msg := g.resolveCraftMessage(sess, c.Message, "You fletch a %n.", c, item.ID, nil, p.HasItem) - if p.OptionBool("xp_drops") && c.XP > 0 { - msg += g.formatXpDropSingle(sess, p, skill, c.XP) - } - sess.WriteLine(msg) - - if remaining > 0 { - remaining-- - data.Remaining = remaining - if remaining <= 0 { - sess.WriteLine("You've finished fletching.") - g.cancelBgAction(p) - return - } - } - - if !g.canContinueProduction(p, item) { - sess.WriteLine("You've run out of fletching materials.") - g.cancelBgAction(p) - return - } - - p.BackgroundAction.WaitLeft = engine.ToTicks(wait) -} diff --git a/internal/game/action_gather.go b/internal/game/action_gather.go deleted file mode 100644 index 1d685be..0000000 --- a/internal/game/action_gather.go +++ /dev/null @@ -1,405 +0,0 @@ -package game - -import ( - "fmt" - "math/rand" - "strings" - - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/color" - "thehouseoficarus/internal/engine" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" - "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" -) - -const birdsNestItemID = "birds_nest" - -func loadGatherConfig(g *Game, objDefID string) *action.GatherConfig { - def, err := g.ObjectStore.Load(objDefID) - if err != nil { - return nil - } - return def.Gather -} - -func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.ObjectDef, st *world.ObjState) { - cfg := obj.Gather - if cfg == nil { - sess.WriteLine("Something is wrong with this object.") - return - } - - skillLevel := p.Level(player.SkillName(cfg.Skill)) - if cfg.Level > 0 && skillLevel < cfg.Level { - sess.WriteLine(fmt.Sprintf("You need level %d %s to do that.", cfg.Level, cfg.Skill)) - return - } - - if st.Depleted { - if cfg.DepletedMessage != "" { - msg := cfg.DepletedMessage - if p.OptionBool("depletion") { - msg += fmt.Sprintf(" (%d ticks to respawn)", int(st.DepleteTimer)) - } - sess.WriteLine(msg) - } else if cfg.DepleteTimer > 0 { - sess.WriteLine(fmt.Sprintf("The %s has been cut down for %d more ticks.", obj.Name, int(st.DepleteTimer))) - } else { - sess.WriteLine(fmt.Sprintf("The %s is depleted for %d more ticks.", obj.Name, int(st.DepleteTimer))) - } - return - } - - wait := cfg.BaseWait - var toolName string - - if len(cfg.Tools) > 0 { - toolSpeed, name, found := g.findTool(p, cfg.Tools) - if !found { - names := make([]string, len(cfg.Tools)) - for i, t := range cfg.Tools { - names[i] = strings.ReplaceAll(t, "_", " ") - } - toolList := strings.Join(names, " or ") - sess.WriteLine(fmt.Sprintf("You need a %s to do that.", toolList)) - return - } - toolName = name - wait = cfg.BaseWait - toolSpeed - if wait < 1 { - wait = 1 - } - } - - if cfg.Bait != "" { - if !p.HasItem(cfg.Bait) { - baitName := cfg.Bait - if def, err := g.ItemStore.Load(cfg.Bait); err == nil { - baitName = def.Name - } - sess.WriteLine(fmt.Sprintf("You need %s to fish here.", baitName)) - return - } - } - - if p.FirstFreeSlot() == -1 { - sess.WriteLine("Your inventory is too full!") - return - } - verb := cfg.Skill - switch cfg.Skill { - case "woodcutting": - verb = "chopping" - } - - g.broadcastAction(sess, "\n%s starts %s the %s.", p.Name, verb, obj.Name) - - d := &action.GatherData{ - ObjDefID: obj.ID, - InstanceKey: g.World.ObjStateKey(st.RoomID, st.DefID, st.Index), - InstanceIdx: st.Index + 1, - EffectiveWait: wait, - Verb: verb, - ToolName: toolName, - } - if cfg.DepleteTimer > 0 { - d.DepleteTimer = true - } - p.Action = &action.Action{ - Type: action.TypeGather, - TargetID: st.DefID, - TargetName: obj.Name, - WaitLeft: 0, - Data: d, - } -} - -func (g *Game) advanceGather(sess *net.Session, p *player.Player) { - d := p.Action.Data.(*action.GatherData) - cfg := loadGatherConfig(g, d.ObjDefID) - if cfg == nil { - g.cancelAction(p) - return - } - - step := d.Step - wait := d.EffectiveWait - instanceKey := d.InstanceKey - shared := d.DepleteTimer - - if step == 0 { - if p.FirstFreeSlot() == -1 { - sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!")) - g.cancelAction(p) - return - } - if cfg.Bait != "" { - if !p.HasItem(cfg.Bait) { - baitName := cfg.Bait - if def, err := g.ItemStore.Load(cfg.Bait); err == nil { - baitName = def.Name - } - sess.WriteLine(g.colorize(sess, "error", fmt.Sprintf("You've run out of %s.", baitName))) - g.cancelAction(p) - return - } - } - sess.WriteLine(fmt.Sprintf("%s", cfg.GatherMsg)) - d.Step = 1 - p.Action.WaitLeft = engine.ToTicks(wait) - return - } - - if step == 2 { - st := g.World.GetObjStateByKey(instanceKey) - if st != nil && st.Depleted { - p.Action.WaitLeft = engine.ToTicks(3) - return - } - d.Step = 0 - p.Action.WaitLeft = engine.ToTicks(wait) - return - } - - if cfg.Bait != "" { - if !p.HasItem(cfg.Bait) { - baitName := cfg.Bait - if def, err := g.ItemStore.Load(cfg.Bait); err == nil { - baitName = def.Name - } - sess.WriteLine(g.colorize(sess, "error", fmt.Sprintf("You've run out of %s.", baitName))) - g.cancelAction(p) - return - } - p.RemoveItem(cfg.Bait, 1) - } - - skillLevel := p.Level(player.SkillName(cfg.Skill)) - chance := action.SuccessChance(cfg.Success, skillLevel, cfg.Level) - - if rand.Float64() < chance { - eligible := filterDropsByLevel(cfg.Drops, skillLevel) - drop := action.ResolveDrop(g.DataDir, eligible) - if drop != nil { - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!")) - g.cancelAction(p) - return - } - - qty := drop.Quantity - if qty <= 0 { - qty = 1 - } - - itemName := drop.ItemID - itemDef, _ := g.ItemStore.Load(drop.ItemID) - if itemDef != nil { - itemName = itemDef.Name - } - - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty}) - xp := drop.XP - if xp <= 0 { - xp = cfg.XP - } - if xp > 0 { - g.awardSkillXP(sess, p, player.SkillName(cfg.Skill), xp) - } - g.AccountStore.SaveCharacter(p) - - msg := drop.Message - if msg == "" { - msg = fmt.Sprintf("You manage to get some %s.", g.itemColorize(sess, itemDef, itemName)) - } else { - msg = color.ExpandTags(g.colorMode(sess), msg) - } - if xp > 0 && p.OptionBool("xp_drops") { - msg += g.formatXpDropSingle(sess, p, player.SkillName(cfg.Skill), xp) - } - sess.WriteLine(msg) - - g.checkBirdNest(sess, p, cfg, freeSlot) - - if shared { - st := g.World.GetObjStateByKey(instanceKey) - if st != nil && st.SharedTimer <= 0 { - g.pendingDepletions = append(g.pendingDepletions, pendingDepletion{ - instanceKey: instanceKey, - objDefID: d.ObjDefID, - targetName: p.Action.TargetName, - playerNames: []string{p.Name}, - }) - d.Step = 2 - p.Action.WaitLeft = engine.ToTicks(1) - return - } - } - - if !shared && drop.Depletes { - delay := engine.ToTicks(cfg.RespawnTimer) - if delay <= 0 { - delay = 10 - } - st := g.World.GetObjStateByKey(instanceKey) - if st != nil { - st.Depleted = true - st.DepleteTimer = float64(delay) - } - - for _, other := range g.Hub.PlayersInRoom(p.RoomID) { - if other == sess { - continue - } - op := other.Player - if op == nil || op.Action == nil || op.Action.Type != action.TypeGather { - continue - } - if gd, ok := op.Action.Data.(*action.GatherData); ok && gd.InstanceKey == instanceKey { - other.WriteLine(fmt.Sprintf("\nThe %s was depleted by %s!", g.colorize(sess, "item", p.Action.TargetName), g.colorize(sess, "player_name", p.Name))) - g.cancelAction(op) - g.writePrompt(other) - } - } - - d.Step = 2 - p.Action.WaitLeft = engine.ToTicks(1) - return - } - - if p.FirstFreeSlot() == -1 { - sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!")) - g.cancelAction(p) - return - } - d.Step = 0 - p.Action.WaitLeft = engine.ToTicks(wait) - return - } - } - - sess.WriteLine(cfg.FailMsg) - if p.FirstFreeSlot() == -1 { - sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!")) - g.cancelAction(p) - return - } - d.Step = 0 - p.Action.WaitLeft = engine.ToTicks(wait) -} - -func filterDropsByLevel(drops []action.DropEntry, level int) []action.DropEntry { - var eligible []action.DropEntry - for _, d := range drops { - if level >= d.Level { - eligible = append(eligible, d) - } - } - if len(eligible) == 0 { - return drops - } - return eligible -} - -func (g *Game) depleteSharedTree(st *world.ObjState, cfg *action.GatherConfig, targetName string, playerNames []string) { - st.Depleted = true - st.DepleteTimer = float64(engine.ToTicks(cfg.RespawnTimer)) - st.SharedTimer = 0 - - msg := cfg.ExhaustedMessage - if msg == "" { - msg = fmt.Sprintf("The %s falls to the ground!", targetName) - } - - for _, sess := range g.Hub.PlayersInRoom(st.RoomID) { - op := sess.Player - if op == nil || op.Action == nil || op.Action.Type != action.TypeGather { - continue - } - if gd, ok := op.Action.Data.(*action.GatherData); !ok || gd.InstanceKey != g.World.ObjStateKey(st.RoomID, st.DefID, st.Index) { - continue - } - isDepleter := false - for _, name := range playerNames { - if op.Name == name { - isDepleter = true - break - } - } - if isDepleter { - sess.WriteLine(fmt.Sprintf("\n%s", msg)) - continue - } - sess.WriteLine(fmt.Sprintf("\n%s", msg)) - g.cancelAction(op) - g.writePrompt(sess) - } -} - -func (g *Game) flushPendingDepletions() { - if len(g.pendingDepletions) == 0 { - return - } - - merged := make(map[string]*pendingDepletion) - for _, pd := range g.pendingDepletions { - if existing, ok := merged[pd.instanceKey]; ok { - existing.playerNames = append(existing.playerNames, pd.playerNames...) - } else { - copy := pd - merged[pd.instanceKey] = © - } - } - - for _, pd := range merged { - st := g.World.GetObjStateByKey(pd.instanceKey) - if st == nil || st.Depleted { - continue - } - cfg := loadGatherConfig(g, pd.objDefID) - if cfg == nil { - continue - } - g.depleteSharedTree(st, cfg, pd.targetName, pd.playerNames) - } - - g.pendingDepletions = nil -} - -func (g *Game) checkBirdNest(sess *net.Session, p *player.Player, cfg *action.GatherConfig, currentDropSlot int) { - if cfg.NestChance <= 0 { - return - } - if rand.Intn(cfg.NestChance) != 0 { - return - } - - nestName := "bird's nest" - nestDef, _ := g.ItemStore.Load(birdsNestItemID) - if nestDef != nil { - nestName = g.itemColorize(sess, nestDef, nestDef.Name) - } - - freeSlot := -1 - for i := 0; i < 28; i++ { - if i == currentDropSlot { - continue - } - if p.InvSlot(i) == nil { - freeSlot = i - break - } - } - if freeSlot == -1 { - g.World.AddGroundItem(p.RoomID, birdsNestItemID, 1) - sess.WriteLine(fmt.Sprintf("A %s falls to the ground.", nestName)) - return - } - - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: birdsNestItemID, Quantity: 1}) - g.AccountStore.SaveCharacter(p) - sess.WriteLine(fmt.Sprintf("A %s falls out of the tree!", nestName)) -} diff --git a/internal/game/action_identify.go b/internal/game/action_identify.go deleted file mode 100644 index 401ab94..0000000 --- a/internal/game/action_identify.go +++ /dev/null @@ -1,83 +0,0 @@ -package game - -import ( - "fmt" - - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" -) - -func (g *Game) advanceIdentify(sess *net.Session, p *player.Player) { - data, ok := p.Action.Data.(*action.IdentifyData) - if !ok { - g.cancelAction(p) - return - } - junkID := data.JunkID - xpPer := data.XPPer - - scrapCount := p.CountItem("scrap") - if scrapCount == 0 { - sess.WriteLine("You don't have any scrap.") - g.cancelAction(p) - return - } - - scavLevel := p.Level(player.Scavenging) - multiplier := junkMultiplier(junkID, scavLevel) - totalJunk := scrapCount * multiplier - - hasExistingStack := false - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == junkID { - hasExistingStack = true - break - } - } - if !hasExistingStack && p.FirstFreeSlot() == -1 { - sess.WriteLine("Your inventory is too full!") - g.cancelAction(p) - return - } - - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == "scrap" { - p.SetInvSlot(i, nil) - } - } - - placed := false - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == junkID { - slot.Quantity += totalJunk - placed = true - break - } - } - if !placed { - freeSlot := p.FirstFreeSlot() - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: junkID, Quantity: totalJunk}) - } - - totalXP := scrapCount * xpPer - junkName := junkID - if def, err := g.ItemStore.Load(junkID); err == nil { - junkName = g.itemColorize(sess, def, def.Name) - } - - msg := fmt.Sprintf("The altar hums. You identify %d scrap into %d %s.", scrapCount, totalJunk, junkName) - - g.awardSkillXP(sess, p, player.Scavenging, totalXP) - - if p.OptionBool("xp_drops") && totalXP > 0 { - msg += g.formatXpDropSingle(sess, p, player.Scavenging, totalXP) - } - sess.WriteLine(msg) - - g.AccountStore.SaveCharacter(p) - g.cancelAction(p) -} diff --git a/internal/game/action_room.go b/internal/game/action_room.go deleted file mode 100644 index 86c1737..0000000 --- a/internal/game/action_room.go +++ /dev/null @@ -1,220 +0,0 @@ -package game - -import ( - "fmt" - "strings" - - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" -) - -// enterSeq is an in-flight on_enter cutscene for a single player. It is driven -// one step at a time by EnterSeqTick. -type enterSeq struct { - sess *net.Session - playerName string - roomID int - steps []world.EnterStep - wait int - started bool -} - -// runEnterSteps evaluates a room's on_enter script for the player. Steps whose -// condition fails are dropped. If none of the surviving steps are "timed" -// (no delay, no flag mutation) the messages are printed synchronously, exactly -// as before. Otherwise the surviving steps become a scheduled cutscene driven -// by EnterSeqTick. -func (g *Game) runEnterSteps(sess *net.Session, roomID int) { - room, err := g.World.LoadRoom(roomID) - if err != nil || len(room.OnEnter) == 0 { - return - } - p := sess.Player - if p == nil { - return - } - - var steps []world.EnterStep - cutscene := false - for _, step := range room.OnEnter { - if step.Condition != nil && !g.checkCondition(sess, step.Condition) { - continue - } - steps = append(steps, step) - if step.IsTimed() { - cutscene = true - } - } - if len(steps) == 0 { - return - } - - if !cutscene { - for _, step := range steps { - if step.Message != "" { - sess.WriteLine(step.Message) - } - } - return - } - - g.startEnterCutscene(sess, p, roomID, steps) -} - -// startEnterCutscene registers a scheduled on_enter sequence and persists a -// marker on the player so the cutscene can be resumed on reconnect if it is -// interrupted (disconnect, server restart). -func (g *Game) startEnterCutscene(sess *net.Session, p *player.Player, roomID int, steps []world.EnterStep) { - g.cancelEnterSeq(p.Name) - if p.CutsceneRoom != roomID { - p.CutsceneRoom = roomID - g.AccountStore.SaveCharacter(p) - } - g.enterMu.Lock() - g.enterSeqs[p.Name] = &enterSeq{ - sess: sess, - playerName: p.Name, - roomID: roomID, - steps: steps, - wait: steps[0].Delay, - } - g.enterMu.Unlock() -} - -// EnterSeqTick advances every in-flight cutscene by one tick. Sequences for a -// player who is no longer connected are paused (left for resume); sequences are -// removed from the map on completion or cancellation. -func (g *Game) EnterSeqTick() { - g.enterMu.Lock() - seqs := make([]*enterSeq, 0, len(g.enterSeqs)) - for _, s := range g.enterSeqs { - seqs = append(seqs, s) - } - g.enterMu.Unlock() - - for _, seq := range seqs { - if !g.isCharLive(seq.playerName, seq.sess) { - continue - } - - var jobs []world.EnterStep - done := false - - g.enterMu.Lock() - if g.enterSeqs[seq.playerName] != seq { - g.enterMu.Unlock() - continue - } - seq.wait-- - for seq.wait <= 0 && len(seq.steps) > 0 { - step := seq.steps[0] - seq.steps = seq.steps[1:] - jobs = append(jobs, step) - if len(seq.steps) > 0 { - seq.wait = seq.steps[0].Delay - } else { - seq.wait = 0 - } - } - if len(seq.steps) == 0 { - done = true - delete(g.enterSeqs, seq.playerName) - } - g.enterMu.Unlock() - - for _, step := range jobs { - g.fireEnterStep(seq, step) - } - if done { - g.completeEnterSeq(seq) - } - } -} - -func (g *Game) fireEnterStep(seq *enterSeq, step world.EnterStep) { - p := seq.sess.Player - if p == nil || p.RoomID != seq.roomID { - return - } - if step.Message != "" { - // Break from the prompt on the first line only, so a multi-line - // cutscene reads as tight consecutive lines. - if !seq.started { - seq.sess.WriteLine("\n" + step.Message) - seq.started = true - } else { - seq.sess.WriteLine(step.Message) - } - } - if len(step.SetFlags) > 0 || len(step.SetPlayerFlags) > 0 { - g.applyFlagMutations(p, step.SetFlags, step.SetPlayerFlags) - g.AccountStore.SaveCharacter(p) - } -} - -func (g *Game) completeEnterSeq(seq *enterSeq) { - if !g.isCharLive(seq.playerName, seq.sess) { - return - } - p := seq.sess.Player - if p == nil { - return - } - if p.CutsceneRoom == seq.roomID { - p.CutsceneRoom = 0 - g.AccountStore.SaveCharacter(p) - } - g.writePrompt(seq.sess) -} - -// cancelEnterSeq drops any in-flight cutscene for the player. It does not touch -// the persisted CutsceneRoom marker, so a cutscene cancelled by disconnect can -// still be resumed on reconnect. -func (g *Game) cancelEnterSeq(name string) { - g.enterMu.Lock() - delete(g.enterSeqs, name) - g.enterMu.Unlock() -} - -func (g *Game) isCharLive(name string, sess *net.Session) bool { - g.charsMu.Lock() - defer g.charsMu.Unlock() - return g.loggedInChars[name] == sess -} - -// applyFlagMutations sets world and player flags, shared by on_enter steps and -// exit traversal. -func (g *Game) applyFlagMutations(p *player.Player, setFlags, setPlayerFlags map[string]any) { - g.Flags.SetAll(setFlags) - if len(setPlayerFlags) > 0 { - p.EnsureFlags() - for k, v := range setPlayerFlags { - p.Flags[k] = v - } - } -} - -func (g *Game) BroadcastRespawns() { - respawns := g.World.FlushObjRespawns() - for _, st := range respawns { - def, err := g.ObjectStore.Load(st.DefID) - if err != nil || !def.IsGatherable() { - continue - } - cfg := def.Gather - if cfg.RespawnBroadcast == "" { - continue - } - name := def.Name - if idx := st.Index + 1; len(g.World.FindObjInstances(st.RoomID, st.DefID)) > 1 { - name += fmt.Sprintf(" [%d]", idx) - } - msg := strings.ReplaceAll(cfg.RespawnBroadcast, "{name}", name) - if g.Hub != nil { - for _, sess := range g.Hub.PlayersInRoom(st.RoomID) { - sess.WriteLine(g.colorize(sess, "broadcast", fmt.Sprintf("\n%s", msg))) - } - } - } -} diff --git a/internal/game/action_search.go b/internal/game/action_search.go deleted file mode 100644 index 0f755a5..0000000 --- a/internal/game/action_search.go +++ /dev/null @@ -1,126 +0,0 @@ -package game - -import ( - "fmt" - - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/engine" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" -) - -func (g *Game) startSearch(sess *net.Session, p *player.Player, itemID string, slotIdx int) { - def, err := g.ItemStore.Load(itemID) - if err != nil || def.SearchTable == "" { - sess.WriteLine("You can't search that.") - return - } - - ticks := def.SearchTicks - if ticks <= 0 { - ticks = 3 - } - - p.Action = &action.Action{ - Type: "search", - TargetID: itemID, - TargetName: def.Name, - WaitLeft: engine.ToTicks(ticks), - Data: &action.SearchData{ - ItemID: itemID, - SlotIdx: slotIdx, - Started: false, - }, - } - - g.broadcastAction(sess, "\n%s searches a %s.", p.Name, def.Name) -} - -func (g *Game) advanceSearch(sess *net.Session, p *player.Player) { - d, ok := p.Action.Data.(*action.SearchData) - if !ok || d == nil { - sess.WriteLine("Something went wrong.") - g.cancelAction(p) - return - } - - def, err := g.ItemStore.Load(d.ItemID) - if err != nil { - sess.WriteLine("Something went wrong.") - g.cancelAction(p) - return - } - - if !d.Started { - msg := def.SearchMessage - if msg == "" { - msg = fmt.Sprintf("digging through the %s", def.Name) - } - sess.WriteLine(fmt.Sprintf("You begin %s.", msg)) - d.Started = true - return - } - - slot := p.InvSlot(d.SlotIdx) - if slot == nil || slot.ItemID != d.ItemID || slot.Quantity <= 0 { - sess.WriteLine("The item is gone.") - g.cancelAction(p) - return - } - - if slot.Quantity > 1 { - slot.Quantity-- - } else { - p.SetInvSlot(d.SlotIdx, nil) - } - - dt, err := action.LoadDropTable(g.DataDir, def.SearchTable) - if err == nil && len(dt.Drops) > 0 { - drop := action.ResolveDrop(g.DataDir, dt.Drops) - if drop != nil && drop.ItemID != "" { - g.giveSearchLoot(sess, p, drop) - } - } - - if def.SearchMiscTable != "" { - dt2, err := action.LoadDropTable(g.DataDir, def.SearchMiscTable) - if err == nil && len(dt2.Drops) > 0 { - drop := action.ResolveDrop(g.DataDir, dt2.Drops) - if drop != nil && drop.ItemID != "" { - g.giveSearchLoot(sess, p, drop) - } - } - } - - g.AccountStore.SaveCharacter(p) - g.cancelAction(p) -} - -func (g *Game) giveSearchLoot(sess *net.Session, p *player.Player, drop *action.DropEntry) { - qty := drop.Quantity - if qty <= 0 { - qty = 1 - } - - name := drop.ItemID - lootDef, _ := g.ItemStore.Load(drop.ItemID) - if lootDef != nil { - name = lootDef.Name - } - - if drop.ItemID == "credits" { - p.Credits += qty - sess.WriteLine(g.colorize(sess, "credits_pickup", fmt.Sprintf("You find %d credits.", qty))) - return - } - - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - g.World.AddGroundItem(p.RoomID, drop.ItemID, qty) - sess.WriteLine(fmt.Sprintf("You find %s. It falls to the ground.", g.itemColorize(sess, lootDef, name))) - return - } - - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty}) - sess.WriteLine(fmt.Sprintf("You find %s.", g.itemColorize(sess, lootDef, name))) -} diff --git a/internal/game/action_state.go b/internal/game/action_state.go deleted file mode 100644 index fc4b461..0000000 --- a/internal/game/action_state.go +++ /dev/null @@ -1,98 +0,0 @@ -package game - -import ( - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/combat" - "thehouseoficarus/internal/player" -) - -func (g *Game) playerActionDisplay(p *player.Player) string { - if cs := combat.GetCombat(p.Name); cs != nil { - mob := g.MobStore.GetInstance(cs.MobID) - if mob != nil { - if mob.IsTask() { - return "working on a " + mob.Name - } - return "fighting a " + mob.Name - } - } - - if p.MoveTicks > 0 { - return "heading " + p.MoveDirection - } - if len(p.WalkSequence) > 0 { - return "walking somewhere with a purpose!" - } - - if ss, ok := g.safespot.Get(p.Name); ok && ss.Active { - obj, err := g.ObjectStore.Load(ss.ObjectDefID) - if err == nil { - return "positioning behind a " + obj.Name - } - return "hiding" - } - - if a := p.Action; a != nil { - switch a.Type { - case action.TypeGather: - if d, ok := a.Data.(*action.GatherData); ok { - desc := d.Verb + " a " + a.TargetName - if d.ToolName != "" { - desc += " with a " + d.ToolName - } - return desc - } - case action.TypeSteal: - return "stealing from " + a.TargetName - case action.TypeTalk: - return "talking to " + a.TargetName - case action.TypeUse: - return "using a " + a.TargetName - case action.TypeBurn: - return "trying to start a fire" - case action.TypeStoke: - return "tending to a fire" - case action.TypeSearch: - return "digging through a " + a.TargetName - case action.TypeIdentify: - return "identifying scrap at " + a.TargetName - case action.TypePlant: - return "planting " + a.TargetName - case action.TypeHarvest: - return "harvesting " + a.TargetName - case action.TypeRake: - return "raking a " + a.TargetName - case action.TypeWater: - return "watering a " + a.TargetName - case action.TypeCure: - return "curing a " + a.TargetName - case action.TypeObstacle: - return "traversing across " + a.TargetName - case action.TypeCook: - return "cooking some " + a.TargetName - case action.TypeSmelt: - return "smelting some " + a.TargetName - case action.TypeSmith: - return "smithing some " + a.TargetName - case action.TypeCraft: - return "crafting some " + a.TargetName - case action.TypeCombine: - return "combining some " + a.TargetName - case action.TypeMix: - return "mixing some " + a.TargetName - case action.TypeConstruct: - return "constructing some " + a.TargetName - } - } - - if a := p.BackgroundAction; a != nil { - switch a.Type { - case action.TypeFletch: - return "fletching " + a.TargetName - case action.TypeClean: - return "cleaning herbs" - } - } - - return "" -} diff --git a/internal/game/action_steal.go b/internal/game/action_steal.go deleted file mode 100644 index 65c684c..0000000 --- a/internal/game/action_steal.go +++ /dev/null @@ -1,477 +0,0 @@ -package game - -import ( - "fmt" - "math/rand" - "sort" - "strconv" - "strings" - - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/combat" - "thehouseoficarus/internal/engine" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" - "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" -) - -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": { - Message: "Caught you red-handed! You have three options, thief.", - Options: []action.TalkOption{ - {Text: "\"I'll pay a fine. (500 credits)\"", Goto: "bribe", Condition: &action.Condition{MinCredits: 500}}, - {Text: "\"Take me to jail.\"", Goto: "jail"}, - {Text: "\"You'll have to catch me first!\"", Goto: "fight"}, - }, - }, - "bribe": { - Message: "Smart choice. Hand over 500 credits and we'll forget this happened.", - Action: &action.NodeAction{Cost: 500}, - Options: []action.TalkOption{{Text: "\"Fine, take it.\"", End: true}}, - }, - "jail": { - Message: "Off to the detention cell with you!", - Action: &action.NodeAction{Teleport: 162}, - Options: []action.TalkOption{{Text: "(You are dragged away)", End: true}}, - }, - "fight": { - Message: "Then defend yourself!", - Action: &action.NodeAction{SetFlags: map[string]any{"guard_hostile": true}}, - Options: []action.TalkOption{{Text: "(The guard attacks!)", End: true}}, - }, - }, -} - -var stealSuccess = action.SuccessFormula{ - Base: 0.5, - PerLevel: 0.03, - Cap: 0.95, -} - -func (g *Game) doSteal(sess *net.Session, input string) { - p := sess.Player - - if combat.GetCombat(p.Name) != nil { - sess.WriteLine("You can't do that during combat!") - return - } - - g.cancelAction(p) - - targetType, mob, objDef, errMsg := g.resolveStealTarget(sess, p, input) - if errMsg != "" { - sess.WriteLine(errMsg) - return - } - - g.startSteal(sess, p, targetType, mob, objDef) -} - -func (g *Game) resolveStealTarget(sess *net.Session, p *player.Player, input string) (targetType string, mob *world.MobInstance, objDef *object.ObjectDef, errMsg string) { - input = strings.ToLower(strings.TrimSpace(input)) - instanceIdx := -1 - searchName := input - - if dotPos := strings.Index(input, "."); dotPos > 0 { - if n, err := strconv.Atoi(input[:dotPos]); err == nil && n > 0 { - instanceIdx = n - 1 - searchName = input[dotPos+1:] - } - } - - roomMobs := g.MobStore.MobsInRoom(p.RoomID) - var stealMobs []*world.MobInstance - for _, m := range roomMobs { - if m.StealTable != "" { - stealMobs = append(stealMobs, m) - } - } - - roomObjs := g.World.AllObjInstances(p.RoomID) - var stealObjs []*object.ObjectDef - for _, st := range roomObjs { - def, err := g.ObjectStore.Load(st.DefID) - if err != nil || def.StealTable == "" { - continue - } - stealObjs = append(stealObjs, def) - } - - if input == "" { - if len(stealMobs) == 0 && len(stealObjs) == 0 { - return "", nil, nil, "There's nothing here to steal from." - } - - uniqueMobDefs := make(map[string]bool) - for _, m := range stealMobs { - uniqueMobDefs[m.DefID] = true - } - - if len(stealMobs) > 0 && len(stealObjs) == 0 { - if len(uniqueMobDefs) <= 1 { - return "mob", stealMobs[0], nil, "" - } - } - if len(stealObjs) > 0 && len(stealMobs) == 0 { - uniqueObjDefs := make(map[string]bool) - for _, o := range stealObjs { - uniqueObjDefs[o.ID] = true - } - if len(uniqueObjDefs) <= 1 { - return "object", nil, stealObjs[0], "" - } - } - return "", nil, nil, "Steal from what?" - } - - var matchingMobs []*world.MobInstance - for _, m := range stealMobs { - if m.MatchQuality(searchName) >= world.MatchPrefix { - matchingMobs = append(matchingMobs, m) - } - } - sort.Slice(matchingMobs, func(i, j int) bool { - return matchingMobs[i].InstanceID < matchingMobs[j].InstanceID - }) - - if len(matchingMobs) > 0 { - uniqueDefs := make(map[string]bool) - for _, m := range matchingMobs { - uniqueDefs[m.DefID] = true - } - if len(uniqueDefs) > 1 && instanceIdx < 0 { - return "", nil, nil, "Which one?" - } - if instanceIdx >= 0 && instanceIdx < len(matchingMobs) { - return "mob", matchingMobs[instanceIdx], nil, "" - } - if len(matchingMobs) == 1 { - return "mob", matchingMobs[0], nil, "" - } - for i := range matchingMobs { - if instanceIdx == i || instanceIdx < 0 { - return "mob", matchingMobs[i], nil, "" - } - } - } - - var matchingObjs []*object.ObjectDef - for _, o := range stealObjs { - if object.WordPrefixMatch(searchName, o.Name) { - matchingObjs = append(matchingObjs, o) - } - } - if len(matchingObjs) > 1 { - return "", nil, nil, "Which one?" - } - if len(matchingObjs) == 1 { - return "object", nil, matchingObjs[0], "" - } - - return "", nil, nil, "There's nothing here to steal from." -} - -func (g *Game) startSteal(sess *net.Session, p *player.Player, targetType string, mob *world.MobInstance, objDef *object.ObjectDef) { - var stealTable string - var stealLevel int - var stealXP int - var stealSpeed float64 - var targetName string - var targetID string - var mobInstanceID string - var objDefID string - var guardMob string - guardWatching := false - - if targetType == "mob" { - stealTable = mob.StealTable - stealLevel = mob.StealLevel - stealXP = mob.StealXP - stealSpeed = mob.StealSpeed - targetName = mob.Name - targetID = mob.InstanceID - mobInstanceID = mob.InstanceID - - if stealTable == "" { - sess.WriteLine(fmt.Sprintf("You can't steal from the %s.", targetName)) - return - } - if thievingLevel := p.Level(player.Thieving); stealLevel > 0 && thievingLevel < stealLevel { - sess.WriteLine(fmt.Sprintf("You need level %d thieving to steal from the %s.", stealLevel, targetName)) - return - } - if mob.HP <= 0 { - sess.WriteLine("That is already dead.") - return - } - if combat.IsMobInCombat(mob.InstanceID) { - sess.WriteLine(fmt.Sprintf("The %s is busy.", targetName)) - return - } - } else { - stealTable = objDef.StealTable - stealLevel = objDef.StealLevel - stealXP = objDef.StealXP - stealSpeed = objDef.StealSpeed - targetName = objDef.Name - targetID = objDef.ID - objDefID = objDef.ID - guardMob = objDef.GuardMob - - if stealTable == "" { - sess.WriteLine(fmt.Sprintf("You can't steal from the %s.", targetName)) - return - } - if thievingLevel := p.Level(player.Thieving); stealLevel > 0 && thievingLevel < stealLevel { - sess.WriteLine(fmt.Sprintf("You need level %d thieving to steal from the %s.", stealLevel, targetName)) - return - } - - if guardMob != "" { - guard := g.findGuardInRoom(p.RoomID, guardMob) - if guard != nil { - timerKey := fmt.Sprintf("%d:%s", p.RoomID, objDefID) - if _, exists := g.guardWatchTimers[timerKey]; !exists { - g.guardWatchTimers[timerKey] = 0 - } - guardWatching = g.isGuardWatching(p.RoomID, objDefID) - } - } - } - - if p.FirstFreeSlot() == -1 { - sess.WriteLine("Your inventory is too full!") - return - } - - sess.WriteLine(fmt.Sprintf("You attempt to steal from the %s...", targetName)) - - g.broadcastAction(sess, "\n%s glances around the room.", p.Name) - - p.Action = &action.Action{ - Type: action.TypeSteal, - TargetID: targetID, - TargetName: targetName, - WaitLeft: engine.ToTicks(stealSpeed), - Data: &action.StealData{ - TargetType: targetType, - StealTable: stealTable, - StealLevel: stealLevel, - StealXP: stealXP, - StealSpeed: stealSpeed, - TargetName: targetName, - MobInstanceID: mobInstanceID, - ObjDefID: objDefID, - GuardMob: guardMob, - GuardWatching: guardWatching, - }, - } -} - -func (g *Game) advanceSteal(sess *net.Session, p *player.Player) { - d := p.Action.Data.(*action.StealData) - targetType := d.TargetType - stealTable := d.StealTable - stealLevel := d.StealLevel - stealXP := d.StealXP - stealSpeed := d.StealSpeed - targetName := d.TargetName - guardMob := d.GuardMob - guardWatching := d.GuardWatching - - if targetType == "mob" { - mobInstanceID := d.MobInstanceID - mob := g.MobStore.GetInstance(mobInstanceID) - if mob == nil || mob.HP <= 0 || mob.RoomID != p.RoomID { - sess.WriteLine("Your target is gone.") - g.cancelAction(p) - return - } - if combat.IsMobInCombat(mob.InstanceID) { - sess.WriteLine(fmt.Sprintf("The %s is busy.", targetName)) - g.cancelAction(p) - return - } - d.StealLevel = mob.StealLevel - d.StealXP = mob.StealXP - d.StealSpeed = mob.StealSpeed - stealLevel = mob.StealLevel - stealXP = mob.StealXP - stealSpeed = mob.StealSpeed - } else { - objDefID := d.ObjDefID - found := false - for _, st := range g.World.AllObjInstances(p.RoomID) { - if st.DefID == objDefID && !st.Depleted { - found = true - break - } - } - if !found { - sess.WriteLine("Your target is gone.") - g.cancelAction(p) - return - } - objDef, err := g.ObjectStore.Load(objDefID) - if err == nil { - d.StealLevel = objDef.StealLevel - d.StealXP = objDef.StealXP - d.StealSpeed = objDef.StealSpeed - stealLevel = objDef.StealLevel - stealXP = objDef.StealXP - stealSpeed = objDef.StealSpeed - d.GuardMob = objDef.GuardMob - guardMob = objDef.GuardMob - - if guardMob != "" { - guard := g.findGuardInRoom(p.RoomID, guardMob) - if guard != nil { - timerKey := fmt.Sprintf("%d:%s", p.RoomID, objDefID) - if _, exists := g.guardWatchTimers[timerKey]; !exists { - g.guardWatchTimers[timerKey] = 0 - } - guardWatching = g.isGuardWatching(p.RoomID, objDefID) - d.GuardWatching = guardWatching - } - } - } - } - - level := p.Level(player.Thieving) - chance := action.SuccessChance(stealSuccess, level, stealLevel) - if guardWatching { - chance *= 0.5 - if chance < 0.05 { - chance = 0.05 - } - } - - if rand.Float64() < chance { - dt, err := action.LoadDropTable(g.DataDir, stealTable) - if err != nil || len(dt.Drops) == 0 { - sess.WriteLine("You steal nothing of value.") - } else { - drop := action.ResolveDrop(g.DataDir, dt.Drops) - if drop == nil || drop.ItemID == "" { - sess.WriteLine("You steal nothing of value.") - } else { - g.giveStealLoot(sess, p, drop) - } - } - - if stealXP > 0 { - g.awardSkillXP(sess, p, player.Thieving, stealXP) - } - - g.AccountStore.SaveCharacter(p) - - if p.FirstFreeSlot() == -1 { - sess.WriteLine("Your inventory is full.") - g.cancelAction(p) - return - } - - p.Action.WaitLeft = engine.ToTicks(stealSpeed) - } else { - if targetType == "mob" { - mobInstanceID := d.MobInstanceID - mob := g.MobStore.GetInstance(mobInstanceID) - sess.WriteLine(fmt.Sprintf("The %s notices you! They attack!", targetName)) - g.cancelAction(p) - if mob != nil && mob.HP > 0 { - g.startCombat(sess, p, mob) - } - } else { - if guardMob != "" && guardWatching { - guard := g.findGuardInRoom(p.RoomID, guardMob) - if guard != nil { - sess.WriteLine("You fumble and the Guard spots you!") - g.cancelAction(p) - g.startStealGuardTalk(sess, p, guard) - return - } - } - sess.WriteLine("You fail to steal anything.") - p.Action.WaitLeft = engine.ToTicks(stealSpeed) - } - } -} - -func (g *Game) giveStealLoot(sess *net.Session, p *player.Player, drop *action.DropEntry) { - qty := drop.Quantity - if qty <= 0 { - qty = 1 - } - - if drop.ItemID == "credits" { - p.Credits += qty - sess.WriteLine(g.colorize(sess, "credits_pickup", fmt.Sprintf("You steal %d credits.", qty))) - return - } - - name := drop.ItemID - lootDef, _ := g.ItemStore.Load(drop.ItemID) - if lootDef != nil { - name = lootDef.Name - } - - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - g.World.AddGroundItem(p.RoomID, drop.ItemID, qty) - sess.WriteLine(fmt.Sprintf("You steal %s but your inventory is full. It falls to the ground.", g.itemColorize(sess, lootDef, name))) - return - } - - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty}) - sess.WriteLine(fmt.Sprintf("You steal %s.", g.itemColorize(sess, lootDef, name))) -} - -func (g *Game) findGuardInRoom(roomID int, guardDefID string) *world.MobInstance { - mobs := g.MobStore.MobsInRoom(roomID) - for _, m := range mobs { - if m.DefID == guardDefID && m.HP > 0 { - return m - } - } - return nil -} - -const guardWatchDuration = 8 -const guardLookAwayDuration = 4 -const guardCycleLength = guardWatchDuration + guardLookAwayDuration - -func (g *Game) isGuardWatching(roomID int, objDefID string) bool { - key := fmt.Sprintf("%d:%s", roomID, objDefID) - counter := g.guardWatchTimers[key] - return counter%guardCycleLength < guardWatchDuration -} - -func (g *Game) startStealGuardTalk(sess *net.Session, p *player.Player, guardMob *world.MobInstance) { - cfg := stallGuardTalk - node, ok := cfg.Nodes["start"] - if !ok { - sess.WriteLine("The Guard glares at you but says nothing.") - return - } - - p.Action = &action.Action{ - Type: "talk", - TargetID: guardMob.DefID, - TargetName: guardMob.Name, - Data: &action.TalkData{Node: "start", Cfg: cfg, StealGuard: true}, - } - - g.showTalkNode(sess, node) -} diff --git a/internal/game/action_talk.go b/internal/game/action_talk.go deleted file mode 100644 index 5b3a095..0000000 --- a/internal/game/action_talk.go +++ /dev/null @@ -1,277 +0,0 @@ -package game - -import ( - "fmt" - "strconv" - "strings" - - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" - "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" -) - -func (g *Game) startMobTalk(sess *net.Session, p *player.Player, mob *world.MobInstance) { - cfg := mob.TalkConfig - if cfg == nil { - sess.WriteLine("This person has nothing to say.") - return - } - - if node, ok := cfg.Nodes["start"]; ok { - p.Action = &action.Action{ - Type: action.TypeTalk, - TargetID: mob.DefID, - TargetName: mob.Name, - Data: &action.TalkData{Node: "start", Cfg: cfg}, - } - g.showTalkNode(sess, node) - } else { - sess.WriteLine("This person has nothing to say.") - } -} - -func (g *Game) startTalk(sess *net.Session, p *player.Player, obj *object.ObjectDef) { - cfg := obj.Talk - if cfg == nil { - sess.WriteLine("This person has nothing to say.") - return - } - - if node, ok := cfg.Nodes["start"]; ok { - p.Action = &action.Action{ - Type: action.TypeTalk, - TargetID: obj.ID, - TargetName: obj.Name, - Data: &action.TalkData{Node: "start", Cfg: cfg}, - } - g.showTalkNode(sess, node) - } else { - sess.WriteLine("This person has nothing to say.") - } -} - -func (g *Game) showTalkNode(sess *net.Session, node action.TalkNode) { - sess.WriteLine(fmt.Sprintf("%s", g.colorize(sess, "dialog", node.Message))) - - if node.Action != nil { - if node.Action.Shop != nil { - g.applyNodeAction(sess, node.Action) - g.enterShop(sess, node.Action.Shop) - return - } - g.applyNodeAction(sess, node.Action) - - if v, ok := g.Flags.Get("guard_hostile"); ok && v != nil { - g.Flags.Delete("guard_hostile") - p := sess.Player - if p != nil && p.Action != nil { - if td, ok := p.Action.Data.(*action.TalkData); ok && td.StealGuard { - guardMob := g.findGuardInRoom(p.RoomID, "guard") - if guardMob != nil { - sess.State = net.StateGame - g.cancelAction(p) - guardMob.Protected = false - g.startCombat(sess, p, guardMob) - } - return - } - } - } - } - - if len(node.Options) == 0 { - sess.State = net.StateGame - g.writePrompt(sess) - return - } - - visible := 0 - for _, opt := range node.Options { - if opt.Condition != nil && !g.checkCondition(sess, opt.Condition) { - continue - } - visible++ - sess.WriteLine(fmt.Sprintf(" %d. %s", visible, opt.Text)) - } - sess.State = net.StateTalk - sess.Write("\nChoice: ") -} - -func (g *Game) handleTalkInput(sess *net.Session, input string) { - p := sess.Player - if p == nil || p.Action == nil || p.Action.Type != action.TypeTalk { - sess.State = net.StateGame - g.writePrompt(sess) - return - } - - input = strings.TrimSpace(input) - lower := strings.ToLower(input) - if lower == "bye" || lower == "exit" || lower == "end" || lower == "quit" { - g.cancelAction(p) - sess.State = net.StateGame - g.writePrompt(sess) - return - } - - if td, ok := p.Action.Data.(*action.TalkData); ok && td.EstateDirectory { - g.handleEstateDirectoryTalk(sess, input) - return - } - - td, ok := p.Action.Data.(*action.TalkData) - if !ok || td.Cfg == nil { - g.cancelAction(p) - sess.State = net.StateGame - g.writePrompt(sess) - return - } - cfg := td.Cfg - - nodeKey := td.Node - node, ok := cfg.Nodes[nodeKey] - if !ok { - g.cancelAction(p) - sess.State = net.StateGame - g.writePrompt(sess) - return - } - - idx, err := parseChoiceIndex(input) - if err != nil || idx <= 0 { - g.cancelAction(p) - sess.State = net.StateGame - g.writePrompt(sess) - return - } - - validIdx := 0 - var chosen *action.TalkOption - for i := range node.Options { - opt := &node.Options[i] - if opt.Condition != nil && !g.checkCondition(sess, opt.Condition) { - continue - } - validIdx++ - if validIdx == idx { - chosen = opt - break - } - } - - if chosen == nil { - g.cancelAction(p) - sess.State = net.StateGame - g.writePrompt(sess) - return - } - - if chosen.End { - g.cancelAction(p) - sess.State = net.StateGame - g.writePrompt(sess) - return - } - - if chosen.Goto != "" { - if nextNode, ok := cfg.Nodes[chosen.Goto]; ok { - td.Node = chosen.Goto - g.showTalkNode(sess, nextNode) - return - } - } - - g.cancelAction(p) - sess.State = net.StateGame - g.writePrompt(sess) -} - -func (g *Game) enterShop(sess *net.Session, cfg *action.ShopConfig) { - sess.Shop = cfg - sess.State = net.StateShop - g.showShopBrowse(sess) - g.writeShopPrompt(sess) -} - -func (g *Game) applyNodeAction(sess *net.Session, na *action.NodeAction) { - p := sess.Player - if p == nil { - return - } - - if na.AssignTask { - g.assignAssassinTask(sess, p) - } - if na.SkipTask { - g.skipAssassinTask(sess, p) - } - if na.ExtendTask { - g.extendAssassinTask(sess, p) - } - if na.ReputationCost > 0 { - rep := getPlayerFlagInt(p, "assassin_reputation") - if rep < na.ReputationCost { - sess.WriteLine(fmt.Sprintf("You don't have enough Reputation. (Need %d, have %d)", na.ReputationCost, rep)) - return - } - setPlayerFlag(p, "assassin_reputation", rep-na.ReputationCost) - g.AccountStore.SaveCharacter(p) - } - - for k, v := range na.SetFlags { - g.Flags.Set(k, v) - } - for k, v := range na.SetPlayerFlags { - p.EnsureFlags() - p.Flags[k] = v - } - if na.TakeItem != "" { - if p.HasItem(na.TakeItem) { - p.RemoveItem(na.TakeItem, 1) - } - } - if na.GiveItem != "" { - slot := p.FirstFreeSlot() - if slot == -1 { - sess.WriteLine("Your inventory is too full to receive that.") - } else { - p.SetInvSlot(slot, &player.InventorySlot{ItemID: na.GiveItem, Quantity: 1}) - g.AccountStore.SaveCharacter(p) - } - } - if na.Teleport > 0 { - p.RoomID = na.Teleport - p.Stats.RecordRoomVisit(na.Teleport) - g.AccountStore.SaveCharacter(p) - 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.runEnterSteps(sess, p.RoomID) - } - if na.Heal > 0 { - p.HP += na.Heal - if maxHP := p.MaxHP(); p.HP > maxHP { - p.HP = maxHP - } - g.AccountStore.SaveCharacter(p) - sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", na.Heal)) - } - if na.Cost > 0 { - if p.Credits >= na.Cost { - p.Credits -= na.Cost - g.AccountStore.SaveCharacter(p) - } - } - if na.Sawmill { - g.processSawmill(sess, p) - } - if na.ApsNode { - setPlayerFlag(p, "aps_node_"+strconv.Itoa(p.RoomID), true) - } -} diff --git a/internal/game/action_use.go b/internal/game/action_use.go deleted file mode 100644 index 4bac96d..0000000 --- a/internal/game/action_use.go +++ /dev/null @@ -1,123 +0,0 @@ -package game - -import ( - "fmt" - "math/rand" - - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/engine" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" - "thehouseoficarus/internal/player" -) - -func (g *Game) startUse(sess *net.Session, p *player.Player, obj *object.ObjectDef) { - cfg := obj.Use - if cfg == nil { - sess.WriteLine("You can't use this.") - return - } - - for itemID := range cfg.Consume { - if !p.HasItem(itemID) { - defName := itemID - if def, err := g.ItemStore.Load(itemID); err == nil { - defName = g.itemColorize(sess, def, def.Name) - } - sess.WriteLine(fmt.Sprintf("You need %s to use this.", defName)) - return - } - } - - if cfg.Success == nil && p.FirstFreeSlot() == -1 { - sess.WriteLine("Your inventory is full.") - return - } - - g.broadcastAction(sess, "\n%s uses the %s.", p.Name, obj.Name) - - p.Action = &action.Action{ - Type: "use", - TargetID: obj.ID, - TargetName: obj.Name, - WaitLeft: engine.ToTicks(1), - Data: &action.UseData{Cfg: cfg, Step: 0}, - } - - sess.WriteLine(fmt.Sprintf("%s", cfg.Message)) -} - -func (g *Game) advanceUse(sess *net.Session, p *player.Player) { - d, ok := p.Action.Data.(*action.UseData) - if !ok || d == nil || d.Cfg == nil { - g.cancelAction(p) - return - } - cfg := d.Cfg - - for itemID, qty := range cfg.Consume { - if !p.HasItem(itemID) { - defName := itemID - if def, err := g.ItemStore.Load(itemID); err == nil { - defName = g.itemColorize(sess, def, def.Name) - } - sess.WriteLine(fmt.Sprintf("You've run out of %s.", defName)) - g.cancelAction(p) - return - } - if !p.RemoveItem(itemID, qty) { - sess.WriteLine(fmt.Sprintf("You need %d of %s.", qty, itemID)) - g.cancelAction(p) - return - } - } - - if cfg.Success != nil { - skillLevel := p.Level(player.SkillName(cfg.Skill)) - chance := action.SuccessChance(*cfg.Success, skillLevel, cfg.Level) - if rand.Float64() >= chance { - if cfg.FailMsg != "" { - sess.WriteLine(cfg.FailMsg) - } - p.Action.WaitLeft = engine.ToTicks(cfg.Wait) - return - } - } - - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - sess.WriteLine("Your inventory is full.") - g.cancelAction(p) - return - } - - qty := cfg.Reward.Quantity - if qty <= 0 { - qty = 1 - } - - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: cfg.Reward.ItemID, Quantity: qty}) - if cfg.XP > 0 { - g.awardSkillXP(sess, p, player.SkillName(cfg.Skill), cfg.XP) - } - g.AccountStore.SaveCharacter(p) - - itemName := cfg.Reward.ItemID - rewardDef, _ := g.ItemStore.Load(cfg.Reward.ItemID) - if rewardDef != nil { - itemName = rewardDef.Name - } - line := fmt.Sprintf("You make a %s.", g.itemColorize(sess, rewardDef, itemName)) - if cfg.XP > 0 && p.OptionBool("xp_drops") { - line += g.formatXpDropSingle(sess, p, player.SkillName(cfg.Skill), cfg.XP) - } - sess.WriteLine(line) - - if p.FirstFreeSlot() == -1 { - sess.WriteLine("Your inventory is full.") - g.cancelAction(p) - return - } - - p.Action.WaitLeft = engine.ToTicks(cfg.Wait) -} diff --git a/internal/game/cmd_agility.go b/internal/game/cmd_agility.go index e347322..4a6f733 100644 --- a/internal/game/cmd_agility.go +++ b/internal/game/cmd_agility.go @@ -3,7 +3,6 @@ package game import ( "fmt" - "thehouseoficarus/internal/combat" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) @@ -11,7 +10,7 @@ import ( func (g *Game) doObstacle(sess *net.Session, verb string) { p := sess.Player - if combat.GetCombat(p.Name) != nil { + if g.Combat.Get(p.Name) != nil { sess.WriteLine("You can't do that during combat!") return } diff --git a/internal/game/cmd_aps.go b/internal/game/cmd_aps.go index 8e20c6c..265277a 100644 --- a/internal/game/cmd_aps.go +++ b/internal/game/cmd_aps.go @@ -9,6 +9,7 @@ import ( "thehouseoficarus/internal/color" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" + "thehouseoficarus/internal/ui" ) func (g *Game) executeAps(sess *net.Session, args []string, rawInput string) { @@ -137,7 +138,7 @@ func displayApsNodes(g *Game, sess *net.Session, p *player.Player, known []int) } } - t := &Table{ + t := &ui.Table{ Title: "APS Datapad", Columns: []string{ color.Render(mode, color.Parse("245"), "Room"), diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go index abaefbb..5635fd0 100644 --- a/internal/game/cmd_attack.go +++ b/internal/game/cmd_attack.go @@ -2,740 +2,11 @@ package game import ( "fmt" - "math/rand" - "sort" - "strconv" "strings" - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/color" - "thehouseoficarus/internal/combat" - "thehouseoficarus/internal/engine" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" - "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" ) -func (g *Game) doAttack(sess *net.Session, input string) { - p := sess.Player - - if combat.GetCombat(p.Name) != nil { - sess.WriteLine("You are already in combat!") - return - } - - if p.Action != nil { - g.cancelAction(p) - } - - mob := g.findMob(sess, input, p.RoomID) - if mob == nil { - return - } - - if mob.HP <= 0 { - if mob.IsTask() { - sess.WriteLine(fmt.Sprintf("%s is already complete.", mobDisplayName(mob, true))) - } else { - sess.WriteLine("That is already dead.") - } - return - } - - if mob.Protected { - sess.WriteLine(fmt.Sprintf("You can't attack %s!", mobDisplayName(mob, true))) - return - } - - if mob.AssassinLevel > 0 && p.Level(player.Assassin) < mob.AssassinLevel { - sess.WriteLine(fmt.Sprintf("You need Assassin level %d to attack %s.", mob.AssassinLevel, mobDisplayName(mob, true))) - return - } - - if combat.IsMobInCombat(mob.InstanceID) { - sess.WriteLine(fmt.Sprintf("%s is already engaged in combat!", mobDisplayName(mob, false))) - return - } - - if itemID, ok := p.Equipment[object.SlotMainHand]; ok { - if def, err := g.ItemStore.Load(itemID); err == nil && def.WeaponType == object.WeaponRanged { - if _, hasAmmo := p.Equipment[object.SlotAmmo]; !hasAmmo || p.AmmoQty <= 0 { - sess.WriteLine("You don't have any ammo equipped.") - return - } - } - } - - g.startCombat(sess, p, mob) -} - -func (g *Game) findMob(sess *net.Session, input string, roomID int) *world.MobInstance { - lower := strings.ToLower(input) - mobs := g.MobStore.MobsInRoom(roomID) - - idx := -1 - name := lower - if dotPos := strings.Index(lower, "."); dotPos > 0 { - if n, err := strconv.Atoi(lower[:dotPos]); err == nil && n > 0 { - idx = n - name = lower[dotPos+1:] - } - } - - var exact []*world.MobInstance - var prefix []*world.MobInstance - for _, m := range mobs { - q := m.MatchQuality(name) - if q == world.MatchExact { - exact = append(exact, m) - } else if q == world.MatchPrefix { - prefix = append(prefix, m) - } - } - - candidates := exact - if len(candidates) == 0 { - candidates = prefix - } - - if len(candidates) == 0 { - sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input)) - return nil - } - - sort.Slice(candidates, func(i, j int) bool { - return candidates[i].InstanceID < candidates[j].InstanceID - }) - - if idx > 0 { - if idx-1 < len(candidates) { - return candidates[idx-1] - } - return nil - } - - if len(candidates) == 1 { - return candidates[0] - } - - seen := make(map[string]bool) - for _, m := range candidates { - seen[mobDisplayName(m, false)] = true - } - if len(seen) > 1 { - sess.WriteLine("Which one?") - return nil - } - return candidates[0] -} - -func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) { - g.cancelRest(p.Name) - g.cancelBgAction(p) - - combat.EnterCombat(p.Name, mob.InstanceID) - p.AttackTimer = 0 - - isTask := mob.IsTask() - autotriggerActive := p.AutotriggerMod != "" - - if !autotriggerActive { - attBonus, strBonus, defBonus := combat.AttackStyleBonus(string(p.AttackStyle)) - var styleParts []string - if attBonus > 0 { - styleParts = append(styleParts, fmt.Sprintf("+%d atk", attBonus)) - } - if strBonus > 0 { - styleParts = append(styleParts, fmt.Sprintf("+%d str", strBonus)) - } - if defBonus > 0 { - styleParts = append(styleParts, fmt.Sprintf("+%d def", defBonus)) - } - styleStr := "" - if len(styleParts) > 0 { - styleStr = " Style: " + string(p.AttackStyle) + " (" + strings.Join(styleParts, ", ") + ")" - } - if isTask { - sess.WriteLine(fmt.Sprintf("You begin to %s %s.%s", taskWorkVerb(mob), g.colorize(sess, "mob", mobDisplayName(mob, true)), styleStr)) - } else { - sess.WriteLine(fmt.Sprintf("You attack %s!%s", g.colorize(sess, "mob", mobDisplayName(mob, true)), styleStr)) - } - } else { - mod := GetMod(p.AutotriggerMod) - modName := p.AutotriggerMod - if mod != nil { - modName = mod.Name - } - if isTask { - sess.WriteLine(fmt.Sprintf("You begin to %s %s using %s!", - taskWorkVerb(mob), - g.colorize(sess, "mob", mobDisplayName(mob, true)), - g.colorize(sess, "science_mod", modName))) - } else { - sess.WriteLine(fmt.Sprintf("You attack %s with %s!", - g.colorize(sess, "mob", mobDisplayName(mob, true)), - g.colorize(sess, "science_mod", modName))) - } - } - if isTask { - g.broadcastAction(sess, "\n%s begins working on %s.", p.Name, mobDisplayName(mob, true)) - } else { - g.broadcastAction(sess, "\n%s attacks %s!", p.Name, mobDisplayName(mob, true)) - } - - g.Ticks.Subscribe(1, func() bool { - cs := combat.GetCombat(p.Name) - if cs == nil || !cs.Active { - return false - } - currentMob := g.MobStore.GetInstance(cs.MobID) - if currentMob == nil || currentMob.HP <= 0 { - g.endCombat(sess, p, currentMob) - return false - } - - p.AttackTimer++ - if float64(p.AttackTimer) >= g.playerWeaponSpeed(p) { - p.AttackTimer = 0 - - if g.hasDeckEquipped(p) { - if p.AutotriggerMod != "" { - mod := GetMod(p.AutotriggerMod) - if mod != nil && g.hasJunkCost(p, mod) { - g.scienceAttack(sess, p, currentMob, mod) - } else if mod != nil { - sess.WriteLine(g.colorize(sess, "error", - fmt.Sprintf("\nYou're out of junk for %s!! You flail with your fists in desperation!", mod.Name))) - g.playerAttackUnarmed(sess, p, currentMob) - } else { - p.AutotriggerMod = "" - sess.WriteLine(g.colorize(sess, "error", - "\nNo autotrigger set! Set a module with the 'autotrigger' command.")) - g.playerAttackUnarmed(sess, p, currentMob) - } - } else { - sess.WriteLine(g.colorize(sess, "error", - "\nNo autotrigger set! Set a module with the 'autotrigger' command.")) - g.playerAttackUnarmed(sess, p, currentMob) - } - } else { - if p.QueuedTrigger != "" { - mod := GetMod(p.QueuedTrigger) - p.QueuedTrigger = "" - if mod != nil && g.hasJunkCost(p, mod) { - g.scienceAttack(sess, p, currentMob, mod) - } else { - g.playerAttack(sess, p, currentMob) - } - } else { - g.playerAttack(sess, p, currentMob) - } - } - } - - if currentMob.HP <= 0 { - g.endCombat(sess, p, currentMob) - return false - } - return true - }) - - if !isTask { - g.Ticks.Subscribe(engine.ToTicks(mob.Speed), func() bool { - cs := combat.GetCombat(p.Name) - if cs == nil || !cs.Active { - return false - } - currentMob := g.MobStore.GetInstance(cs.MobID) - if currentMob == nil || currentMob.HP <= 0 { - return false - } - g.mobAttack(sess, p, currentMob) - if p.HP <= 0 { - g.endCombat(sess, p, currentMob) - return false - } - return true - }) - } -} - -func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) { - if g.processConsumeQueue(p, sess) { - return - } - - attackType, weaponType, attRoll, defRoll, maxHit := g.calculatePlayerAttackRoll(p, mob) - - if g.isSafespotted(p.Name) && attackType != "ranged" && attackType != "science" { - if ss, ok := g.safespot.Get(p.Name); ok { - g.forceLeaveSafespot(sess, p, &ss, "You leave your cover to close in for melee!") - } - } - - if combat.HitCheck(attRoll, defRoll) { - g.applyPlayerHit(sess, p, mob, attackType, weaponType, maxHit) - } else { - g.applyPlayerMiss(sess, p, mob, weaponType) - } -} - -func (g *Game) calculatePlayerAttackRoll(p *player.Player, mob *world.MobInstance) (attackType string, weaponType object.WeaponType, attRoll int, defRoll int, maxHit int) { - attackType = "crush" - if itemID, ok := p.Equipment[object.SlotMainHand]; ok { - if def, err := g.ItemStore.Load(itemID); err == nil { - if def.AttackType != "" { - attackType = def.AttackType - } else if def.WeaponType == object.WeaponRanged { - attackType = "ranged" - } else if def.WeaponType == object.WeaponScience { - attackType = "science" - } - weaponType = def.WeaponType - } - } - - totals := g.playerEquipBonuses(p) - isRanged := weaponType == object.WeaponRanged - - if isRanged { - rangedBonus, _ := combat.RangedStyleBonus(string(p.AttackStyle)) - equipAttack := totals.RangedAttack - effectiveRanged := p.Level(player.Ranged) + g.techLevelBonus(p, "ranged") + g.buffLevelBonus(p, "ranged") - attRoll = combat.EffectiveRoll(effectiveRanged, rangedBonus, equipAttack) - - mobDefBonus := combat.SelectBonus(attackType, - mob.StabDefense, mob.SlashDefense, mob.CrushDefense, - mob.ScienceDefense, mob.RangedDefense) - defRoll = combat.EffectiveRoll(mob.Defense, 0, mobDefBonus) - maxHit = combat.MaxHit(effectiveRanged, rangedBonus, totals.RangedStrength) - } else { - attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle)) - equipAttack := combat.SelectBonus(attackType, - totals.StabAttack, totals.SlashAttack, totals.CrushAttack, - totals.ScienceAttack, totals.RangedAttack) - effectiveAccuracy := p.Level(player.Accuracy) + g.techLevelBonus(p, "accuracy") + g.buffLevelBonus(p, "accuracy") - attRoll = combat.EffectiveRoll(effectiveAccuracy, attBonus, equipAttack) - - mobDefBonus := combat.SelectBonus(attackType, - mob.StabDefense, mob.SlashDefense, mob.CrushDefense, - mob.ScienceDefense, mob.RangedDefense) - defRoll = combat.EffectiveRoll(mob.Defense, 0, mobDefBonus) - maxHit = combat.MaxHit(p.Level(player.Strength)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus, totals.StrengthBonus) - } - return -} - -func (g *Game) playerAttackUnarmed(sess *net.Session, p *player.Player, mob *world.MobInstance) { - if g.processConsumeQueue(p, sess) { - return - } - - attackType, _, attRoll, defRoll, maxHit := g.calculateUnarmedAttackRoll(p, mob) - - if combat.HitCheck(attRoll, defRoll) { - g.applyPlayerHit(sess, p, mob, attackType, object.WeaponMelee, maxHit) - } else { - g.applyPlayerMiss(sess, p, mob, object.WeaponMelee) - } -} - -func (g *Game) calculateUnarmedAttackRoll(p *player.Player, mob *world.MobInstance) (attackType string, weaponType object.WeaponType, attRoll int, defRoll int, maxHit int) { - attackType = "crush" - weaponType = object.WeaponMelee - - attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle)) - effectiveAccuracy := p.Level(player.Accuracy) + g.techLevelBonus(p, "accuracy") + g.buffLevelBonus(p, "accuracy") - attRoll = combat.EffectiveRoll(effectiveAccuracy, attBonus, 0) - - mobDefBonus := combat.SelectBonus(attackType, - mob.StabDefense, mob.SlashDefense, mob.CrushDefense, - mob.ScienceDefense, mob.RangedDefense) - defRoll = combat.EffectiveRoll(mob.Defense, 0, mobDefBonus) - maxHit = combat.MaxHit(p.Level(player.Strength)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus, 0) - return -} - -func (g *Game) applyPlayerHit(sess *net.Session, p *player.Player, mob *world.MobInstance, attackType string, weaponType object.WeaponType, maxHit int) { - dmg := combat.RollDamage(maxHit) - - mob.HP -= dmg - if mob.HP < 0 { - mob.HP = 0 - } - if mob.FinishingBlow != "" && mob.HP <= 0 { - mob.HP = 1 - } - if mob.HP < mob.MaxHP && mob.HP > 0 { - mob.StartRegen() - } - - if mob.FinishingBlow != "" && mob.HP == 1 { - fbDef, _ := g.ItemStore.Load(mob.FinishingBlow) - fbName := mob.FinishingBlow - if fbDef != nil { - fbName = fbDef.Name - } - sess.WriteLine(g.colorize(sess, "warning", - fmt.Sprintf("%s resists death! Use %s on it to finish it off.", - mobDisplayName(mob, false), fbName))) - } - - isRanged := weaponType == object.WeaponRanged - gains := g.awardCombatXP(sess, p, dmg, isRanged) - - if isRanged { - g.consumeAmmo(sess, p) - } - - if mob.IsTask() { - g.writeTaskProgress(sess, p, mob, dmg, gains) - } else { - mobName := mobDisplayName(mob, true) - attacker := mob.Name - if !mob.Unique { - attacker = "The " + mob.Name - } - w := len(fmt.Sprintf("You hit %s for %d damage.", mobName, 999)) - if w2 := len(fmt.Sprintf("%s hits you for %d damage.", attacker, 999)); w2 > w { - w = w2 - } - prefix := fmt.Sprintf("You hit %s for %s damage.", g.colorize(sess, "mob", mobName), g.colorize(sess, "damage_dealt", fmt.Sprint(dmg))) - visLen := color.VisibleLen(prefix) - if visLen < w { - prefix += strings.Repeat(" ", w-visLen+1) - } - hpSuffix := fmt.Sprintf("%s [%2d/%d]", g.hpBar(sess, mob.HP, mob.MaxHP), mob.HP, mob.MaxHP) - line := prefix + hpSuffix + g.formatXpDrop(sess, p, gains) - sess.WriteLine(line) - } - - if isRanged && p.AmmoQty <= 0 { - sess.WriteLine("You've run out of ammo!") - combat.LeaveCombat(p.Name) - } -} - -func (g *Game) applyPlayerMiss(sess *net.Session, p *player.Player, mob *world.MobInstance, weaponType object.WeaponType) { - sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf("You miss %s.", mobDisplayName(mob, true)))) - - if weaponType == object.WeaponRanged { - g.consumeAmmo(sess, p) - if p.AmmoQty <= 0 { - sess.WriteLine("You've run out of ammo!") - combat.LeaveCombat(p.Name) - } - } -} - -func (g *Game) consumeAmmo(sess *net.Session, p *player.Player) { - ammoID := p.Equipment[object.SlotAmmo] - - p.AmmoQty-- - if p.AmmoQty <= 0 { - delete(p.Equipment, object.SlotAmmo) - p.AmmoQty = 0 - } - - if ammoID != "" { - if def, err := g.ItemStore.Load(ammoID); err == nil && def.Recoverable { - if rand.Float64() < 0.80 { - g.World.AddGroundItem(p.RoomID, def.ID, 1) - } - } - } - - g.AccountStore.SaveCharacter(p) -} - -func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) { - if g.isSafespotted(p.Name) && g.safespotBlocksMob(p, mob) { - return - } - - attRoll, defRoll := g.calculateMobAttack(p, mob) - - if combat.HitCheck(attRoll, defRoll) { - g.applyMobHit(sess, p, mob) - } else { - g.applyMobMiss(sess, mob) - } -} - -func (g *Game) calculateMobAttack(p *player.Player, mob *world.MobInstance) (attRoll int, defRoll int) { - _, _, defStyleBonus := combat.AttackStyleBonus(string(p.AttackStyle)) - - mobAttackType := mob.AttackType - if mobAttackType == "" { - mobAttackType = "crush" - } - - attRoll = combat.EffectiveRoll(mob.Attack, 0, mob.AttackBonus) - - totals := g.playerEquipBonuses(p) - equipDef := combat.SelectBonus(mobAttackType, - totals.StabDefense, totals.SlashDefense, totals.CrushDefense, - totals.ScienceDefense, totals.RangedDefense) - defRoll = combat.EffectiveRoll(p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"), defStyleBonus, equipDef) - return -} - -func (g *Game) applyMobHit(sess *net.Session, p *player.Player, mob *world.MobInstance) { - maxHit := combat.MaxHit(mob.Strength, 0, mob.StrengthBonus) - dmg := combat.RollDamage(maxHit) - dmg = g.applyTechProtection(p, mob, dmg) - - if mob.DamageWithout != "" { - hasProtection := false - for _, itemID := range p.Equipment { - if itemID == mob.DamageWithout { - hasProtection = true - break - } - } - if !hasProtection { - dmg = dmg * 3 / 2 - if dmg < 1 { - dmg = 1 - } - cs := combat.GetCombat(p.Name) - if cs != nil && !cs.DamageWarningShown { - cs.DamageWarningShown = true - fbDef, _ := g.ItemStore.Load(mob.DamageWithout) - fbName := mob.DamageWithout - if fbDef != nil { - fbName = fbDef.Name - } - attacker := mob.Name - if !mob.Unique { - attacker = "The " + mob.Name - } - sess.WriteLine(g.colorize(sess, "warning", - fmt.Sprintf(" %s's attack is extra effective! Equip %s for protection.", - attacker, fbName))) - } - } - } - - p.HP -= dmg - if p.HP < 0 { - p.HP = 0 - } - p.StartRegen() - g.AccountStore.SaveCharacter(p) - - attacker := mob.Name - if !mob.Unique { - attacker = "The " + mob.Name - } - mobName := mobDisplayName(mob, true) - w := len(fmt.Sprintf("%s hits you for %d damage.", attacker, 999)) - if w2 := len(fmt.Sprintf("You hit %s for %d damage.", mobName, 999)); w2 > w { - w = w2 - } - prefix := fmt.Sprintf("%s hits you for %s damage.", g.colorize(sess, "mob", attacker), g.colorize(sess, "damage_taken", fmt.Sprint(dmg))) - visLen := color.VisibleLen(prefix) - if visLen < w { - prefix += strings.Repeat(" ", w-visLen+1) - } - hpSuffix := fmt.Sprintf("%s [%2d/%d]", g.hpBar(sess, p.HP, p.MaxHP()), p.HP, p.MaxHP()) - sess.WriteLine(prefix + hpSuffix) - - if ss, hasSS := g.safespot.Get(p.Name); hasSS && ss.HideCountdown > 0 { - g.safespot.Delete(p.Name) - sess.WriteLine(g.colorize(sess, "error", "You're hit while repositioning! You failed to hide.")) - } - - if p.MoveTicks > 0 { - p.ClearMoveState() - sess.WriteLine("Can't escape!") - } -} - -func (g *Game) applyMobMiss(sess *net.Session, mob *world.MobInstance) { - attacker := mob.Name - if !mob.Unique { - attacker = "The " + mob.Name - } - sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf("%s misses you.", attacker))) -} - -func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) { - combat.LeaveCombat(p.Name) - - if p.HP <= 0 { - g.killPlayer(sess, p, mob) - return - } - - if mob != nil && mob.HP <= 0 { - isTask := mob.IsTask() - p.Stats.RecordMobKill(mob.DefID) - - if isTask { - complete := mob.CompleteMessage - if complete == "" { - complete = fmt.Sprintf("You finish your work on %s!", mobDisplayName(mob, true)) - } - sess.WriteLine(g.colorize(sess, "victory", "\n"+complete)) - } else { - sess.WriteLine(g.colorize(sess, "victory", fmt.Sprintf("\nYou have defeated %s!", mobDisplayName(mob, true)))) - g.onAssassinKill(sess, p, mob) - } - - if g.Hub != nil { - for _, other := range g.Hub.PlayersInRoom(p.RoomID) { - if other != sess && other.Player != nil { - if isTask { - other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s finishes working on %s.", p.Name, mobDisplayName(mob, false)))) - } else { - op := other.Player - mobLvl := mobCombatLevel(mob) - levelStr := g.levelColorize(other, op.CombatLevel(), mobLvl, fmt.Sprintf("(level %d)", mobLvl)) - other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s has slain %s %s!", p.Name, mobDisplayName(mob, false), levelStr))) - } - } - } - } - - dropLabel := func() string { - if isTask { - return "You receive:" - } - dropper := mob.Name - if !mob.Unique { - dropper = "The " + mob.Name - } - return dropper + " drops:" - }() - - if mob.Drops.Remains != "" { - g.World.AddReservedItem(p.RoomID, mob.Drops.Remains, 1, p.Name) - def, _ := g.ItemStore.Load(mob.Drops.Remains) - name := mob.Drops.Remains - if def != nil { - name = def.Name - } - coloredName := g.itemColorize(sess, def, name) - sess.WriteLine(fmt.Sprintf("%s %s", g.colorize(sess, "drop_message", dropLabel), coloredName)) - } - - if len(mob.Drops.Loot) > 0 { - entry := action.ResolveDrop(g.DataDir, mob.Drops.Loot) - if entry != nil && entry.ItemID != "" { - qty := entry.Quantity - if qty <= 0 { - qty = 1 - } - g.World.AddReservedItem(p.RoomID, entry.ItemID, qty, p.Name) - def, _ := g.ItemStore.Load(entry.ItemID) - name := entry.ItemID - if def != nil { - name = def.Name - } - coloredName := g.itemColorize(sess, def, name) - if qty > 1 { - sess.WriteLine(fmt.Sprintf("%s %d x %s", g.colorize(sess, "drop_message", dropLabel), qty, coloredName)) - } else { - sess.WriteLine(fmt.Sprintf("%s %s", g.colorize(sess, "drop_message", dropLabel), coloredName)) - } - } - } - - respawnTicks := engine.ToTicks(mob.RespawnTicks) - if respawnTicks <= 0 { - respawnTicks = engine.ToTicks(30) - } - instanceID := mob.InstanceID - g.Ticks.Subscribe(respawnTicks, func() bool { - g.respawnMob(instanceID) - return false - }) - g.writePrompt(sess) - } -} - -// killPlayer handles a player death from any source (combat or a room hazard). -// mob may be nil (e.g. a hazard kill); it is only used for Dead Man's Switch -// retribution. -func (g *Game) killPlayer(sess *net.Session, p *player.Player, mob *world.MobInstance) { - combat.LeaveCombat(p.Name) - - if p.HasActiveTech("retribution") { - retDef := GetTechDef("retribution") - if retDef != nil && mob != nil && mob.HP > 0 { - retDmg := int(float64(p.MaxHP()) * retDef.Effects.RetributionPct) - if retDmg > 0 { - mob.HP -= retDmg - if mob.HP < 0 { - mob.HP = 0 - } - sess.WriteLine(fmt.Sprintf("\nDead Man's Switch activates! %s takes %d damage!", - mobDisplayName(mob, true), retDmg)) - } - } - } - p.DeactivateAllTechs() - p.Stats.RecordDeath() - sess.WriteLine(g.colorize(sess, "death", "\nOh dear, you are dead!")) - p.ClearMoveState() - p.HazardTimer = 0 - if ss, ok := g.safespot.Get(p.Name); ok { - g.forceLeaveSafespot(sess, p, &ss, "") - } - g.dropItemsOnDeath(p) - p.HP = p.MaxHP() - p.RoomID = 1001 - g.AccountStore.SaveCharacter(p) - if g.Hub != nil { - g.Hub.EnterRoom(sess, p.RoomID) - } - g.doLook(sess) - g.writePrompt(sess) -} - -func (g *Game) awardCombatXP(sess *net.Session, p *player.Player, dmg int, isRanged bool) []xpGain { - baseXP := dmg * 4 - var gains []xpGain - - if isRanged { - gains = []xpGain{ - {string(player.Ranged), baseXP * 3 / 4}, - {string(player.Hitpoints), baseXP / 4}, - } - } else { - switch p.AttackStyle { - case player.Accurate: - gains = []xpGain{{string(player.Accuracy), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}} - case player.Aggressive: - gains = []xpGain{{string(player.Strength), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}} - case player.Defensive: - gains = []xpGain{{string(player.Defense), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}} - case player.Balanced: - quarter := baseXP / 4 - gains = []xpGain{ - {string(player.Accuracy), quarter}, - {string(player.Strength), quarter}, - {string(player.Defense), quarter}, - {string(player.Hitpoints), quarter}, - } - } - } - - for _, gain := range gains { - g.awardSkillXP(sess, p, player.SkillName(gain.Skill), gain.XP) - } - g.AccountStore.SaveCharacter(p) - return gains -} - -func (g *Game) stopCombat(playerName string) { - if cs := combat.GetCombat(playerName); cs != nil { - combat.LeaveCombat(playerName) - } -} - func (g *Game) executeAttack(sess *net.Session, args []string, rawInput string) { if len(args) == 0 { p := sess.Player @@ -786,4 +57,3 @@ func (g *Game) respawnMob(instanceID string) { } } } - diff --git a/internal/game/cmd_bank.go b/internal/game/cmd_bank.go index b31dd16..8d96c5f 100644 --- a/internal/game/cmd_bank.go +++ b/internal/game/cmd_bank.go @@ -5,9 +5,10 @@ import ( "sort" "strings" + "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" + "thehouseoficarus/internal/ui" ) func (g *Game) executeBank(sess *net.Session, args []string, rawInput string) { @@ -65,7 +66,7 @@ func (g *Game) showBankBrowse(sess *net.Session) { unicode := p.OptionBool("unicode") - t := Table{ + t := ui.Table{ Title: "Bank Contents", Columns: []string{"#", "Item", "Quantity"}, } @@ -240,7 +241,7 @@ func (g *Game) doBankWithdraw(sess *net.Session, input string) { } lower := strings.ToLower(input) for _, e := range entries { - if object.WordPrefixMatch(e.name, lower) { + if behavior.WordPrefixMatch(e.name, lower) { matches = append(matches, e) } } diff --git a/internal/game/cmd_clean.go b/internal/game/cmd_clean.go index 63bea24..a43295a 100644 --- a/internal/game/cmd_clean.go +++ b/internal/game/cmd_clean.go @@ -3,11 +3,10 @@ package game import ( "strings" - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" ) @@ -18,7 +17,7 @@ func (g *Game) executeClean(sess *net.Session, args []string, rawInput string) { func (g *Game) doClean(sess *net.Session, input string) { p := sess.Player - if combat.GetCombat(p.Name) != nil { + if g.Combat.Get(p.Name) != nil { sess.WriteLine("You can't do that during combat!") return } @@ -63,10 +62,10 @@ func (g *Game) doClean(sess *net.Session, input string) { g.cancelBgAction(p) - p.BackgroundAction = &action.Action{ + p.BackgroundAction = &behavior.Action{ Type: "clean", TargetID: "clean_herbs", - Data: &action.CleanData{ + Data: &behavior.CleanData{ Phase: 0, Filter: filter, }, @@ -78,7 +77,7 @@ func (g *Game) doClean(sess *net.Session, input string) { sess.WriteLine("You begin cleaning herbs.") } -func matchesCleanFilter(item *object.ItemDef, filter string) bool { +func matchesCleanFilter(item *item.ItemDef, filter string) bool { if len(item.Craft) > 0 && len(item.FirstCraft().Consume) > 0 && len(item.FirstCraft().Consume[0].Items) > 0 { if strings.Contains(item.FirstCraft().Consume[0].Items[0], filter) { return true diff --git a/internal/game/cmd_color.go b/internal/game/cmd_color.go index 7bf073d..666a622 100644 --- a/internal/game/cmd_color.go +++ b/internal/game/cmd_color.go @@ -7,6 +7,7 @@ import ( "thehouseoficarus/internal/color" "thehouseoficarus/internal/config" "thehouseoficarus/internal/net" + "thehouseoficarus/internal/ui" ) func (g *Game) doColor(sess *net.Session, input string) { @@ -166,7 +167,7 @@ var colorCategoryOrder = []string{ func showColorTable(g *Game, sess *net.Session) { p := sess.Player mode := g.colorMode(sess) - table := &Table{ + table := &ui.Table{ Columns: []string{ color.Render(mode, color.Parse("75"), "Target"), "Color", diff --git a/internal/game/cmd_consume.go b/internal/game/cmd_consume.go index 249bc9a..133bf57 100644 --- a/internal/game/cmd_consume.go +++ b/internal/game/cmd_consume.go @@ -6,8 +6,8 @@ import ( "time" "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" ) @@ -76,7 +76,7 @@ func (g *Game) doEat(sess *net.Session, input string) { } } -func (g *Game) doEatNow(sess *net.Session, p *player.Player, itemID string, def *object.ItemDef) { +func (g *Game) doEatNow(sess *net.Session, p *player.Player, itemID string, def *item.ItemDef) { if def == nil { var err error def, err = g.ItemStore.Load(itemID) @@ -211,7 +211,7 @@ func (g *Game) doDrink(sess *net.Session, args []string) { } } -func (g *Game) applyPotion(sess *net.Session, p *player.Player, itemID string, def *object.ItemDef) { +func (g *Game) applyPotion(sess *net.Session, p *player.Player, itemID string, def *item.ItemDef) { if def == nil { var err error def, err = g.ItemStore.Load(itemID) diff --git a/internal/game/cmd_cook.go b/internal/game/cmd_cook.go index 6dfbcce..0e4f194 100644 --- a/internal/game/cmd_cook.go +++ b/internal/game/cmd_cook.go @@ -4,8 +4,8 @@ import ( "fmt" "strings" + "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" ) @@ -47,7 +47,7 @@ func (g *Game) doCook(sess *net.Session, input string) { itemID := matches[0].ID - var recipes []*object.ItemDef + var recipes []*item.ItemDef for _, item := range items { c := item.FirstCraft() if stationMatch(stationDefID, c.Station) && craftMatchesEntry(c, itemID) && craftHasAllItems(c, p.HasItem) { @@ -59,9 +59,9 @@ func (g *Game) doCook(sess *net.Session, input string) { g.showRecipeChoice(sess, p, recipes, "You can't cook that.", title, "") } -func (g *Game) showCookMenu(sess *net.Session, p *player.Player, items []*object.ItemDef, stationDefID, stationName string) { +func (g *Game) showCookMenu(sess *net.Session, p *player.Player, items []*item.ItemDef, stationDefID, stationName string) { seen := make(map[string]bool) - var recipes []*object.ItemDef + var recipes []*item.ItemDef for _, item := range items { c := item.FirstCraft() diff --git a/internal/game/cmd_craft.go b/internal/game/cmd_craft.go index 3290daa..254b938 100644 --- a/internal/game/cmd_craft.go +++ b/internal/game/cmd_craft.go @@ -3,8 +3,9 @@ package game import ( "strings" + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" ) @@ -30,7 +31,7 @@ func (g *Game) doStationSkill(sess *net.Session, input string, recipeType string items := g.CraftIndex.ByType(recipeType) - var filtered []*object.ItemDef + var filtered []*item.ItemDef for _, item := range items { if !g.itemVisible(p, item) { continue @@ -75,13 +76,13 @@ func (g *Game) doStationSkill(sess *net.Session, input string, recipeType string g.showProductionTable(sess, p, available, label, recipeType, flagKey, verbCap, false) } -func (g *Game) doWithInput(sess *net.Session, p *player.Player, items []*object.ItemDef, input string, skill player.SkillName, flagKey, label, verbCap string) { +func (g *Game) doWithInput(sess *net.Session, p *player.Player, items []*item.ItemDef, input string, skill player.SkillName, flagKey, label, verbCap string) { qty, productName := parseQty(input) productName = strings.TrimSpace(productName) - var matched []*object.ItemDef + var matched []*item.ItemDef for _, item := range items { - if object.WordPrefixMatch(productName, item.Name) { + if behavior.WordPrefixMatch(productName, item.Name) { matched = append(matched, item) } } @@ -91,7 +92,7 @@ func (g *Game) doWithInput(sess *net.Session, p *player.Player, items []*object. return } - var available []*object.ItemDef + var available []*item.ItemDef for _, item := range matched { if g.canDoItem(p, item, skill) { available = append(available, item) @@ -111,8 +112,8 @@ func (g *Game) doWithInput(sess *net.Session, p *player.Player, items []*object. g.showProductionTable(sess, p, available, label, label, flagKey, verbCap, false) } -func (g *Game) itemVisible(p *player.Player, item *object.ItemDef) bool { - return item.FindCraft(func(c object.CraftDef) bool { +func (g *Game) itemVisible(p *player.Player, def *item.ItemDef) bool { + return def.FindCraft(func(c item.CraftDef) bool { if len(c.Station) > 0 { stID, _ := g.findStation(p.RoomID, c.Station) if stID == "" { @@ -126,8 +127,8 @@ func (g *Game) itemVisible(p *player.Player, item *object.ItemDef) bool { }) != nil } -func (g *Game) canDoItem(p *player.Player, item *object.ItemDef, skill player.SkillName) bool { - m := item.FindCraft(func(c object.CraftDef) bool { +func (g *Game) canDoItem(p *player.Player, def *item.ItemDef, skill player.SkillName) bool { + m := def.FindCraft(func(c item.CraftDef) bool { if p.Level(skill) < c.Level { return false } @@ -148,31 +149,31 @@ func (g *Game) canDoItem(p *player.Player, item *object.ItemDef, skill player.Sk return m != nil } -func (g *Game) availableItems(p *player.Player, allItems []*object.ItemDef) []*object.ItemDef { - var result []*object.ItemDef - for _, item := range allItems { - m := item.FindCraft(func(c object.CraftDef) bool { +func (g *Game) availableItems(p *player.Player, allItems []*item.ItemDef) []*item.ItemDef { + var result []*item.ItemDef + for _, def := range allItems { + m := def.FindCraft(func(c item.CraftDef) bool { return craftHasAllItemsQty(&c, p.CountItem) }) if m != nil { - result = append(result, item) + result = append(result, def) } } return result } -func (g *Game) startItem(sess *net.Session, p *player.Player, item *object.ItemDef, count int, flagKey string) { +func (g *Game) startItem(sess *net.Session, p *player.Player, def *item.ItemDef, count int, flagKey string) { p.EnsureFlags() - p.Flags[flagKey] = item.ID + p.Flags[flagKey] = def.ID g.AccountStore.SaveCharacter(p) - g.startProductionFromItem(sess, p, item, count) + g.startProductionFromItem(sess, p, def, count) } func (g *Game) hasGrimyHerbs(p *player.Player) bool { items := g.CraftIndex.ByType("clean") - for _, item := range items { - if item.FindCraft(func(c object.CraftDef) bool { + for _, def := range items { + if def.FindCraft(func(c item.CraftDef) bool { return craftHasAllItems(&c, p.HasItem) }) != nil { return true @@ -181,7 +182,7 @@ func (g *Game) hasGrimyHerbs(p *player.Player) bool { return false } -func (g *Game) findCraftItems(p *player.Player, itemAID, itemBID string) []*object.ItemDef { +func (g *Game) findCraftItems(p *player.Player, itemAID, itemBID string) []*item.ItemDef { items := g.CraftIndex.ByType("crafting") toolTypeA := "" @@ -193,9 +194,9 @@ func (g *Game) findCraftItems(p *player.Player, itemAID, itemBID string) []*obje toolTypeB = defB.ToolType } - var matched []*object.ItemDef - for _, item := range items { - match := item.FindCraft(func(c object.CraftDef) bool { + var matched []*item.ItemDef + for _, def := range items { + match := def.FindCraft(func(c item.CraftDef) bool { if c.Tool == "" { return false } @@ -208,7 +209,7 @@ func (g *Game) findCraftItems(p *player.Player, itemAID, itemBID string) []*obje return false }) if match != nil { - matched = append(matched, item) + matched = append(matched, def) } } return matched diff --git a/internal/game/cmd_equipment.go b/internal/game/cmd_equipment.go index f5a9a11..88bc7f3 100644 --- a/internal/game/cmd_equipment.go +++ b/internal/game/cmd_equipment.go @@ -3,8 +3,8 @@ package game import ( "fmt" + "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" ) func (g *Game) doEquipment(sess *net.Session) { @@ -23,7 +23,7 @@ func (g *Game) doEquipment(sess *net.Session) { if err == nil { name = def.Name } - if slot == object.SlotAmmo && p.AmmoQty > 0 { + if slot == item.SlotAmmo && p.AmmoQty > 0 { sess.WriteLine(fmt.Sprintf(" %-12s %s (x%d)", slot, g.itemColorize(sess, def, name), p.AmmoQty)) } else { sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, g.itemColorize(sess, def, name))) diff --git a/internal/game/cmd_estate_directory.go b/internal/game/cmd_estate_directory.go index ca28018..5a4423b 100644 --- a/internal/game/cmd_estate_directory.go +++ b/internal/game/cmd_estate_directory.go @@ -7,7 +7,7 @@ import ( "sort" "strings" - "thehouseoficarus/internal/action" + "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" @@ -72,11 +72,11 @@ func (g *Game) showEstateDirectoryTalk(sess *net.Session, p *player.Player, home sess.WriteLine(" You are a registered homeowner in this neighborhood.") sess.WriteLine(" Type 'enter' to go to your house, or 'leave' to step away.") sess.State = net.StateTalk - p.Action = &action.Action{ + p.Action = &behavior.Action{ Type: "talk", TargetID: "estate_directory", TargetName: "Estate Directory", - Data: &action.TalkData{Node: "start", EstateDirectory: true}, + Data: &behavior.TalkData{Node: "start", EstateDirectory: true}, } sess.Write("\nChoice: ") return diff --git a/internal/game/cmd_farm.go b/internal/game/cmd_farm.go index eca0429..b601e49 100644 --- a/internal/game/cmd_farm.go +++ b/internal/game/cmd_farm.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "thehouseoficarus/internal/action" + "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) @@ -134,12 +134,12 @@ func (g *Game) doPlant(sess *net.Session, input string) { p.RemoveItem(seedID, 1) - p.Action = &action.Action{ + p.Action = &behavior.Action{ Type: "plant", TargetID: seedID, TargetName: seedDef.Name, WaitLeft: 2, - Data: &action.FarmData{ + Data: &behavior.FarmData{ SeedID: seedID, Prefix: prefix, XP: float64(seedDef.FarmPlantXP), @@ -216,12 +216,12 @@ func (g *Game) doHarvest(sess *net.Session, input string) { } harvestXP := seedDef.FarmHarvestXP - p.Action = &action.Action{ + p.Action = &behavior.Action{ Type: "harvest", TargetID: prefix, TargetName: productName, WaitLeft: 3, - Data: &action.FarmData{ + Data: &behavior.FarmData{ Prefix: prefix, SeedID: seedID, Product: product, @@ -266,12 +266,12 @@ func (g *Game) doRake(sess *net.Session, input string) { patchName := patchDisplayName(prefix) - p.Action = &action.Action{ + p.Action = &behavior.Action{ Type: "rake", TargetID: prefix, TargetName: patchName, WaitLeft: 4, - Data: &action.FarmData{ + Data: &behavior.FarmData{ Prefix: prefix, }, } @@ -325,12 +325,12 @@ func (g *Game) doWater(sess *net.Session, input string) { patchName := patchDisplayName(prefix) - p.Action = &action.Action{ + p.Action = &behavior.Action{ Type: "water", TargetID: prefix, TargetName: patchName, WaitLeft: 2, - Data: &action.FarmData{ + Data: &behavior.FarmData{ Prefix: prefix, }, } @@ -368,12 +368,12 @@ func (g *Game) doCure(sess *net.Session, input string) { patchName := patchDisplayName(prefix) - p.Action = &action.Action{ + p.Action = &behavior.Action{ Type: "cure", TargetID: prefix, TargetName: patchName, WaitLeft: 2, - Data: &action.FarmData{ + Data: &behavior.FarmData{ Prefix: prefix, }, } diff --git a/internal/game/cmd_fletch.go b/internal/game/cmd_fletch.go index 9cf50bc..0da8129 100644 --- a/internal/game/cmd_fletch.go +++ b/internal/game/cmd_fletch.go @@ -3,9 +3,9 @@ package game import ( "strings" - "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" ) @@ -23,15 +23,15 @@ var fletchGemItems = map[string]bool{ "uncut_ruby": true, "uncut_diamond": true, } -func fletchNeedsKnife(item *object.ItemDef) bool { - return item.FindCraft(func(c object.CraftDef) bool { +func fletchNeedsKnife(def *item.ItemDef) bool { + return def.FindCraft(func(c item.CraftDef) bool { if c.Tool == "knife" { return true } for _, e := range c.Consume { for _, id := range e.Items { if fletchLogItems[id] { - if strings.Contains(item.ID, "shaft") || strings.Contains(item.ID, "bow") { + if strings.Contains(def.ID, "shaft") || strings.Contains(def.ID, "bow") { return true } } @@ -41,8 +41,8 @@ func fletchNeedsKnife(item *object.ItemDef) bool { }) != nil } -func fletchNeedsChisel(item *object.ItemDef) bool { - return item.FindCraft(func(c object.CraftDef) bool { +func fletchNeedsChisel(def *item.ItemDef) bool { + return def.FindCraft(func(c item.CraftDef) bool { if c.Tool == "chisel" { return true } @@ -60,7 +60,7 @@ func fletchNeedsChisel(item *object.ItemDef) bool { func (g *Game) doFletch(sess *net.Session, input string) { p := sess.Player - if combat.GetCombat(p.Name) != nil { + if g.Combat.Get(p.Name) != nil { sess.WriteLine("You can't do that during combat!") return } @@ -103,13 +103,13 @@ func (g *Game) doFletch(sess *net.Session, input string) { g.showProductionTable(sess, p, available, "Fletching", "fletching", "last_fletch", "Fletch", true) } -func (g *Game) doFletchWithInput(sess *net.Session, p *player.Player, items []*object.ItemDef, input string) { +func (g *Game) doFletchWithInput(sess *net.Session, p *player.Player, items []*item.ItemDef, input string) { qty, productName := parseQty(input) productName = strings.TrimSpace(productName) - var matched []*object.ItemDef + var matched []*item.ItemDef for _, item := range items { - if object.WordPrefixMatch(productName, item.Name) { + if behavior.WordPrefixMatch(productName, item.Name) { matched = append(matched, item) } } @@ -119,7 +119,7 @@ func (g *Game) doFletchWithInput(sess *net.Session, p *player.Player, items []*o return } - var available []*object.ItemDef + var available []*item.ItemDef for _, item := range matched { if g.canDoFletchItem(p, item) { available = append(available, item) @@ -139,7 +139,7 @@ func (g *Game) doFletchWithInput(sess *net.Session, p *player.Player, items []*o g.showProductionTable(sess, p, available, "Fletching", "fletching", "last_fletch", "Fletch", true) } -func (g *Game) hasFletchTools(p *player.Player, item *object.ItemDef) bool { +func (g *Game) hasFletchTools(p *player.Player, item *item.ItemDef) bool { c := item.FirstCraft() if c.Tool != "" { return g.hasToolType(p, c.Tool) @@ -153,27 +153,27 @@ func (g *Game) hasFletchTools(p *player.Player, item *object.ItemDef) bool { return true } -func (g *Game) canDoFletchItem(p *player.Player, item *object.ItemDef) bool { - return item.FindCraft(func(c object.CraftDef) bool { +func (g *Game) canDoFletchItem(p *player.Player, def *item.ItemDef) bool { + return def.FindCraft(func(c item.CraftDef) bool { return p.Level(player.Fletching) >= c.Level && craftHasAllItemsQty(&c, p.CountItem) - }) != nil && g.hasFletchTools(p, item) + }) != nil && g.hasFletchTools(p, def) } -func (g *Game) availableFletchItems(p *player.Player, allItems []*object.ItemDef) []*object.ItemDef { - var result []*object.ItemDef - for _, item := range allItems { - match := item.FindCraft(func(c object.CraftDef) bool { +func (g *Game) availableFletchItems(p *player.Player, allItems []*item.ItemDef) []*item.ItemDef { + var result []*item.ItemDef + for _, def := range allItems { + match := def.FindCraft(func(c item.CraftDef) bool { return craftHasAllItemsQty(&c, p.CountItem) }) - if match != nil && g.hasFletchTools(p, item) { - result = append(result, item) + if match != nil && g.hasFletchTools(p, def) { + result = append(result, def) } } return result } -func (g *Game) findFletchItems(p *player.Player, itemAID, itemBID string) []*object.ItemDef { +func (g *Game) findFletchItems(p *player.Player, itemAID, itemBID string) []*item.ItemDef { items := g.CraftIndex.ByType("fletching") toolTypeA := "" @@ -187,7 +187,7 @@ func (g *Game) findFletchItems(p *player.Player, itemAID, itemBID string) []*obj isToolA := toolTypeA == "knife" || toolTypeA == "chisel" isToolB := toolTypeB == "knife" || toolTypeB == "chisel" - var matched []*object.ItemDef + var matched []*item.ItemDef for _, item := range items { c := item.FirstCraft() diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go index fe5d322..ea1c667 100644 --- a/internal/game/cmd_get.go +++ b/internal/game/cmd_get.go @@ -97,9 +97,9 @@ func (g *Game) doGet(sess *net.Session, input string) { p.SetInvSlot(freeSlot, g.newInventorySlot(itemID, qty)) g.AccountStore.SaveCharacter(p) if qty == 1 { - sess.WriteLine(fmt.Sprintf("You pick up a %s.", g.itemColorize(sess, def, def.Name))) - } else { - sess.WriteLine(fmt.Sprintf("You pick up %d x %s.", qty, g.itemColorize(sess, def, def.Name))) + sess.WriteLine(fmt.Sprintf("You pick up a %s.", g.itemColorize(sess, def, def.Name))) + } else { + sess.WriteLine(fmt.Sprintf("You pick up %d x %s.", qty, g.itemColorize(sess, def, def.Name))) } return } diff --git a/internal/game/cmd_hide.go b/internal/game/cmd_hide.go index d22f3fa..b5d38fe 100644 --- a/internal/game/cmd_hide.go +++ b/internal/game/cmd_hide.go @@ -4,7 +4,6 @@ import ( "fmt" "strings" - "thehouseoficarus/internal/combat" "thehouseoficarus/internal/net" "thehouseoficarus/internal/world" ) @@ -91,7 +90,7 @@ func (g *Game) executeHide(sess *net.Session, args []string, rawInput string) { st.SafespotTicksAtLevel = 0 } - inCombat := combat.GetCombat(p.Name) != nil + inCombat := g.Combat.Get(p.Name) != nil hideCountdown := 1 if inCombat { hideCountdown = 4 diff --git a/internal/game/cmd_id.go b/internal/game/cmd_id.go index 3ddfe33..87adce7 100644 --- a/internal/game/cmd_id.go +++ b/internal/game/cmd_id.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "thehouseoficarus/internal/action" + "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) @@ -134,12 +134,12 @@ func (g *Game) doIdentify(sess *net.Session, input string) { altarName = def.Name } - p.Action = &action.Action{ + p.Action = &behavior.Action{ Type: "identify", TargetID: found.AltarID, TargetName: altarName, WaitLeft: 1, - Data: &action.IdentifyData{ + Data: &behavior.IdentifyData{ JunkID: found.JunkID, XPPer: found.XPPer, }, diff --git a/internal/game/cmd_jack.go b/internal/game/cmd_jack.go index ea31b7d..a0f102e 100644 --- a/internal/game/cmd_jack.go +++ b/internal/game/cmd_jack.go @@ -4,7 +4,6 @@ import ( "fmt" "strings" - "thehouseoficarus/internal/combat" "thehouseoficarus/internal/game/hacking" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" @@ -19,7 +18,7 @@ func (g *Game) executeJack(sess *net.Session, args []string, rawInput string) { func (g *Game) doJack(sess *net.Session, target string) { p := sess.Player - if combat.GetCombat(p.Name) != nil { + if g.Combat.Get(p.Name) != nil { sess.WriteLine("You can't do that during combat!") return } diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go index 5cdc15d..a1ad708 100644 --- a/internal/game/cmd_look.go +++ b/internal/game/cmd_look.go @@ -7,9 +7,7 @@ import ( "strings" "thehouseoficarus/internal/color" - "thehouseoficarus/internal/combat" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" ) @@ -50,721 +48,6 @@ func (g *Game) doLook(sess *net.Session) { g.showRoomPlayers(sess, p, room) } -func (g *Game) showRoomName(sess *net.Session, p *player.Player, room *world.Room) { - sess.WriteLines( - "", - fmt.Sprintf("%s (%s)", g.colorize(sess, "room_name", room.Name), g.colorize(sess, "room_number", fmt.Sprintf("#%d", room.ID))), - ) -} - -func (g *Game) showRoomDescription(sess *net.Session, p *player.Player, room *world.Room) []string { - descWidth := p.OptionInt("room_desc_width") - if descWidth <= 0 { - descWidth = 70 - } - rawLines := wrapText(g.roomDescription(sess, room), descWidth) - mode := g.colorMode(sess) - roomDescSpec := g.resolveColor(sess, "room_desc") - lines := make([]string, len(rawLines)) - for i, l := range rawLines { - lines[i] = color.ExpandTagsDefault(mode, roomDescSpec, l) - } - return lines -} - -// roomDescription resolves the room's effective description for this player, -// picking the first conditional variant whose condition passes, else the base -// Description. -func (g *Game) roomDescription(sess *net.Session, room *world.Room) string { - for _, d := range room.Descriptions { - if d.Condition == nil || g.checkCondition(sess, d.Condition) { - return d.Text - } - } - return room.Description -} - -// resolveObjDesc resolves the description an object shows to this player. For an -// object with conditional Descriptions, the first variant whose condition -// passes wins; if none pass the object is reported as not present (false), so -// look falls through as if it were not there. Objects without Descriptions use -// their plain Description and are always present. -func (g *Game) resolveObjDesc(sess *net.Session, def *object.ObjectDef) (string, bool) { - if len(def.Descriptions) == 0 { - return def.Description, true - } - for _, d := range def.Descriptions { - if d.Condition == nil || g.checkCondition(sess, d.Condition) { - return d.Text, true - } - } - return "", false -} - -func (g *Game) showRoomMobs(sess *net.Session, p *player.Player, room *world.Room) []string { - mobs := g.MobStore.MobsInRoom(p.RoomID) - if len(mobs) == 0 { - return nil - } - sort.Slice(mobs, func(i, j int) bool { - iDamaged := mobs[i].HP < mobs[i].MaxHP - jDamaged := mobs[j].HP < mobs[j].MaxHP - if iDamaged != jDamaged { - return iDamaged - } - return mobs[i].InstanceID < mobs[j].InstanceID - }) - var lines []string - lines = append(lines, "") - playerLevel := p.CombatLevel() - for _, m := range mobs { - hp := "" - if m.HP < m.MaxHP { - if m.IsTask() { - pct := 0 - if m.MaxHP > 0 { - pct = (m.MaxHP - m.HP) * 100 / m.MaxHP - } - hp = fmt.Sprintf(" [%d%% complete]", pct) - } else { - hp = fmt.Sprintf(" [%s/%dhp]", color.Render(g.colorMode(sess), color.Parse("167"), fmt.Sprint(m.HP)), m.MaxHP) - } - } - var desc string - if combat.IsMobInCombat(m.InstanceID) { - def, err := g.MobStore.LoadDef(m.DefID) - if err == nil && len(def.CombatDescriptions) > 0 { - target := combat.GetMobTarget(m.InstanceID) - pattern := def.CombatDescriptions[randInt(len(def.CombatDescriptions))] - desc = " " + fmt.Sprintf(pattern, target) - } - } else if m.IdleDescription != "" { - desc = fmt.Sprintf(" %s", m.IdleDescription) - } - displayName := m.Name - if !m.Unique { - displayName = "A " + m.Name - } - mobColor := "mob" - if m.Protected { - mobColor = "protected_mob" - } - mobLevel := mobCombatLevel(m) - levelStr := g.levelColorize(sess, playerLevel, mobLevel, fmt.Sprintf("(level %d)", mobLevel)) - lines = append(lines, fmt.Sprintf("%s %s%s%s", g.colorize(sess, mobColor, displayName), levelStr, hp, desc)) - } - return lines -} - -func (g *Game) showRoomObjects(sess *net.Session, p *player.Player, room *world.Room) []string { - objs := g.World.AllObjInstances(p.RoomID) - if len(objs) == 0 { - return g.showFarmPatches(sess, p) - } - var lines []string - lines = append(lines, "") - grouped := make(map[string]int) - var order []string - for _, o := range objs { - if _, ok := grouped[o.DefID]; !ok { - order = append(order, o.DefID) - } - grouped[o.DefID]++ - } - for _, objID := range order { - count := grouped[objID] - def, err := g.ObjectStore.Load(objID) - if err != nil { - continue - } - - if def.Hidden { - continue - } - - if farmPatchDefIDs[objID] { - continue - } - - type instInfo struct { - idx int - depleted bool - sharedMax int - sharedCur int - respawnIn int - quality int - } - var instances []instInfo - for i := 0; i < count; i++ { - st := g.World.GetObjState(p.RoomID, objID, i) - if st == nil { - continue - } - instances = append(instances, instInfo{ - idx: i + 1, - depleted: st.Depleted, - sharedMax: int(st.SharedMax), - sharedCur: st.SharedTimer, - respawnIn: int(st.DepleteTimer), - quality: int(st.Quality), - }) - } - multi := len(instances) > 1 - showTimers := p.OptionBool("depletion") - - var freshIdxs []int - var timed, depleted []instInfo - for _, ins := range instances { - if ins.depleted { - depleted = append(depleted, ins) - } else if ins.sharedMax > 0 && ins.sharedCur < ins.sharedMax { - timed = append(timed, ins) - } else { - freshIdxs = append(freshIdxs, ins.idx) - } - } - - roomDesc := def.InRoomDescription - if roomDesc != "" { - roomDesc = color.ExpandTags(g.colorMode(sess), roomDesc) - } - coloredName := g.objColorize(sess, def, def.Name) - coloredPlural := g.objColorize(sess, def, def.Name+"s") - - if len(freshIdxs) > 0 { - var line string - if roomDesc != "" { - line = roomDesc - } else if len(freshIdxs) == 1 { - line = fmt.Sprintf("A %s is here.", coloredName) - } else { - line = fmt.Sprintf("%d %s are here.", len(freshIdxs), coloredPlural) - } - var suffix string - if multi && (len(timed) > 0 || len(depleted) > 0) { - suffix = fmt.Sprintf(" [%s]", joinInts(freshIdxs)) - } - qualityTimer := "" - if showTimers && len(instances) > 0 && instances[0].quality > 0 { - qualityTimer = fmt.Sprintf(" (Burning for %d more ticks)", instances[0].quality) - } - lines = append(lines, fmt.Sprintf("%s%s%s", line, suffix, qualityTimer)) - } - - for _, ins := range timed { - var line string - if roomDesc != "" { - line = roomDesc - } else { - line = fmt.Sprintf("A %s is here.", coloredName) - } - tag := "" - if multi { - tag = fmt.Sprintf(" [%d]", ins.idx) - } - timer := "" - if showTimers { - timer = fmt.Sprintf(" (despawn: %d/%d)", ins.sharedCur, ins.sharedMax) - } - lines = append(lines, fmt.Sprintf("%s%s%s", line, tag, timer)) - } - - for _, ins := range depleted { - var line string - if roomDesc != "" { - line = roomDesc - } else { - line = fmt.Sprintf("A %s is here.", coloredName) - } - tag := "" - if multi { - tag = fmt.Sprintf(" [%d]", ins.idx) - } - timer := "" - if showTimers { - timer = fmt.Sprintf(" (respawns in %d ticks)", ins.respawnIn) - } - lines = append(lines, fmt.Sprintf("%s (depleted)%s%s", line, tag, timer)) - } - } - - lines = append(lines, g.showFarmPatches(sess, p)...) - return lines -} - -func (g *Game) showGroundItems(sess *net.Session, p *player.Player, room *world.Room) []string { - ground := g.World.GroundItemsDetailed(p.RoomID) - if len(ground) == 0 { - return nil - } - showDespawn := p.OptionBool("despawn") - showReserve := p.OptionBool("reserve") - - type displayLine struct { - name string - quantity int - colorName string - annotation string - } - - type groupKey struct { - itemID string - despawnTimer int - } - - var lines []displayLine - groups := make(map[groupKey]*displayLine) - var groupOrder []groupKey - - for _, info := range ground { - def, err := g.ItemStore.Load(info.ItemID) - name := info.ItemID - if err == nil { - name = def.Name - } - coloredName := g.itemColorize(sess, def, name) - - if info.ReservedFor != "" { - var parts []string - if showReserve { - parts = append(parts, fmt.Sprintf("reserved for %s %dt", info.ReservedFor, info.ReserveTimer)) - } else { - parts = append(parts, "reserved") - } - if showDespawn && !info.IsSpawn { - parts = append(parts, fmt.Sprintf("despawns %dt", info.DespawnTimer)) - } - annotation := "" - if len(parts) > 0 { - annotation = fmt.Sprintf(" (%s)", strings.Join(parts, ", ")) - } - lines = append(lines, displayLine{ - name: name, - quantity: info.Quantity, - colorName: coloredName, - annotation: annotation, - }) - continue - } - - timerKey := -1 - if showDespawn && !info.IsSpawn { - timerKey = info.DespawnTimer - } - key := groupKey{itemID: info.ItemID, despawnTimer: timerKey} - - if existing, ok := groups[key]; ok { - existing.quantity += info.Quantity - } else { - annotation := "" - if showDespawn && !info.IsSpawn { - annotation = fmt.Sprintf(" (despawns %dt)", info.DespawnTimer) - } - dl := &displayLine{ - name: name, - quantity: info.Quantity, - colorName: coloredName, - annotation: annotation, - } - groups[key] = dl - groupOrder = append(groupOrder, key) - } - } - - for _, key := range groupOrder { - lines = append(lines, *groups[key]) - } - - sort.Slice(lines, func(i, j int) bool { - return strings.ToLower(lines[i].name) < strings.ToLower(lines[j].name) - }) - - var out []string - out = append(out, "") - out = append(out, "On the ground:") - - type fmtLine struct { - prefix string - annotation string - } - var fmtLines []fmtLine - maxPrefix := 0 - - for _, dl := range lines { - prefix := "" - if dl.quantity > 1 { - prefix = fmt.Sprintf(" %d x %s", dl.quantity, dl.colorName) - } else { - prefix = fmt.Sprintf(" %s", dl.colorName) - } - if visibleLen(prefix) > maxPrefix { - maxPrefix = visibleLen(prefix) - } - fmtLines = append(fmtLines, fmtLine{prefix, dl.annotation}) - } - - for _, l := range fmtLines { - if l.annotation != "" { - pad := maxPrefix + 1 + (len(l.prefix) - visibleLen(l.prefix)) - out = append(out, fmt.Sprintf("%-*s%s", pad, l.prefix, l.annotation)) - } else { - out = append(out, l.prefix) - } - } - return out -} - -func (g *Game) showRoomExits(sess *net.Session, p *player.Player, room *world.Room) { - if len(room.Exits) > 0 { - sess.WriteLine("") - if p.OptionBool("exits") { - sess.WriteLine("Exits:") - type exitLine struct { - dir string - targetName string - } - var lines []exitLine - maxDirLen := 0 - for _, dir := range world.ExitOrder { - exitDef, ok := room.Exits[dir] - if !ok { - continue - } - coloredDir := g.colorize(sess, "exit_direction", string(dir)) - targetRoom, err := g.World.LoadRoom(exitDef.Room) - targetName := g.colorize(sess, "room_number", fmt.Sprintf("#%d", exitDef.Room)) - if err == nil { - targetName = g.colorize(sess, "exit_name", targetRoom.Name) - } - if exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) { - targetName += " (blocked)" - } - lines = append(lines, exitLine{coloredDir, targetName}) - if visibleLen(coloredDir) > maxDirLen { - maxDirLen = visibleLen(coloredDir) - } - } - for _, l := range lines { - pad := maxDirLen + (len(l.dir) - visibleLen(l.dir)) - sess.WriteLine(fmt.Sprintf(" %-*s - %s", pad, l.dir, l.targetName)) - } - } else { - sess.Write("Exits: ") - first := true - for _, dir := range world.ExitOrder { - if _, ok := room.Exits[dir]; ok { - if !first { - sess.Write(", ") - } - sess.Write(g.colorize(sess, "exit_direction", string(dir))) - first = false - } - } - sess.WriteLine("") - } - } -} - -func (g *Game) showRoomPlayers(sess *net.Session, p *player.Player, room *world.Room) { - others := g.Hub.PlayersInRoom(p.RoomID) - for _, other := range others { - if other != sess && other.Player != nil { - op := other.Player - line := fmt.Sprintf("\n%s is here", g.colorize(sess, "player_name", op.Name)) - if cs := combat.GetCombat(op.Name); cs != nil { - if mob := g.MobStore.GetInstance(cs.MobID); mob != nil && mob.HP > 0 { - name := mobDisplayName(mob, false) - if idx := mobInstanceIdx(mob, g.MobStore.MobsInRoom(p.RoomID)); idx > 0 { - name += fmt.Sprintf(" [%d]", idx) - } - line += fmt.Sprintf(" (fighting %s)", name) - } - } else if desc := g.playerActionDisplay(op); desc != "" { - line += ", " + desc - } - sess.WriteLine(line + ".") - } - } -} - -func (g *Game) doLookTarget(sess *net.Session, input string) { - p := sess.Player - lower := strings.ToLower(input) - - if exitDir := g.World.ResolveExit(lower); exitDir != "" { - room, err := g.World.LoadRoom(p.RoomID) - if err != nil { - sess.WriteLine("You can't see anything that way.") - return - } - exitDef, ok := room.Exits[exitDir] - if !ok { - sess.WriteLine("You can't see anything that way.") - return - } - if exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) { - msg := exitDef.BlockedMessage - if msg == "" { - msg = fmt.Sprintf("The way %s is blocked.", exitDir) - } - sess.WriteLine(msg) - return - } - g.World.SeedGroundItems(exitDef.Room) - g.seedRoomMobs(exitDef.Room) - origRoom := p.RoomID - p.RoomID = exitDef.Room - g.doLook(sess) - p.RoomID = origRoom - return - } - - var best *world.MobInstance - bestQ := world.MatchNone - for _, m := range g.MobStore.MobsInRoom(p.RoomID) { - q := m.MatchQuality(lower) - if q > bestQ { - bestQ = q - best = m - } - } - if best != nil { - mobColor := "mob" - if best.Protected { - mobColor = "protected_mob" - } - mobLevel := mobCombatLevel(best) - levelStr := g.levelColorize(sess, p.CombatLevel(), mobLevel, fmt.Sprintf("(level %d)", mobLevel)) - sess.WriteLines( - "", - fmt.Sprintf("%s %s", g.colorize(sess, mobColor, best.Name), levelStr), - ) - if best.IdleDescription != "" { - sess.WriteLine(best.IdleDescription) - } - sess.WriteLines( - "", - fmt.Sprintf("Attack: %d", best.Attack), - fmt.Sprintf("Strength: %d", best.Strength), - fmt.Sprintf("Defense: %d", best.Defense), - fmt.Sprintf("HP: %d/%d", best.HP, best.MaxHP), - ) - if best.StabDefense != 0 || best.SlashDefense != 0 || best.CrushDefense != 0 || - best.ScienceDefense != 0 || best.RangedDefense != 0 { - sess.WriteLines( - "", - "Defense bonuses:", - fmt.Sprintf(" Stab: %+d Slash: %+d Crush: %+d", best.StabDefense, best.SlashDefense, best.CrushDefense), - fmt.Sprintf(" Science: %+d Ranged: %+d", best.ScienceDefense, best.RangedDefense), - ) - } - if best.Weakness != "" { - sess.WriteLine(fmt.Sprintf("Weakness: %s", best.Weakness)) - } - return - } - - rawInstances := g.World.FindObjInstances(p.RoomID, lower) - - // Group matched object instances by definition, keeping only those visible - // to this player (an object whose conditional descriptions all fail is - // absent). If more than one distinct object matches, disambiguate. - var presentDefs []string - byDef := map[string][]world.ObjState{} - descByDef := map[string]string{} - for _, ist := range rawInstances { - def, err := g.ObjectStore.Load(ist.DefID) - if err != nil { - continue - } - text, present := g.resolveObjDesc(sess, def) - if !present { - continue - } - if _, seen := byDef[ist.DefID]; !seen { - presentDefs = append(presentDefs, ist.DefID) - descByDef[ist.DefID] = text - } - byDef[ist.DefID] = append(byDef[ist.DefID], ist) - } - - if len(presentDefs) > 1 { - sess.WriteLine("That's ambiguous, which one?") - for _, defID := range presentDefs { - if def, err := g.ObjectStore.Load(defID); err == nil { - sess.WriteLine(fmt.Sprintf(" %s", def.Name)) - } - } - return - } - - if len(presentDefs) == 1 { - instances := byDef[presentDefs[0]] - objDescText := descByDef[presentDefs[0]] - st := &instances[0] - def, _ := g.ObjectStore.Load(st.DefID) - - if st.DefID == "estate_directory" { - g.lookEstateDirectory(sess) - return - } - - sess.WriteLine("") - if len(instances) > 1 { - sess.WriteLine(fmt.Sprintf("%d %ss:", len(instances), def.Name)) - } - - if objDescText != "" && !strings.Contains(objDescText, "{quality}") { - sess.WriteLine(color.ExpandTags(g.colorMode(sess), objDescText)) - } - - if def.Safespot != nil { - realSt := g.World.GetObjState(p.RoomID, st.DefID, st.Index) - if realSt != nil { - levelStr := "intact" - if realSt.SafespotLevel <= 0 { - levelStr = fmt.Sprintf("intact (level %d/%d)", len(def.Safespot.Levels), len(def.Safespot.Levels)) - } else if realSt.SafespotLevel < len(def.Safespot.Levels) { - levelStr = fmt.Sprintf("degraded (level %d/%d)", realSt.SafespotLevel, len(def.Safespot.Levels)) - } else { - levelStr = fmt.Sprintf("intact (level %d/%d)", realSt.SafespotLevel, len(def.Safespot.Levels)) - } - blockInfo := "anything" - if def.Safespot.MaxBlockSize != "" { - blockInfo = fmt.Sprintf("up to %s mobs", def.Safespot.MaxBlockSize) - } - sess.WriteLine(fmt.Sprintf("Safespot: %s — blocks %s", levelStr, blockInfo)) - if len(realSt.SafespotOccupants) > 0 { - sess.WriteLine(fmt.Sprintf("Occupied by: %s", strings.Join(realSt.SafespotOccupants, ", "))) - } - } - } - - if p.OptionBool("depletion") { - for _, ist := range instances { - if ist.Depleted { - if len(instances) > 1 { - sess.WriteLine(fmt.Sprintf("%s %d: depleted, respawns in %d ticks.", def.Name, ist.Index+1, int(ist.DepleteTimer))) - } else { - sess.WriteLine(fmt.Sprintf("Depleted, respawns in %d ticks.", int(ist.DepleteTimer))) - } - } else if ist.SharedMax > 0 && float64(ist.SharedTimer) < ist.SharedMax { - if len(instances) > 1 { - sess.WriteLine(fmt.Sprintf("%s %d: despawn timer %d/%.0f.", def.Name, ist.Index+1, ist.SharedTimer, ist.SharedMax)) - } else { - sess.WriteLine(fmt.Sprintf("Despawn timer: %d/%.0f ticks.", ist.SharedTimer, ist.SharedMax)) - } - } - } - } - for _, ist := range instances { - if ist.Quality > 0 && strings.Contains(def.Description, "{quality}") { - desc := strings.ReplaceAll(def.Description, "{quality}", fmt.Sprintf("%.0f", ist.Quality)) - sess.WriteLine(color.ExpandTags(g.colorMode(sess), desc)) - } - } - - farmSuffix := g.farmObjSuffix(p, st.DefID, st.Index) - if farmSuffix != "" { - sess.WriteLine(farmSuffix) - } - return - } - - ground := g.World.GroundItems(p.RoomID) - for itemID := range ground { - def, err := g.ItemStore.Load(itemID) - if err != nil || !def.MatchesName(input) { - continue - } - sess.WriteLines( - "", - g.itemColorize(sess, def, def.Name), - color.ExpandTags(g.colorMode(sess), def.Description), - fmt.Sprintf("Value: %d credits", def.Value), - ) - g.showItemStats(sess, def) - return - } - - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot == nil { - continue - } - def, err := g.ItemStore.Load(slot.ItemID) - if err != nil || !def.MatchesName(input) { - continue - } - lines := []string{ - "", - g.itemColorize(sess, def, def.Name), - color.ExpandTags(g.colorMode(sess), def.Description), - fmt.Sprintf("Value: %d credits", def.Value), - } - if slot.MaxQuality > 0 { - lines = append(lines, fmt.Sprintf("Has %d units of butane left.", slot.Quality)) - } - sess.WriteLines(lines...) - g.showItemStats(sess, def) - return - } - - others := g.Hub.PlayersInRoom(p.RoomID) - for _, other := range others { - if other == sess || other.Player == nil { - continue - } - op := other.Player - if strings.ToLower(op.Name) != lower { - continue - } - showPlayerInfo(g, sess, op) - return - } - - sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input)) -} - -func showPlayerInfo(g *Game, sess *net.Session, p *player.Player) { - myP := sess.Player - theirLevel := p.CombatLevel() - levelStr := g.levelColorize(sess, myP.CombatLevel(), theirLevel, fmt.Sprint(theirLevel)) - sess.WriteLines( - "", - p.Name, - fmt.Sprintf("Combat Level: %s", levelStr), - fmt.Sprintf("HP: %d/%d", p.HP, p.MaxHP()), - "", - ) - - for _, s := range player.AllSkills { - level := p.Level(s) - sess.WriteLine(fmt.Sprintf("%-12s Level: %d", s, level)) - } - - sess.WriteLine("") - sess.WriteLine("Equipment:") - for _, slot := range EquipSlots { - itemID, ok := p.Equipment[slot] - if !ok { - continue - } - name := itemID - var itemDef *object.ItemDef - if def, err := g.ItemStore.Load(itemID); err == nil { - name = def.Name - itemDef = def - } - sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, g.itemColorize(sess, itemDef, name))) - } - - if p.Description != "" { - sess.WriteLine("") - sess.WriteLine(p.Description) - } -} - func (g *Game) doExits(sess *net.Session) { p := sess.Player room, err := g.World.LoadRoom(p.RoomID) @@ -898,56 +181,3 @@ func (g *Game) executeLook(sess *net.Session, args []string, rawInput string) { 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 || - s.ScienceAttack != 0 || s.RangedAttack != 0 - hasDefense := s.StabDefense != 0 || s.SlashDefense != 0 || s.CrushDefense != 0 || - s.ScienceDefense != 0 || s.RangedDefense != 0 - hasOther := s.StrengthBonus != 0 || s.RangedStrength != 0 || - s.ScienceDamage != 0 || s.TechnologyBonus != 0 - - if !hasAttack && !hasDefense && !hasOther { - return - } - - sess.WriteLine("") - if hasAttack || hasDefense { - sess.WriteLine("Attack bonuses: Defense bonuses:") - sess.WriteLine(fmt.Sprintf(" Stab: %+4d Stab: %+4d", s.StabAttack, s.StabDefense)) - sess.WriteLine(fmt.Sprintf(" Slash: %+4d Slash: %+4d", s.SlashAttack, s.SlashDefense)) - sess.WriteLine(fmt.Sprintf(" Crush: %+4d Crush: %+4d", s.CrushAttack, s.CrushDefense)) - sess.WriteLine(fmt.Sprintf(" Science:%+4d Science:%+4d", s.ScienceAttack, s.ScienceDefense)) - sess.WriteLine(fmt.Sprintf(" Ranged: %+4d Ranged: %+4d", s.RangedAttack, s.RangedDefense)) - } - if hasOther { - sess.WriteLine("") - sess.WriteLine("Other bonuses:") - if s.StrengthBonus != 0 { - sess.WriteLine(fmt.Sprintf(" Melee strength: %+d", s.StrengthBonus)) - } - if s.RangedStrength != 0 { - sess.WriteLine(fmt.Sprintf(" Ranged strength: %+d", s.RangedStrength)) - } - if s.ScienceDamage != 0 { - sess.WriteLine(fmt.Sprintf(" Science damage: %+d", s.ScienceDamage)) - } - if s.TechnologyBonus != 0 { - sess.WriteLine(fmt.Sprintf(" Technology: %+d", s.TechnologyBonus)) - } - } - if def.AttackType != "" { - sess.WriteLine(fmt.Sprintf("Attack type: %s", def.AttackType)) - } - if def.Speed > 0 { - sess.WriteLine(fmt.Sprintf("Speed: %.0f", def.Speed)) - } - if len(def.Requirements) > 0 { - sess.WriteLine("") - sess.WriteLine("Requirements:") - for skill, level := range def.Requirements { - sess.WriteLine(fmt.Sprintf(" %s: %d", skill, level)) - } - } -} diff --git a/internal/game/cmd_mods.go b/internal/game/cmd_mods.go index 438e11a..54c8067 100644 --- a/internal/game/cmd_mods.go +++ b/internal/game/cmd_mods.go @@ -7,6 +7,7 @@ import ( "thehouseoficarus/internal/color" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" + "thehouseoficarus/internal/ui" ) func (g *Game) executeMods(sess *net.Session, args []string, rawInput string) { @@ -51,7 +52,7 @@ func (g *Game) doMods(sess *net.Session, showAll bool) { return catMods[i].Level < catMods[j].Level }) - t := &Table{ + t := &ui.Table{ Title: cat.Name, Columns: []string{ color.Render(mode, color.Parse("75"), "Module"), diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go index 2939702..63f9bcd 100644 --- a/internal/game/cmd_move.go +++ b/internal/game/cmd_move.go @@ -4,10 +4,9 @@ import ( "fmt" "strings" - "thehouseoficarus/internal/combat" "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" ) @@ -70,7 +69,7 @@ func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) { p.MovePendingFlags = exitDef.SetFlags p.MovePendingPlayerFlags = exitDef.SetPlayerFlags - inCombat := combat.GetCombat(p.Name) != nil + inCombat := g.Combat.Get(p.Name) != nil if !inCombat && p.Action != nil { g.cancelAction(p) @@ -106,7 +105,7 @@ var gracefulItems = map[string]bool{ } func (g *Game) moveTicks(p *player.Player, multiplier float64) int { - if id, ok := p.Equipment[object.SlotBack]; ok && id == "cape_of_agility" { + if id, ok := p.Equipment[item.SlotBack]; ok && id == "cape_of_agility" { return 0 } @@ -134,7 +133,7 @@ func (g *Game) completeMove(sess *net.Session, p *player.Player) { p.ClearMoveState() - if combat.GetCombat(p.Name) != nil { + if g.Combat.Get(p.Name) != nil { g.stopCombat(p.Name) } diff --git a/internal/game/cmd_option.go b/internal/game/cmd_option.go index a5bf11c..05a6ece 100644 --- a/internal/game/cmd_option.go +++ b/internal/game/cmd_option.go @@ -8,6 +8,7 @@ import ( "thehouseoficarus/internal/color" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" + "thehouseoficarus/internal/ui" ) func (g *Game) doOption(sess *net.Session, input string) { @@ -16,7 +17,7 @@ func (g *Game) doOption(sess *net.Session, input string) { if input == "" { sess.WriteLine("") - table := &Table{ + table := &ui.Table{ Columns: []string{ color.Render(mode, color.Parse("75"), "Option"), color.Render(mode, color.Parse("230"), "Value"), diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go index 4599614..2740275 100644 --- a/internal/game/cmd_quit.go +++ b/internal/game/cmd_quit.go @@ -1,7 +1,6 @@ package game import ( - "thehouseoficarus/internal/combat" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/net" ) @@ -13,7 +12,7 @@ func (g *Game) executeQuit(sess *net.Session, args []string, rawInput string) { func (g *Game) doQuit(sess *net.Session) { p := sess.Player - if combat.GetCombat(p.Name) != nil { + if g.Combat.Get(p.Name) != nil { sess.WriteLine("You can't rest during combat!") return } @@ -36,8 +35,8 @@ func (g *Game) doQuit(sess *net.Session) { g.cancelRest(p.Name) g.AccountStore.SaveCharacter(p) g.charsMu.Lock() - delete(g.loggedInChars, p.Name) - g.charsMu.Unlock() + delete(g.loggedInChars, p.Name) + g.charsMu.Unlock() if g.Hub != nil { g.Hub.LeaveRoom(sess) } diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index bc910ed..7ac9d0b 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -15,124 +15,124 @@ type commandDef struct { } var commandRegistry = map[string]commandDef{ - "get": {(*Game).executeGet, ClassActive}, - "take": {(*Game).executeGet, ClassActive}, - "grab": {(*Game).executeGet, ClassActive}, - "pick": {(*Game).executeGet, ClassActive}, - "drop": {(*Game).executeDrop, ClassActive}, - "attack": {(*Game).executeAttack, ClassActive}, - "kill": {(*Game).executeAttack, ClassActive}, - "work": {(*Game).executeWork, ClassActive}, - "style": {(*Game).executeStyle, ClassInstant}, - "look": {(*Game).executeLook, ClassInstant}, - "l": {(*Game).executeLook, ClassInstant}, - "north": {(*Game).executeMove, ClassActive}, - "n": {(*Game).executeMove, ClassActive}, - "south": {(*Game).executeMove, ClassActive}, - "s": {(*Game).executeMove, ClassActive}, - "east": {(*Game).executeMove, ClassActive}, - "e": {(*Game).executeMove, ClassActive}, - "west": {(*Game).executeMove, ClassActive}, - "w": {(*Game).executeMove, ClassActive}, - "up": {(*Game).executeMove, ClassActive}, - "u": {(*Game).executeMove, ClassActive}, - "down": {(*Game).executeMove, ClassActive}, - "d": {(*Game).executeMove, ClassActive}, - "say": {(*Game).executeSay, ClassInstant}, - "global": {(*Game).executeGlobal, ClassInstant}, - "who": {(*Game).executeWho, ClassInstant}, - "sc": {(*Game).executeScore, ClassInstant}, - "score": {(*Game).executeScore, ClassInstant}, - "skills": {(*Game).executeSkills, ClassInstant}, - "sk": {(*Game).executeSkills, ClassInstant}, - "task": {(*Game).executeTask, ClassInstant}, - "stats": {(*Game).executeStats, ClassInstant}, - "tech": {(*Game).executeTech, ClassInstant}, - "t": {(*Game).executeTech, ClassInstant}, - "i": {(*Game).executeInventory, ClassInstant}, - "inv": {(*Game).executeInventory, ClassInstant}, - "inventory": {(*Game).executeInventory, ClassInstant}, - "quit": {(*Game).executeQuit, ClassActive}, + "get": {(*Game).executeGet, ClassActive}, + "take": {(*Game).executeGet, ClassActive}, + "grab": {(*Game).executeGet, ClassActive}, + "pick": {(*Game).executeGet, ClassActive}, + "drop": {(*Game).executeDrop, ClassActive}, + "attack": {(*Game).executeAttack, ClassActive}, + "kill": {(*Game).executeAttack, ClassActive}, + "work": {(*Game).executeWork, ClassActive}, + "style": {(*Game).executeStyle, ClassInstant}, + "look": {(*Game).executeLook, ClassInstant}, + "l": {(*Game).executeLook, ClassInstant}, + "north": {(*Game).executeMove, ClassActive}, + "n": {(*Game).executeMove, ClassActive}, + "south": {(*Game).executeMove, ClassActive}, + "s": {(*Game).executeMove, ClassActive}, + "east": {(*Game).executeMove, ClassActive}, + "e": {(*Game).executeMove, ClassActive}, + "west": {(*Game).executeMove, ClassActive}, + "w": {(*Game).executeMove, ClassActive}, + "up": {(*Game).executeMove, ClassActive}, + "u": {(*Game).executeMove, ClassActive}, + "down": {(*Game).executeMove, ClassActive}, + "d": {(*Game).executeMove, ClassActive}, + "say": {(*Game).executeSay, ClassInstant}, + "global": {(*Game).executeGlobal, ClassInstant}, + "who": {(*Game).executeWho, ClassInstant}, + "sc": {(*Game).executeScore, ClassInstant}, + "score": {(*Game).executeScore, ClassInstant}, + "skills": {(*Game).executeSkills, ClassInstant}, + "sk": {(*Game).executeSkills, ClassInstant}, + "task": {(*Game).executeTask, ClassInstant}, + "stats": {(*Game).executeStats, ClassInstant}, + "tech": {(*Game).executeTech, ClassInstant}, + "t": {(*Game).executeTech, ClassInstant}, + "i": {(*Game).executeInventory, ClassInstant}, + "inv": {(*Game).executeInventory, ClassInstant}, + "inventory": {(*Game).executeInventory, ClassInstant}, + "quit": {(*Game).executeQuit, ClassActive}, "description": {(*Game).executeDescription, ClassInstant}, - "desc": {(*Game).executeDescription, ClassInstant}, - "option": {(*Game).executeOption, ClassInstant}, - "options": {(*Game).executeOption, ClassInstant}, - "color": {(*Game).executeColor, ClassInstant}, - "colors": {(*Game).executeColor, ClassInstant}, - "colortable": {(*Game).executeColortable, ClassInstant}, - "prompt": {(*Game).executePrompt, ClassInstant}, - "exits": {(*Game).executeExits, ClassInstant}, - "map": {(*Game).executeMap, ClassInstant}, - "symbol": {(*Game).executeSymbol, ClassInstant}, - "queued": {(*Game).executeQueued, ClassInstant}, - "help": {(*Game).executeHelp, ClassInstant}, - "mine": {(*Game).executeGather, ClassActive}, - "chop": {(*Game).executeGather, ClassActive}, - "fish": {(*Game).executeGather, ClassActive}, - "cut": {(*Game).executeGather, ClassActive}, - "pull": {(*Game).executeUse, ClassActive}, - "push": {(*Game).executeUse, ClassActive}, - "use": {(*Game).executeUse, ClassActive}, - "cook": {(*Game).executeCook, ClassActive}, - "smelt": {(*Game).executeSmelt, ClassActive}, - "smith": {(*Game).executeSmith, ClassActive}, - "craft": {(*Game).executeCraft, ClassActive}, - "eat": {(*Game).executeEat, ClassFree}, - "drink": {(*Game).executeDrink, ClassFree}, - "fletch": {(*Game).executeFletch, ClassFree}, - "clean": {(*Game).executeClean, ClassFree}, - "mix": {(*Game).executeMix, ClassActive}, - "id": {(*Game).executeIdentify, ClassActive}, - "identify": {(*Game).executeIdentify, ClassActive}, + "desc": {(*Game).executeDescription, ClassInstant}, + "option": {(*Game).executeOption, ClassInstant}, + "options": {(*Game).executeOption, ClassInstant}, + "color": {(*Game).executeColor, ClassInstant}, + "colors": {(*Game).executeColor, ClassInstant}, + "colortable": {(*Game).executeColortable, ClassInstant}, + "prompt": {(*Game).executePrompt, ClassInstant}, + "exits": {(*Game).executeExits, ClassInstant}, + "map": {(*Game).executeMap, ClassInstant}, + "symbol": {(*Game).executeSymbol, ClassInstant}, + "queued": {(*Game).executeQueued, ClassInstant}, + "help": {(*Game).executeHelp, ClassInstant}, + "mine": {(*Game).executeGather, ClassActive}, + "chop": {(*Game).executeGather, ClassActive}, + "fish": {(*Game).executeGather, ClassActive}, + "cut": {(*Game).executeGather, ClassActive}, + "pull": {(*Game).executeUse, ClassActive}, + "push": {(*Game).executeUse, ClassActive}, + "use": {(*Game).executeUse, ClassActive}, + "cook": {(*Game).executeCook, ClassActive}, + "smelt": {(*Game).executeSmelt, ClassActive}, + "smith": {(*Game).executeSmith, ClassActive}, + "craft": {(*Game).executeCraft, ClassActive}, + "eat": {(*Game).executeEat, ClassFree}, + "drink": {(*Game).executeDrink, ClassFree}, + "fletch": {(*Game).executeFletch, ClassFree}, + "clean": {(*Game).executeClean, ClassFree}, + "mix": {(*Game).executeMix, ClassActive}, + "id": {(*Game).executeIdentify, ClassActive}, + "identify": {(*Game).executeIdentify, ClassActive}, "autotrigger": {(*Game).executeAutotrigger, ClassInstant}, - "auto": {(*Game).executeAutotrigger, ClassInstant}, - "mods": {(*Game).executeMods, ClassInstant}, - "modlist": {(*Game).executeMods, ClassInstant}, - "mod": {(*Game).executeMods, ClassInstant}, - "modules": {(*Game).executeMods, ClassInstant}, - "module": {(*Game).executeMods, ClassInstant}, - "sneak": {(*Game).executeSneak, ClassInstant}, - "trigger": {(*Game).executeTrigger, ClassActive}, - "talk": {(*Game).executeTalk, ClassActive}, - "speak": {(*Game).executeTalk, ClassActive}, - "ask": {(*Game).executeTalk, ClassActive}, - "burn": {(*Game).executeBurn, ClassActive}, - "stoke": {(*Game).executeStoke, ClassActive}, - "alias": {(*Game).executeAlias, ClassInstant}, - "unalias": {(*Game).executeUnalias, ClassInstant}, - "search": {(*Game).executeSearch, ClassActive}, - "steal": {(*Game).executeSteal, ClassActive}, - "thieve": {(*Game).executeSteal, ClassActive}, - "plant": {(*Game).executePlant, ClassActive}, - "harvest": {(*Game).executeHarvest, ClassActive}, - "rake": {(*Game).executeRake, ClassActive}, - "water": {(*Game).executeWater, ClassActive}, - "cure": {(*Game).executeCure, ClassActive}, - "inspect": {(*Game).executeInspect, ClassInstant}, - "walk": {(*Game).executeWalk, ClassActive}, - "bank": {(*Game).executeBank, ClassActive}, - "construct": {(*Game).executeConstruct, ClassActive}, - "make": {(*Game).executeConstruct, ClassActive}, - "jack": {(*Game).executeJack, ClassActive}, - "jackin": {(*Game).executeJack, ClassActive}, - "eq": {(*Game).executeWear, ClassFree}, - "equipment": {(*Game).executeWear, ClassFree}, - "equip": {(*Game).executeWear, ClassFree}, - "wear": {(*Game).executeWear, ClassFree}, - "wield": {(*Game).executeWear, ClassFree}, - "remove": {(*Game).executeRemove, ClassFree}, - "unwear": {(*Game).executeRemove, ClassFree}, - "unwield": {(*Game).executeRemove, ClassFree}, - "aps": {(*Game).executeAps, ClassActive}, - "verbs": {(*Game).executeVerbs, ClassInstant}, - "what": {(*Game).executeVerbs, ClassInstant}, - "syntax": {(*Game).executeVerbs, ClassInstant}, - "commands": {(*Game).executeVerbs, ClassInstant}, - "stop": {(*Game).executeStop, ClassInstant}, - "hide": {(*Game).executeHide, ClassActive}, - "safespot": {(*Game).executeHide, ClassActive}, - "unhide": {(*Game).executeUnhide, ClassActive}, - "unsafespot": {(*Game).executeUnhide, ClassActive}, + "auto": {(*Game).executeAutotrigger, ClassInstant}, + "mods": {(*Game).executeMods, ClassInstant}, + "modlist": {(*Game).executeMods, ClassInstant}, + "mod": {(*Game).executeMods, ClassInstant}, + "modules": {(*Game).executeMods, ClassInstant}, + "module": {(*Game).executeMods, ClassInstant}, + "sneak": {(*Game).executeSneak, ClassInstant}, + "trigger": {(*Game).executeTrigger, ClassActive}, + "talk": {(*Game).executeTalk, ClassActive}, + "speak": {(*Game).executeTalk, ClassActive}, + "ask": {(*Game).executeTalk, ClassActive}, + "burn": {(*Game).executeBurn, ClassActive}, + "stoke": {(*Game).executeStoke, ClassActive}, + "alias": {(*Game).executeAlias, ClassInstant}, + "unalias": {(*Game).executeUnalias, ClassInstant}, + "search": {(*Game).executeSearch, ClassActive}, + "steal": {(*Game).executeSteal, ClassActive}, + "thieve": {(*Game).executeSteal, ClassActive}, + "plant": {(*Game).executePlant, ClassActive}, + "harvest": {(*Game).executeHarvest, ClassActive}, + "rake": {(*Game).executeRake, ClassActive}, + "water": {(*Game).executeWater, ClassActive}, + "cure": {(*Game).executeCure, ClassActive}, + "inspect": {(*Game).executeInspect, ClassInstant}, + "walk": {(*Game).executeWalk, ClassActive}, + "bank": {(*Game).executeBank, ClassActive}, + "construct": {(*Game).executeConstruct, ClassActive}, + "make": {(*Game).executeConstruct, ClassActive}, + "jack": {(*Game).executeJack, ClassActive}, + "jackin": {(*Game).executeJack, ClassActive}, + "eq": {(*Game).executeWear, ClassFree}, + "equipment": {(*Game).executeWear, ClassFree}, + "equip": {(*Game).executeWear, ClassFree}, + "wear": {(*Game).executeWear, ClassFree}, + "wield": {(*Game).executeWear, ClassFree}, + "remove": {(*Game).executeRemove, ClassFree}, + "unwear": {(*Game).executeRemove, ClassFree}, + "unwield": {(*Game).executeRemove, ClassFree}, + "aps": {(*Game).executeAps, ClassActive}, + "verbs": {(*Game).executeVerbs, ClassInstant}, + "what": {(*Game).executeVerbs, ClassInstant}, + "syntax": {(*Game).executeVerbs, ClassInstant}, + "commands": {(*Game).executeVerbs, ClassInstant}, + "stop": {(*Game).executeStop, ClassInstant}, + "hide": {(*Game).executeHide, ClassActive}, + "safespot": {(*Game).executeHide, ClassActive}, + "unhide": {(*Game).executeUnhide, ClassActive}, + "unsafespot": {(*Game).executeUnhide, ClassActive}, } func classifyCommand(cmd string) CommandClass { diff --git a/internal/game/cmd_remove.go b/internal/game/cmd_remove.go index c046537..3a451af 100644 --- a/internal/game/cmd_remove.go +++ b/internal/game/cmd_remove.go @@ -4,8 +4,8 @@ import ( "fmt" "strings" + "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" ) @@ -30,7 +30,7 @@ func (g *Game) doRemove(sess *net.Session, input string) { qty, itemName := parseQty(input) - var foundSlot object.EquipSlot + var foundSlot item.EquipSlot var foundItemID string for _, slot := range EquipSlots { @@ -59,7 +59,7 @@ func (g *Game) doRemove(sess *net.Session, input string) { return } - if foundSlot == object.SlotAmmo && p.AmmoQty > 0 { + if foundSlot == item.SlotAmmo && p.AmmoQty > 0 { g.removeAmmo(sess, p, qty) return } @@ -91,7 +91,7 @@ func (g *Game) doRemove(sess *net.Session, input string) { } func (g *Game) removeAmmo(sess *net.Session, p *player.Player, qty int) { - ammoID := p.Equipment[object.SlotAmmo] + ammoID := p.Equipment[item.SlotAmmo] removeQty := p.AmmoQty if qty > 0 && qty < removeQty { removeQty = qty @@ -103,7 +103,7 @@ func (g *Game) removeAmmo(sess *net.Session, p *player.Player, qty int) { s.Quantity += removeQty p.AmmoQty -= removeQty if p.AmmoQty <= 0 { - delete(p.Equipment, object.SlotAmmo) + delete(p.Equipment, item.SlotAmmo) p.AmmoQty = 0 } g.AccountStore.SaveCharacter(p) @@ -126,7 +126,7 @@ func (g *Game) removeAmmo(sess *net.Session, p *player.Player, qty int) { p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: ammoID, Quantity: removeQty}) p.AmmoQty -= removeQty if p.AmmoQty <= 0 { - delete(p.Equipment, object.SlotAmmo) + delete(p.Equipment, item.SlotAmmo) p.AmmoQty = 0 } g.AccountStore.SaveCharacter(p) @@ -148,7 +148,7 @@ func (g *Game) doRemoveAll(sess *net.Session) { } needed := len(p.Equipment) - if _, hasAmmo := p.Equipment[object.SlotAmmo]; hasAmmo { + if _, hasAmmo := p.Equipment[item.SlotAmmo]; hasAmmo { needed-- needed++ } @@ -165,7 +165,7 @@ func (g *Game) doRemoveAll(sess *net.Session) { continue } - if slot == object.SlotAmmo && p.AmmoQty > 0 { + if slot == item.SlotAmmo && p.AmmoQty > 0 { g.removeAllAmmo(sess, p, itemID) continue } @@ -198,7 +198,7 @@ func (g *Game) removeAllAmmo(sess *net.Session, p *player.Player, itemID string) s := p.InvSlot(i) if s != nil && s.ItemID == itemID { s.Quantity += p.AmmoQty - delete(p.Equipment, object.SlotAmmo) + delete(p.Equipment, item.SlotAmmo) p.AmmoQty = 0 def, _ := g.ItemStore.Load(itemID) name := itemID @@ -213,7 +213,7 @@ func (g *Game) removeAllAmmo(sess *net.Session, p *player.Player, itemID string) if freeSlot >= 0 { p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: p.AmmoQty}) } - delete(p.Equipment, object.SlotAmmo) + delete(p.Equipment, item.SlotAmmo) p.AmmoQty = 0 def, _ := g.ItemStore.Load(itemID) name := itemID diff --git a/internal/game/cmd_score.go b/internal/game/cmd_score.go index d4dd823..20c61d9 100644 --- a/internal/game/cmd_score.go +++ b/internal/game/cmd_score.go @@ -8,6 +8,7 @@ import ( "thehouseoficarus/internal/color" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" + "thehouseoficarus/internal/ui" ) func (g *Game) executeScore(sess *net.Session, args []string, rawInput string) { @@ -49,10 +50,10 @@ func (g *Game) doScore(sess *net.Session) { player.Fishing: "Fishing", player.Woodcutting: "Woodcutting", player.Mining: "Mining", player.Hacking: "Hacking", player.Scavenging: "Scavenging", player.Farming: "Farming", player.Thieving: "Thieving", - player.Cooking: "Cooking", player.Smithing: "Smithing", player.Crafting: "Crafting", + player.Cooking: "Cooking", player.Smithing: "Smithing", player.Crafting: "Crafting", player.Fletching: "Fletching", player.Pharmacy: "Pharmacy", player.Construction: "Construction", player.Firemaking: "Firemaking", - player.Agility: "Agility", + player.Agility: "Agility", } type skillCat struct { @@ -105,7 +106,7 @@ func (g *Game) doScore(sess *net.Session) { gap() hpStyle := hpBarStyle(p.HP, p.MaxHP()) - hpBarStr := RenderColoredBar(p.HP, p.MaxHP(), 26, unicode, hpStyle, mode) + hpBarStr := ui.RenderColoredBar(p.HP, p.MaxHP(), 26, unicode, hpStyle, mode) hpFg := hpBarFg(p.HP, p.MaxHP()) hpLine := fmt.Sprintf("HP [%s] %s / %s", hpBarStr, @@ -113,7 +114,7 @@ func (g *Game) doScore(sess *net.Session) { color.Render(mode, color.Parse("230"), fmt.Sprint(p.MaxHP()))) content(hpLine) - batBarRaw := RenderBarF(p.Battery, p.MaxBattery(), 26, unicode) + batBarRaw := ui.RenderBarF(p.Battery, p.MaxBattery(), 26, unicode) batBarStr := color.Render(mode, color.ColorSpec{Fg: 45}, batBarRaw) batLine := fmt.Sprintf("BAT [%s] %s / %s", batBarStr, @@ -242,7 +243,7 @@ func formatInt(n int) string { return strings.Join(parts, ",") } -func hpBarStyle(current, max int) *BarStyle { +func hpBarStyle(current, max int) *ui.BarStyle { if max <= 0 { max = 1 } @@ -256,7 +257,7 @@ func hpBarStyle(current, max int) *BarStyle { default: fg = 196 } - return &BarStyle{FilledColor: fg, EmptyColor: 238, EmptyDim: true} + return &ui.BarStyle{FilledColor: fg, EmptyColor: 238, EmptyDim: true} } func hpBarFg(current, max int) int { @@ -292,7 +293,7 @@ func skillLevelColor(level int) int { func xpProgressBar(xp int, width int, unicode bool) string { currentLevel := player.LevelForXP(xp) if currentLevel >= 99 { - fillRune, _ := barRunes(unicode) + fillRune, _ := ui.BarRunes(unicode) return strings.Repeat(fillRune, width) } xpForLevel := player.XPForLevel(currentLevel) @@ -302,7 +303,7 @@ func xpProgressBar(xp int, width int, unicode bool) string { if needed <= 0 { needed = 1 } - fillRune, emptyRune := barRunes(unicode) + fillRune, emptyRune := ui.BarRunes(unicode) filled := progress * width / needed if filled > width { filled = width diff --git a/internal/game/cmd_shop.go b/internal/game/cmd_shop.go index b293ff9..51d0ab1 100644 --- a/internal/game/cmd_shop.go +++ b/internal/game/cmd_shop.go @@ -5,9 +5,10 @@ import ( "strconv" "strings" - "thehouseoficarus/internal/action" + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" + "thehouseoficarus/internal/ui" ) func (g *Game) handleShopInput(sess *net.Session, input string) { @@ -90,15 +91,15 @@ func (g *Game) writeShopPrompt(sess *net.Session) { sess.Write(fmt.Sprintf("\n%s", g.colorize(sess, "dialog", "> "))) } -func (g *Game) shopConfig(sess *net.Session) *action.ShopConfig { +func (g *Game) shopConfig(sess *net.Session) *behavior.ShopConfig { cfg := sess.Shop return cfg } type shopMatch struct { - si *action.ShopItem + si *behavior.ShopItem name string - def *object.ItemDef + def *item.ItemDef } func (g *Game) showShopBrowse(sess *net.Session) { @@ -121,7 +122,7 @@ func (g *Game) showShopBrowse(sess *net.Session) { unicode = p.OptionBool("unicode") } - t := Table{ + t := ui.Table{ Title: "Shop Inventory", Columns: []string{"Item", "Buy Price", "Sell Price"}, } @@ -149,7 +150,7 @@ func (g *Game) showShopBrowse(sess *net.Session) { } } -func (g *Game) findShopMatches(input string, cfg *action.ShopConfig) []shopMatch { +func (g *Game) findShopMatches(input string, cfg *behavior.ShopConfig) []shopMatch { var matches []shopMatch for i := range cfg.Items { si := &cfg.Items[i] @@ -158,7 +159,7 @@ func (g *Game) findShopMatches(input string, cfg *action.ShopConfig) []shopMatch if def != nil { itemName = def.Name } - if object.WordPrefixMatch(input, itemName) { + if behavior.WordPrefixMatch(input, itemName) { matches = append(matches, shopMatch{si: si, name: itemName, def: def}) } } @@ -216,7 +217,7 @@ func (g *Game) doShopBuy(sess *net.Session, input string, qty int, all bool) { g.buyShopItem(sess, *si, actualQty) } -func (g *Game) buyShopItem(sess *net.Session, si action.ShopItem, qty int) { +func (g *Game) buyShopItem(sess *net.Session, si behavior.ShopItem, qty int) { p := sess.Player totalCost := si.BuyPrice * qty @@ -334,7 +335,7 @@ func (g *Game) doShopSell(sess *net.Session, input string, qty int, all bool) { match := matches[0] - var shopItem *action.ShopItem + var shopItem *behavior.ShopItem for i := range cfg.Items { si := &cfg.Items[i] if si.ItemID == match.ID && si.SellPrice > 0 { @@ -393,7 +394,7 @@ func (g *Game) leaveShop(sess *net.Session) { return } - td, ok := p.Action.Data.(*action.TalkData) + td, ok := p.Action.Data.(*behavior.TalkData) if !ok || td.Cfg == nil { sess.State = net.StateGame g.cancelAction(p) diff --git a/internal/game/cmd_skills.go b/internal/game/cmd_skills.go index 9d2e247..9dba5b7 100644 --- a/internal/game/cmd_skills.go +++ b/internal/game/cmd_skills.go @@ -6,6 +6,7 @@ import ( "thehouseoficarus/internal/color" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" + "thehouseoficarus/internal/ui" ) func (g *Game) executeSkills(sess *net.Session, args []string, rawInput string) { @@ -15,7 +16,7 @@ func (g *Game) executeSkills(sess *net.Session, args []string, rawInput string) func (g *Game) doSkills(sess *net.Session) { p := sess.Player mode := g.colorMode(sess) - t := &Table{Title: "Skills", Columns: []string{ + t := &ui.Table{Title: "Skills", Columns: []string{ color.Render(mode, color.Parse("75"), "Skill"), color.Render(mode, color.Parse("245"), "Abbr"), color.Render(mode, color.Parse("230"), "Level"), diff --git a/internal/game/cmd_smelt.go b/internal/game/cmd_smelt.go index 8ad2391..59e52db 100644 --- a/internal/game/cmd_smelt.go +++ b/internal/game/cmd_smelt.go @@ -4,8 +4,8 @@ import ( "fmt" "strings" + "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" ) @@ -44,7 +44,7 @@ func (g *Game) doSmelt(sess *net.Session, input string) { itemID := matches[0].ID - var recipes []*object.ItemDef + var recipes []*item.ItemDef for _, item := range items { c := item.FirstCraft() if !stationMatch(stationDefID, c.Station) || !craftMatchesEntry(c, itemID) { @@ -59,9 +59,9 @@ func (g *Game) doSmelt(sess *net.Session, input string) { g.showRecipeChoice(sess, p, recipes, "You can't smelt that.", "What would you like to smelt?", "") } -func (g *Game) showSmeltMenu(sess *net.Session, p *player.Player, items []*object.ItemDef, stationDefID, stationName string) { +func (g *Game) showSmeltMenu(sess *net.Session, p *player.Player, items []*item.ItemDef, stationDefID, stationName string) { seen := make(map[string]bool) - var recipes []*object.ItemDef + var recipes []*item.ItemDef for _, item := range items { c := item.FirstCraft() diff --git a/internal/game/cmd_smith.go b/internal/game/cmd_smith.go index 712c03c..9722896 100644 --- a/internal/game/cmd_smith.go +++ b/internal/game/cmd_smith.go @@ -5,8 +5,8 @@ import ( "sort" "strings" + "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" ) @@ -90,7 +90,7 @@ func (g *Game) showSmithTable(sess *net.Session, p *player.Player, barID string) } allItems := g.CraftIndex.ByType("smithing") - var recipes []*object.ItemDef + var recipes []*item.ItemDef for _, item := range allItems { if craftMatchesEntry(item.FirstCraft(), barID) { recipes = append(recipes, item) @@ -106,7 +106,7 @@ func (g *Game) showSmithTable(sess *net.Session, p *player.Player, barID string) g.showProductionTable(sess, p, recipes, titleCase(barName)+" Smithing", "smithing", lastFlag, "Produce", false) } -func hasSmithingItems(items []*object.ItemDef, barID string) bool { +func hasSmithingItems(items []*item.ItemDef, barID string) bool { for _, item := range items { if craftMatchesEntry(item.FirstCraft(), barID) { return true @@ -115,7 +115,7 @@ func hasSmithingItems(items []*object.ItemDef, barID string) bool { return false } -func findSmithableBars(items []*object.ItemDef, p *player.Player) []string { +func findSmithableBars(items []*item.ItemDef, p *player.Player) []string { barSet := make(map[string]bool) for _, item := range items { for _, e := range item.FirstCraft().Consume { @@ -142,8 +142,8 @@ func barMenuData(barIDs []string) []map[string]string { return out } -func (g *Game) findSmithItem(p *player.Player, barID string, items []*object.ItemDef) *object.ItemDef { - var makeable []*object.ItemDef +func (g *Game) findSmithItem(p *player.Player, barID string, items []*item.ItemDef) *item.ItemDef { + var makeable []*item.ItemDef skillLevel := p.Level(player.Smithing) for _, item := range items { c := item.FirstCraft() diff --git a/internal/game/cmd_stats.go b/internal/game/cmd_stats.go index 2466a5f..ef5e2ba 100644 --- a/internal/game/cmd_stats.go +++ b/internal/game/cmd_stats.go @@ -4,8 +4,8 @@ import ( "fmt" "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" ) @@ -19,18 +19,18 @@ func (g *Game) doStats(sess *net.Session) { attackType := "crush" weaponName := "unarmed" - var weaponType object.WeaponType - var weaponDef *object.ItemDef - if itemID, ok := p.Equipment[object.SlotMainHand]; ok { + var weaponType item.WeaponType + var weaponDef *item.ItemDef + if itemID, ok := p.Equipment[item.SlotMainHand]; ok { if def, err := g.ItemStore.Load(itemID); err == nil { weaponDef = def weaponName = def.Name weaponType = def.WeaponType if def.AttackType != "" { attackType = def.AttackType - } else if def.WeaponType == object.WeaponRanged { + } else if def.WeaponType == item.WeaponRanged { attackType = "ranged" - } else if def.WeaponType == object.WeaponScience { + } else if def.WeaponType == item.WeaponScience { attackType = "science" } } @@ -58,7 +58,7 @@ func (g *Game) doStats(sess *net.Session) { ) var attRoll, maxHitVal int - if weaponType == object.WeaponRanged { + if weaponType == item.WeaponRanged { rangedBonus, _ := combat.RangedStyleBonus(string(p.AttackStyle)) attRoll = combat.EffectiveRoll(p.Level(player.Ranged), rangedBonus, totals.RangedAttack) maxHitVal = combat.MaxHit(p.Level(player.Ranged), rangedBonus, totals.RangedStrength) diff --git a/internal/game/cmd_stop.go b/internal/game/cmd_stop.go index bfd8fd4..d1b8faa 100644 --- a/internal/game/cmd_stop.go +++ b/internal/game/cmd_stop.go @@ -1,14 +1,13 @@ package game import ( - "thehouseoficarus/internal/combat" "thehouseoficarus/internal/net" ) func (g *Game) executeStop(sess *net.Session, args []string, rawInput string) { p := sess.Player ss, _ := g.safespot.Get(p.Name) - if p.Action == nil && p.BackgroundAction == nil && combat.GetCombat(p.Name) == nil && p.MoveTicks == 0 && len(p.WalkSequence) == 0 && !ss.Active { + if p.Action == nil && p.BackgroundAction == nil && g.Combat.Get(p.Name) == nil && p.MoveTicks == 0 && len(p.WalkSequence) == 0 && !ss.Active { sess.WriteLine("You're not doing anything.") return } diff --git a/internal/game/cmd_tech.go b/internal/game/cmd_tech.go index d4a6adc..cb4c9f7 100644 --- a/internal/game/cmd_tech.go +++ b/internal/game/cmd_tech.go @@ -7,6 +7,7 @@ import ( "thehouseoficarus/internal/color" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" + "thehouseoficarus/internal/ui" ) func (g *Game) doTech(sess *net.Session, input string) { @@ -121,7 +122,7 @@ func (g *Game) doTechList(sess *net.Session) { ), ) - t := &Table{Title: "Technology", Columns: []string{"Tech", "Level", "Drain/tick", "Effect", "Status"}} + t := &ui.Table{Title: "Technology", Columns: []string{"Tech", "Level", "Drain/tick", "Effect", "Status"}} for _, tech := range AllTechs { status := "" diff --git a/internal/game/cmd_trigger.go b/internal/game/cmd_trigger.go index 0acbeff..34e438a 100644 --- a/internal/game/cmd_trigger.go +++ b/internal/game/cmd_trigger.go @@ -4,7 +4,6 @@ import ( "fmt" "strings" - "thehouseoficarus/internal/combat" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) @@ -65,7 +64,7 @@ func (g *Game) parseTriggerArgs(input string, sciLevel int) (*ModDef, string) { } func (g *Game) triggerCombatMod(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { - if cs := combat.GetCombat(p.Name); cs != nil { + if cs := g.Combat.Get(p.Name); cs != nil { p.QueuedTrigger = mod.ID sess.WriteLine(fmt.Sprintf("You queue %s for your next attack.", mod.Name)) return @@ -101,7 +100,7 @@ func (g *Game) triggerCombatMod(sess *net.Session, p *player.Player, mod *ModDef return } - if combat.IsMobInCombat(mob.InstanceID) { + if g.Combat.IsMobInCombat(mob.InstanceID) { sess.WriteLine(fmt.Sprintf("%s is already engaged in combat!", mobDisplayName(mob, false))) return } diff --git a/internal/game/cmd_trigger_transport.go b/internal/game/cmd_trigger_transport.go index d3f7368..4272a07 100644 --- a/internal/game/cmd_trigger_transport.go +++ b/internal/game/cmd_trigger_transport.go @@ -3,13 +3,12 @@ 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 { + if g.Combat.Get(p.Name) != nil { sess.WriteLine("You can't teleport during combat!") return } diff --git a/internal/game/cmd_trigger_utility.go b/internal/game/cmd_trigger_utility.go index 5733722..0dae505 100644 --- a/internal/game/cmd_trigger_utility.go +++ b/internal/game/cmd_trigger_utility.go @@ -4,9 +4,8 @@ import ( "fmt" "strings" - "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" ) @@ -26,7 +25,7 @@ func (g *Game) triggerUtility(sess *net.Session, p *player.Player, mod *ModDef, } func (g *Game) triggerProcessing(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { - if combat.GetCombat(p.Name) != nil { + if g.Combat.Get(p.Name) != nil { sess.WriteLine("You can't do that during combat!") return } @@ -75,7 +74,7 @@ func (g *Game) triggerProcessing(sess *net.Session, p *player.Player, mod *ModDe } func (g *Game) triggerBonesToNuts(sess *net.Session, p *player.Player, mod *ModDef) { - if combat.GetCombat(p.Name) != nil { + if g.Combat.Get(p.Name) != nil { sess.WriteLine("You can't do that during combat!") return } @@ -154,7 +153,7 @@ func (g *Game) triggerEmGrab(sess *net.Session, p *player.Player, mod *ModDef, t } func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { - if combat.GetCombat(p.Name) != nil { + if g.Combat.Get(p.Name) != nil { sess.WriteLine("You can't do that during combat!") return } @@ -170,7 +169,7 @@ func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef return } - var match *object.ItemDef + var match *item.ItemDef for _, item := range g.CraftIndex.ByType("smelting") { if craftMatchesEntry(item.FirstCraft(), inv.ItemID) { match = item @@ -190,18 +189,18 @@ func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef for _, e := range c.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 + qty := e.Quantity + if qty <= 0 { + qty = 1 } - sess.WriteLine(fmt.Sprintf("You need %d %s.", qty, name)) - return + 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 } } } @@ -247,7 +246,7 @@ func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef } func (g *Game) triggerPlankMake(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { - if combat.GetCombat(p.Name) != nil { + if g.Combat.Get(p.Name) != nil { sess.WriteLine("You can't do that during combat!") return } diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go index a2cddaf..64f35c0 100644 --- a/internal/game/cmd_use.go +++ b/internal/game/cmd_use.go @@ -4,8 +4,8 @@ import ( "fmt" "strings" + "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" ) @@ -114,9 +114,9 @@ func (g *Game) useRoomObject(sess *net.Session, p *player.Player, input string) return true } -func (g *Game) stationRecipes(p *player.Player, stationDefID string) []*object.ItemDef { +func (g *Game) stationRecipes(p *player.Player, stationDefID string) []*item.ItemDef { items := g.CraftIndex.ByStation(stationDefID) - var results []*object.ItemDef + var results []*item.ItemDef for _, item := range items { c := item.FirstCraft() if !craftHasAllItemsQty(c, p.CountItem) { @@ -137,7 +137,7 @@ func (g *Game) stationRecipes(p *player.Player, stationDefID string) []*object.I return results } -func (g *Game) showRecipeMenu(sess *net.Session, p *player.Player, items []*object.ItemDef, stationName string) { +func (g *Game) showRecipeMenu(sess *net.Session, p *player.Player, items []*item.ItemDef, stationName string) { if len(items) == 0 { sess.WriteLine(fmt.Sprintf("You don't have anything you can make at the %s.", stationName)) return @@ -214,7 +214,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, cType = recipes[0].FirstCraft().Type } if cType == "smelting" { - var filtered []*object.ItemDef + var filtered []*item.ItemDef for _, item := range recipes { c := item.FirstCraft() if c.Type != "smelting" { @@ -237,7 +237,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, g.promptHowMany(sess, filtered[0].ID) return } - sess.PendingMenu = itemMenuData(itemIDs(filtered)) + sess.PendingMenu = itemMenuData(itemIDs(filtered)) sess.State = net.StateRecipeChoice var names []string for _, item := range filtered { @@ -258,7 +258,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, g.promptHowMany(sess, recipes[0].ID) return } - sess.PendingMenu = itemMenuData(itemIDs(recipes)) + sess.PendingMenu = itemMenuData(itemIDs(recipes)) sess.State = net.StateRecipeChoice var names []string for _, item := range recipes { @@ -328,7 +328,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, } if len(craftItems) > 0 { - var available []*object.ItemDef + var available []*item.ItemDef for _, item := range craftItems { if g.canDoItem(p, item, player.Crafting) { available = append(available, item) @@ -345,7 +345,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, } if len(fletchItems) > 0 { - var available []*object.ItemDef + var available []*item.ItemDef for _, item := range fletchItems { if g.canDoFletchItem(p, item) { available = append(available, item) @@ -362,7 +362,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, } matched := g.CraftIndex.FindByTwoInputs(itemAID, itemBID) - var matchable []*object.ItemDef + var matchable []*item.ItemDef for _, item := range matched { if craftHasAllItemsQty(item.FirstCraft(), p.CountItem) { matchable = append(matchable, item) @@ -373,7 +373,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, return } if len(matchable) > 1 { - sess.PendingMenu = itemMenuData(itemIDs(matchable)) + sess.PendingMenu = itemMenuData(itemIDs(matchable)) sess.State = net.StateRecipeChoice var names []string for _, item := range matchable { diff --git a/internal/game/cmd_verbs.go b/internal/game/cmd_verbs.go index f76ee6c..2ba1a7e 100644 --- a/internal/game/cmd_verbs.go +++ b/internal/game/cmd_verbs.go @@ -3,7 +3,7 @@ package game import ( "strings" - "thehouseoficarus/internal/action" + "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/net" "thehouseoficarus/internal/world" ) @@ -92,7 +92,7 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) { addUnique(§ionObjs, &seen, "use "+name) for _, r := range recipes { if info, ok := productionTypes[r.FirstCraft().Type]; ok { - if info.ActionType != action.TypeCombine && info.ActionType != action.TypeFletch && info.ActionType != action.TypeMix { + if info.ActionType != behavior.TypeCombine && info.ActionType != behavior.TypeFletch && info.ActionType != behavior.TypeMix { addUnique(§ionObjs, &seen, string(info.ActionType)) } } diff --git a/internal/game/cmd_wear.go b/internal/game/cmd_wear.go index 8f8117f..64069bc 100644 --- a/internal/game/cmd_wear.go +++ b/internal/game/cmd_wear.go @@ -4,8 +4,8 @@ import ( "fmt" "strings" + "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" ) @@ -13,7 +13,7 @@ func (g *Game) executeWear(sess *net.Session, args []string, rawInput string) { g.doWear(sess, strings.Join(args, " ")) } -func (g *Game) checkRequirements(sess *net.Session, p *player.Player, def *object.ItemDef) string { +func (g *Game) checkRequirements(sess *net.Session, p *player.Player, def *item.ItemDef) string { if len(def.Requirements) == 0 { return "" } @@ -85,7 +85,7 @@ func (g *Game) doWear(sess *net.Session, input string) { return } - if def.EquipSlot == object.SlotAmmo && def.Stackable { + if def.EquipSlot == item.SlotAmmo && def.Stackable { g.equipAmmo(sess, p, match.Slot, slot, def, qty) return } @@ -126,13 +126,13 @@ func (g *Game) doWear(sess *net.Session, input string) { sess.WriteLine(fmt.Sprintf("You %s %s.", verb, g.itemColorize(sess, def, match.Name))) } -func (g *Game) equipAmmo(sess *net.Session, p *player.Player, invIdx int, slot *player.InventorySlot, def *object.ItemDef, qty int) { +func (g *Game) equipAmmo(sess *net.Session, p *player.Player, invIdx int, slot *player.InventorySlot, def *item.ItemDef, qty int) { transferQty := slot.Quantity if qty > 0 && qty < transferQty { transferQty = qty } - existingAmmoID, hasAmmo := p.Equipment[object.SlotAmmo] + existingAmmoID, hasAmmo := p.Equipment[item.SlotAmmo] if hasAmmo && existingAmmoID != slot.ItemID { g.unequipAmmo(p) } @@ -146,7 +146,7 @@ func (g *Game) equipAmmo(sess *net.Session, p *player.Player, invIdx int, slot * if hasAmmo && existingAmmoID == slot.ItemID { p.AmmoQty += transferQty } else { - p.Equipment[object.SlotAmmo] = slot.ItemID + p.Equipment[item.SlotAmmo] = slot.ItemID p.AmmoQty = transferQty } @@ -156,7 +156,7 @@ func (g *Game) equipAmmo(sess *net.Session, p *player.Player, invIdx int, slot * } func (g *Game) unequipAmmo(p *player.Player) { - ammoID, ok := p.Equipment[object.SlotAmmo] + ammoID, ok := p.Equipment[item.SlotAmmo] if !ok || p.AmmoQty <= 0 { return } @@ -164,7 +164,7 @@ func (g *Game) unequipAmmo(p *player.Player) { s := p.InvSlot(i) if s != nil && s.ItemID == ammoID { s.Quantity += p.AmmoQty - delete(p.Equipment, object.SlotAmmo) + delete(p.Equipment, item.SlotAmmo) p.AmmoQty = 0 return } @@ -173,7 +173,7 @@ func (g *Game) unequipAmmo(p *player.Player) { if freeSlot >= 0 { p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: ammoID, Quantity: p.AmmoQty}) } - delete(p.Equipment, object.SlotAmmo) + delete(p.Equipment, item.SlotAmmo) p.AmmoQty = 0 } @@ -181,7 +181,7 @@ func (g *Game) doWearAll(sess *net.Session) { p := sess.Player g.cancelAction(p) - bestPerSlot := make(map[object.EquipSlot]struct { + bestPerSlot := make(map[item.EquipSlot]struct { itemID string value int invSlot int @@ -201,7 +201,7 @@ func (g *Game) doWearAll(sess *net.Session) { } current, exists := bestPerSlot[def.EquipSlot] val := def.Value - if def.EquipSlot == object.SlotAmmo && def.Stackable { + if def.EquipSlot == item.SlotAmmo && def.Stackable { val = def.Value * slot.Quantity } if !exists || val > current.value { @@ -221,19 +221,19 @@ func (g *Game) doWearAll(sess *net.Session) { var equipped []string for eqSlot, best := range bestPerSlot { if existing, ok := p.Equipment[eqSlot]; ok && existing == best.itemID { - if eqSlot != object.SlotAmmo { + if eqSlot != item.SlotAmmo { continue } } - if eqSlot == object.SlotAmmo { + if eqSlot == item.SlotAmmo { bestDef, _ := g.ItemStore.Load(best.itemID) if bestDef != nil && bestDef.Stackable { invSlot := p.InvSlot(best.invSlot) if invSlot == nil { continue } - existingID, hasAmmo := p.Equipment[object.SlotAmmo] + existingID, hasAmmo := p.Equipment[item.SlotAmmo] if hasAmmo && existingID != best.itemID { g.unequipAmmo(p) } @@ -242,7 +242,7 @@ func (g *Game) doWearAll(sess *net.Session) { if hasAmmo && existingID == best.itemID { p.AmmoQty += transferQty } else { - p.Equipment[object.SlotAmmo] = best.itemID + p.Equipment[item.SlotAmmo] = best.itemID p.AmmoQty = transferQty } equipped = append(equipped, g.itemColorize(sess, bestDef, bestDef.Name)) diff --git a/internal/game/combat_attack.go b/internal/game/combat_attack.go new file mode 100644 index 0000000..ee7dee8 --- /dev/null +++ b/internal/game/combat_attack.go @@ -0,0 +1,449 @@ +package game + +import ( + "fmt" + "math/rand" + "sort" + "strconv" + "strings" + + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/item" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func (g *Game) doAttack(sess *net.Session, input string) { + p := sess.Player + + if g.Combat.Get(p.Name) != nil { + sess.WriteLine("You are already in combat!") + return + } + + if p.Action != nil { + g.cancelAction(p) + } + + mob := g.findMob(sess, input, p.RoomID) + if mob == nil { + return + } + + if mob.HP <= 0 { + if mob.IsTask() { + sess.WriteLine(fmt.Sprintf("%s is already complete.", mobDisplayName(mob, true))) + } else { + sess.WriteLine("That is already dead.") + } + return + } + + if mob.Protected { + sess.WriteLine(fmt.Sprintf("You can't attack %s!", mobDisplayName(mob, true))) + return + } + + if mob.AssassinLevel > 0 && p.Level(player.Assassin) < mob.AssassinLevel { + sess.WriteLine(fmt.Sprintf("You need Assassin level %d to attack %s.", mob.AssassinLevel, mobDisplayName(mob, true))) + return + } + + if g.Combat.IsMobInCombat(mob.InstanceID) { + sess.WriteLine(fmt.Sprintf("%s is already engaged in combat!", mobDisplayName(mob, false))) + return + } + + if itemID, ok := p.Equipment[item.SlotMainHand]; ok { + if def, err := g.ItemStore.Load(itemID); err == nil && def.WeaponType == item.WeaponRanged { + if _, hasAmmo := p.Equipment[item.SlotAmmo]; !hasAmmo || p.AmmoQty <= 0 { + sess.WriteLine("You don't have any ammo equipped.") + return + } + } + } + + g.startCombat(sess, p, mob) +} + +func (g *Game) findMob(sess *net.Session, input string, roomID int) *world.MobInstance { + lower := strings.ToLower(input) + mobs := g.MobStore.MobsInRoom(roomID) + + idx := -1 + name := lower + if dotPos := strings.Index(lower, "."); dotPos > 0 { + if n, err := strconv.Atoi(lower[:dotPos]); err == nil && n > 0 { + idx = n + name = lower[dotPos+1:] + } + } + + var exact []*world.MobInstance + var prefix []*world.MobInstance + for _, m := range mobs { + q := m.MatchQuality(name) + if q == world.MatchExact { + exact = append(exact, m) + } else if q == world.MatchPrefix { + prefix = append(prefix, m) + } + } + + candidates := exact + if len(candidates) == 0 { + candidates = prefix + } + + if len(candidates) == 0 { + sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input)) + return nil + } + + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].InstanceID < candidates[j].InstanceID + }) + + if idx > 0 { + if idx-1 < len(candidates) { + return candidates[idx-1] + } + return nil + } + + if len(candidates) == 1 { + return candidates[0] + } + + seen := make(map[string]bool) + for _, m := range candidates { + seen[mobDisplayName(m, false)] = true + } + if len(seen) > 1 { + sess.WriteLine("Which one?") + return nil + } + return candidates[0] +} + +func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) { + g.cancelRest(p.Name) + g.cancelBgAction(p) + + g.Combat.Enter(p.Name, mob.InstanceID) + p.AttackTimer = 0 + + isTask := mob.IsTask() + autotriggerActive := p.AutotriggerMod != "" + + if !autotriggerActive { + attBonus, strBonus, defBonus := combat.AttackStyleBonus(string(p.AttackStyle)) + var styleParts []string + if attBonus > 0 { + styleParts = append(styleParts, fmt.Sprintf("+%d atk", attBonus)) + } + if strBonus > 0 { + styleParts = append(styleParts, fmt.Sprintf("+%d str", strBonus)) + } + if defBonus > 0 { + styleParts = append(styleParts, fmt.Sprintf("+%d def", defBonus)) + } + styleStr := "" + if len(styleParts) > 0 { + styleStr = " Style: " + string(p.AttackStyle) + " (" + strings.Join(styleParts, ", ") + ")" + } + if isTask { + sess.WriteLine(fmt.Sprintf("You begin to %s %s.%s", taskWorkVerb(mob), g.colorize(sess, "mob", mobDisplayName(mob, true)), styleStr)) + } else { + sess.WriteLine(fmt.Sprintf("You attack %s!%s", g.colorize(sess, "mob", mobDisplayName(mob, true)), styleStr)) + } + } else { + mod := GetMod(p.AutotriggerMod) + modName := p.AutotriggerMod + if mod != nil { + modName = mod.Name + } + if isTask { + sess.WriteLine(fmt.Sprintf("You begin to %s %s using %s!", + taskWorkVerb(mob), + g.colorize(sess, "mob", mobDisplayName(mob, true)), + g.colorize(sess, "science_mod", modName))) + } else { + sess.WriteLine(fmt.Sprintf("You attack %s with %s!", + g.colorize(sess, "mob", mobDisplayName(mob, true)), + g.colorize(sess, "science_mod", modName))) + } + } + if isTask { + g.broadcastAction(sess, "\n%s begins working on %s.", p.Name, mobDisplayName(mob, true)) + } else { + g.broadcastAction(sess, "\n%s attacks %s!", p.Name, mobDisplayName(mob, true)) + } + + g.Ticks.Subscribe(1, func() bool { + cs := g.Combat.Get(p.Name) + if cs == nil || !cs.Active { + return false + } + currentMob := g.MobStore.GetInstance(cs.MobID) + if currentMob == nil || currentMob.HP <= 0 { + g.endCombat(sess, p, currentMob) + return false + } + + p.AttackTimer++ + if float64(p.AttackTimer) >= g.playerWeaponSpeed(p) { + p.AttackTimer = 0 + + if g.hasDeckEquipped(p) { + if p.AutotriggerMod != "" { + mod := GetMod(p.AutotriggerMod) + if mod != nil && g.hasJunkCost(p, mod) { + g.scienceAttack(sess, p, currentMob, mod) + } else if mod != nil { + sess.WriteLine(g.colorize(sess, "error", + fmt.Sprintf("\nYou're out of junk for %s!! You flail with your fists in desperation!", mod.Name))) + g.playerAttackUnarmed(sess, p, currentMob) + } else { + p.AutotriggerMod = "" + sess.WriteLine(g.colorize(sess, "error", + "\nNo autotrigger set! Set a module with the 'autotrigger' command.")) + g.playerAttackUnarmed(sess, p, currentMob) + } + } else { + sess.WriteLine(g.colorize(sess, "error", + "\nNo autotrigger set! Set a module with the 'autotrigger' command.")) + g.playerAttackUnarmed(sess, p, currentMob) + } + } else { + if p.QueuedTrigger != "" { + mod := GetMod(p.QueuedTrigger) + p.QueuedTrigger = "" + if mod != nil && g.hasJunkCost(p, mod) { + g.scienceAttack(sess, p, currentMob, mod) + } else { + g.playerAttack(sess, p, currentMob) + } + } else { + g.playerAttack(sess, p, currentMob) + } + } + } + + if currentMob.HP <= 0 { + g.endCombat(sess, p, currentMob) + return false + } + return true + }) + + if !isTask { + g.Ticks.Subscribe(engine.ToTicks(mob.Speed), func() bool { + cs := g.Combat.Get(p.Name) + if cs == nil || !cs.Active { + return false + } + currentMob := g.MobStore.GetInstance(cs.MobID) + if currentMob == nil || currentMob.HP <= 0 { + return false + } + g.mobAttack(sess, p, currentMob) + if p.HP <= 0 { + g.endCombat(sess, p, currentMob) + return false + } + return true + }) + } +} + +func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) { + if g.processConsumeQueue(p, sess) { + return + } + + attackType, weaponType, attRoll, defRoll, maxHit := g.calculatePlayerAttackRoll(p, mob) + + if g.isSafespotted(p.Name) && attackType != "ranged" && attackType != "science" { + if ss, ok := g.safespot.Get(p.Name); ok { + g.forceLeaveSafespot(sess, p, &ss, "You leave your cover to close in for melee!") + } + } + + if combat.HitCheck(attRoll, defRoll) { + g.applyPlayerHit(sess, p, mob, attackType, weaponType, maxHit) + } else { + g.applyPlayerMiss(sess, p, mob, weaponType) + } +} + +func (g *Game) calculatePlayerAttackRoll(p *player.Player, mob *world.MobInstance) (attackType string, weaponType item.WeaponType, attRoll int, defRoll int, maxHit int) { + attackType = "crush" + if itemID, ok := p.Equipment[item.SlotMainHand]; ok { + if def, err := g.ItemStore.Load(itemID); err == nil { + if def.AttackType != "" { + attackType = def.AttackType + } else if def.WeaponType == item.WeaponRanged { + attackType = "ranged" + } else if def.WeaponType == item.WeaponScience { + attackType = "science" + } + weaponType = def.WeaponType + } + } + + totals := g.playerEquipBonuses(p) + isRanged := weaponType == item.WeaponRanged + + if isRanged { + rangedBonus, _ := combat.RangedStyleBonus(string(p.AttackStyle)) + equipAttack := totals.RangedAttack + effectiveRanged := p.Level(player.Ranged) + g.techLevelBonus(p, "ranged") + g.buffLevelBonus(p, "ranged") + attRoll = combat.EffectiveRoll(effectiveRanged, rangedBonus, equipAttack) + + mobDefBonus := combat.SelectBonus(attackType, + mob.StabDefense, mob.SlashDefense, mob.CrushDefense, + mob.ScienceDefense, mob.RangedDefense) + defRoll = combat.EffectiveRoll(mob.Defense, 0, mobDefBonus) + maxHit = combat.MaxHit(effectiveRanged, rangedBonus, totals.RangedStrength) + } else { + attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle)) + equipAttack := combat.SelectBonus(attackType, + totals.StabAttack, totals.SlashAttack, totals.CrushAttack, + totals.ScienceAttack, totals.RangedAttack) + effectiveAccuracy := p.Level(player.Accuracy) + g.techLevelBonus(p, "accuracy") + g.buffLevelBonus(p, "accuracy") + attRoll = combat.EffectiveRoll(effectiveAccuracy, attBonus, equipAttack) + + mobDefBonus := combat.SelectBonus(attackType, + mob.StabDefense, mob.SlashDefense, mob.CrushDefense, + mob.ScienceDefense, mob.RangedDefense) + defRoll = combat.EffectiveRoll(mob.Defense, 0, mobDefBonus) + maxHit = combat.MaxHit(p.Level(player.Strength)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus, totals.StrengthBonus) + } + return +} + +func (g *Game) playerAttackUnarmed(sess *net.Session, p *player.Player, mob *world.MobInstance) { + if g.processConsumeQueue(p, sess) { + return + } + + attackType, _, attRoll, defRoll, maxHit := g.calculateUnarmedAttackRoll(p, mob) + + if combat.HitCheck(attRoll, defRoll) { + g.applyPlayerHit(sess, p, mob, attackType, item.WeaponMelee, maxHit) + } else { + g.applyPlayerMiss(sess, p, mob, item.WeaponMelee) + } +} + +func (g *Game) calculateUnarmedAttackRoll(p *player.Player, mob *world.MobInstance) (attackType string, weaponType item.WeaponType, attRoll int, defRoll int, maxHit int) { + attackType = "crush" + weaponType = item.WeaponMelee + + attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle)) + effectiveAccuracy := p.Level(player.Accuracy) + g.techLevelBonus(p, "accuracy") + g.buffLevelBonus(p, "accuracy") + attRoll = combat.EffectiveRoll(effectiveAccuracy, attBonus, 0) + + mobDefBonus := combat.SelectBonus(attackType, + mob.StabDefense, mob.SlashDefense, mob.CrushDefense, + mob.ScienceDefense, mob.RangedDefense) + defRoll = combat.EffectiveRoll(mob.Defense, 0, mobDefBonus) + maxHit = combat.MaxHit(p.Level(player.Strength)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus, 0) + return +} + +func (g *Game) applyPlayerHit(sess *net.Session, p *player.Player, mob *world.MobInstance, attackType string, weaponType item.WeaponType, maxHit int) { + dmg := combat.RollDamage(maxHit) + + mob.HP -= dmg + if mob.HP < 0 { + mob.HP = 0 + } + if mob.FinishingBlow != "" && mob.HP <= 0 { + mob.HP = 1 + } + if mob.HP < mob.MaxHP && mob.HP > 0 { + mob.StartRegen() + } + + if mob.FinishingBlow != "" && mob.HP == 1 { + fbDef, _ := g.ItemStore.Load(mob.FinishingBlow) + fbName := mob.FinishingBlow + if fbDef != nil { + fbName = fbDef.Name + } + sess.WriteLine(g.colorize(sess, "warning", + fmt.Sprintf("%s resists death! Use %s on it to finish it off.", + mobDisplayName(mob, false), fbName))) + } + + isRanged := weaponType == item.WeaponRanged + gains := g.awardCombatXP(sess, p, dmg, isRanged) + + if isRanged { + g.consumeAmmo(sess, p) + } + + if mob.IsTask() { + g.writeTaskProgress(sess, p, mob, dmg, gains) + } else { + mobName := mobDisplayName(mob, true) + attacker := mob.Name + if !mob.Unique { + attacker = "The " + mob.Name + } + w := len(fmt.Sprintf("You hit %s for %d damage.", mobName, 999)) + if w2 := len(fmt.Sprintf("%s hits you for %d damage.", attacker, 999)); w2 > w { + w = w2 + } + prefix := fmt.Sprintf("You hit %s for %s damage.", g.colorize(sess, "mob", mobName), g.colorize(sess, "damage_dealt", fmt.Sprint(dmg))) + visLen := color.VisibleLen(prefix) + if visLen < w { + prefix += strings.Repeat(" ", w-visLen+1) + } + hpSuffix := fmt.Sprintf("%s [%2d/%d]", g.hpBar(sess, mob.HP, mob.MaxHP), mob.HP, mob.MaxHP) + line := prefix + hpSuffix + g.formatXpDrop(sess, p, gains) + sess.WriteLine(line) + } + + if isRanged && p.AmmoQty <= 0 { + sess.WriteLine("You've run out of ammo!") + g.Combat.Leave(p.Name) + } +} + +func (g *Game) applyPlayerMiss(sess *net.Session, p *player.Player, mob *world.MobInstance, weaponType item.WeaponType) { + sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf("You miss %s.", mobDisplayName(mob, true)))) + + if weaponType == item.WeaponRanged { + g.consumeAmmo(sess, p) + if p.AmmoQty <= 0 { + sess.WriteLine("You've run out of ammo!") + g.Combat.Leave(p.Name) + } + } +} + +func (g *Game) consumeAmmo(sess *net.Session, p *player.Player) { + ammoID := p.Equipment[item.SlotAmmo] + + p.AmmoQty-- + if p.AmmoQty <= 0 { + delete(p.Equipment, item.SlotAmmo) + p.AmmoQty = 0 + } + + if ammoID != "" { + if def, err := g.ItemStore.Load(ammoID); err == nil && def.Recoverable { + if rand.Float64() < 0.80 { + g.World.AddGroundItem(p.RoomID, def.ID, 1) + } + } + } + + g.AccountStore.SaveCharacter(p) +} diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go new file mode 100644 index 0000000..e06e307 --- /dev/null +++ b/internal/game/combat_mob.go @@ -0,0 +1,301 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) { + if g.isSafespotted(p.Name) && g.safespotBlocksMob(p, mob) { + return + } + + attRoll, defRoll := g.calculateMobAttack(p, mob) + + if combat.HitCheck(attRoll, defRoll) { + g.applyMobHit(sess, p, mob) + } else { + g.applyMobMiss(sess, mob) + } +} + +func (g *Game) calculateMobAttack(p *player.Player, mob *world.MobInstance) (attRoll int, defRoll int) { + _, _, defStyleBonus := combat.AttackStyleBonus(string(p.AttackStyle)) + + mobAttackType := mob.AttackType + if mobAttackType == "" { + mobAttackType = "crush" + } + + attRoll = combat.EffectiveRoll(mob.Attack, 0, mob.AttackBonus) + + totals := g.playerEquipBonuses(p) + equipDef := combat.SelectBonus(mobAttackType, + totals.StabDefense, totals.SlashDefense, totals.CrushDefense, + totals.ScienceDefense, totals.RangedDefense) + defRoll = combat.EffectiveRoll(p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"), defStyleBonus, equipDef) + return +} + +func (g *Game) applyMobHit(sess *net.Session, p *player.Player, mob *world.MobInstance) { + maxHit := combat.MaxHit(mob.Strength, 0, mob.StrengthBonus) + dmg := combat.RollDamage(maxHit) + dmg = g.applyTechProtection(p, mob, dmg) + + if mob.DamageWithout != "" { + hasProtection := false + for _, itemID := range p.Equipment { + if itemID == mob.DamageWithout { + hasProtection = true + break + } + } + if !hasProtection { + dmg = dmg * 3 / 2 + if dmg < 1 { + dmg = 1 + } + cs := g.Combat.Get(p.Name) + if cs != nil && !cs.DamageWarningShown { + cs.DamageWarningShown = true + fbDef, _ := g.ItemStore.Load(mob.DamageWithout) + fbName := mob.DamageWithout + if fbDef != nil { + fbName = fbDef.Name + } + attacker := mob.Name + if !mob.Unique { + attacker = "The " + mob.Name + } + sess.WriteLine(g.colorize(sess, "warning", + fmt.Sprintf(" %s's attack is extra effective! Equip %s for protection.", + attacker, fbName))) + } + } + } + + p.HP -= dmg + if p.HP < 0 { + p.HP = 0 + } + p.StartRegen() + g.AccountStore.SaveCharacter(p) + + attacker := mob.Name + if !mob.Unique { + attacker = "The " + mob.Name + } + mobName := mobDisplayName(mob, true) + w := len(fmt.Sprintf("%s hits you for %d damage.", attacker, 999)) + if w2 := len(fmt.Sprintf("You hit %s for %d damage.", mobName, 999)); w2 > w { + w = w2 + } + prefix := fmt.Sprintf("%s hits you for %s damage.", g.colorize(sess, "mob", attacker), g.colorize(sess, "damage_taken", fmt.Sprint(dmg))) + visLen := color.VisibleLen(prefix) + if visLen < w { + prefix += strings.Repeat(" ", w-visLen+1) + } + hpSuffix := fmt.Sprintf("%s [%2d/%d]", g.hpBar(sess, p.HP, p.MaxHP()), p.HP, p.MaxHP()) + sess.WriteLine(prefix + hpSuffix) + + if ss, hasSS := g.safespot.Get(p.Name); hasSS && ss.HideCountdown > 0 { + g.safespot.Delete(p.Name) + sess.WriteLine(g.colorize(sess, "error", "You're hit while repositioning! You failed to hide.")) + } + + if p.MoveTicks > 0 { + p.ClearMoveState() + sess.WriteLine("Can't escape!") + } +} + +func (g *Game) applyMobMiss(sess *net.Session, mob *world.MobInstance) { + attacker := mob.Name + if !mob.Unique { + attacker = "The " + mob.Name + } + sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf("%s misses you.", attacker))) +} + +func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) { + g.Combat.Leave(p.Name) + + if p.HP <= 0 { + g.killPlayer(sess, p, mob) + return + } + + if mob != nil && mob.HP <= 0 { + isTask := mob.IsTask() + p.Stats.RecordMobKill(mob.DefID) + + if isTask { + complete := mob.CompleteMessage + if complete == "" { + complete = fmt.Sprintf("You finish your work on %s!", mobDisplayName(mob, true)) + } + sess.WriteLine(g.colorize(sess, "victory", "\n"+complete)) + } else { + sess.WriteLine(g.colorize(sess, "victory", fmt.Sprintf("\nYou have defeated %s!", mobDisplayName(mob, true)))) + g.onAssassinKill(sess, p, mob) + } + + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other != sess && other.Player != nil { + if isTask { + other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s finishes working on %s.", p.Name, mobDisplayName(mob, false)))) + } else { + op := other.Player + mobLvl := mobCombatLevel(mob) + levelStr := g.levelColorize(other, op.CombatLevel(), mobLvl, fmt.Sprintf("(level %d)", mobLvl)) + other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s has slain %s %s!", p.Name, mobDisplayName(mob, false), levelStr))) + } + } + } + } + + dropLabel := func() string { + if isTask { + return "You receive:" + } + dropper := mob.Name + if !mob.Unique { + dropper = "The " + mob.Name + } + return dropper + " drops:" + }() + + if mob.Drops.Remains != "" { + g.World.AddReservedItem(p.RoomID, mob.Drops.Remains, 1, p.Name) + def, _ := g.ItemStore.Load(mob.Drops.Remains) + name := mob.Drops.Remains + if def != nil { + name = def.Name + } + coloredName := g.itemColorize(sess, def, name) + sess.WriteLine(fmt.Sprintf("%s %s", g.colorize(sess, "drop_message", dropLabel), coloredName)) + } + + if len(mob.Drops.Loot) > 0 { + entry := behavior.ResolveDrop(g.DataDir, mob.Drops.Loot) + if entry != nil && entry.ItemID != "" { + qty := entry.Quantity + if qty <= 0 { + qty = 1 + } + g.World.AddReservedItem(p.RoomID, entry.ItemID, qty, p.Name) + def, _ := g.ItemStore.Load(entry.ItemID) + name := entry.ItemID + if def != nil { + name = def.Name + } + coloredName := g.itemColorize(sess, def, name) + if qty > 1 { + sess.WriteLine(fmt.Sprintf("%s %d x %s", g.colorize(sess, "drop_message", dropLabel), qty, coloredName)) + } else { + sess.WriteLine(fmt.Sprintf("%s %s", g.colorize(sess, "drop_message", dropLabel), coloredName)) + } + } + } + + respawnTicks := engine.ToTicks(mob.RespawnTicks) + if respawnTicks <= 0 { + respawnTicks = engine.ToTicks(30) + } + instanceID := mob.InstanceID + g.Ticks.Subscribe(respawnTicks, func() bool { + g.respawnMob(instanceID) + return false + }) + g.writePrompt(sess) + } +} + +// killPlayer handles a player death from any source (combat or a room hazard). +// mob may be nil (e.g. a hazard kill); it is only used for Dead Man's Switch +// retribution. +func (g *Game) killPlayer(sess *net.Session, p *player.Player, mob *world.MobInstance) { + g.Combat.Leave(p.Name) + + if p.HasActiveTech("retribution") { + retDef := GetTechDef("retribution") + if retDef != nil && mob != nil && mob.HP > 0 { + retDmg := int(float64(p.MaxHP()) * retDef.Effects.RetributionPct) + if retDmg > 0 { + mob.HP -= retDmg + if mob.HP < 0 { + mob.HP = 0 + } + sess.WriteLine(fmt.Sprintf("\nDead Man's Switch activates! %s takes %d damage!", + mobDisplayName(mob, true), retDmg)) + } + } + } + p.DeactivateAllTechs() + p.Stats.RecordDeath() + sess.WriteLine(g.colorize(sess, "death", "\nOh dear, you are dead!")) + p.ClearMoveState() + p.HazardTimer = 0 + if ss, ok := g.safespot.Get(p.Name); ok { + g.forceLeaveSafespot(sess, p, &ss, "") + } + g.dropItemsOnDeath(p) + p.HP = p.MaxHP() + p.RoomID = 1001 + g.AccountStore.SaveCharacter(p) + if g.Hub != nil { + g.Hub.EnterRoom(sess, p.RoomID) + } + g.doLook(sess) + g.writePrompt(sess) +} + +func (g *Game) awardCombatXP(sess *net.Session, p *player.Player, dmg int, isRanged bool) []xpGain { + baseXP := dmg * 4 + var gains []xpGain + + if isRanged { + gains = []xpGain{ + {string(player.Ranged), baseXP * 3 / 4}, + {string(player.Hitpoints), baseXP / 4}, + } + } else { + switch p.AttackStyle { + case player.Accurate: + gains = []xpGain{{string(player.Accuracy), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}} + case player.Aggressive: + gains = []xpGain{{string(player.Strength), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}} + case player.Defensive: + gains = []xpGain{{string(player.Defense), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}} + case player.Balanced: + quarter := baseXP / 4 + gains = []xpGain{ + {string(player.Accuracy), quarter}, + {string(player.Strength), quarter}, + {string(player.Defense), quarter}, + {string(player.Hitpoints), quarter}, + } + } + } + + for _, gain := range gains { + g.awardSkillXP(sess, p, player.SkillName(gain.Skill), gain.XP) + } + g.AccountStore.SaveCharacter(p) + return gains +} + +func (g *Game) stopCombat(playerName string) { + if cs := g.Combat.Get(playerName); cs != nil { + g.Combat.Leave(playerName) + } +} diff --git a/internal/game/condition_test.go b/internal/game/condition_test.go index d932d18..f365f2e 100644 --- a/internal/game/condition_test.go +++ b/internal/game/condition_test.go @@ -3,7 +3,7 @@ package game import ( "testing" - "thehouseoficarus/internal/action" + "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/net" "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" @@ -45,7 +45,7 @@ func TestCheckConditionPlayerFlagTruthy(t *testing.T) { unset := sessWithFlags(map[string]any{}) // {player_flag: x} -> set means present+truthy - posCond := &action.Condition{PlayerFlag: "x"} + posCond := &behavior.Condition{PlayerFlag: "x"} if !g.checkCondition(set, posCond) { t.Error("expected positive flag check to pass when flag is true") } @@ -54,7 +54,7 @@ func TestCheckConditionPlayerFlagTruthy(t *testing.T) { } // {player_flag: x, not: true} -> negative - negCond := &action.Condition{PlayerFlag: "x", Not: true} + negCond := &behavior.Condition{PlayerFlag: "x", Not: true} if g.checkCondition(set, negCond) { t.Error("expected negative flag check to fail when flag is true") } @@ -76,13 +76,13 @@ func TestCheckConditionValueComparisonPreserved(t *testing.T) { g := &Game{Flags: NewFlagStore()} sess := sessWithFlags(map[string]any{"n": 1}) - if !g.checkCondition(sess, &action.Condition{PlayerFlag: "n", Value: 1}) { + if !g.checkCondition(sess, &behavior.Condition{PlayerFlag: "n", Value: 1}) { t.Error("value match should pass for equal int") } - if g.checkCondition(sess, &action.Condition{PlayerFlag: "n", Value: 2}) { + if g.checkCondition(sess, &behavior.Condition{PlayerFlag: "n", Value: 2}) { t.Error("value match should fail for unequal int") } - if !g.checkCondition(sess, &action.Condition{PlayerFlag: "n", Value: 2, Not: true}) { + if !g.checkCondition(sess, &behavior.Condition{PlayerFlag: "n", Value: 2, Not: true}) { t.Error("negated value match should pass for unequal int") } } @@ -92,13 +92,13 @@ func TestCheckConditionWorldFlag(t *testing.T) { g.Flags.Set("gate_open", true) sess := sessWithFlags(map[string]any{}) - if !g.checkCondition(sess, &action.Condition{Flag: "gate_open"}) { + if !g.checkCondition(sess, &behavior.Condition{Flag: "gate_open"}) { t.Error("world flag truthy check should pass") } - if g.checkCondition(sess, &action.Condition{Flag: "gate_open", Not: true}) { + if g.checkCondition(sess, &behavior.Condition{Flag: "gate_open", Not: true}) { t.Error("negated world flag should fail when set") } - if g.checkCondition(sess, &action.Condition{Flag: "missing"}) { + if g.checkCondition(sess, &behavior.Condition{Flag: "missing"}) { t.Error("missing world flag should not pass") } } @@ -107,9 +107,9 @@ func TestResolveObjDescPresence(t *testing.T) { g := &Game{Flags: NewFlagStore()} def := &object.ObjectDef{ - Descriptions: []object.ConditionalDesc{ - {Text: "lined", Condition: &action.Condition{PlayerFlag: "lined_up"}}, - {Text: "milling", Condition: &action.Condition{PlayerFlag: "lined_up", Not: true}}, + Description: behavior.DescList{ + {Text: "lined", Condition: &behavior.Condition{PlayerFlag: "lined_up"}}, + {Text: "milling", Condition: &behavior.Condition{PlayerFlag: "lined_up", Not: true}}, }, } @@ -124,16 +124,16 @@ func TestResolveObjDescPresence(t *testing.T) { // gated entirely off -> absent gated := &object.ObjectDef{ - Descriptions: []object.ConditionalDesc{ - {Text: "x", Condition: &action.Condition{PlayerFlag: "done", Not: true}}, + Description: behavior.DescList{ + {Text: "x", Condition: &behavior.Condition{PlayerFlag: "done", Not: true}}, }, } if _, present := g.resolveObjDesc(sessWithFlags(map[string]any{"done": true}), gated); present { t.Error("expected object to be absent when no variant matches") } - // plain object (no Descriptions) always present - plain := &object.ObjectDef{Description: "plain"} + // plain object always present + plain := &object.ObjectDef{Description: behavior.DescList{{Text: "plain"}}} if text, present := g.resolveObjDesc(sessWithFlags(nil), plain); !present || text != "plain" { t.Errorf("expected plain/present, got %q/%v", text, present) } @@ -142,9 +142,9 @@ func TestResolveObjDescPresence(t *testing.T) { func TestRoomDescriptionSelection(t *testing.T) { g := &Game{Flags: NewFlagStore()} room := &world.Room{ - Description: "empty pad", - Descriptions: []world.RoomDesc{ - {Text: "crowd", Condition: &action.Condition{PlayerFlag: "done", Not: true}}, + Description: behavior.DescList{ + {Text: "crowd", Condition: &behavior.Condition{PlayerFlag: "done", Not: true}}, + {Text: "empty pad"}, }, } if got := g.roomDescription(sessWithFlags(map[string]any{}), room); got != "crowd" { diff --git a/internal/game/core_course.go b/internal/game/core_course.go index 7d7fe08..2b6b28f 100644 --- a/internal/game/core_course.go +++ b/internal/game/core_course.go @@ -4,8 +4,8 @@ import ( "path/filepath" "sync" - "thehouseoficarus/internal/action" "gopkg.in/yaml.v3" + "thehouseoficarus/internal/behavior" ) type ObstacleDef struct { @@ -77,6 +77,16 @@ func (cs *CourseStore) LoadAll() { cs.loadAllLocked() } +// AllCourses returns the full course config map, loading on first access. +func (cs *CourseStore) AllCourses() map[string]*CourseConfig { + cs.mu.Lock() + defer cs.mu.Unlock() + if !cs.loaded { + cs.loadAllLocked() + } + return cs.courses +} + func (cs *CourseStore) GetObstacle(roomID int) *ObstacleInfo { cs.mu.Lock() defer cs.mu.Unlock() @@ -91,7 +101,7 @@ func (cs *CourseStore) loadAllLocked() { 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 { + behavior.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 diff --git a/internal/game/core_equip.go b/internal/game/core_equip.go index 02b8348..cc8016d 100644 --- a/internal/game/core_equip.go +++ b/internal/game/core_equip.go @@ -1,12 +1,12 @@ package game import ( - "thehouseoficarus/internal/object" + "thehouseoficarus/internal/item" "thehouseoficarus/internal/player" ) -func (g *Game) playerEquipBonuses(p *player.Player) object.ItemStats { - var totals object.ItemStats +func (g *Game) playerEquipBonuses(p *player.Player) item.ItemStats { + var totals item.ItemStats for _, itemID := range p.Equipment { def, err := g.ItemStore.Load(itemID) if err != nil { @@ -31,7 +31,7 @@ func (g *Game) playerEquipBonuses(p *player.Player) object.ItemStats { } func (g *Game) playerWeaponSpeed(p *player.Player) float64 { - if itemID, ok := p.Equipment[object.SlotMainHand]; ok { + if itemID, ok := p.Equipment[item.SlotMainHand]; ok { def, err := g.ItemStore.Load(itemID) if err == nil && def.Speed > 0 { return def.Speed diff --git a/internal/game/core_production.go b/internal/game/core_production.go index 56d1ccc..8a38f36 100644 --- a/internal/game/core_production.go +++ b/internal/game/core_production.go @@ -1,622 +1,14 @@ 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/behavior" + "thehouseoficarus/internal/item" "thehouseoficarus/internal/player" ) -func (g *Game) resolveCraftVar(sess *net.Session, varSpec string, craft *object.CraftDef, outputID string, byproducts []string, hasItem func(string) bool) string { - if !strings.Contains(varSpec, "%") { - return varSpec - } - - outDef, _ := g.ItemStore.Load(outputID) - outputName := outputID - if outDef != nil { - outputName = g.itemColorize(sess, outDef, outDef.Name) - } - varSpec = strings.ReplaceAll(varSpec, "%n", outputName) - - for i, e := range craft.Consume { - key := fmt.Sprintf("%%i%d", i+1) - if !strings.Contains(varSpec, key) { - continue - } - var matchedID string - for _, id := range e.Items { - if hasItem(id) { - matchedID = id - break - } - } - if matchedID == "" && len(e.Items) > 0 { - matchedID = e.Items[0] - } - if def, err := g.ItemStore.Load(matchedID); err == nil { - varSpec = strings.ReplaceAll(varSpec, key, g.itemColorize(sess, def, def.Name)) - } else { - varSpec = strings.ReplaceAll(varSpec, key, matchedID) - } - } - - for i, bpID := range byproducts { - key := fmt.Sprintf("%%b%d", i+1) - if !strings.Contains(varSpec, key) { - continue - } - if def, err := g.ItemStore.Load(bpID); err == nil { - varSpec = strings.ReplaceAll(varSpec, key, g.itemColorize(sess, def, def.Name)) - } else { - varSpec = strings.ReplaceAll(varSpec, key, bpID) - } - } - - return color.ExpandTags(g.colorMode(sess), varSpec) -} - -func (g *Game) resolveCraftMessage(sess *net.Session, itemMsg, defaultMsg string, craft *object.CraftDef, outputID string, byproducts []string, hasItem func(string) bool) string { - msg := itemMsg - if msg == "" { - msg = defaultMsg - } - if msg == "" { - return "" - } - return g.resolveCraftVar(sess, msg, craft, outputID, byproducts, hasItem) -} - -func (g *Game) startProduction(sess *net.Session, p *player.Player, item *object.ItemDef, craft *object.CraftDef, actionType action.ActionType, displayVerb, startMsg, endMsg string, count int) { - g.cancelAction(p) - g.cancelBgAction(p) - - skill := craft.EffectiveSkill() - if skill != "" { - skillLevel := p.Level(player.SkillName(skill)) - if skillLevel < craft.Level { - sess.WriteLine(fmt.Sprintf("You need level %d %s to do that.", craft.Level, skill)) - g.reprompt(sess) - return - } - } - - if !craftHasAllItemsQty(craft, p.CountItem) { - sess.WriteLine("You don't have the required materials.") - g.reprompt(sess) - return - } - - outDef, _ := g.ItemStore.Load(item.ID) - - wait := craft.Wait - if wait <= 0 { - wait = 4 - } - - outputName := item.ID - if outDef != nil { - outputName = outDef.Name - } - - p.Action = &action.Action{ - Type: actionType, - TargetID: item.ID, - TargetName: outputName, - Data: &action.ProductionData{ - ItemID: item.ID, - Phase: 0, - Wait: wait, - StartMsg: startMsg, - EndMsg: endMsg, - Remaining: count, - }, - WaitLeft: engine.ToTicks(1), - } - - g.broadcastAction(sess, "%s starts %s.", p.Name, displayVerb) -} - -func (g *Game) startProductionFromItem(sess *net.Session, p *player.Player, item *object.ItemDef, count int) { - craft := item.FirstCraft() - if craft == nil { - return - } - - _, stationName := g.findStation(p.RoomID, craft.Station) - if stationName == "" { - stationName = "inventory" - } - - info, ok := productionTypes[craft.Type] - if !ok { - info = productionTypeInfo{ - ActionType: action.ActionType(craft.Type), - DisplayVerb: craft.Type, - StartMessage: "You start " + craft.Type + " %i1.", - SuccessMessage: "You produce %n.", - EndMessage: "You've finished " + craft.Type + ".", - } - } - - startMsg := g.resolveCraftMessage(sess, craft.StartMessage, info.StartMessage, craft, item.ID, nil, p.HasItem) - if stationName != "inventory" { - startMsg += " on the " + stationName + "." - } - - endMsg := g.resolveCraftMessage(sess, craft.EndMessage, info.EndMessage, craft, item.ID, nil, p.HasItem) - - g.startProduction(sess, p, item, craft, info.ActionType, info.DisplayVerb, startMsg, endMsg, count) -} - -func (g *Game) loadCraftItem(itemID string) *object.ItemDef { - item, err := g.ItemStore.Load(itemID) - if err != nil || len(item.Craft) == 0 { - return nil - } - return item -} - -func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool { - d, ok := p.Action.Data.(*action.ProductionData) - if !ok { - g.cancelAction(p) - return false - } - itemID := d.ItemID - phase := d.Phase - wait := d.Wait - startMsg := d.StartMsg - endMsg := d.EndMsg - remaining := d.Remaining - - item := g.loadCraftItem(itemID) - if item == nil { - g.cancelAction(p) - return false - } - craft := item.FirstCraft() - - if phase == 0 { - if startMsg != "" { - sess.WriteLine(startMsg) - } - if len(craft.Steps) > 0 { - d.NextStepIndex = 0 - d.StepsAccumulatedTicks = 0 - } - d.Phase = 1 - p.Action.WaitLeft = engine.ToTicks(wait) - return true - } - - if stepsIdx := d.NextStepIndex; stepsIdx < len(craft.Steps) { - accTicks := d.StepsAccumulatedTicks - accTicks += wait - d.StepsAccumulatedTicks = accTicks - for i := stepsIdx; i < len(craft.Steps); i++ { - if accTicks >= craft.Steps[i].Tick { - sess.WriteLine(g.resolveCraftVar(sess, craft.Steps[i].Message, craft, item.ID, nil, p.HasItem)) - d.NextStepIndex = i + 1 - } - } - } - - skill := craft.EffectiveSkill() - skillLevel := p.Level(player.SkillName(skill)) - chance := 1.0 - if craft.Success != nil { - chance = action.SuccessChance(action.SuccessFormula(*craft.Success), skillLevel, craft.Level) - } - - info, _ := productionTypes[craft.Type] - - if rand.Float64() < chance { - outputQty := craft.OutputQty - if outputQty <= 0 { - outputQty = 1 - } - - byproducts := g.collectByproducts(p, item) - - placed := false - outDef, _ := g.ItemStore.Load(item.ID) - if outDef != nil && outDef.Stackable { - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == item.ID { - craftConsumeAll(craft, p.HasItem, p.RemoveItem) - slot.Quantity += outputQty - placed = true - break - } - } - } - if !placed { - craftConsumeAll(craft, 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: item.ID, Quantity: outputQty}) - } - - for _, bp := range byproducts { - if slot := p.FirstFreeSlot(); slot >= 0 { - p.SetInvSlot(slot, &player.InventorySlot{ItemID: bp, Quantity: 1}) - } - } - - if craft.XP > 0 { - if newLevel := p.AddSkillXP(player.SkillName(skill), craft.XP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, skill))) - } - } - g.AccountStore.SaveCharacter(p) - - var defaultSuccess string - if info.SuccessMessage != "" { - defaultSuccess = info.SuccessMessage - } else { - defaultSuccess = "You produce %n." - } - msg := g.resolveCraftMessage(sess, craft.Message, defaultSuccess, craft, item.ID, byproducts, p.HasItem) - if p.OptionBool("xp_drops") && craft.XP > 0 { - msg += g.formatXpDropSingle(sess, p, player.SkillName(skill), craft.XP) - } - sess.WriteLine(msg) - } else { - craftConsumeAll(craft, p.HasItem, p.RemoveItem) - - if craft.Fail != "" { - freeSlot := p.FirstFreeSlot() - if freeSlot >= 0 { - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: craft.Fail, Quantity: 1}) - } - } - g.AccountStore.SaveCharacter(p) - - msg := g.resolveCraftMessage(sess, craft.FailMessage, info.FailMessage, craft, item.ID, nil, p.HasItem) - if msg != "" { - sess.WriteLine(g.colorize(sess, "damage_taken", msg)) - } - } - - if remaining > 0 { - remaining-- - d.Remaining = remaining - if remaining <= 0 { - if endMsg != "" { - sess.WriteLine(endMsg) - } - g.cancelAction(p) - return false - } - } - - if g.canContinueProduction(p, item) { - p.Action.WaitLeft = engine.ToTicks(wait) - return true - } - - if endMsg != "" { - sess.WriteLine(endMsg) - } - g.cancelAction(p) - return false -} - -func (g *Game) collectByproducts(p *player.Player, item *object.ItemDef) []string { - craft := item.FirstCraft() - if craft == nil { - return nil - } - var byproducts []string - for _, e := range craft.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, item *object.ItemDef) bool { - craft := item.FirstCraft() - if craft == nil { - return false - } - if !craftHasAllItemsQty(craft, p.CountItem) { - return false - } - outputQty := craft.OutputQty - if outputQty <= 0 { - outputQty = 1 - } - outDef, _ := g.ItemStore.Load(item.ID) - if outDef != nil && outDef.Stackable { - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == item.ID { - 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 || object.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 iid, ok := entry["item_id"]; ok { - if def, _ := g.ItemStore.Load(iid); def != nil { - return def.Name - } - return iid - } - if barID, ok := entry["bar_id"]; ok { - if def, _ := g.ItemStore.Load(barID); def != nil { - return def.Name - } - return barID - } - return "" -} - -func (g *Game) dispatchMenuEntry(sess *net.Session, entry map[string]string) { - if iid, ok := entry["item_id"]; ok { - g.promptHowMany(sess, iid) - return - } - if barID, ok := entry["bar_id"]; ok { - g.showSmithTable(sess, sess.Player, barID) - return - } - g.reprompt(sess) -} - -func (g *Game) formatMaterials(sess *net.Session, item *object.ItemDef) string { - craft := item.FirstCraft() - if craft == nil { - return "" - } - var parts []string - for _, e := range craft.Consume { - itemName := e.Items[0] - if def, err := g.ItemStore.Load(e.Items[0]); err == nil { - itemName = g.itemColorize(sess, def, 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, items []*object.ItemDef, title, skill, lastFlagKey, promptVerb string, background bool) { - sort.Slice(items, func(i, j int) bool { - return items[i].FirstCraft().Level < items[j].FirstCraft().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, item := range items { - craft := item.FirstCraft() - outDef, _ := g.ItemStore.Load(item.ID) - productName := item.ID - if outDef != nil { - productName = outDef.Name - } - - outputQty := craft.OutputQty - if outputQty <= 0 { - outputQty = 1 - } - if outDef != nil && outDef.Stackable && outputQty > 1 { - productName = fmt.Sprintf("%s x%d", productName, outputQty) - } - - matStr := g.formatMaterials(sess, item) - levelStr := fmt.Sprint(craft.Level) - numStr := fmt.Sprintf("%d", i+1) - - canMake := skillLevel >= craft.Level && craftHasAllItemsQty(craft, 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) - } - - lastItemID, _ := p.Flags[lastFlagKey].(string) - hint := "" - if lastItemID != "" { - for _, item := range items { - if item.ID == lastItemID { - if outDef, err := g.ItemStore.Load(item.ID); 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 = itemMenuData(itemIDs(items)) - 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) - - var items []*object.ItemDef - for _, entry := range menuData { - iid := entry["item_id"] - if item := g.loadCraftItem(iid); item != nil { - items = append(items, item) - } - } - - if len(items) == 0 { - g.reprompt(sess) - return - } - - sort.Slice(items, func(i, j int) bool { - return items[i].FirstCraft().Level < items[j].FirstCraft().Level - }) - - lastItemID, _ := p.Flags[lastFlagKey].(string) - - sel, errMsg := g.resolveCraftByInput(input, items, lastItemID) - switch errMsg { - case "ambiguous": - sess.WriteLine("That's ambiguous.") - g.reprompt(sess) - return - case "never_mind": - sess.WriteLine("Never mind.") - g.reprompt(sess) - return - } - - craft := sel.Item.FirstCraft() - skillLevel := p.Level(player.SkillName(skill)) - if skillLevel < craft.Level { - sess.WriteLine(fmt.Sprintf("You need level %d %s to make that.", craft.Level, skill)) - g.reprompt(sess) - return - } - if !craftHasAllItemsQty(craft, p.CountItem) { - sess.WriteLine("You don't have the materials for that.") - g.reprompt(sess) - return - } - - p.EnsureFlags() - p.Flags[lastFlagKey] = sel.Item.ID - g.AccountStore.SaveCharacter(p) - - if background { - g.startFletchAction(sess, p, sel.Item, sel.Count) - } else { - g.startProductionFromItem(sess, p, sel.Item, sel.Count) - } -} - func (g *Game) findTool(p *player.Player, toolTypes []string) (toolSpeed float64, toolName string, found bool) { toolSpeed = -1 - if itemID, ok := p.Equipment[object.SlotMainHand]; ok { + if itemID, ok := p.Equipment[item.SlotMainHand]; ok { if def, err := g.ItemStore.Load(itemID); err == nil { for _, t := range toolTypes { if def.ToolType == t { @@ -649,7 +41,7 @@ func (g *Game) hasToolType(p *player.Player, toolType string) bool { } type productionTypeInfo struct { - ActionType action.ActionType + ActionType behavior.ActionType DisplayVerb string StartMessage string SuccessMessage string @@ -659,7 +51,7 @@ type productionTypeInfo struct { var productionTypes = map[string]productionTypeInfo{ "cooking": { - ActionType: action.TypeCook, + ActionType: behavior.TypeCook, DisplayVerb: "cooking", StartMessage: "You start cooking %i1.", SuccessMessage: "Cooked to perfection. %n looks great!", @@ -667,7 +59,7 @@ var productionTypes = map[string]productionTypeInfo{ FailMessage: "You accidentally burn the %i1.", }, "smelting": { - ActionType: action.TypeSmelt, + ActionType: behavior.TypeSmelt, DisplayVerb: "smelting", StartMessage: "You place the %i1 into the furnace.", SuccessMessage: "You remove a white hot %n!", @@ -675,42 +67,42 @@ var productionTypes = map[string]productionTypeInfo{ FailMessage: "The %i1 is consumed by the flames.", }, "smithing": { - ActionType: action.TypeSmith, + ActionType: behavior.TypeSmith, DisplayVerb: "smithing", StartMessage: "You begin smithing %i1.", SuccessMessage: "You smith a %n.", EndMessage: "You've finished smithing.", }, "crafting": { - ActionType: action.TypeCraft, + ActionType: behavior.TypeCraft, DisplayVerb: "crafting", StartMessage: "You begin crafting %i1.", SuccessMessage: "You craft a %n.", EndMessage: "You've finished crafting.", }, "combine": { - ActionType: action.TypeCombine, + ActionType: behavior.TypeCombine, DisplayVerb: "combining", StartMessage: "You start combining %i1.", SuccessMessage: "You produce %n.", EndMessage: "You've finished combining.", }, "fletching": { - ActionType: action.TypeFletch, + ActionType: behavior.TypeFletch, DisplayVerb: "fletching", StartMessage: "You begin fletching %i1.", SuccessMessage: "You fletch a %n.", EndMessage: "You've finished fletching.", }, "pharmacy": { - ActionType: action.TypeMix, + ActionType: behavior.TypeMix, DisplayVerb: "mixing", StartMessage: "You start mixing %i1.", SuccessMessage: "You mix a %n.", EndMessage: "You've finished mixing.", }, "construction": { - ActionType: action.TypeConstruct, + ActionType: behavior.TypeConstruct, DisplayVerb: "constructing", StartMessage: "You begin constructing %i1.", SuccessMessage: "You construct a %n.", @@ -726,150 +118,3 @@ func init() { productionActionTypes[string(pt.ActionType)] = true } } - -func itemMenuData(itemIDs []string) []map[string]string { - out := make([]map[string]string, len(itemIDs)) - for i, id := range itemIDs { - out[i] = map[string]string{"item_id": id} - } - return out -} - -func itemIDs(defs []*object.ItemDef) []string { - ids := make([]string, len(defs)) - for i, d := range defs { - ids[i] = d.ID - } - return ids -} - -func (g *Game) promptHowMany(sess *net.Session, itemID string) { - sess.PendingItemID = itemID - sess.State = net.StateHowMany - sess.Write("How many (return for all)?: ") -} - -type craftSelection struct { - Item *object.ItemDef - Count int -} - -func (g *Game) resolveCraftByInput(input string, items []*object.ItemDef, lastItemID string) (sel craftSelection, errMsg string) { - if input == "" { - if lastItemID == "" { - return sel, "never_mind" - } - for i := range items { - if items[i].ID == lastItemID { - sel.Item = items[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(items) { - sel.Item = items[idx-1] - sel.Count = qty - return sel, "" - } - - matchCount := 0 - for i := range items { - outDef, _ := g.ItemStore.Load(items[i].ID) - name := items[i].ID - if outDef != nil { - name = outDef.Name - } - if object.WordPrefixMatch(productName, name) { - if matchCount == 0 { - sel.Item = items[i] - sel.Count = qty - } - matchCount++ - } - } - if matchCount > 1 { - return sel, "ambiguous" - } - if sel.Item == 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) itemName(sess *net.Session, item *object.ItemDef) string { - if item != nil { - if def, err := g.ItemStore.Load(item.ID); err == nil { - return g.itemColorize(sess, def, def.Name) - } - return item.ID - } - return "" -} - -func (g *Game) showRecipeChoice(sess *net.Session, p *player.Player, items []*object.ItemDef, emptyMsg, title string, autoOption string) { - if len(items) == 0 { - sess.WriteLine(emptyMsg) - return - } - if len(items) == 1 { - if autoOption != "" && p.OptionBool(autoOption) { - g.startProductionFromItem(sess, p, items[0], 0) - return - } - g.promptHowMany(sess, items[0].ID) - return - } - sess.State = net.StateRecipeChoice - sess.PendingMenu = itemMenuData(itemIDs(items)) - var names []string - for _, item := range items { - names = append(names, g.itemName(sess, item)) - } - g.showMenuTable(sess, title, names) -} - -func (g *Game) handleHowMany(sess *net.Session, input string) { - p := sess.Player - itemID := sess.PendingItemID - sess.PendingItemID = "" - 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 - } - - item := g.loadCraftItem(itemID) - if item == nil { - g.reprompt(sess) - return - } - - g.startProductionFromItem(sess, p, item, count) -} diff --git a/internal/game/core_startup.go b/internal/game/core_startup.go deleted file mode 100644 index 2e81eac..0000000 --- a/internal/game/core_startup.go +++ /dev/null @@ -1,887 +0,0 @@ -package game - -import ( - "fmt" - "os" - "path/filepath" - "sort" - "strings" - - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/world" -) - -type StartupIssue struct { - Level string - Type string - Message string -} - -func (g *Game) ValidateStartup() []StartupIssue { - var issues []StartupIssue - - issues = append(issues, validateIDs(g.DataDir, "items", "item")...) - issues = append(issues, validateIDs(g.DataDir, "rooms", "room")...) - issues = append(issues, validateIDs(g.DataDir, "mobs", "mob")...) - issues = append(issues, validateIDs(g.DataDir, "objects", "object")...) - issues = append(issues, validateIDs(g.DataDir, "drops", "drop table")...) - issues = append(issues, validateIDs(g.DataDir, "courses", "course")...) - issues = append(issues, validateIDs(g.DataDir, "modules", "module")...) - issues = append(issues, validateIDs(g.DataDir, "techs", "tech")...) - issues = append(issues, validateIDs(g.DataDir, "help", "help topic")...) - - issues = append(issues, validateRooms(g)...) - issues = append(issues, validateMobs(g)...) - issues = append(issues, validateHazards(g)...) - issues = append(issues, validateObjects(g)...) - issues = append(issues, validateItems(g)...) - issues = append(issues, validateCraftBlocks(g)...) - issues = append(issues, validateDropTables(g)...) - issues = append(issues, validateCourses(g)...) - issues = append(issues, validateTechs(g)...) - issues = append(issues, validateRoomWiring(g)...) - - return issues -} - -func validateIDs(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 validateRooms(g *Game) []StartupIssue { - var issues []StartupIssue - roomIndex := g.World.RoomIndex() - itemIDs := g.ItemStore.IDSet() - mobIDs := g.MobStore.AllDefIDs() - objIDs := g.ObjectStore.IDSet() - hazardIDs := g.World.HazardIndex() - - 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 { - switch dir { - case world.North, world.South, world.East, world.West, world.Up, world.Down: - default: - issues = append(issues, StartupIssue{ - Level: "ERROR", - Type: "reference", - Message: fmt.Sprintf("Room %d: invalid exit direction %q (only north/south/east/west/up/down supported)", id, dir), - }) - continue - } - 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), - }) - } - } - } - - if room.Hazard != "" && !hazardIDs[room.Hazard] { - issues = append(issues, StartupIssue{ - Level: "ERROR", - Type: "reference", - Message: fmt.Sprintf("Room %d: references nonexistent hazard %q", - id, room.Hazard), - }) - } - } - - return issues -} - -func validateMobs(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.Kind != "" && def.Kind != "combat" && def.Kind != "task" { - issues = append(issues, StartupIssue{ - Level: "ERROR", - Type: "reference", - Message: fmt.Sprintf("Mob %q: invalid kind %q (must be \"combat\" or \"task\")", id, def.Kind), - }) - } - - 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 validateHazards(g *Game) []StartupIssue { - var issues []StartupIssue - itemIDs := g.ItemStore.IDSet() - validTypes := map[string]bool{"stab": true, "slash": true, "crush": true, "ranged": true, "science": true} - - for id := range g.World.HazardIndex() { - def, err := g.World.LoadHazard(id) - if err != nil { - issues = append(issues, StartupIssue{ - Level: "ERROR", - Type: "reference", - Message: fmt.Sprintf("Hazard %q: failed to load: %v", id, err), - }) - continue - } - if def.AttackType != "" && !validTypes[def.AttackType] { - issues = append(issues, StartupIssue{ - Level: "ERROR", - Type: "reference", - Message: fmt.Sprintf("Hazard %q: invalid attack_type %q (must be stab/slash/crush/ranged/science)", id, def.AttackType), - }) - } - if def.RequiredItem != "" && !itemIDs[def.RequiredItem] { - issues = append(issues, StartupIssue{ - Level: "ERROR", - Type: "reference", - Message: fmt.Sprintf("Hazard %q: required_item %q does not exist", id, def.RequiredItem), - }) - } - } - - return issues -} - -func validateObjects(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, validateGather(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 validateItems(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), - }) - } - - 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 validateCraftBlocks(g *Game) []StartupIssue { - var issues []StartupIssue - itemIDs := g.ItemStore.IDSet() - objIDs := g.ObjectStore.IDSet() - - allItems, err := g.ItemStore.LoadAll() - if err != nil { - issues = append(issues, StartupIssue{ - Level: "ERROR", - Type: "reference", - Message: fmt.Sprintf("Failed to load items: %v", err), - }) - return issues - } - - for _, item := range allItems { - if len(item.Craft) == 0 { - continue - } - for ci := range item.Craft { - c := &item.Craft[ci] - - for _, e := range c.Consume { - for _, consumeItem := range e.Items { - if consumeItem != "" && !itemIDs[consumeItem] { - issues = append(issues, StartupIssue{ - Level: "ERROR", - Type: "reference", - Message: fmt.Sprintf("Item %q (craft: %s): consumes nonexistent item %q", - item.ID, c.Type, consumeItem), - }) - } - } - } - - if c.Fail != "" && !itemIDs[c.Fail] { - issues = append(issues, StartupIssue{ - Level: "ERROR", - Type: "reference", - Message: fmt.Sprintf("Item %q (craft: %s): fail product %q does not exist", - item.ID, c.Type, c.Fail), - }) - } - - for _, sid := range c.Station { - if sid != "" && !objIDs[sid] { - issues = append(issues, StartupIssue{ - Level: "ERROR", - Type: "reference", - Message: fmt.Sprintf("Item %q (craft: %s): station object %q does not exist", - item.ID, c.Type, sid), - }) - } - } - } - } - - return issues -} - -func validateDropTables(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 validateCourses(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 validateGather(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 -} - -var knownTechCategories = map[string]bool{ - "accuracy": true, - "strength": true, - "defense": true, - "ranged": true, - "science": true, - "protection": true, - "utility": true, - "combo": true, -} - -// Reserved tech IDs whose semantics are coupled to code logic — -// damageAfterTechProtection maps attack types to protect_melee/ranged/science -// and killPlayer checks for retribution. These IDs must exist in YAML. -var reservedTechIDs = map[string]bool{ - "protect_melee": true, - "protect_ranged": true, - "protect_science": true, - "retribution": true, -} - -func validateTechs(g *Game) []StartupIssue { - var issues []StartupIssue - seen := make(map[string]bool) - for _, def := range AllTechs { - if def.ID == "" { - issues = append(issues, StartupIssue{ - Level: "ERROR", - Type: "reference", - Message: "Tech has empty id", - }) - continue - } - if seen[def.ID] { - issues = append(issues, StartupIssue{ - Level: "ERROR", - Type: "duplicate", - Message: fmt.Sprintf("Duplicate tech %q", def.ID), - }) - } - seen[def.ID] = true - - if def.Name == "" { - issues = append(issues, StartupIssue{ - Level: "WARN", - Type: "reference", - Message: fmt.Sprintf("Tech %q: has no name", def.ID), - }) - } - if def.Category != "" && !knownTechCategories[def.Category] { - issues = append(issues, StartupIssue{ - Level: "WARN", - Type: "reference", - Message: fmt.Sprintf("Tech %q: unknown category %q", def.ID, def.Category), - }) - } - if def.DrainRate < 0 { - issues = append(issues, StartupIssue{ - Level: "ERROR", - Type: "reference", - Message: fmt.Sprintf("Tech %q: negative drain_rate %.2f", def.ID, def.DrainRate), - }) - } - } - - for id := range reservedTechIDs { - if _, ok := techByID[id]; !ok { - issues = append(issues, StartupIssue{ - Level: "ERROR", - Type: "reference", - Message: fmt.Sprintf("Reserved tech %q does not exist — required by code (damage protection / retribution)", id), - }) - } - } - - 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) - } -} diff --git a/internal/game/core_types.go b/internal/game/core_types.go index 898078b..08866cc 100644 --- a/internal/game/core_types.go +++ b/internal/game/core_types.go @@ -1,11 +1,11 @@ package game -import "thehouseoficarus/internal/object" +import "thehouseoficarus/internal/item" -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, +var EquipSlots = []item.EquipSlot{ + item.SlotHead, item.SlotNeck, item.SlotTorso, item.SlotLegs, + item.SlotHands, item.SlotFeet, item.SlotBack, item.SlotAmmo, + item.SlotMainHand, item.SlotOffHand, item.SlotRing, } type itemMatch struct { @@ -20,10 +20,10 @@ type xpGain struct { } type deathDrop struct { - itemID string - quantity int + itemID string + quantity int totalValue int - isEquip bool - equipSlot object.EquipSlot - invSlot int + isEquip bool + equipSlot item.EquipSlot + invSlot int } diff --git a/internal/game/core_utils.go b/internal/game/core_utils.go index c169580..4c087e8 100644 --- a/internal/game/core_utils.go +++ b/internal/game/core_utils.go @@ -128,5 +128,3 @@ func (g *Game) newInventorySlot(itemID string, qty int) *player.InventorySlot { } return slot } - - diff --git a/internal/game/craft_index.go b/internal/game/craft_index.go index 078ec3c..648ccc8 100644 --- a/internal/game/craft_index.go +++ b/internal/game/craft_index.go @@ -3,31 +3,31 @@ package game import ( "sync" - "thehouseoficarus/internal/object" + "thehouseoficarus/internal/item" ) type CraftIndex struct { mu sync.RWMutex - byType map[string][]*object.ItemDef - byInput map[string][]*object.ItemDef - byStation map[string][]*object.ItemDef + byType map[string][]*item.ItemDef + byInput map[string][]*item.ItemDef + byStation map[string][]*item.ItemDef } func NewCraftIndex() *CraftIndex { return &CraftIndex{ - byType: make(map[string][]*object.ItemDef), - byInput: make(map[string][]*object.ItemDef), - byStation: make(map[string][]*object.ItemDef), + byType: make(map[string][]*item.ItemDef), + byInput: make(map[string][]*item.ItemDef), + byStation: make(map[string][]*item.ItemDef), } } -func (idx *CraftIndex) Build(items []*object.ItemDef) { +func (idx *CraftIndex) Build(items []*item.ItemDef) { idx.mu.Lock() defer idx.mu.Unlock() - idx.byType = make(map[string][]*object.ItemDef) - idx.byInput = make(map[string][]*object.ItemDef) - idx.byStation = make(map[string][]*object.ItemDef) + idx.byType = make(map[string][]*item.ItemDef) + idx.byInput = make(map[string][]*item.ItemDef) + idx.byStation = make(map[string][]*item.ItemDef) for _, def := range items { if len(def.Craft) == 0 { @@ -54,7 +54,7 @@ func (idx *CraftIndex) Build(items []*object.ItemDef) { } } -func appendUnique(items []*object.ItemDef, item *object.ItemDef) []*object.ItemDef { +func appendUnique(items []*item.ItemDef, item *item.ItemDef) []*item.ItemDef { for _, existing := range items { if existing == item { return items @@ -63,34 +63,34 @@ func appendUnique(items []*object.ItemDef, item *object.ItemDef) []*object.ItemD return append(items, item) } -func (idx *CraftIndex) ByType(craftType string) []*object.ItemDef { +func (idx *CraftIndex) ByType(craftType string) []*item.ItemDef { idx.mu.RLock() defer idx.mu.RUnlock() items := idx.byType[craftType] - out := make([]*object.ItemDef, len(items)) + out := make([]*item.ItemDef, len(items)) copy(out, items) return out } -func (idx *CraftIndex) ByStation(stationDefID string) []*object.ItemDef { +func (idx *CraftIndex) ByStation(stationDefID string) []*item.ItemDef { idx.mu.RLock() defer idx.mu.RUnlock() items := idx.byStation[stationDefID] - out := make([]*object.ItemDef, len(items)) + out := make([]*item.ItemDef, len(items)) copy(out, items) return out } -func (idx *CraftIndex) ByInput(itemID string) []*object.ItemDef { +func (idx *CraftIndex) ByInput(itemID string) []*item.ItemDef { idx.mu.RLock() defer idx.mu.RUnlock() items := idx.byInput[itemID] - out := make([]*object.ItemDef, len(items)) + out := make([]*item.ItemDef, len(items)) copy(out, items) return out } -func (idx *CraftIndex) FindByTwoInputs(itemA, itemB string) []*object.ItemDef { +func (idx *CraftIndex) FindByTwoInputs(itemA, itemB string) []*item.ItemDef { idx.mu.RLock() defer idx.mu.RUnlock() @@ -104,7 +104,7 @@ func (idx *CraftIndex) FindByTwoInputs(itemA, itemB string) []*object.ItemDef { candidateSetB[def.ID] = true } - var results []*object.ItemDef + var results []*item.ItemDef for _, def := range candidatesA { if !candidateSetB[def.ID] { continue @@ -121,11 +121,11 @@ func (idx *CraftIndex) FindByTwoInputs(itemA, itemB string) []*object.ItemDef { return results } -func (idx *CraftIndex) FindByStationInput(stationDefID, itemID string) []*object.ItemDef { +func (idx *CraftIndex) FindByStationInput(stationDefID, itemID string) []*item.ItemDef { idx.mu.RLock() defer idx.mu.RUnlock() - var results []*object.ItemDef + var results []*item.ItemDef for _, def := range idx.byStation[stationDefID] { for _, craft := range def.Craft { if craftMatchesEntry(&craft, itemID) { @@ -137,7 +137,7 @@ func (idx *CraftIndex) FindByStationInput(stationDefID, itemID string) []*object return results } -func craftEntryFor(def *object.ItemDef, itemID string) int { +func craftEntryFor(def *item.ItemDef, itemID string) int { for _, craft := range def.Craft { if ei := craftEntry(craft, itemID); ei >= 0 { return ei @@ -146,7 +146,7 @@ func craftEntryFor(def *object.ItemDef, itemID string) int { return -1 } -func craftEntry(craft object.CraftDef, itemID string) int { +func craftEntry(craft item.CraftDef, itemID string) int { for ei, e := range craft.Consume { for _, id := range e.Items { if id == itemID { @@ -157,7 +157,7 @@ func craftEntry(craft object.CraftDef, itemID string) int { return -1 } -func craftMatchesEntry(c *object.CraftDef, itemID string) bool { +func craftMatchesEntry(c *item.CraftDef, itemID string) bool { if c == nil { return false } @@ -171,7 +171,7 @@ func craftMatchesEntry(c *object.CraftDef, itemID string) bool { return false } -func craftHasAllItems(c *object.CraftDef, hasItem func(string) bool) bool { +func craftHasAllItems(c *item.CraftDef, hasItem func(string) bool) bool { if c == nil { return false } @@ -190,7 +190,7 @@ func craftHasAllItems(c *object.CraftDef, hasItem func(string) bool) bool { return true } -func craftHasAllItemsQty(c *object.CraftDef, countItem func(string) int) bool { +func craftHasAllItemsQty(c *item.CraftDef, countItem func(string) int) bool { if c == nil { return false } @@ -213,7 +213,7 @@ func craftHasAllItemsQty(c *object.CraftDef, countItem func(string) int) bool { return true } -func craftConsumeAll(c *object.CraftDef, hasItem func(string) bool, removeItem func(string, int) bool) bool { +func craftConsumeAll(c *item.CraftDef, hasItem func(string) bool, removeItem func(string, int) bool) bool { if c == nil { return false } diff --git a/internal/game/game.go b/internal/game/game.go index dfa5143..320304f 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -11,6 +11,7 @@ import ( "thehouseoficarus/internal/config" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/game/hacking" + "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" @@ -26,55 +27,71 @@ const ( ClassUnknown ) +// Deps holds the long-lived data stores and engines a Game depends on. They are +// constructed once at startup and never replaced. Deps is embedded in Game, so +// game code accesses them directly (e.g. g.World, g.ItemStore). +type Deps struct { + World *world.World + ObjectStore *object.ObjectStore + ItemStore *item.ItemStore + AccountStore *player.AccountStore + MobStore *world.MobStore + CraftIndex *CraftIndex + CourseStore *CourseStore + Ticks *engine.Engine + ColorConfig *config.ColorsConfig + DataDir string +} + +// Game is the central orchestrator: it owns the data stores (via the embedded +// Deps), the shared mutable game state (flags, combat tracker, command queue, +// safespots), and the per-session runtime bookkeeping. type Game struct { - World *world.World - ObjectStore *object.ObjectStore - ItemStore *object.ItemStore - AccountStore *player.AccountStore - MobStore *world.MobStore - CraftIndex *CraftIndex - CourseStore *CourseStore - Hub *net.Hub - Ticks *engine.Engine - Flags *FlagStore - ColorConfig *config.ColorsConfig - DataDir string - ValidationConfig config.ValidationConfig - restTimers map[string]uint64 - charsMu sync.Mutex - loggedInChars map[string]*net.Session - queue *CommandQueue - pendingDepletions []pendingDepletion + Deps + + Hub *net.Hub + Flags *FlagStore + Combat *combat.Tracker + queue *CommandQueue + safespot *SafespotManager + ValidationConfig config.ValidationConfig + + // Per-tick / per-session runtime bookkeeping. + charsMu sync.Mutex + loggedInChars map[string]*net.Session + restTimers map[string]uint64 guardWatchTimers map[string]int - farmTickCounter int hackingStates map[string]*hacking.Session - safespot *SafespotManager - enterMu sync.Mutex - enterSeqs map[string]*enterSeq + pendingDepletions []pendingDepletion + farmTickCounter int + enterMu sync.Mutex + enterSeqs map[string]*enterSeq } func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.ValidationConfig) *Game { g := &Game{ - World: world.New(dataDir), - ObjectStore: object.NewObjectStore(dataDir), - ItemStore: object.NewItemStore(dataDir), - AccountStore: player.NewAccountStore(dataDir), - MobStore: world.NewMobStore(dataDir), - CraftIndex: NewCraftIndex(), - CourseStore: NewCourseStore(dataDir), - Ticks: engine.New(), - Flags: NewFlagStore(), - ColorConfig: colorConfig, - DataDir: dataDir, + Deps: Deps{ + World: world.New(dataDir), + ObjectStore: object.NewObjectStore(dataDir), + ItemStore: item.NewItemStore(dataDir), + AccountStore: player.NewAccountStore(dataDir), + MobStore: world.NewMobStore(dataDir), + CraftIndex: NewCraftIndex(), + CourseStore: NewCourseStore(dataDir), + Ticks: engine.New(), + ColorConfig: colorConfig, + DataDir: dataDir, + }, + Flags: NewFlagStore(), + Combat: combat.NewTracker(), + queue: NewCommandQueue(), + safespot: NewSafespotManager(), ValidationConfig: valConfig, - restTimers: make(map[string]uint64), loggedInChars: make(map[string]*net.Session), - queue: NewCommandQueue(), - pendingDepletions: nil, - guardWatchTimers: make(map[string]int), - hackingStates: make(map[string]*hacking.Session), - safespot: NewSafespotManager(), - enterSeqs: make(map[string]*enterSeq), + restTimers: make(map[string]uint64), + guardWatchTimers: make(map[string]int), + hackingStates: make(map[string]*hacking.Session), + enterSeqs: make(map[string]*enterSeq), } g.CourseStore.LoadAll() g.LoadMods() @@ -299,7 +316,7 @@ func (g *Game) ProcessQueuedCommands() { } ss, _ := g.safespot.Get(p.Name) isHiding := ss.Active - isBusy := p.Action != nil || len(p.WalkSequence) > 0 || combat.GetCombat(p.Name) != nil || p.MoveTicks > 0 || isHiding + isBusy := p.Action != nil || len(p.WalkSequence) > 0 || g.Combat.Get(p.Name) != nil || p.MoveTicks > 0 || isHiding _, isResting := g.restTimers[p.Name] if !isResting && !isBusy && qc.Session.State == net.StateGame { g.writePrompt(qc.Session) diff --git a/internal/game/hacking/liars_dice.go b/internal/game/hacking/liars_dice.go index bcfbd40..1dd27b6 100644 --- a/internal/game/hacking/liars_dice.go +++ b/internal/game/hacking/liars_dice.go @@ -8,16 +8,16 @@ import ( ) type LiarsDiceGame struct { - playerDice []int - aiDice []int - currentBid LiarsBid - hasBid bool - playerTurn bool - round int - gameOver bool - won bool - playerLost int - completed bool + playerDice []int + aiDice []int + currentBid LiarsBid + hasBid bool + playerTurn bool + round int + gameOver bool + won bool + playerLost int + completed bool } type LiarsBid struct { diff --git a/internal/game/look_entities.go b/internal/game/look_entities.go new file mode 100644 index 0000000..f9412d4 --- /dev/null +++ b/internal/game/look_entities.go @@ -0,0 +1,347 @@ +package game + +import ( + "fmt" + "sort" + "strings" + + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func (g *Game) showRoomMobs(sess *net.Session, p *player.Player, room *world.Room) []string { + mobs := g.MobStore.MobsInRoom(p.RoomID) + if len(mobs) == 0 { + return nil + } + sort.Slice(mobs, func(i, j int) bool { + iDamaged := mobs[i].HP < mobs[i].MaxHP + jDamaged := mobs[j].HP < mobs[j].MaxHP + if iDamaged != jDamaged { + return iDamaged + } + return mobs[i].InstanceID < mobs[j].InstanceID + }) + var lines []string + lines = append(lines, "") + playerLevel := p.CombatLevel() + for _, m := range mobs { + hp := "" + if m.HP < m.MaxHP { + if m.IsTask() { + pct := 0 + if m.MaxHP > 0 { + pct = (m.MaxHP - m.HP) * 100 / m.MaxHP + } + hp = fmt.Sprintf(" [%d%% complete]", pct) + } else { + hp = fmt.Sprintf(" [%s/%dhp]", color.Render(g.colorMode(sess), color.Parse("167"), fmt.Sprint(m.HP)), m.MaxHP) + } + } + var desc string + if g.Combat.IsMobInCombat(m.InstanceID) { + def, err := g.MobStore.LoadDef(m.DefID) + if err == nil && len(def.CombatDescriptions) > 0 { + target := g.Combat.MobTarget(m.InstanceID) + pattern := def.CombatDescriptions[randInt(len(def.CombatDescriptions))] + desc = " " + fmt.Sprintf(pattern, target) + } + } else if m.IdleDescription != "" { + desc = fmt.Sprintf(" %s", m.IdleDescription) + } + displayName := m.Name + if !m.Unique { + displayName = "A " + m.Name + } + mobColor := "mob" + if m.Protected { + mobColor = "protected_mob" + } + mobLevel := mobCombatLevel(m) + levelStr := g.levelColorize(sess, playerLevel, mobLevel, fmt.Sprintf("(level %d)", mobLevel)) + lines = append(lines, fmt.Sprintf("%s %s%s%s", g.colorize(sess, mobColor, displayName), levelStr, hp, desc)) + } + return lines +} + +func (g *Game) showRoomObjects(sess *net.Session, p *player.Player, room *world.Room) []string { + objs := g.World.AllObjInstances(p.RoomID) + if len(objs) == 0 { + return g.showFarmPatches(sess, p) + } + var lines []string + lines = append(lines, "") + grouped := make(map[string]int) + var order []string + for _, o := range objs { + if _, ok := grouped[o.DefID]; !ok { + order = append(order, o.DefID) + } + grouped[o.DefID]++ + } + for _, objID := range order { + count := grouped[objID] + def, err := g.ObjectStore.Load(objID) + if err != nil { + continue + } + + if def.Hidden { + continue + } + + if farmPatchDefIDs[objID] { + continue + } + + type instInfo struct { + idx int + depleted bool + sharedMax int + sharedCur int + respawnIn int + quality int + } + var instances []instInfo + for i := 0; i < count; i++ { + st := g.World.GetObjState(p.RoomID, objID, i) + if st == nil { + continue + } + instances = append(instances, instInfo{ + idx: i + 1, + depleted: st.Depleted, + sharedMax: int(st.SharedMax), + sharedCur: st.SharedTimer, + respawnIn: int(st.DepleteTimer), + quality: int(st.Quality), + }) + } + multi := len(instances) > 1 + showTimers := p.OptionBool("depletion") + + var freshIdxs []int + var timed, depleted []instInfo + for _, ins := range instances { + if ins.depleted { + depleted = append(depleted, ins) + } else if ins.sharedMax > 0 && ins.sharedCur < ins.sharedMax { + timed = append(timed, ins) + } else { + freshIdxs = append(freshIdxs, ins.idx) + } + } + + roomDesc := def.InRoomDescription + if roomDesc != "" { + roomDesc = color.ExpandTags(g.colorMode(sess), roomDesc) + } + coloredName := g.objColorize(sess, def, def.Name) + coloredPlural := g.objColorize(sess, def, def.Name+"s") + + if len(freshIdxs) > 0 { + var line string + if roomDesc != "" { + line = roomDesc + } else if len(freshIdxs) == 1 { + line = fmt.Sprintf("A %s is here.", coloredName) + } else { + line = fmt.Sprintf("%d %s are here.", len(freshIdxs), coloredPlural) + } + var suffix string + if multi && (len(timed) > 0 || len(depleted) > 0) { + suffix = fmt.Sprintf(" [%s]", joinInts(freshIdxs)) + } + qualityTimer := "" + if showTimers && len(instances) > 0 && instances[0].quality > 0 { + qualityTimer = fmt.Sprintf(" (Burning for %d more ticks)", instances[0].quality) + } + lines = append(lines, fmt.Sprintf("%s%s%s", line, suffix, qualityTimer)) + } + + for _, ins := range timed { + var line string + if roomDesc != "" { + line = roomDesc + } else { + line = fmt.Sprintf("A %s is here.", coloredName) + } + tag := "" + if multi { + tag = fmt.Sprintf(" [%d]", ins.idx) + } + timer := "" + if showTimers { + timer = fmt.Sprintf(" (despawn: %d/%d)", ins.sharedCur, ins.sharedMax) + } + lines = append(lines, fmt.Sprintf("%s%s%s", line, tag, timer)) + } + + for _, ins := range depleted { + var line string + if roomDesc != "" { + line = roomDesc + } else { + line = fmt.Sprintf("A %s is here.", coloredName) + } + tag := "" + if multi { + tag = fmt.Sprintf(" [%d]", ins.idx) + } + timer := "" + if showTimers { + timer = fmt.Sprintf(" (respawns in %d ticks)", ins.respawnIn) + } + lines = append(lines, fmt.Sprintf("%s (depleted)%s%s", line, tag, timer)) + } + } + + lines = append(lines, g.showFarmPatches(sess, p)...) + return lines +} + +func (g *Game) showGroundItems(sess *net.Session, p *player.Player, room *world.Room) []string { + ground := g.World.GroundItemsDetailed(p.RoomID) + if len(ground) == 0 { + return nil + } + showDespawn := p.OptionBool("despawn") + showReserve := p.OptionBool("reserve") + + type displayLine struct { + name string + quantity int + colorName string + annotation string + } + + type groupKey struct { + itemID string + despawnTimer int + } + + var lines []displayLine + groups := make(map[groupKey]*displayLine) + var groupOrder []groupKey + + for _, info := range ground { + def, err := g.ItemStore.Load(info.ItemID) + name := info.ItemID + if err == nil { + name = def.Name + } + coloredName := g.itemColorize(sess, def, name) + + if info.ReservedFor != "" { + var parts []string + if showReserve { + parts = append(parts, fmt.Sprintf("reserved for %s %dt", info.ReservedFor, info.ReserveTimer)) + } else { + parts = append(parts, "reserved") + } + if showDespawn && !info.IsSpawn { + parts = append(parts, fmt.Sprintf("despawns %dt", info.DespawnTimer)) + } + annotation := "" + if len(parts) > 0 { + annotation = fmt.Sprintf(" (%s)", strings.Join(parts, ", ")) + } + lines = append(lines, displayLine{ + name: name, + quantity: info.Quantity, + colorName: coloredName, + annotation: annotation, + }) + continue + } + + timerKey := -1 + if showDespawn && !info.IsSpawn { + timerKey = info.DespawnTimer + } + key := groupKey{itemID: info.ItemID, despawnTimer: timerKey} + + if existing, ok := groups[key]; ok { + existing.quantity += info.Quantity + } else { + annotation := "" + if showDespawn && !info.IsSpawn { + annotation = fmt.Sprintf(" (despawns %dt)", info.DespawnTimer) + } + dl := &displayLine{ + name: name, + quantity: info.Quantity, + colorName: coloredName, + annotation: annotation, + } + groups[key] = dl + groupOrder = append(groupOrder, key) + } + } + + for _, key := range groupOrder { + lines = append(lines, *groups[key]) + } + + sort.Slice(lines, func(i, j int) bool { + return strings.ToLower(lines[i].name) < strings.ToLower(lines[j].name) + }) + + var out []string + out = append(out, "") + out = append(out, "On the ground:") + + type fmtLine struct { + prefix string + annotation string + } + var fmtLines []fmtLine + maxPrefix := 0 + + for _, dl := range lines { + prefix := "" + if dl.quantity > 1 { + prefix = fmt.Sprintf(" %d x %s", dl.quantity, dl.colorName) + } else { + prefix = fmt.Sprintf(" %s", dl.colorName) + } + if visibleLen(prefix) > maxPrefix { + maxPrefix = visibleLen(prefix) + } + fmtLines = append(fmtLines, fmtLine{prefix, dl.annotation}) + } + + for _, l := range fmtLines { + if l.annotation != "" { + pad := maxPrefix + 1 + (len(l.prefix) - visibleLen(l.prefix)) + out = append(out, fmt.Sprintf("%-*s%s", pad, l.prefix, l.annotation)) + } else { + out = append(out, l.prefix) + } + } + return out +} + +func (g *Game) showRoomPlayers(sess *net.Session, p *player.Player, room *world.Room) { + others := g.Hub.PlayersInRoom(p.RoomID) + for _, other := range others { + if other != sess && other.Player != nil { + op := other.Player + line := fmt.Sprintf("\n%s is here", g.colorize(sess, "player_name", op.Name)) + if cs := g.Combat.Get(op.Name); cs != nil { + if mob := g.MobStore.GetInstance(cs.MobID); mob != nil && mob.HP > 0 { + name := mobDisplayName(mob, false) + if idx := mobInstanceIdx(mob, g.MobStore.MobsInRoom(p.RoomID)); idx > 0 { + name += fmt.Sprintf(" [%d]", idx) + } + line += fmt.Sprintf(" (fighting %s)", name) + } + } else if desc := g.playerActionDisplay(op); desc != "" { + line += ", " + desc + } + sess.WriteLine(line + ".") + } + } +} diff --git a/internal/game/look_room.go b/internal/game/look_room.go new file mode 100644 index 0000000..8a1da04 --- /dev/null +++ b/internal/game/look_room.go @@ -0,0 +1,107 @@ +package game + +import ( + "fmt" + + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/object" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func (g *Game) showRoomName(sess *net.Session, p *player.Player, room *world.Room) { + sess.WriteLines( + "", + fmt.Sprintf("%s (%s)", g.colorize(sess, "room_name", room.Name), g.colorize(sess, "room_number", fmt.Sprintf("#%d", room.ID))), + ) +} + +func (g *Game) showRoomDescription(sess *net.Session, p *player.Player, room *world.Room) []string { + descWidth := p.OptionInt("room_desc_width") + if descWidth <= 0 { + descWidth = 70 + } + rawLines := wrapText(g.roomDescription(sess, room), descWidth) + mode := g.colorMode(sess) + roomDescSpec := g.resolveColor(sess, "room_desc") + lines := make([]string, len(rawLines)) + for i, l := range rawLines { + lines[i] = color.ExpandTagsDefault(mode, roomDescSpec, l) + } + return lines +} + +// roomDescription resolves the room's effective description for this player, +// picking the first conditional variant whose condition passes. +func (g *Game) roomDescription(sess *net.Session, room *world.Room) string { + for _, d := range room.Description { + if d.Condition == nil || g.checkCondition(sess, d.Condition) { + return d.Text + } + } + return "" +} + +// resolveObjDesc resolves the description an object shows to this player. +// The first variant whose condition passes wins; if none pass the object is +// reported as not present (false), so look falls through as if it were not there. +func (g *Game) resolveObjDesc(sess *net.Session, def *object.ObjectDef) (string, bool) { + for _, d := range def.Description { + if d.Condition == nil || g.checkCondition(sess, d.Condition) { + return d.Text, true + } + } + return "", false +} + +func (g *Game) showRoomExits(sess *net.Session, p *player.Player, room *world.Room) { + if len(room.Exits) > 0 { + sess.WriteLine("") + if p.OptionBool("exits") { + sess.WriteLine("Exits:") + type exitLine struct { + dir string + targetName string + } + var lines []exitLine + maxDirLen := 0 + for _, dir := range world.ExitOrder { + exitDef, ok := room.Exits[dir] + if !ok { + continue + } + coloredDir := g.colorize(sess, "exit_direction", string(dir)) + targetRoom, err := g.World.LoadRoom(exitDef.Room) + targetName := g.colorize(sess, "room_number", fmt.Sprintf("#%d", exitDef.Room)) + if err == nil { + targetName = g.colorize(sess, "exit_name", targetRoom.Name) + } + if exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) { + targetName += " (blocked)" + } + lines = append(lines, exitLine{coloredDir, targetName}) + if visibleLen(coloredDir) > maxDirLen { + maxDirLen = visibleLen(coloredDir) + } + } + for _, l := range lines { + pad := maxDirLen + (len(l.dir) - visibleLen(l.dir)) + sess.WriteLine(fmt.Sprintf(" %-*s - %s", pad, l.dir, l.targetName)) + } + } else { + sess.Write("Exits: ") + first := true + for _, dir := range world.ExitOrder { + if _, ok := room.Exits[dir]; ok { + if !first { + sess.Write(", ") + } + sess.Write(g.colorize(sess, "exit_direction", string(dir))) + first = false + } + } + sess.WriteLine("") + } + } +} diff --git a/internal/game/look_target.go b/internal/game/look_target.go new file mode 100644 index 0000000..4819411 --- /dev/null +++ b/internal/game/look_target.go @@ -0,0 +1,343 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/item" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func (g *Game) doLookTarget(sess *net.Session, input string) { + p := sess.Player + lower := strings.ToLower(input) + + if exitDir := g.World.ResolveExit(lower); exitDir != "" { + room, err := g.World.LoadRoom(p.RoomID) + if err != nil { + sess.WriteLine("You can't see anything that way.") + return + } + exitDef, ok := room.Exits[exitDir] + if !ok { + sess.WriteLine("You can't see anything that way.") + return + } + if exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) { + msg := exitDef.BlockedMessage + if msg == "" { + msg = fmt.Sprintf("The way %s is blocked.", exitDir) + } + sess.WriteLine(msg) + return + } + g.World.SeedGroundItems(exitDef.Room) + g.seedRoomMobs(exitDef.Room) + origRoom := p.RoomID + p.RoomID = exitDef.Room + g.doLook(sess) + p.RoomID = origRoom + return + } + + var best *world.MobInstance + bestQ := world.MatchNone + for _, m := range g.MobStore.MobsInRoom(p.RoomID) { + q := m.MatchQuality(lower) + if q > bestQ { + bestQ = q + best = m + } + } + if best != nil { + mobColor := "mob" + if best.Protected { + mobColor = "protected_mob" + } + mobLevel := mobCombatLevel(best) + levelStr := g.levelColorize(sess, p.CombatLevel(), mobLevel, fmt.Sprintf("(level %d)", mobLevel)) + sess.WriteLines( + "", + fmt.Sprintf("%s %s", g.colorize(sess, mobColor, best.Name), levelStr), + ) + if best.IdleDescription != "" { + sess.WriteLine(best.IdleDescription) + } + sess.WriteLines( + "", + fmt.Sprintf("Attack: %d", best.Attack), + fmt.Sprintf("Strength: %d", best.Strength), + fmt.Sprintf("Defense: %d", best.Defense), + fmt.Sprintf("HP: %d/%d", best.HP, best.MaxHP), + ) + if best.StabDefense != 0 || best.SlashDefense != 0 || best.CrushDefense != 0 || + best.ScienceDefense != 0 || best.RangedDefense != 0 { + sess.WriteLines( + "", + "Defense bonuses:", + fmt.Sprintf(" Stab: %+d Slash: %+d Crush: %+d", best.StabDefense, best.SlashDefense, best.CrushDefense), + fmt.Sprintf(" Science: %+d Ranged: %+d", best.ScienceDefense, best.RangedDefense), + ) + } + if best.Weakness != "" { + sess.WriteLine(fmt.Sprintf("Weakness: %s", best.Weakness)) + } + return + } + + rawInstances := g.World.FindObjInstances(p.RoomID, lower) + + // Group matched object instances by definition, keeping only those visible + // to this player (an object whose conditional descriptions all fail is + // absent). If more than one distinct object matches, disambiguate. + var presentDefs []string + byDef := map[string][]world.ObjState{} + descByDef := map[string]string{} + for _, ist := range rawInstances { + def, err := g.ObjectStore.Load(ist.DefID) + if err != nil { + continue + } + text, present := g.resolveObjDesc(sess, def) + if !present { + continue + } + if _, seen := byDef[ist.DefID]; !seen { + presentDefs = append(presentDefs, ist.DefID) + descByDef[ist.DefID] = text + } + byDef[ist.DefID] = append(byDef[ist.DefID], ist) + } + + if len(presentDefs) > 1 { + sess.WriteLine("That's ambiguous, which one?") + for _, defID := range presentDefs { + if def, err := g.ObjectStore.Load(defID); err == nil { + sess.WriteLine(fmt.Sprintf(" %s", def.Name)) + } + } + return + } + + if len(presentDefs) == 1 { + instances := byDef[presentDefs[0]] + objDescText := descByDef[presentDefs[0]] + st := &instances[0] + def, _ := g.ObjectStore.Load(st.DefID) + + if st.DefID == "estate_directory" { + g.lookEstateDirectory(sess) + return + } + + sess.WriteLine("") + if len(instances) > 1 { + sess.WriteLine(fmt.Sprintf("%d %ss:", len(instances), def.Name)) + } + + if objDescText != "" && !strings.Contains(objDescText, "{quality}") { + sess.WriteLine(color.ExpandTags(g.colorMode(sess), objDescText)) + } + + if def.Safespot != nil { + realSt := g.World.GetObjState(p.RoomID, st.DefID, st.Index) + if realSt != nil { + levelStr := "intact" + if realSt.SafespotLevel <= 0 { + levelStr = fmt.Sprintf("intact (level %d/%d)", len(def.Safespot.Levels), len(def.Safespot.Levels)) + } else if realSt.SafespotLevel < len(def.Safespot.Levels) { + levelStr = fmt.Sprintf("degraded (level %d/%d)", realSt.SafespotLevel, len(def.Safespot.Levels)) + } else { + levelStr = fmt.Sprintf("intact (level %d/%d)", realSt.SafespotLevel, len(def.Safespot.Levels)) + } + blockInfo := "anything" + if def.Safespot.MaxBlockSize != "" { + blockInfo = fmt.Sprintf("up to %s mobs", def.Safespot.MaxBlockSize) + } + sess.WriteLine(fmt.Sprintf("Safespot: %s — blocks %s", levelStr, blockInfo)) + if len(realSt.SafespotOccupants) > 0 { + sess.WriteLine(fmt.Sprintf("Occupied by: %s", strings.Join(realSt.SafespotOccupants, ", "))) + } + } + } + + if p.OptionBool("depletion") { + for _, ist := range instances { + if ist.Depleted { + if len(instances) > 1 { + sess.WriteLine(fmt.Sprintf("%s %d: depleted, respawns in %d ticks.", def.Name, ist.Index+1, int(ist.DepleteTimer))) + } else { + sess.WriteLine(fmt.Sprintf("Depleted, respawns in %d ticks.", int(ist.DepleteTimer))) + } + } else if ist.SharedMax > 0 && float64(ist.SharedTimer) < ist.SharedMax { + if len(instances) > 1 { + sess.WriteLine(fmt.Sprintf("%s %d: despawn timer %d/%.0f.", def.Name, ist.Index+1, ist.SharedTimer, ist.SharedMax)) + } else { + sess.WriteLine(fmt.Sprintf("Despawn timer: %d/%.0f ticks.", ist.SharedTimer, ist.SharedMax)) + } + } + } + } + for _, ist := range instances { + if ist.Quality > 0 && strings.Contains(objDescText, "{quality}") { + desc := strings.ReplaceAll(objDescText, "{quality}", fmt.Sprintf("%.0f", ist.Quality)) + sess.WriteLine(color.ExpandTags(g.colorMode(sess), desc)) + } + } + + farmSuffix := g.farmObjSuffix(p, st.DefID, st.Index) + if farmSuffix != "" { + sess.WriteLine(farmSuffix) + } + return + } + + ground := g.World.GroundItems(p.RoomID) + for itemID := range ground { + def, err := g.ItemStore.Load(itemID) + if err != nil || !def.MatchesName(input) { + continue + } + sess.WriteLines( + "", + g.itemColorize(sess, def, def.Name), + color.ExpandTags(g.colorMode(sess), def.Description), + fmt.Sprintf("Value: %d credits", def.Value), + ) + g.showItemStats(sess, def) + return + } + + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot == nil { + continue + } + def, err := g.ItemStore.Load(slot.ItemID) + if err != nil || !def.MatchesName(input) { + continue + } + lines := []string{ + "", + g.itemColorize(sess, def, def.Name), + color.ExpandTags(g.colorMode(sess), def.Description), + fmt.Sprintf("Value: %d credits", def.Value), + } + if slot.MaxQuality > 0 { + lines = append(lines, fmt.Sprintf("Has %d units of butane left.", slot.Quality)) + } + sess.WriteLines(lines...) + g.showItemStats(sess, def) + return + } + + others := g.Hub.PlayersInRoom(p.RoomID) + for _, other := range others { + if other == sess || other.Player == nil { + continue + } + op := other.Player + if strings.ToLower(op.Name) != lower { + continue + } + showPlayerInfo(g, sess, op) + return + } + + sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input)) +} + +func showPlayerInfo(g *Game, sess *net.Session, p *player.Player) { + myP := sess.Player + theirLevel := p.CombatLevel() + levelStr := g.levelColorize(sess, myP.CombatLevel(), theirLevel, fmt.Sprint(theirLevel)) + sess.WriteLines( + "", + p.Name, + fmt.Sprintf("Combat Level: %s", levelStr), + fmt.Sprintf("HP: %d/%d", p.HP, p.MaxHP()), + "", + ) + + for _, s := range player.AllSkills { + level := p.Level(s) + sess.WriteLine(fmt.Sprintf("%-12s Level: %d", s, level)) + } + + sess.WriteLine("") + sess.WriteLine("Equipment:") + for _, slot := range EquipSlots { + itemID, ok := p.Equipment[slot] + if !ok { + continue + } + name := itemID + var itemDef *item.ItemDef + if def, err := g.ItemStore.Load(itemID); err == nil { + name = def.Name + itemDef = def + } + sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, g.itemColorize(sess, itemDef, name))) + } + + if p.Description != "" { + sess.WriteLine("") + sess.WriteLine(p.Description) + } +} + +func (g *Game) showItemStats(sess *net.Session, def *item.ItemDef) { + s := def.Stats + hasAttack := s.StabAttack != 0 || s.SlashAttack != 0 || s.CrushAttack != 0 || + s.ScienceAttack != 0 || s.RangedAttack != 0 + hasDefense := s.StabDefense != 0 || s.SlashDefense != 0 || s.CrushDefense != 0 || + s.ScienceDefense != 0 || s.RangedDefense != 0 + hasOther := s.StrengthBonus != 0 || s.RangedStrength != 0 || + s.ScienceDamage != 0 || s.TechnologyBonus != 0 + + if !hasAttack && !hasDefense && !hasOther { + return + } + + sess.WriteLine("") + if hasAttack || hasDefense { + sess.WriteLine("Attack bonuses: Defense bonuses:") + sess.WriteLine(fmt.Sprintf(" Stab: %+4d Stab: %+4d", s.StabAttack, s.StabDefense)) + sess.WriteLine(fmt.Sprintf(" Slash: %+4d Slash: %+4d", s.SlashAttack, s.SlashDefense)) + sess.WriteLine(fmt.Sprintf(" Crush: %+4d Crush: %+4d", s.CrushAttack, s.CrushDefense)) + sess.WriteLine(fmt.Sprintf(" Science:%+4d Science:%+4d", s.ScienceAttack, s.ScienceDefense)) + sess.WriteLine(fmt.Sprintf(" Ranged: %+4d Ranged: %+4d", s.RangedAttack, s.RangedDefense)) + } + if hasOther { + sess.WriteLine("") + sess.WriteLine("Other bonuses:") + if s.StrengthBonus != 0 { + sess.WriteLine(fmt.Sprintf(" Melee strength: %+d", s.StrengthBonus)) + } + if s.RangedStrength != 0 { + sess.WriteLine(fmt.Sprintf(" Ranged strength: %+d", s.RangedStrength)) + } + if s.ScienceDamage != 0 { + sess.WriteLine(fmt.Sprintf(" Science damage: %+d", s.ScienceDamage)) + } + if s.TechnologyBonus != 0 { + sess.WriteLine(fmt.Sprintf(" Technology: %+d", s.TechnologyBonus)) + } + } + if def.AttackType != "" { + sess.WriteLine(fmt.Sprintf("Attack type: %s", def.AttackType)) + } + if def.Speed > 0 { + sess.WriteLine(fmt.Sprintf("Speed: %.0f", def.Speed)) + } + if len(def.Requirements) > 0 { + sess.WriteLine("") + sess.WriteLine("Requirements:") + for skill, level := range def.Requirements { + sess.WriteLine(fmt.Sprintf(" %s: %d", skill, level)) + } + } +} diff --git a/internal/game/map_test.go b/internal/game/map_test.go index dded62e..9a85806 100644 --- a/internal/game/map_test.go +++ b/internal/game/map_test.go @@ -10,7 +10,7 @@ import ( func TestBuildTinyMap(t *testing.T) { g := &Game{ - World: world.New("../../data"), + Deps: Deps{World: world.New("../../data")}, } mg := mapGlyphsForPlayer(true) @@ -97,7 +97,7 @@ func TestWrapText(t *testing.T) { func TestBuildFullMap(t *testing.T) { g := &Game{ - World: world.New("../../data"), + Deps: Deps{World: world.New("../../data")}, } mg := mapGlyphsForPlayer(true) diff --git a/internal/game/production_engine.go b/internal/game/production_engine.go new file mode 100644 index 0000000..c30e7bb --- /dev/null +++ b/internal/game/production_engine.go @@ -0,0 +1,359 @@ +package game + +import ( + "fmt" + "math/rand" + "strings" + + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/item" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) resolveCraftVar(sess *net.Session, varSpec string, craft *item.CraftDef, outputID string, byproducts []string, hasItem func(string) bool) string { + if !strings.Contains(varSpec, "%") { + return varSpec + } + + outDef, _ := g.ItemStore.Load(outputID) + outputName := outputID + if outDef != nil { + outputName = g.itemColorize(sess, outDef, outDef.Name) + } + varSpec = strings.ReplaceAll(varSpec, "%n", outputName) + + for i, e := range craft.Consume { + key := fmt.Sprintf("%%i%d", i+1) + if !strings.Contains(varSpec, key) { + continue + } + var matchedID string + for _, id := range e.Items { + if hasItem(id) { + matchedID = id + break + } + } + if matchedID == "" && len(e.Items) > 0 { + matchedID = e.Items[0] + } + if def, err := g.ItemStore.Load(matchedID); err == nil { + varSpec = strings.ReplaceAll(varSpec, key, g.itemColorize(sess, def, def.Name)) + } else { + varSpec = strings.ReplaceAll(varSpec, key, matchedID) + } + } + + for i, bpID := range byproducts { + key := fmt.Sprintf("%%b%d", i+1) + if !strings.Contains(varSpec, key) { + continue + } + if def, err := g.ItemStore.Load(bpID); err == nil { + varSpec = strings.ReplaceAll(varSpec, key, g.itemColorize(sess, def, def.Name)) + } else { + varSpec = strings.ReplaceAll(varSpec, key, bpID) + } + } + + return color.ExpandTags(g.colorMode(sess), varSpec) +} + +func (g *Game) resolveCraftMessage(sess *net.Session, itemMsg, defaultMsg string, craft *item.CraftDef, outputID string, byproducts []string, hasItem func(string) bool) string { + msg := itemMsg + if msg == "" { + msg = defaultMsg + } + if msg == "" { + return "" + } + return g.resolveCraftVar(sess, msg, craft, outputID, byproducts, hasItem) +} + +func (g *Game) startProduction(sess *net.Session, p *player.Player, item *item.ItemDef, craft *item.CraftDef, actionType behavior.ActionType, displayVerb, startMsg, endMsg string, count int) { + g.cancelAction(p) + g.cancelBgAction(p) + + skill := craft.EffectiveSkill() + if skill != "" { + skillLevel := p.Level(player.SkillName(skill)) + if skillLevel < craft.Level { + sess.WriteLine(fmt.Sprintf("You need level %d %s to do that.", craft.Level, skill)) + g.reprompt(sess) + return + } + } + + if !craftHasAllItemsQty(craft, p.CountItem) { + sess.WriteLine("You don't have the required materials.") + g.reprompt(sess) + return + } + + outDef, _ := g.ItemStore.Load(item.ID) + + wait := craft.Wait + if wait <= 0 { + wait = 4 + } + + outputName := item.ID + if outDef != nil { + outputName = outDef.Name + } + + p.Action = &behavior.Action{ + Type: actionType, + TargetID: item.ID, + TargetName: outputName, + Data: &behavior.ProductionData{ + ItemID: item.ID, + Phase: 0, + Wait: wait, + StartMsg: startMsg, + EndMsg: endMsg, + Remaining: count, + }, + WaitLeft: engine.ToTicks(1), + } + + g.broadcastAction(sess, "%s starts %s.", p.Name, displayVerb) +} + +func (g *Game) startProductionFromItem(sess *net.Session, p *player.Player, item *item.ItemDef, count int) { + craft := item.FirstCraft() + if craft == nil { + return + } + + _, stationName := g.findStation(p.RoomID, craft.Station) + if stationName == "" { + stationName = "inventory" + } + + info, ok := productionTypes[craft.Type] + if !ok { + info = productionTypeInfo{ + ActionType: behavior.ActionType(craft.Type), + DisplayVerb: craft.Type, + StartMessage: "You start " + craft.Type + " %i1.", + SuccessMessage: "You produce %n.", + EndMessage: "You've finished " + craft.Type + ".", + } + } + + startMsg := g.resolveCraftMessage(sess, craft.StartMessage, info.StartMessage, craft, item.ID, nil, p.HasItem) + if stationName != "inventory" { + startMsg += " on the " + stationName + "." + } + + endMsg := g.resolveCraftMessage(sess, craft.EndMessage, info.EndMessage, craft, item.ID, nil, p.HasItem) + + g.startProduction(sess, p, item, craft, info.ActionType, info.DisplayVerb, startMsg, endMsg, count) +} + +func (g *Game) loadCraftItem(itemID string) *item.ItemDef { + item, err := g.ItemStore.Load(itemID) + if err != nil || len(item.Craft) == 0 { + return nil + } + return item +} + +func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool { + d, ok := p.Action.Data.(*behavior.ProductionData) + if !ok { + g.cancelAction(p) + return false + } + itemID := d.ItemID + phase := d.Phase + wait := d.Wait + startMsg := d.StartMsg + endMsg := d.EndMsg + remaining := d.Remaining + + item := g.loadCraftItem(itemID) + if item == nil { + g.cancelAction(p) + return false + } + craft := item.FirstCraft() + + if phase == 0 { + if startMsg != "" { + sess.WriteLine(startMsg) + } + if len(craft.Steps) > 0 { + d.NextStepIndex = 0 + d.StepsAccumulatedTicks = 0 + } + d.Phase = 1 + p.Action.WaitLeft = engine.ToTicks(wait) + return true + } + + if stepsIdx := d.NextStepIndex; stepsIdx < len(craft.Steps) { + accTicks := d.StepsAccumulatedTicks + accTicks += wait + d.StepsAccumulatedTicks = accTicks + for i := stepsIdx; i < len(craft.Steps); i++ { + if accTicks >= craft.Steps[i].Tick { + sess.WriteLine(g.resolveCraftVar(sess, craft.Steps[i].Message, craft, item.ID, nil, p.HasItem)) + d.NextStepIndex = i + 1 + } + } + } + + skill := craft.EffectiveSkill() + skillLevel := p.Level(player.SkillName(skill)) + chance := 1.0 + if craft.Success != nil { + chance = behavior.SuccessChance(behavior.SuccessFormula(*craft.Success), skillLevel, craft.Level) + } + + info, _ := productionTypes[craft.Type] + + if rand.Float64() < chance { + outputQty := craft.OutputQty + if outputQty <= 0 { + outputQty = 1 + } + + byproducts := g.collectByproducts(p, item) + + placed := false + outDef, _ := g.ItemStore.Load(item.ID) + if outDef != nil && outDef.Stackable { + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot != nil && slot.ItemID == item.ID { + craftConsumeAll(craft, p.HasItem, p.RemoveItem) + slot.Quantity += outputQty + placed = true + break + } + } + } + if !placed { + craftConsumeAll(craft, 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: item.ID, Quantity: outputQty}) + } + + for _, bp := range byproducts { + if slot := p.FirstFreeSlot(); slot >= 0 { + p.SetInvSlot(slot, &player.InventorySlot{ItemID: bp, Quantity: 1}) + } + } + + if craft.XP > 0 { + if newLevel := p.AddSkillXP(player.SkillName(skill), craft.XP); newLevel > 0 { + sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, skill))) + } + } + g.AccountStore.SaveCharacter(p) + + var defaultSuccess string + if info.SuccessMessage != "" { + defaultSuccess = info.SuccessMessage + } else { + defaultSuccess = "You produce %n." + } + msg := g.resolveCraftMessage(sess, craft.Message, defaultSuccess, craft, item.ID, byproducts, p.HasItem) + if p.OptionBool("xp_drops") && craft.XP > 0 { + msg += g.formatXpDropSingle(sess, p, player.SkillName(skill), craft.XP) + } + sess.WriteLine(msg) + } else { + craftConsumeAll(craft, p.HasItem, p.RemoveItem) + + if craft.Fail != "" { + freeSlot := p.FirstFreeSlot() + if freeSlot >= 0 { + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: craft.Fail, Quantity: 1}) + } + } + g.AccountStore.SaveCharacter(p) + + msg := g.resolveCraftMessage(sess, craft.FailMessage, info.FailMessage, craft, item.ID, nil, p.HasItem) + if msg != "" { + sess.WriteLine(g.colorize(sess, "damage_taken", msg)) + } + } + + if remaining > 0 { + remaining-- + d.Remaining = remaining + if remaining <= 0 { + if endMsg != "" { + sess.WriteLine(endMsg) + } + g.cancelAction(p) + return false + } + } + + if g.canContinueProduction(p, item) { + p.Action.WaitLeft = engine.ToTicks(wait) + return true + } + + if endMsg != "" { + sess.WriteLine(endMsg) + } + g.cancelAction(p) + return false +} + +func (g *Game) collectByproducts(p *player.Player, item *item.ItemDef) []string { + craft := item.FirstCraft() + if craft == nil { + return nil + } + var byproducts []string + for _, e := range craft.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, item *item.ItemDef) bool { + craft := item.FirstCraft() + if craft == nil { + return false + } + if !craftHasAllItemsQty(craft, p.CountItem) { + return false + } + outputQty := craft.OutputQty + if outputQty <= 0 { + outputQty = 1 + } + outDef, _ := g.ItemStore.Load(item.ID) + if outDef != nil && outDef.Stackable { + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot != nil && slot.ItemID == item.ID { + return true + } + } + } + return p.FirstFreeSlot() >= 0 +} diff --git a/internal/game/production_menu.go b/internal/game/production_menu.go new file mode 100644 index 0000000..7181a0e --- /dev/null +++ b/internal/game/production_menu.go @@ -0,0 +1,416 @@ +package game + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/item" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/ui" +) + +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 || behavior.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 iid, ok := entry["item_id"]; ok { + if def, _ := g.ItemStore.Load(iid); def != nil { + return def.Name + } + return iid + } + if barID, ok := entry["bar_id"]; ok { + if def, _ := g.ItemStore.Load(barID); def != nil { + return def.Name + } + return barID + } + return "" +} + +func (g *Game) dispatchMenuEntry(sess *net.Session, entry map[string]string) { + if iid, ok := entry["item_id"]; ok { + g.promptHowMany(sess, iid) + return + } + if barID, ok := entry["bar_id"]; ok { + g.showSmithTable(sess, sess.Player, barID) + return + } + g.reprompt(sess) +} + +func (g *Game) formatMaterials(sess *net.Session, item *item.ItemDef) string { + craft := item.FirstCraft() + if craft == nil { + return "" + } + var parts []string + for _, e := range craft.Consume { + itemName := e.Items[0] + if def, err := g.ItemStore.Load(e.Items[0]); err == nil { + itemName = g.itemColorize(sess, def, 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, items []*item.ItemDef, title, skill, lastFlagKey, promptVerb string, background bool) { + sort.Slice(items, func(i, j int) bool { + return items[i].FirstCraft().Level < items[j].FirstCraft().Level + }) + + mode := g.colorMode(sess) + skillLevel := p.Level(player.SkillName(skill)) + dimSpec := color.Parse("240") + + tbl := &ui.Table{ + Title: title, + Columns: []string{"#", "Product", "Materials", "Level"}, + } + + for i, item := range items { + craft := item.FirstCraft() + outDef, _ := g.ItemStore.Load(item.ID) + productName := item.ID + if outDef != nil { + productName = outDef.Name + } + + outputQty := craft.OutputQty + if outputQty <= 0 { + outputQty = 1 + } + if outDef != nil && outDef.Stackable && outputQty > 1 { + productName = fmt.Sprintf("%s x%d", productName, outputQty) + } + + matStr := g.formatMaterials(sess, item) + levelStr := fmt.Sprint(craft.Level) + numStr := fmt.Sprintf("%d", i+1) + + canMake := skillLevel >= craft.Level && craftHasAllItemsQty(craft, 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) + } + + lastItemID, _ := p.Flags[lastFlagKey].(string) + hint := "" + if lastItemID != "" { + for _, item := range items { + if item.ID == lastItemID { + if outDef, err := g.ItemStore.Load(item.ID); 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 = itemMenuData(itemIDs(items)) + 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) + + var items []*item.ItemDef + for _, entry := range menuData { + iid := entry["item_id"] + if item := g.loadCraftItem(iid); item != nil { + items = append(items, item) + } + } + + if len(items) == 0 { + g.reprompt(sess) + return + } + + sort.Slice(items, func(i, j int) bool { + return items[i].FirstCraft().Level < items[j].FirstCraft().Level + }) + + lastItemID, _ := p.Flags[lastFlagKey].(string) + + sel, errMsg := g.resolveCraftByInput(input, items, lastItemID) + switch errMsg { + case "ambiguous": + sess.WriteLine("That's ambiguous.") + g.reprompt(sess) + return + case "never_mind": + sess.WriteLine("Never mind.") + g.reprompt(sess) + return + } + + craft := sel.Item.FirstCraft() + skillLevel := p.Level(player.SkillName(skill)) + if skillLevel < craft.Level { + sess.WriteLine(fmt.Sprintf("You need level %d %s to make that.", craft.Level, skill)) + g.reprompt(sess) + return + } + if !craftHasAllItemsQty(craft, p.CountItem) { + sess.WriteLine("You don't have the materials for that.") + g.reprompt(sess) + return + } + + p.EnsureFlags() + p.Flags[lastFlagKey] = sel.Item.ID + g.AccountStore.SaveCharacter(p) + + if background { + g.startFletchAction(sess, p, sel.Item, sel.Count) + } else { + g.startProductionFromItem(sess, p, sel.Item, sel.Count) + } +} + +func itemMenuData(itemIDs []string) []map[string]string { + out := make([]map[string]string, len(itemIDs)) + for i, id := range itemIDs { + out[i] = map[string]string{"item_id": id} + } + return out +} + +func itemIDs(defs []*item.ItemDef) []string { + ids := make([]string, len(defs)) + for i, d := range defs { + ids[i] = d.ID + } + return ids +} + +func (g *Game) promptHowMany(sess *net.Session, itemID string) { + sess.PendingItemID = itemID + sess.State = net.StateHowMany + sess.Write("How many (return for all)?: ") +} + +type craftSelection struct { + Item *item.ItemDef + Count int +} + +func (g *Game) resolveCraftByInput(input string, items []*item.ItemDef, lastItemID string) (sel craftSelection, errMsg string) { + if input == "" { + if lastItemID == "" { + return sel, "never_mind" + } + for i := range items { + if items[i].ID == lastItemID { + sel.Item = items[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(items) { + sel.Item = items[idx-1] + sel.Count = qty + return sel, "" + } + + matchCount := 0 + for i := range items { + outDef, _ := g.ItemStore.Load(items[i].ID) + name := items[i].ID + if outDef != nil { + name = outDef.Name + } + if behavior.WordPrefixMatch(productName, name) { + if matchCount == 0 { + sel.Item = items[i] + sel.Count = qty + } + matchCount++ + } + } + if matchCount > 1 { + return sel, "ambiguous" + } + if sel.Item == nil { + return sel, "never_mind" + } + return sel, "" +} + +func (g *Game) showMenuTable(sess *net.Session, title string, names []string) { + p := sess.Player + tbl := &ui.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) itemName(sess *net.Session, item *item.ItemDef) string { + if item != nil { + if def, err := g.ItemStore.Load(item.ID); err == nil { + return g.itemColorize(sess, def, def.Name) + } + return item.ID + } + return "" +} + +func (g *Game) showRecipeChoice(sess *net.Session, p *player.Player, items []*item.ItemDef, emptyMsg, title string, autoOption string) { + if len(items) == 0 { + sess.WriteLine(emptyMsg) + return + } + if len(items) == 1 { + if autoOption != "" && p.OptionBool(autoOption) { + g.startProductionFromItem(sess, p, items[0], 0) + return + } + g.promptHowMany(sess, items[0].ID) + return + } + sess.State = net.StateRecipeChoice + sess.PendingMenu = itemMenuData(itemIDs(items)) + var names []string + for _, item := range items { + names = append(names, g.itemName(sess, item)) + } + g.showMenuTable(sess, title, names) +} + +func (g *Game) handleHowMany(sess *net.Session, input string) { + p := sess.Player + itemID := sess.PendingItemID + sess.PendingItemID = "" + 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 + } + + item := g.loadCraftItem(itemID) + if item == nil { + g.reprompt(sess) + return + } + + g.startProductionFromItem(sess, p, item, count) +} diff --git a/internal/game/render_color.go b/internal/game/render_color.go new file mode 100644 index 0000000..c6f154c --- /dev/null +++ b/internal/game/render_color.go @@ -0,0 +1,80 @@ +package game + +import ( + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/config" + "thehouseoficarus/internal/item" + "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 *item.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/render_combat.go b/internal/game/render_combat.go new file mode 100644 index 0000000..7285ce7 --- /dev/null +++ b/internal/game/render_combat.go @@ -0,0 +1,41 @@ +package game + +import ( + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/ui" +) + +// renderBar is the shared bar renderer for both HP bars and task-progress bars. +// lowColor is the color used when the value falls below 40% (160 red for HP, +// 39 cyan for progress). +func (g *Game) renderBar(sess *net.Session, current, max, lowColor int) string { + if max <= 0 { + max = 1 + } + if current < 0 { + current = 0 + } + if current > max { + current = max + } + + pct := float64(current) / float64(max) * 100.0 + var fgColor int + switch { + case pct >= 70: + fgColor = 82 + case pct >= 40: + fgColor = 220 + default: + fgColor = lowColor + } + + unicode := sess.Player != nil && sess.Player.OptionBool("unicode") + style := &ui.BarStyle{FilledColor: fgColor, EmptyColor: 238, EmptyDim: true} + bar := ui.RenderColoredBar(current, max, 10, unicode, style, g.colorMode(sess)) + return "[" + bar + "]" +} + +func (g *Game) hpBar(sess *net.Session, current, max int) string { + return g.renderBar(sess, current, max, 160) +} diff --git a/internal/game/render_help.go b/internal/game/render_help.go new file mode 100644 index 0000000..1727153 --- /dev/null +++ b/internal/game/render_help.go @@ -0,0 +1,156 @@ +package game + +import ( + "fmt" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/ui" +) + +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"}, + {"global", "Instant", "Chat with everyone on the server"}, + {"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"}, + {"symbol", "Instant", "Set custom map symbol for current room"}, + {"talk / speak / ask", "Active", "Talk to mobs"}, + {"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"}, + {"who", "Instant", "List everyone online and their combat level"}, +} + +func LoadHelp(dataDir string) ([]HelpDef, error) { + dir := filepath.Join(dataDir, "help") + var helps []HelpDef + behavior.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 := &ui.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/render_map.go b/internal/game/render_map.go new file mode 100644 index 0000000..2bac749 --- /dev/null +++ b/internal/game/render_map.go @@ -0,0 +1,428 @@ +package game + +import ( + "strings" + "unicode/utf8" + + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/net" + "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 mapCell struct { + char rune + spec color.ColorSpec +} + +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, visited map[int]bool) *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 + } + + if visited != nil && !visited[n.roomID] { + continue + } + + for _, d := range bfsDirs { + targetID, ok := exitTarget(room, d.dir) + if !ok { + continue + } + if _, seen := mg.roomToPos[targetID]; seen { + 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 renderMapCells(grid [][]mapCell, colorMode string, startRow, endRow int, border rune) []string { + lines := make([]string, 0, endRow-startRow) + for row := startRow; row < endRow; row++ { + var sb strings.Builder + if border != 0 { + sb.WriteRune(border) + } + for _, cell := range grid[row] { + if cell.char == ' ' { + sb.WriteRune(' ') + } else if !cell.spec.Empty() { + sb.WriteString(color.Render(colorMode, cell.spec, string(cell.char))) + } else { + sb.WriteRune(cell.char) + } + } + if border != 0 { + sb.WriteRune(border) + } + lines = append(lines, sb.String()) + } + return lines +} + +func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string { + visited := roomsVisited(sess) + bg := buildGraph(g, roomID, visited) + + colorMode := colorModeFor(sess) + atSpec := resolveMapAt(g, sess) + dimSpec := resolveDim(g, sess) + + grid := make([][]mapCell, 5) + for i := range grid { + grid[i] = make([]mapCell, 5) + for j := range grid[i] { + grid[i][j] = mapCell{char: ' '} + } + } + + 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] = mapCell{char: '@', spec: atSpec} + } else { + unvisited := visited != nil && !visited[rid] + ch, spec := roomMapSymbol(g, sess, rid, unvisited) + if unvisited { + spec = dimSpec + } + grid[gr][gc] = mapCell{char: ch, spec: spec} + } + } + } + + 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) { + connSpec := color.NoColor() + if visited != nil && (!visited[leftRoom] || !visited[rightRoom]) { + connSpec = dimSpec + } + grid[(y+1)*2][(x+1)*2+1] = mapCell{char: mg.connectorH, spec: connSpec} + } + } + } + + 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) { + connSpec := color.NoColor() + if visited != nil && (!visited[topRoom] || !visited[bottomRoom]) { + connSpec = dimSpec + } + grid[(y+1)*2+1][(x+1)*2] = mapCell{char: mg.connectorV, spec: connSpec} + } + } + } + + cur, _ := loadRoom(g, roomID) + if cur != nil { + if _, ok := exitTarget(cur, world.Up); ok { + grid[1][3] = mapCell{char: mg.upArrow, spec: color.NoColor()} + } + if _, ok := exitTarget(cur, world.Down); ok { + grid[3][1] = mapCell{char: mg.downArrow, spec: color.NoColor()} + } + } + + topFill := strings.Repeat(string(mg.topFill), 5) + lines := make([]string, 7) + lines[0] = string(mg.topLeft) + topFill + string(mg.topRight) + inner := renderMapCells(grid, colorMode, 0, 5, mg.side) + copy(lines[1:], inner) + botFill := strings.Repeat(string(mg.topFill), 5) + lines[6] = string(mg.bottomLeft) + botFill + string(mg.bottomRight) + + return lines +} + +func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, mg mapGlyphs) []string { + visited := roomsVisited(sess) + bg := buildGraph(g, roomID, visited) + + colorMode := colorModeFor(sess) + atSpec := resolveMapAt(g, sess) + dimSpec := resolveDim(g, sess) + + grid := make([][]mapCell, mapHeight) + for i := range grid { + grid[i] = make([]mapCell, mapWidth) + for j := range grid[i] { + grid[i][j] = mapCell{char: ' '} + } + } + + 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] = mapCell{char: '@', spec: atSpec} + } else { + unvisited := visited != nil && !visited[rid] + ch, spec := roomMapSymbol(g, sess, rid, unvisited) + if unvisited { + spec = dimSpec + } + grid[gr][gc] = mapCell{char: ch, spec: spec} + } + } + + 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 { + connSpec := color.NoColor() + if visited != nil && (!visited[rid] || !visited[rightID]) { + connSpec = dimSpec + } + grid[gr][gc] = mapCell{char: mg.connectorH, spec: connSpec} + } + } + } + + 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 { + connSpec := color.NoColor() + if visited != nil && (!visited[rid] || !visited[bottomID]) { + connSpec = dimSpec + } + grid[gr][gc] = mapCell{char: mg.connectorV, spec: connSpec} + } + } + } + } + + return renderMapCells(grid, colorMode, 0, mapHeight, 0) +} + +func roomsVisited(sess *net.Session) map[int]bool { + if sess != nil && sess.Player != nil { + return sess.Player.Stats.RoomsVisited + } + return nil +} + +func colorModeFor(sess *net.Session) string { + if sess != nil && sess.Player != nil { + return sess.Player.OptionString("color") + } + return "none" +} + +func resolveMapAt(g *Game, sess *net.Session) color.ColorSpec { + if sess != nil { + return g.resolveColor(sess, "map_at") + } + return color.NoColor() +} + +func resolveDim(g *Game, sess *net.Session) color.ColorSpec { + if sess != nil { + return g.resolveColor(sess, "dim") + } + return color.Parse("243 dim") +} + +func roomMapSymbol(g *Game, sess *net.Session, roomID int, unvisited bool) (rune, color.ColorSpec) { + if sess != nil && sess.Player != nil { + if data, ok := sess.Player.MapSymbols[roomID]; ok { + r, size := utf8.DecodeRuneInString(data.Char) + if size > 0 && r != utf8.RuneError { + spec := color.NoColor() + if data.Color != "" { + spec = color.Parse(data.Color) + } + // ponytail: non-ASCII custom symbols fall back to 'o' + // when unicode mode is off, but preserve the color. + if !sess.Player.OptionBool("unicode") && r > 127 { + return 'o', spec + } + return r, spec + } + } + if sess.Player.OptionBool("unicode") { + if unvisited { + return '□', color.NoColor() + } + return '■', color.NoColor() + } + return 'o', color.NoColor() + } + return '■', color.NoColor() +} + +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 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 +} diff --git a/internal/game/render_prompt.go b/internal/game/render_prompt.go new file mode 100644 index 0000000..7d3fafd --- /dev/null +++ b/internal/game/render_prompt.go @@ -0,0 +1,97 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) promptStr(sess *net.Session) string { + prompt := "> " + if p := sess.Player; p != nil { + 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 := g.Combat.Get(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/render_xpdrop.go b/internal/game/render_xpdrop.go new file mode 100644 index 0000000..a9bff23 --- /dev/null +++ b/internal/game/render_xpdrop.go @@ -0,0 +1,31 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +// formatXpDrop returns the colored XP-drop suffix for a slice of gains, or "" +// if xp_drops are disabled or there are no gains. +func (g *Game) formatXpDrop(sess *net.Session, p *player.Player, gains []xpGain) string { + if !p.OptionBool("xp_drops") || len(gains) == 0 { + return "" + } + var parts []string + for _, gain := range gains { + parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)])) + } + return g.colorize(sess, "xp", " ("+strings.Join(parts, ", ")+")") +} + +// formatXpDropSingle returns the colored XP-drop suffix for a single skill, or "" +// if xp_drops are disabled or xp is 0. +func (g *Game) formatXpDropSingle(sess *net.Session, p *player.Player, skill player.SkillName, xp int) string { + if !p.OptionBool("xp_drops") || xp <= 0 { + return "" + } + return g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp %s)", xp, player.SkillAbbr[skill])) +} diff --git a/internal/game/sys_combat.go b/internal/game/sys_combat.go index 91a07cf..e020ccf 100644 --- a/internal/game/sys_combat.go +++ b/internal/game/sys_combat.go @@ -4,7 +4,6 @@ import ( "fmt" "sort" - "thehouseoficarus/internal/combat" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" @@ -29,10 +28,10 @@ func (g *Game) dropItemsOnDeath(p *player.Player) { val = def.Value * inv.Quantity } items = append(items, deathDrop{ - itemID: inv.ItemID, - quantity: inv.Quantity, + itemID: inv.ItemID, + quantity: inv.Quantity, totalValue: val, - invSlot: slot, + invSlot: slot, }) } @@ -42,11 +41,11 @@ func (g *Game) dropItemsOnDeath(p *player.Player) { val = def.Value } items = append(items, deathDrop{ - itemID: itemID, - quantity: 1, + itemID: itemID, + quantity: 1, totalValue: val, - isEquip: true, - equipSlot: eqSlot, + isEquip: true, + equipSlot: eqSlot, }) } @@ -72,7 +71,7 @@ func (g *Game) dropItemsOnDeath(p *player.Player) { func (g *Game) checkAggro(sess *net.Session) { p := sess.Player - if combat.GetCombat(p.Name) != nil { + if g.Combat.Get(p.Name) != nil { return } @@ -83,7 +82,7 @@ func (g *Game) checkAggro(sess *net.Session) { if !mob.Aggressive || mob.HP <= 0 || mob.Protected { continue } - if combat.IsMobInCombat(mob.InstanceID) { + if g.Combat.IsMobInCombat(mob.InstanceID) { continue } mobLevel := mobCombatLevel(mob) @@ -95,13 +94,13 @@ func (g *Game) checkAggro(sess *net.Session) { } aggMob := mob g.Ticks.Subscribe(engine.ToTicks(1), func() bool { - if combat.GetCombat(p.Name) != nil { + if g.Combat.Get(p.Name) != nil { return false } if aggMob.HP <= 0 || aggMob.RoomID != p.RoomID { return false } - if combat.IsMobInCombat(aggMob.InstanceID) { + if g.Combat.IsMobInCombat(aggMob.InstanceID) { return false } attacker := aggMob.Name diff --git a/internal/game/sys_science.go b/internal/game/sys_science.go index 517e051..0ee446b 100644 --- a/internal/game/sys_science.go +++ b/internal/game/sys_science.go @@ -8,9 +8,9 @@ import ( "gopkg.in/yaml.v3" - "thehouseoficarus/internal/action" + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" ) @@ -44,7 +44,7 @@ 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 { + behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error { var m ModDef if err := yaml.Unmarshal(data, &m); err != nil { return nil @@ -173,7 +173,7 @@ var chipMap = map[string]chipEntry{ } func (g *Game) hasDeckEquipped(p *player.Player) bool { - itemID, ok := p.Equipment[object.SlotMainHand] + itemID, ok := p.Equipment[item.SlotMainHand] if !ok { return false } @@ -181,11 +181,11 @@ func (g *Game) hasDeckEquipped(p *player.Player) bool { if err != nil { return false } - return def.WeaponType == object.WeaponScience + return def.WeaponType == item.WeaponScience } func (g *Game) providesJunk(p *player.Player) string { - itemID, ok := p.Equipment[object.SlotMainHand] + itemID, ok := p.Equipment[item.SlotMainHand] if !ok { return "" } @@ -296,5 +296,3 @@ func (g *Game) totalScienceAttack(p *player.Player) int { } return total } - - diff --git a/internal/game/sys_technology.go b/internal/game/sys_technology.go index fa0301f..7df68d4 100644 --- a/internal/game/sys_technology.go +++ b/internal/game/sys_technology.go @@ -7,7 +7,7 @@ import ( "gopkg.in/yaml.v3" - "thehouseoficarus/internal/action" + "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" @@ -45,7 +45,7 @@ func (g *Game) LoadTechs() error { dir := filepath.Join(g.DataDir, "techs") AllTechs = nil techByID = make(map[string]*TechDef) - action.WalkYAMLDir(dir, func(path, id string, data []byte) error { + behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error { var t TechDef if err := yaml.Unmarshal(data, &t); err != nil { return nil @@ -185,14 +185,14 @@ func (g *Game) tryRecharge(sess *net.Session, p *player.Player, input string) bo 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("Your battery is already full.") - } else { - p.Battery = p.MaxBattery() - g.AccountStore.SaveCharacter(p) - sess.WriteLine(g.colorize(sess, "battery", - "You connect to the charging station. Your battery is fully recharged.")) - } + if p.Battery >= p.MaxBattery() { + sess.WriteLine("Your battery is already full.") + } else { + p.Battery = p.MaxBattery() + g.AccountStore.SaveCharacter(p) + sess.WriteLine(g.colorize(sess, "battery", + "You connect to the charging station. Your battery is fully recharged.")) + } return true } } diff --git a/internal/game/tick.go b/internal/game/tick.go index 6d79964..ee9ecf2 100644 --- a/internal/game/tick.go +++ b/internal/game/tick.go @@ -4,8 +4,7 @@ import ( "fmt" "math/rand" - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" ) @@ -23,7 +22,7 @@ func (g *Game) DisconnectTick() { g.Hub.Remove(sess) continue } - if combat.GetCombat(p.Name) != nil { + if g.Combat.Get(p.Name) != nil { continue } sess.DisconnectTicks-- @@ -91,7 +90,7 @@ func (g *Game) WanderTick() { if inst.HP <= 0 || inst.WanderInterval <= 0 { continue } - if combat.IsMobInCombat(inst.InstanceID) { + if g.Combat.IsMobInCombat(inst.InstanceID) { continue } inst.WanderTickCounter++ @@ -140,23 +139,23 @@ func (g *Game) WanderTick() { if p == nil { continue } - if p.RoomID == m.fromRoom && p.OptionBool("mob_leave") { - if m.level > 0 { - levelStr := g.levelColorize(sess, p.CombatLevel(), m.level, fmt.Sprintf("(level %d)", m.level)) - sess.WriteLine(fmt.Sprintf("\n%s %s leaves.", g.colorize(sess, "mob", m.name), levelStr)) - } else { - sess.WriteLine(fmt.Sprintf("\n%s moves away.", g.colorize(sess, "mob", m.name))) + if p.RoomID == m.fromRoom && p.OptionBool("mob_leave") { + if m.level > 0 { + levelStr := g.levelColorize(sess, p.CombatLevel(), m.level, fmt.Sprintf("(level %d)", m.level)) + sess.WriteLine(fmt.Sprintf("\n%s %s leaves.", g.colorize(sess, "mob", m.name), levelStr)) + } else { + sess.WriteLine(fmt.Sprintf("\n%s moves away.", g.colorize(sess, "mob", m.name))) + } } - } - if p.RoomID == m.toRoom && p.OptionBool("mob_enter") { - if m.level > 0 { - levelStr := g.levelColorize(sess, p.CombatLevel(), m.level, fmt.Sprintf("(level %d)", m.level)) - sess.WriteLine(fmt.Sprintf("\n%s %s enters.", g.colorize(sess, "mob", m.name), levelStr)) - } else { - sess.WriteLine(fmt.Sprintf("\n%s drifts in.", g.colorize(sess, "mob", m.name))) + if p.RoomID == m.toRoom && p.OptionBool("mob_enter") { + if m.level > 0 { + levelStr := g.levelColorize(sess, p.CombatLevel(), m.level, fmt.Sprintf("(level %d)", m.level)) + sess.WriteLine(fmt.Sprintf("\n%s %s enters.", g.colorize(sess, "mob", m.name), levelStr)) + } else { + sess.WriteLine(fmt.Sprintf("\n%s drifts in.", g.colorize(sess, "mob", m.name))) + } } } - } } } @@ -167,7 +166,7 @@ func (g *Game) SharedDepletionTick() { if p == nil || p.Action == nil || p.Action.Type != "gather" { continue } - if gd, ok := p.Action.Data.(*action.GatherData); ok { + if gd, ok := p.Action.Data.(*behavior.GatherData); ok { activeKeys[gd.InstanceKey] = true } } @@ -331,7 +330,7 @@ func (g *Game) MoveTick() { } p.MoveTicks-- if p.MoveTicks > 0 { - if combat.GetCombat(p.Name) != nil && p.OptionBool("run_countdown") { + if g.Combat.Get(p.Name) != nil && p.OptionBool("run_countdown") { if p.MoveTicks > 1 { sess.WriteLine(fmt.Sprintf("Running in %d ticks...", p.MoveTicks)) } else { diff --git a/internal/game/ui_bar.go b/internal/game/ui_bar.go deleted file mode 100644 index ca0b721..0000000 --- a/internal/game/ui_bar.go +++ /dev/null @@ -1,104 +0,0 @@ -package game - -import ( - "math" - "strings" - - "thehouseoficarus/internal/color" -) - -type BarStyle struct { - FilledColor int - EmptyColor int - EmptyDim bool -} - -func RenderBar(current, max, width int, unicode bool) string { - if max <= 0 { - max = 1 - } - if current < 0 { - current = 0 - } - if current > max { - current = max - } - filled := int(math.Round(float64(current) * float64(width) / float64(max))) - if filled > width { - filled = width - } - if filled < 0 { - filled = 0 - } - - fillRune, emptyRune := barRunes(unicode) - return strings.Repeat(fillRune, filled) + strings.Repeat(emptyRune, width-filled) -} - -func RenderBarF(current, max float64, width int, unicode bool) string { - if max <= 0 { - max = 1 - } - ratio := current / max - if ratio > 1 { - ratio = 1 - } - if ratio < 0 { - ratio = 0 - } - filled := int(math.Round(ratio * float64(width))) - if filled > width { - filled = width - } - if filled < 0 { - filled = 0 - } - - fillRune, emptyRune := barRunes(unicode) - return strings.Repeat(fillRune, filled) + strings.Repeat(emptyRune, width-filled) -} - -func RenderColoredBar(current, max, width int, unicode bool, style *BarStyle, mode string) string { - if max <= 0 { - max = 1 - } - if current < 0 { - current = 0 - } - if current > max { - current = max - } - filled := int(math.Round(float64(current) * float64(width) / float64(max))) - if filled > width { - filled = width - } - if filled < 0 { - filled = 0 - } - - fillRune, emptyRune := barRunes(unicode) - fillStr := strings.Repeat(fillRune, filled) - emptyStr := strings.Repeat(emptyRune, width-filled) - - if style != nil && mode != "none" && mode != "" { - if style.FilledColor >= 0 { - fillStr = color.Render(mode, color.ColorSpec{Fg: style.FilledColor}, fillStr) - } - if style.EmptyColor >= 0 { - spec := color.ColorSpec{Fg: style.EmptyColor} - if style.EmptyDim { - spec.Dim = true - } - emptyStr = color.Render(mode, spec, emptyStr) - } - } - - return fillStr + emptyStr -} - -func barRunes(unicode bool) (fill, empty string) { - if unicode { - return "█", "░" - } - return "#", "." -} diff --git a/internal/game/ui_color.go b/internal/game/ui_color.go deleted file mode 100644 index 962728a..0000000 --- a/internal/game/ui_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/ui_combat.go b/internal/game/ui_combat.go deleted file mode 100644 index 77faae8..0000000 --- a/internal/game/ui_combat.go +++ /dev/null @@ -1,38 +0,0 @@ -package game - -import "thehouseoficarus/internal/net" - -// renderBar is the shared bar renderer for both HP bars and task-progress bars. -// lowColor is the color used when the value falls below 40% (160 red for HP, -// 39 cyan for progress). -func (g *Game) renderBar(sess *net.Session, current, max, lowColor int) string { - if max <= 0 { - max = 1 - } - if current < 0 { - current = 0 - } - if current > max { - current = max - } - - pct := float64(current) / float64(max) * 100.0 - var fgColor int - switch { - case pct >= 70: - fgColor = 82 - case pct >= 40: - fgColor = 220 - default: - fgColor = lowColor - } - - unicode := sess.Player != nil && sess.Player.OptionBool("unicode") - style := &BarStyle{FilledColor: fgColor, EmptyColor: 238, EmptyDim: true} - bar := RenderColoredBar(current, max, 10, unicode, style, g.colorMode(sess)) - return "[" + bar + "]" -} - -func (g *Game) hpBar(sess *net.Session, current, max int) string { - return g.renderBar(sess, current, max, 160) -} diff --git a/internal/game/ui_help.go b/internal/game/ui_help.go deleted file mode 100644 index 28f4371..0000000 --- a/internal/game/ui_help.go +++ /dev/null @@ -1,155 +0,0 @@ -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"}, - {"global", "Instant", "Chat with everyone on the server"}, - {"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"}, - {"symbol", "Instant", "Set custom map symbol for current room"}, - {"talk / speak / ask", "Active", "Talk to mobs"}, - {"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"}, - {"who", "Instant", "List everyone online and their combat level"}, -} - -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 deleted file mode 100644 index a07606f..0000000 --- a/internal/game/ui_map.go +++ /dev/null @@ -1,428 +0,0 @@ -package game - -import ( - "strings" - "unicode/utf8" - - "thehouseoficarus/internal/color" - "thehouseoficarus/internal/net" - "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 mapCell struct { - char rune - spec color.ColorSpec -} - -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, visited map[int]bool) *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 - } - - if visited != nil && !visited[n.roomID] { - continue - } - - for _, d := range bfsDirs { - targetID, ok := exitTarget(room, d.dir) - if !ok { - continue - } - if _, seen := mg.roomToPos[targetID]; seen { - 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 renderMapCells(grid [][]mapCell, colorMode string, startRow, endRow int, border rune) []string { - lines := make([]string, 0, endRow-startRow) - for row := startRow; row < endRow; row++ { - var sb strings.Builder - if border != 0 { - sb.WriteRune(border) - } - for _, cell := range grid[row] { - if cell.char == ' ' { - sb.WriteRune(' ') - } else if !cell.spec.Empty() { - sb.WriteString(color.Render(colorMode, cell.spec, string(cell.char))) - } else { - sb.WriteRune(cell.char) - } - } - if border != 0 { - sb.WriteRune(border) - } - lines = append(lines, sb.String()) - } - return lines -} - -func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string { - visited := roomsVisited(sess) - bg := buildGraph(g, roomID, visited) - - colorMode := colorModeFor(sess) - atSpec := resolveMapAt(g, sess) - dimSpec := resolveDim(g, sess) - - grid := make([][]mapCell, 5) - for i := range grid { - grid[i] = make([]mapCell, 5) - for j := range grid[i] { - grid[i][j] = mapCell{char: ' '} - } - } - - 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] = mapCell{char: '@', spec: atSpec} - } else { - unvisited := visited != nil && !visited[rid] - ch, spec := roomMapSymbol(g, sess, rid, unvisited) - if unvisited { - spec = dimSpec - } - grid[gr][gc] = mapCell{char: ch, spec: spec} - } - } - } - - 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) { - connSpec := color.NoColor() - if visited != nil && (!visited[leftRoom] || !visited[rightRoom]) { - connSpec = dimSpec - } - grid[(y+1)*2][(x+1)*2+1] = mapCell{char: mg.connectorH, spec: connSpec} - } - } - } - - 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) { - connSpec := color.NoColor() - if visited != nil && (!visited[topRoom] || !visited[bottomRoom]) { - connSpec = dimSpec - } - grid[(y+1)*2+1][(x+1)*2] = mapCell{char: mg.connectorV, spec: connSpec} - } - } - } - - cur, _ := loadRoom(g, roomID) - if cur != nil { - if _, ok := exitTarget(cur, world.Up); ok { - grid[1][3] = mapCell{char: mg.upArrow, spec: color.NoColor()} - } - if _, ok := exitTarget(cur, world.Down); ok { - grid[3][1] = mapCell{char: mg.downArrow, spec: color.NoColor()} - } - } - - topFill := strings.Repeat(string(mg.topFill), 5) - lines := make([]string, 7) - lines[0] = string(mg.topLeft) + topFill + string(mg.topRight) - inner := renderMapCells(grid, colorMode, 0, 5, mg.side) - copy(lines[1:], inner) - botFill := strings.Repeat(string(mg.topFill), 5) - lines[6] = string(mg.bottomLeft) + botFill + string(mg.bottomRight) - - return lines -} - -func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, mg mapGlyphs) []string { - visited := roomsVisited(sess) - bg := buildGraph(g, roomID, visited) - - colorMode := colorModeFor(sess) - atSpec := resolveMapAt(g, sess) - dimSpec := resolveDim(g, sess) - - grid := make([][]mapCell, mapHeight) - for i := range grid { - grid[i] = make([]mapCell, mapWidth) - for j := range grid[i] { - grid[i][j] = mapCell{char: ' '} - } - } - - 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] = mapCell{char: '@', spec: atSpec} - } else { - unvisited := visited != nil && !visited[rid] - ch, spec := roomMapSymbol(g, sess, rid, unvisited) - if unvisited { - spec = dimSpec - } - grid[gr][gc] = mapCell{char: ch, spec: spec} - } - } - - 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 { - connSpec := color.NoColor() - if visited != nil && (!visited[rid] || !visited[rightID]) { - connSpec = dimSpec - } - grid[gr][gc] = mapCell{char: mg.connectorH, spec: connSpec} - } - } - } - - 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 { - connSpec := color.NoColor() - if visited != nil && (!visited[rid] || !visited[bottomID]) { - connSpec = dimSpec - } - grid[gr][gc] = mapCell{char: mg.connectorV, spec: connSpec} - } - } - } - } - - return renderMapCells(grid, colorMode, 0, mapHeight, 0) -} - -func roomsVisited(sess *net.Session) map[int]bool { - if sess != nil && sess.Player != nil { - return sess.Player.Stats.RoomsVisited - } - return nil -} - -func colorModeFor(sess *net.Session) string { - if sess != nil && sess.Player != nil { - return sess.Player.OptionString("color") - } - return "none" -} - -func resolveMapAt(g *Game, sess *net.Session) color.ColorSpec { - if sess != nil { - return g.resolveColor(sess, "map_at") - } - return color.NoColor() -} - -func resolveDim(g *Game, sess *net.Session) color.ColorSpec { - if sess != nil { - return g.resolveColor(sess, "dim") - } - return color.Parse("243 dim") -} - -func roomMapSymbol(g *Game, sess *net.Session, roomID int, unvisited bool) (rune, color.ColorSpec) { - if sess != nil && sess.Player != nil { - if data, ok := sess.Player.MapSymbols[roomID]; ok { - r, size := utf8.DecodeRuneInString(data.Char) - if size > 0 && r != utf8.RuneError { - spec := color.NoColor() - if data.Color != "" { - spec = color.Parse(data.Color) - } - // ponytail: non-ASCII custom symbols fall back to 'o' - // when unicode mode is off, but preserve the color. - if !sess.Player.OptionBool("unicode") && r > 127 { - return 'o', spec - } - return r, spec - } - } - if sess.Player.OptionBool("unicode") { - if unvisited { - return '□', color.NoColor() - } - return '■', color.NoColor() - } - return 'o', color.NoColor() - } - return '■', color.NoColor() -} - -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 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 -} diff --git a/internal/game/ui_prompt.go b/internal/game/ui_prompt.go deleted file mode 100644 index 6263ac2..0000000 --- a/internal/game/ui_prompt.go +++ /dev/null @@ -1,98 +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 { - 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 deleted file mode 100644 index b96dca1..0000000 --- a/internal/game/ui_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/ui_xpdrop.go b/internal/game/ui_xpdrop.go deleted file mode 100644 index a9bff23..0000000 --- a/internal/game/ui_xpdrop.go +++ /dev/null @@ -1,31 +0,0 @@ -package game - -import ( - "fmt" - "strings" - - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" -) - -// formatXpDrop returns the colored XP-drop suffix for a slice of gains, or "" -// if xp_drops are disabled or there are no gains. -func (g *Game) formatXpDrop(sess *net.Session, p *player.Player, gains []xpGain) string { - if !p.OptionBool("xp_drops") || len(gains) == 0 { - return "" - } - var parts []string - for _, gain := range gains { - parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)])) - } - return g.colorize(sess, "xp", " ("+strings.Join(parts, ", ")+")") -} - -// formatXpDropSingle returns the colored XP-drop suffix for a single skill, or "" -// if xp_drops are disabled or xp is 0. -func (g *Game) formatXpDropSingle(sess *net.Session, p *player.Player, skill player.SkillName, xp int) string { - if !p.OptionBool("xp_drops") || xp <= 0 { - return "" - } - return g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp %s)", xp, player.SkillAbbr[skill])) -} diff --git a/internal/game/validate_source.go b/internal/game/validate_source.go new file mode 100644 index 0000000..b93c45a --- /dev/null +++ b/internal/game/validate_source.go @@ -0,0 +1,53 @@ +package game + +import ( + "thehouseoficarus/internal/validate" +) + +// ValidateAndLog runs all startup integrity checks and logs the results. +func (g *Game) ValidateAndLog() { + validate.LogIssues(validate.Run(g.validationSource())) +} + +// validationSource assembles the read-only view of all data stores that the +// validate package needs, including the game-defined tech and course tables. +func (g *Game) validationSource() validate.Source { + courses := g.CourseStore.AllCourses() + courseViews := make([]validate.CourseView, 0, len(courses)) + for id, cfg := range courses { + rooms := make([]int, 0, len(cfg.Obstacles)) + for _, obs := range cfg.Obstacles { + rooms = append(rooms, obs.RoomID) + } + courseViews = append(courseViews, validate.CourseView{ + ID: id, + StartRoom: cfg.StartRoom, + ObstacleRooms: rooms, + }) + } + + techViews := make([]validate.TechView, 0, len(AllTechs)) + techIDs := make(map[string]bool, len(AllTechs)) + for _, t := range AllTechs { + techViews = append(techViews, validate.TechView{ + ID: t.ID, + Name: t.Name, + Category: t.Category, + DrainRate: t.DrainRate, + }) + techIDs[t.ID] = true + } + + return validate.Source{ + DataDir: g.DataDir, + Items: g.ItemStore, + Objects: g.ObjectStore, + Mobs: g.MobStore, + World: g.World, + Courses: courseViews, + Techs: techViews, + TechIDs: techIDs, + CheckSources: g.ValidationConfig.CheckSources, + IgnoreUnreachable: g.ValidationConfig.IgnoreUnreachable, + } +} diff --git a/internal/item/doc.go b/internal/item/doc.go new file mode 100644 index 0000000..8fef8a7 --- /dev/null +++ b/internal/item/doc.go @@ -0,0 +1,4 @@ +// Package item defines item definitions (ItemDef), equipment stats, weapon +// types, and the ItemStore YAML loader. Items are read from data/items/ on +// demand and cached; editing a YAML takes effect immediately. +package item diff --git a/internal/item/item.go b/internal/item/item.go new file mode 100644 index 0000000..890fa8d --- /dev/null +++ b/internal/item/item.go @@ -0,0 +1,191 @@ +package item + +import ( + "fmt" + "strings" + + "gopkg.in/yaml.v3" + "thehouseoficarus/internal/behavior" +) + +type EquipSlot string + +const ( + SlotHead EquipSlot = "head" + SlotNeck EquipSlot = "neck" + SlotTorso EquipSlot = "torso" + SlotLegs EquipSlot = "legs" + SlotHands EquipSlot = "hands" + SlotFeet EquipSlot = "feet" + SlotBack EquipSlot = "back" + SlotAmmo EquipSlot = "ammo" + SlotMainHand EquipSlot = "main_hand" + SlotOffHand EquipSlot = "off_hand" + SlotRing EquipSlot = "ring" +) + +type WeaponType string + +const ( + WeaponMelee WeaponType = "melee" + WeaponRanged WeaponType = "ranged" + WeaponScience WeaponType = "science" +) + +type ItemDef struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Color string `yaml:"color"` + Description string `yaml:"description"` + Value int `yaml:"value"` + Stackable bool `yaml:"stackable"` + EquipSlot EquipSlot `yaml:"equip_slot"` + WeaponType WeaponType `yaml:"weapon_type"` + AttackType string `yaml:"attack_type"` + Stats ItemStats `yaml:"stats"` + Speed float64 `yaml:"speed"` + Requirements map[string]int `yaml:"requirements,omitempty"` + ToolType string `yaml:"tool_type"` + ToolSpeed float64 `yaml:"tool_speed"` + BurnTicks float64 `yaml:"burn_ticks"` + FireLevel int `yaml:"fire_level"` + FireXP int `yaml:"fire_xp"` + Quality int `yaml:"quality"` + MaxQuality int `yaml:"max_quality"` + SearchTable string `yaml:"search_table"` + SearchMiscTable string `yaml:"search_misc_table"` + SearchTicks float64 `yaml:"search_ticks"` + SearchMessage string `yaml:"search_message"` + HealValue int `yaml:"heal_value"` + EatMessage string `yaml:"eat_message"` + Craft CraftList `yaml:"craft,omitempty"` + ProvidesJunk string `yaml:"provides_junk,omitempty"` + FarmPatchType string `yaml:"farm_patch_type"` + FarmLevel int `yaml:"farm_level"` + FarmPlantXP int `yaml:"farm_plant_xp"` + FarmHarvestXP int `yaml:"farm_harvest_xp"` + FarmStages int `yaml:"farm_stages"` + FarmProduct string `yaml:"farm_product"` + FarmMinYield int `yaml:"farm_min_yield"` + FarmMaxYield int `yaml:"farm_max_yield"` + PotionEffect string `yaml:"potion_effect"` + PotionBonus int `yaml:"potion_bonus"` + PotionDuration float64 `yaml:"potion_duration"` + Recoverable bool `yaml:"recoverable"` +} + +type CraftList []CraftDef + +func (cl *CraftList) UnmarshalYAML(value *yaml.Node) error { + switch value.Kind { + case yaml.SequenceNode: + return value.Decode((*[]CraftDef)(cl)) + case yaml.MappingNode: + var single CraftDef + if err := value.Decode(&single); err != nil { + return err + } + *cl = []CraftDef{single} + return nil + } + return fmt.Errorf("craft: expected mapping or sequence") +} + +func (d *ItemDef) FirstCraft() *CraftDef { + if len(d.Craft) == 0 { + return nil + } + return &d.Craft[0] +} + +func (d *ItemDef) FindCraft(fn func(CraftDef) bool) *CraftDef { + for i := range d.Craft { + if fn(d.Craft[i]) { + return &d.Craft[i] + } + } + return nil +} + +func (d *ItemDef) MatchingCrafts(fn func(CraftDef) bool) []*CraftDef { + var result []*CraftDef + for i := range d.Craft { + if fn(d.Craft[i]) { + result = append(result, &d.Craft[i]) + } + } + return result +} + +type ConsumeEntry struct { + Items []string `yaml:"items"` + Quantity int `yaml:"quantity"` + Byproducts []string `yaml:"byproducts"` +} + +type CraftStep struct { + Tick float64 `yaml:"tick"` + Message string `yaml:"message"` +} + +type SuccessFormula struct { + Base float64 `yaml:"base"` + PerLevel float64 `yaml:"per_level"` + Cap float64 `yaml:"cap"` +} + +type CraftDef struct { + Type string `yaml:"type"` + Skill string `yaml:"skill"` + Level int `yaml:"level"` + XP int `yaml:"xp"` + Wait float64 `yaml:"wait"` + Station []string `yaml:"station"` + Tool string `yaml:"tool"` + Consume []ConsumeEntry `yaml:"consume"` + OutputQty int `yaml:"output_qty"` + Fail string `yaml:"fail"` + Message string `yaml:"message"` + FailMessage string `yaml:"fail_message"` + StartMessage string `yaml:"start_message"` + EndMessage string `yaml:"end_message"` + Steps []CraftStep `yaml:"steps"` + Success *SuccessFormula `yaml:"success"` +} + +func (c *CraftDef) EffectiveSkill() string { + if c.Skill != "" { + return c.Skill + } + return c.Type +} + +type ItemStats struct { + StabAttack int `yaml:"stab_attack"` + SlashAttack int `yaml:"slash_attack"` + CrushAttack int `yaml:"crush_attack"` + ScienceAttack int `yaml:"science_attack"` + RangedAttack int `yaml:"ranged_attack"` + + StabDefense int `yaml:"stab_defense"` + SlashDefense int `yaml:"slash_defense"` + CrushDefense int `yaml:"crush_defense"` + ScienceDefense int `yaml:"science_defense"` + RangedDefense int `yaml:"ranged_defense"` + + StrengthBonus int `yaml:"strength_bonus"` + RangedStrength int `yaml:"ranged_strength"` + ScienceDamage int `yaml:"science_damage"` + TechnologyBonus int `yaml:"technology_bonus"` +} + +func (d *ItemDef) MatchesName(input string) bool { + lower := strings.ToLower(strings.TrimSpace(input)) + if lower == "" { + return false + } + if strings.ToLower(d.Name) == lower { + return true + } + return behavior.WordPrefixMatch(lower, d.Name) +} diff --git a/internal/item/store.go b/internal/item/store.go new file mode 100644 index 0000000..d73d6fe --- /dev/null +++ b/internal/item/store.go @@ -0,0 +1,78 @@ +package item + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" + "thehouseoficarus/internal/behavior" +) + +type ItemStore struct { + dataDir string + pathIndex map[string]string + cache map[string]*ItemDef +} + +func NewItemStore(dataDir string) *ItemStore { + s := &ItemStore{ + dataDir: dataDir, + pathIndex: behavior.BuildPathIndex(filepath.Join(dataDir, "items")), + cache: make(map[string]*ItemDef), + } + return s +} + +func (s *ItemStore) Load(id string) (*ItemDef, error) { + if def, ok := s.cache[id]; ok { + return def, nil + } + path, ok := s.pathIndex[id] + if !ok { + return nil, fmt.Errorf("read item %s: no such item", id) + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read item %s: %w", id, err) + } + var def ItemDef + if err := yaml.Unmarshal(data, &def); err != nil { + return nil, fmt.Errorf("parse item %s: %w", id, err) + } + def.ID = id + s.cache[id] = &def + return &def, nil +} + +func (s *ItemStore) LoadAll() ([]*ItemDef, error) { + dir := filepath.Join(s.dataDir, "items") + var defs []*ItemDef + err := behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error { + if def, ok := s.cache[id]; ok { + defs = append(defs, def) + return nil + } + var def ItemDef + if err := yaml.Unmarshal(data, &def); err != nil { + return fmt.Errorf("parse item %s: %w", id, err) + } + def.ID = id + s.cache[id] = &def + defs = append(defs, &def) + return nil + }) + return defs, err +} + +func (s *ItemStore) PathIndex() map[string]string { + return s.pathIndex +} + +func (s *ItemStore) IDSet() map[string]bool { + ids := make(map[string]bool) + for id := range s.pathIndex { + ids[id] = true + } + return ids +} diff --git a/internal/net/server.go b/internal/net/server.go index 5c98b62..9ae8532 100644 --- a/internal/net/server.go +++ b/internal/net/server.go @@ -8,7 +8,7 @@ import ( "net/http" "sync" - "thehouseoficarus/internal/action" + "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/color" "thehouseoficarus/internal/config" "thehouseoficarus/internal/player" @@ -17,7 +17,7 @@ import ( type SessionState int const ( - StateAccountName SessionState = iota + StateAccountName SessionState = iota StatePassword StateNewAccountPass StateNewAccount @@ -60,20 +60,18 @@ type Session struct { PendingBackground bool Disconnecting bool DisconnectTicks int - Shop *action.ShopConfig + Shop *behavior.ShopConfig writeMu sync.Mutex } - - type Server struct { - config *config.Config - telnetLn net.Listener - telnetTLSLn net.Listener - httpServer *http.Server - httpsServer *http.Server - hub *Hub - handler func(*Session, string) + config *config.Config + telnetLn net.Listener + telnetTLSLn net.Listener + httpServer *http.Server + httpsServer *http.Server + hub *Hub + handler func(*Session, string) } type Hub struct { diff --git a/internal/net/server_test.go b/internal/net/server_test.go index fca5528..b106c53 100644 --- a/internal/net/server_test.go +++ b/internal/net/server_test.go @@ -38,10 +38,10 @@ func TestHubConcurrency(t *testing.T) { type testConn struct{ data []byte } -func (c *testConn) ReadMessage() (string, error) { return "", nil } -func (c *testConn) Write(b []byte) (int, error) { c.data = append(c.data, b...); return len(b), nil } -func (c *testConn) Close() error { return nil } -func (c *testConn) SetEcho(bool) error { return nil } +func (c *testConn) ReadMessage() (string, error) { return "", nil } +func (c *testConn) Write(b []byte) (int, error) { c.data = append(c.data, b...); return len(b), nil } +func (c *testConn) Close() error { return nil } +func (c *testConn) SetEcho(bool) error { return nil } func TestSessionWriteConcurrency(t *testing.T) { conn := &testConn{} diff --git a/internal/object/doc.go b/internal/object/doc.go new file mode 100644 index 0000000..04aa4f7 --- /dev/null +++ b/internal/object/doc.go @@ -0,0 +1,5 @@ +// Package object defines room-object definitions (ObjectDef), safespot +// configuration, use interactions, and the ObjectStore YAML loader. Objects +// are placed in rooms via room YAML and may provide gather/talk/use/safespot +// behaviors. Distinct from the item package (item definitions). +package object diff --git a/internal/object/item.go b/internal/object/item.go deleted file mode 100644 index b90432d..0000000 --- a/internal/object/item.go +++ /dev/null @@ -1,211 +0,0 @@ -package object - -import ( - "fmt" - "strings" - - "gopkg.in/yaml.v3" -) - -func WordPrefixMatch(input, name string) bool { - inputWords := strings.Fields(strings.ToLower(input)) - if len(inputWords) == 0 { - return false - } - nameWords := strings.Fields(strings.ToLower(name)) - for _, iw := range inputWords { - found := false - for _, nw := range nameWords { - if strings.HasPrefix(nw, iw) || strings.HasPrefix(iw, nw) { - found = true - break - } - } - if !found { - return false - } - } - return true -} - -type EquipSlot string - -const ( - SlotHead EquipSlot = "head" - SlotNeck EquipSlot = "neck" - SlotTorso EquipSlot = "torso" - SlotLegs EquipSlot = "legs" - SlotHands EquipSlot = "hands" - SlotFeet EquipSlot = "feet" - SlotBack EquipSlot = "back" - SlotAmmo EquipSlot = "ammo" - SlotMainHand EquipSlot = "main_hand" - SlotOffHand EquipSlot = "off_hand" - SlotRing EquipSlot = "ring" -) - -type WeaponType string - -const ( - WeaponMelee WeaponType = "melee" - WeaponRanged WeaponType = "ranged" - WeaponScience WeaponType = "science" -) - -type ItemDef struct { - ID string `yaml:"id"` - Name string `yaml:"name"` - Color string `yaml:"color"` - Description string `yaml:"description"` - Value int `yaml:"value"` - Stackable bool `yaml:"stackable"` - EquipSlot EquipSlot `yaml:"equip_slot"` - WeaponType WeaponType `yaml:"weapon_type"` - AttackType string `yaml:"attack_type"` - Stats ItemStats `yaml:"stats"` - Speed float64 `yaml:"speed"` - Requirements map[string]int `yaml:"requirements,omitempty"` - ToolType string `yaml:"tool_type"` - ToolSpeed float64 `yaml:"tool_speed"` - BurnTicks float64 `yaml:"burn_ticks"` - FireLevel int `yaml:"fire_level"` - FireXP int `yaml:"fire_xp"` - Quality int `yaml:"quality"` - MaxQuality int `yaml:"max_quality"` - SearchTable string `yaml:"search_table"` - SearchMiscTable string `yaml:"search_misc_table"` - SearchTicks float64 `yaml:"search_ticks"` - SearchMessage string `yaml:"search_message"` - HealValue int `yaml:"heal_value"` - EatMessage string `yaml:"eat_message"` - Craft CraftList `yaml:"craft,omitempty"` - ProvidesJunk string `yaml:"provides_junk,omitempty"` - FarmPatchType string `yaml:"farm_patch_type"` - FarmLevel int `yaml:"farm_level"` - FarmPlantXP int `yaml:"farm_plant_xp"` - FarmHarvestXP int `yaml:"farm_harvest_xp"` - FarmStages int `yaml:"farm_stages"` - FarmProduct string `yaml:"farm_product"` - FarmMinYield int `yaml:"farm_min_yield"` - FarmMaxYield int `yaml:"farm_max_yield"` - PotionEffect string `yaml:"potion_effect"` - PotionBonus int `yaml:"potion_bonus"` - PotionDuration float64 `yaml:"potion_duration"` - Recoverable bool `yaml:"recoverable"` -} - -type CraftList []CraftDef - -func (cl *CraftList) UnmarshalYAML(value *yaml.Node) error { - switch value.Kind { - case yaml.SequenceNode: - return value.Decode((*[]CraftDef)(cl)) - case yaml.MappingNode: - var single CraftDef - if err := value.Decode(&single); err != nil { - return err - } - *cl = []CraftDef{single} - return nil - } - return fmt.Errorf("craft: expected mapping or sequence") -} - -func (d *ItemDef) FirstCraft() *CraftDef { - if len(d.Craft) == 0 { - return nil - } - return &d.Craft[0] -} - -func (d *ItemDef) FindCraft(fn func(CraftDef) bool) *CraftDef { - for i := range d.Craft { - if fn(d.Craft[i]) { - return &d.Craft[i] - } - } - return nil -} - -func (d *ItemDef) MatchingCrafts(fn func(CraftDef) bool) []*CraftDef { - var result []*CraftDef - for i := range d.Craft { - if fn(d.Craft[i]) { - result = append(result, &d.Craft[i]) - } - } - return result -} - -type ConsumeEntry struct { - Items []string `yaml:"items"` - Quantity int `yaml:"quantity"` - Byproducts []string `yaml:"byproducts"` -} - -type CraftStep struct { - Tick float64 `yaml:"tick"` - Message string `yaml:"message"` -} - -type SuccessFormula struct { - Base float64 `yaml:"base"` - PerLevel float64 `yaml:"per_level"` - Cap float64 `yaml:"cap"` -} - -type CraftDef struct { - Type string `yaml:"type"` - Skill string `yaml:"skill"` - Level int `yaml:"level"` - XP int `yaml:"xp"` - Wait float64 `yaml:"wait"` - Station []string `yaml:"station"` - Tool string `yaml:"tool"` - Consume []ConsumeEntry `yaml:"consume"` - OutputQty int `yaml:"output_qty"` - Fail string `yaml:"fail"` - Message string `yaml:"message"` - FailMessage string `yaml:"fail_message"` - StartMessage string `yaml:"start_message"` - EndMessage string `yaml:"end_message"` - Steps []CraftStep `yaml:"steps"` - Success *SuccessFormula `yaml:"success"` -} - -func (c *CraftDef) EffectiveSkill() string { - if c.Skill != "" { - return c.Skill - } - return c.Type -} - -type ItemStats struct { - StabAttack int `yaml:"stab_attack"` - SlashAttack int `yaml:"slash_attack"` - CrushAttack int `yaml:"crush_attack"` - ScienceAttack int `yaml:"science_attack"` - RangedAttack int `yaml:"ranged_attack"` - - StabDefense int `yaml:"stab_defense"` - SlashDefense int `yaml:"slash_defense"` - CrushDefense int `yaml:"crush_defense"` - ScienceDefense int `yaml:"science_defense"` - RangedDefense int `yaml:"ranged_defense"` - - StrengthBonus int `yaml:"strength_bonus"` - RangedStrength int `yaml:"ranged_strength"` - ScienceDamage int `yaml:"science_damage"` - TechnologyBonus int `yaml:"technology_bonus"` -} - -func (d *ItemDef) MatchesName(input string) bool { - lower := strings.ToLower(strings.TrimSpace(input)) - if lower == "" { - return false - } - if strings.ToLower(d.Name) == lower { - return true - } - return WordPrefixMatch(lower, d.Name) -} diff --git a/internal/object/item_store.go b/internal/object/item_store.go deleted file mode 100644 index e930220..0000000 --- a/internal/object/item_store.go +++ /dev/null @@ -1,78 +0,0 @@ -package object - -import ( - "fmt" - "os" - "path/filepath" - - "thehouseoficarus/internal/action" - "gopkg.in/yaml.v3" -) - -type ItemStore struct { - dataDir string - pathIndex map[string]string - cache map[string]*ItemDef -} - -func NewItemStore(dataDir string) *ItemStore { - s := &ItemStore{ - dataDir: dataDir, - pathIndex: action.BuildPathIndex(filepath.Join(dataDir, "items")), - cache: make(map[string]*ItemDef), - } - return s -} - -func (s *ItemStore) Load(id string) (*ItemDef, error) { - if def, ok := s.cache[id]; ok { - return def, nil - } - path, ok := s.pathIndex[id] - if !ok { - return nil, fmt.Errorf("read item %s: no such item", id) - } - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read item %s: %w", id, err) - } - var def ItemDef - if err := yaml.Unmarshal(data, &def); err != nil { - return nil, fmt.Errorf("parse item %s: %w", id, err) - } - def.ID = id - s.cache[id] = &def - return &def, nil -} - -func (s *ItemStore) LoadAll() ([]*ItemDef, error) { - dir := filepath.Join(s.dataDir, "items") - var defs []*ItemDef - err := action.WalkYAMLDir(dir, func(path, id string, data []byte) error { - if def, ok := s.cache[id]; ok { - defs = append(defs, def) - return nil - } - var def ItemDef - if err := yaml.Unmarshal(data, &def); err != nil { - return fmt.Errorf("parse item %s: %w", id, err) - } - def.ID = id - s.cache[id] = &def - defs = append(defs, &def) - return nil - }) - return defs, err -} - -func (s *ItemStore) PathIndex() map[string]string { - return s.pathIndex -} - -func (s *ItemStore) IDSet() map[string]bool { - ids := make(map[string]bool) - for id := range s.pathIndex { - ids[id] = true - } - return ids -} diff --git a/internal/object/object.go b/internal/object/object.go index f7a0e7b..8d1e02e 100644 --- a/internal/object/object.go +++ b/internal/object/object.go @@ -1,44 +1,34 @@ package object -import "thehouseoficarus/internal/action" +import "thehouseoficarus/internal/behavior" type UseInteraction struct { - Item string `yaml:"item_id"` - Condition *action.Condition `yaml:"condition"` - Message string `yaml:"message"` - Action *action.NodeAction `yaml:"action"` + Item string `yaml:"item_id"` + Condition *behavior.Condition `yaml:"condition"` + Message string `yaml:"message"` + Action *behavior.NodeAction `yaml:"action"` } type ObjectDef struct { - ID string `yaml:"id"` - Name string `yaml:"name"` - Aliases []string `yaml:"aliases"` - Color string `yaml:"color"` - Hidden bool `yaml:"hidden"` - InRoomDescription string `yaml:"inroom_description"` - RemovalItem string `yaml:"removal_item"` - Description string `yaml:"description"` - Descriptions []ConditionalDesc `yaml:"descriptions,omitempty"` - UseInteractions []UseInteraction `yaml:"use_interactions"` - StealTable string `yaml:"steal_table"` - StealLevel int `yaml:"steal_level"` - StealXP int `yaml:"steal_xp"` - StealSpeed float64 `yaml:"steal_speed"` - GuardMob string `yaml:"guard_mob"` + ID string `yaml:"id"` + Name string `yaml:"name"` + Aliases []string `yaml:"aliases"` + Color string `yaml:"color"` + Hidden bool `yaml:"hidden"` + InRoomDescription string `yaml:"inroom_description"` + RemovalItem string `yaml:"removal_item"` + Description behavior.DescList `yaml:"description"` + UseInteractions []UseInteraction `yaml:"use_interactions"` + StealTable string `yaml:"steal_table"` + StealLevel int `yaml:"steal_level"` + StealXP int `yaml:"steal_xp"` + StealSpeed float64 `yaml:"steal_speed"` + GuardMob string `yaml:"guard_mob"` - Gather *action.GatherConfig `yaml:"gather,omitempty"` - Talk *action.TalkConfig `yaml:"talk,omitempty"` - Use *action.UseConfig `yaml:"use,omitempty"` - Safespot *SafespotConfig `yaml:"safespot,omitempty"` -} - -// ConditionalDesc is a description variant gated by a condition. They are -// evaluated in order per-looker; the first whose condition passes (or has no -// condition) wins. If a def has Descriptions but none match for a given player, -// the object is treated as absent for that player. -type ConditionalDesc struct { - Text string `yaml:"text"` - Condition *action.Condition `yaml:"condition"` + Gather *behavior.GatherConfig `yaml:"gather,omitempty"` + Talk *behavior.TalkConfig `yaml:"talk,omitempty"` + Use *behavior.UseConfig `yaml:"use,omitempty"` + Safespot *SafespotConfig `yaml:"safespot,omitempty"` } type SafespotConfig struct { @@ -58,11 +48,13 @@ type SafespotLevel struct { DegradeMessage string `yaml:"degrade_message"` } -func (d *ObjectDef) IsGatherable() bool { return d.Gather != nil } -func (d *ObjectDef) IsTalkable() bool { return d.Talk != nil } -func (d *ObjectDef) IsUsable() bool { return d.Use != nil } -func (d *ObjectDef) IsSafespot() bool { return d.Safespot != nil } -func (d *ObjectDef) IsInteractable() bool { return d.Gather != nil || d.Talk != nil || d.Use != nil || d.Safespot != nil } +func (d *ObjectDef) IsGatherable() bool { return d.Gather != nil } +func (d *ObjectDef) IsTalkable() bool { return d.Talk != nil } +func (d *ObjectDef) IsUsable() bool { return d.Use != nil } +func (d *ObjectDef) IsSafespot() bool { return d.Safespot != nil } +func (d *ObjectDef) IsInteractable() bool { + return d.Gather != nil || d.Talk != nil || d.Use != nil || d.Safespot != nil +} func (d *ObjectDef) BehaviorType() string { switch { diff --git a/internal/object/store.go b/internal/object/store.go index cf8b4f9..a441705 100644 --- a/internal/object/store.go +++ b/internal/object/store.go @@ -5,8 +5,8 @@ import ( "os" "path/filepath" - "thehouseoficarus/internal/action" "gopkg.in/yaml.v3" + "thehouseoficarus/internal/behavior" ) type ObjectStore struct { @@ -18,7 +18,7 @@ type ObjectStore struct { func NewObjectStore(dataDir string) *ObjectStore { return &ObjectStore{ dataDir: dataDir, - pathIndex: action.BuildPathIndex(filepath.Join(dataDir, "objects")), + pathIndex: behavior.BuildPathIndex(filepath.Join(dataDir, "objects")), cache: make(map[string]*ObjectDef), } } diff --git a/internal/player/player.go b/internal/player/player.go index 2cc19bb..cb823aa 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -3,8 +3,8 @@ package player import ( "sort" - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/object" + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/item" ) type SkillName string @@ -42,7 +42,7 @@ var AllSkills = []SkillName{ } var SkillAbbr = map[SkillName]string{ - Accuracy: "acc", + Accuracy: "acc", Strength: "str", Defense: "def", Hitpoints: "hp", @@ -95,7 +95,7 @@ type InventorySlot struct { type OptionType int const ( - OptBool OptionType = iota + OptBool OptionType = iota OptString OptInt ) @@ -157,47 +157,47 @@ func GetOptionDef(name string) *OptionDef { } type Player struct { - Name string `yaml:"name"` - Skills map[SkillName]int `yaml:"skills"` - Inventory map[int]*InventorySlot `yaml:"inventory"` - Bank map[int]*InventorySlot `yaml:"bank"` - Equipment map[object.EquipSlot]string `yaml:"equipment"` - EquipQuality map[string]int `yaml:"equip_quality,omitempty"` - RoomID int `yaml:"room_id"` - HP int `yaml:"hp"` - Credits int `yaml:"credits"` - Description string `yaml:"description"` - AttackStyle AttackStyle `yaml:"attack_style"` - Prompt string `yaml:"prompt"` - Options map[string]any `yaml:"-"` - Flags map[string]any `yaml:"flags"` - CutsceneRoom int `yaml:"cutscene_room,omitempty"` - RegenerateTick int - AmmoQty int `yaml:"ammo_qty,omitempty"` - Action *action.Action `yaml:"-"` - BackgroundAction *action.Action `yaml:"-"` - WalkSequence []string `yaml:"-"` - ConsumeCooldown int `yaml:"-"` - MoveTicks int `yaml:"-"` - MoveDirection string `yaml:"-"` - MoveTarget int `yaml:"-"` - MovePendingFlags map[string]any `yaml:"-"` - MovePendingPlayerFlags map[string]any `yaml:"-"` - VisualTickCurrent int `yaml:"-"` - AutotriggerMod string `yaml:"-"` - QueuedTrigger string `yaml:"-"` - AttackTimer int `yaml:"-"` - HazardTimer int `yaml:"-"` - PendingDangerDir string `yaml:"-"` - Battery float64 `yaml:"battery"` - ActiveTechs map[string]bool `yaml:"-"` - QuickTech string `yaml:"quick_tech,omitempty"` - TechActivatedSinceTick map[string]bool `yaml:"-"` - Sneaking bool `yaml:"-"` - SneakNotified map[string]bool `yaml:"-"` - ActiveBuffs []PotionBuff `yaml:"-"` - MapSymbols map[int]MapSymbolData `yaml:"map_symbols,omitempty"` - Stats PlayerStats `yaml:"stats"` + Name string `yaml:"name"` + Skills map[SkillName]int `yaml:"skills"` + Inventory map[int]*InventorySlot `yaml:"inventory"` + Bank map[int]*InventorySlot `yaml:"bank"` + Equipment map[item.EquipSlot]string `yaml:"equipment"` + EquipQuality map[string]int `yaml:"equip_quality,omitempty"` + RoomID int `yaml:"room_id"` + HP int `yaml:"hp"` + Credits int `yaml:"credits"` + Description string `yaml:"description"` + AttackStyle AttackStyle `yaml:"attack_style"` + Prompt string `yaml:"prompt"` + Options map[string]any `yaml:"-"` + Flags map[string]any `yaml:"flags"` + CutsceneRoom int `yaml:"cutscene_room,omitempty"` + RegenerateTick int + AmmoQty int `yaml:"ammo_qty,omitempty"` + Action *behavior.Action `yaml:"-"` + BackgroundAction *behavior.Action `yaml:"-"` + WalkSequence []string `yaml:"-"` + ConsumeCooldown int `yaml:"-"` + MoveTicks int `yaml:"-"` + MoveDirection string `yaml:"-"` + MoveTarget int `yaml:"-"` + MovePendingFlags map[string]any `yaml:"-"` + MovePendingPlayerFlags map[string]any `yaml:"-"` + VisualTickCurrent int `yaml:"-"` + AutotriggerMod string `yaml:"-"` + QueuedTrigger string `yaml:"-"` + AttackTimer int `yaml:"-"` + HazardTimer int `yaml:"-"` + PendingDangerDir string `yaml:"-"` + Battery float64 `yaml:"battery"` + ActiveTechs map[string]bool `yaml:"-"` + QuickTech string `yaml:"quick_tech,omitempty"` + TechActivatedSinceTick map[string]bool `yaml:"-"` + Sneaking bool `yaml:"-"` + SneakNotified map[string]bool `yaml:"-"` + ActiveBuffs []PotionBuff `yaml:"-"` + MapSymbols map[int]MapSymbolData `yaml:"map_symbols,omitempty"` + Stats PlayerStats `yaml:"stats"` } type MapSymbolData struct { @@ -323,11 +323,11 @@ func New(name string) *Player { Name: name, Skills: make(map[SkillName]int), Bank: make(map[int]*InventorySlot), - Equipment: make(map[object.EquipSlot]string), + Equipment: make(map[item.EquipSlot]string), AttackStyle: Accurate, Prompt: "> ", RoomID: 0, - MapSymbols: make(map[int]MapSymbolData), + MapSymbols: make(map[int]MapSymbolData), Stats: PlayerStats{ RoomsVisited: make(map[int]bool), MobKills: make(map[string]int), diff --git a/internal/player/store.go b/internal/player/store.go index 97ff13f..bcadf3b 100644 --- a/internal/player/store.go +++ b/internal/player/store.go @@ -6,8 +6,8 @@ import ( "path/filepath" "strings" - "thehouseoficarus/internal/object" "gopkg.in/yaml.v3" + "thehouseoficarus/internal/item" ) type AccountStore struct { @@ -90,7 +90,7 @@ func (s *AccountStore) LoadCharacter(name string) (*Player, error) { p.Skills = make(map[SkillName]int) } if p.Equipment == nil { - p.Equipment = make(map[object.EquipSlot]string) + p.Equipment = make(map[item.EquipSlot]string) } if p.Inventory == nil { p.Inventory = make(map[int]*InventorySlot) diff --git a/internal/ui/bar.go b/internal/ui/bar.go new file mode 100644 index 0000000..c1abce9 --- /dev/null +++ b/internal/ui/bar.go @@ -0,0 +1,110 @@ +package ui + +import ( + "math" + "strings" + + "thehouseoficarus/internal/color" +) + +type BarStyle struct { + FilledColor int + EmptyColor int + EmptyDim bool +} + +func RenderBar(current, max, width int, unicode bool) string { + if max <= 0 { + max = 1 + } + if current < 0 { + current = 0 + } + if current > max { + current = max + } + filled := int(math.Round(float64(current) * float64(width) / float64(max))) + if filled > width { + filled = width + } + if filled < 0 { + filled = 0 + } + + fillRune, emptyRune := barRunes(unicode) + return strings.Repeat(fillRune, filled) + strings.Repeat(emptyRune, width-filled) +} + +func RenderBarF(current, max float64, width int, unicode bool) string { + if max <= 0 { + max = 1 + } + ratio := current / max + if ratio > 1 { + ratio = 1 + } + if ratio < 0 { + ratio = 0 + } + filled := int(math.Round(ratio * float64(width))) + if filled > width { + filled = width + } + if filled < 0 { + filled = 0 + } + + fillRune, emptyRune := barRunes(unicode) + return strings.Repeat(fillRune, filled) + strings.Repeat(emptyRune, width-filled) +} + +func RenderColoredBar(current, max, width int, unicode bool, style *BarStyle, mode string) string { + if max <= 0 { + max = 1 + } + if current < 0 { + current = 0 + } + if current > max { + current = max + } + filled := int(math.Round(float64(current) * float64(width) / float64(max))) + if filled > width { + filled = width + } + if filled < 0 { + filled = 0 + } + + fillRune, emptyRune := barRunes(unicode) + fillStr := strings.Repeat(fillRune, filled) + emptyStr := strings.Repeat(emptyRune, width-filled) + + if style != nil && mode != "none" && mode != "" { + if style.FilledColor >= 0 { + fillStr = color.Render(mode, color.ColorSpec{Fg: style.FilledColor}, fillStr) + } + if style.EmptyColor >= 0 { + spec := color.ColorSpec{Fg: style.EmptyColor} + if style.EmptyDim { + spec.Dim = true + } + emptyStr = color.Render(mode, spec, emptyStr) + } + } + + return fillStr + emptyStr +} + +func barRunes(unicode bool) (fill, empty string) { + return BarRunes(unicode) +} + +// BarRunes returns the fill and empty runes used for progress bars, selecting +// Unicode block characters when unicode is true and ASCII otherwise. +func BarRunes(unicode bool) (fill, empty string) { + if unicode { + return "█", "░" + } + return "#", "." +} diff --git a/internal/ui/doc.go b/internal/ui/doc.go new file mode 100644 index 0000000..428ac51 --- /dev/null +++ b/internal/ui/doc.go @@ -0,0 +1,5 @@ +// Package ui provides pure rendering primitives (progress bars, Unicode box +// tables) that are independent of game state. Session-coupled rendering +// (colour resolution, prompt formatting, combat display) lives in the game +// package as render_*.go files. +package ui diff --git a/internal/ui/table.go b/internal/ui/table.go new file mode 100644 index 0000000..0ac41ad --- /dev/null +++ b/internal/ui/table.go @@ -0,0 +1,150 @@ +package ui + +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/validate/checks.go b/internal/validate/checks.go new file mode 100644 index 0000000..bb7cbd9 --- /dev/null +++ b/internal/validate/checks.go @@ -0,0 +1,751 @@ +package validate + +import ( + "fmt" + "sort" + "strings" + + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/world" +) + +func validateRooms(s Source) []Issue { + var issues []Issue + roomIndex := s.World.RoomIndex() + itemIDs := s.Items.IDSet() + mobIDs := s.Mobs.AllDefIDs() + objIDs := s.Objects.IDSet() + hazardIDs := s.World.HazardIndex() + + for id := range roomIndex { + room, err := s.World.LoadRoom(id) + if err != nil { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Room %d: failed to load: %v", id, err), + }) + continue + } + if room.Name == "" { + issues = append(issues, Issue{ + Level: "WARN", + Type: "reference", + Message: fmt.Sprintf("Room %d: has no name", id), + }) + } + + for dir, exit := range room.Exits { + switch dir { + case world.North, world.South, world.East, world.West, world.Up, world.Down: + default: + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Room %d: invalid exit direction %q (only north/south/east/west/up/down supported)", id, dir), + }) + continue + } + if exit.Room <= 0 { + continue + } + if _, ok := roomIndex[exit.Room]; !ok { + issues = append(issues, Issue{ + 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, Issue{ + 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, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Room %d: mob %q wander_rooms references nonexistent room %d", + id, rm.ID, wr), + }) + } + } + } + + for _, sp := range room.ItemSpawns { + if sp.ID != "" && !itemIDs[sp.ID] { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Room %d: spawns nonexistent item %q", + id, sp.ID), + }) + } + } + + for _, robj := range room.Objects { + if robj.ID != "" && !objIDs[robj.ID] { + issues = append(issues, Issue{ + 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, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Room %d: object %q wander_rooms references nonexistent room %d", + id, robj.ID, wr), + }) + } + } + } + + if room.Hazard != "" && !hazardIDs[room.Hazard] { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Room %d: references nonexistent hazard %q", + id, room.Hazard), + }) + } + } + + return issues +} + +func validateMobs(s Source) []Issue { + var issues []Issue + itemIDs := s.Items.IDSet() + dropIDs := dropTableIDSet(s.DataDir) + mobIDs := s.Mobs.AllDefIDs() + + for id := range mobIDs { + def, err := s.Mobs.LoadDef(id) + if err != nil { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Mob %q: failed to load: %v", id, err), + }) + continue + } + if def.Name == "" { + issues = append(issues, Issue{ + Level: "WARN", + Type: "reference", + Message: fmt.Sprintf("Mob %q: has no name", id), + }) + } + + if def.Kind != "" && def.Kind != "combat" && def.Kind != "task" { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Mob %q: invalid kind %q (must be \"combat\" or \"task\")", id, def.Kind), + }) + } + + if def.Drops.Remains != "" && !itemIDs[def.Drops.Remains] { + issues = append(issues, Issue{ + 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, Issue{ + 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, Issue{ + 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, Issue{ + 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, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Mob %q: finishing_blow item %q does not exist", + id, def.FinishingBlow), + }) + } + } + + return issues +} + +func validateHazards(s Source) []Issue { + var issues []Issue + itemIDs := s.Items.IDSet() + validTypes := map[string]bool{"stab": true, "slash": true, "crush": true, "ranged": true, "science": true} + + for id := range s.World.HazardIndex() { + def, err := s.World.LoadHazard(id) + if err != nil { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Hazard %q: failed to load: %v", id, err), + }) + continue + } + if def.AttackType != "" && !validTypes[def.AttackType] { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Hazard %q: invalid attack_type %q (must be stab/slash/crush/ranged/science)", id, def.AttackType), + }) + } + if def.RequiredItem != "" && !itemIDs[def.RequiredItem] { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Hazard %q: required_item %q does not exist", id, def.RequiredItem), + }) + } + } + + return issues +} + +func validateObjects(s Source) []Issue { + var issues []Issue + objIDs := s.Objects.IDSet() + itemIDs := s.Items.IDSet() + mobIDs := s.Mobs.AllDefIDs() + dropIDs := dropTableIDSet(s.DataDir) + roomIndex := s.World.RoomIndex() + + for id := range objIDs { + obj, err := s.Objects.Load(id) + if err != nil { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Object %q: failed to load: %v", id, err), + }) + continue + } + if obj.Name == "" { + issues = append(issues, Issue{ + Level: "WARN", + Type: "reference", + Message: fmt.Sprintf("Object %q: has no name", id), + }) + } + + if obj.RemovalItem != "" && !itemIDs[obj.RemovalItem] { + issues = append(issues, Issue{ + 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, Issue{ + 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, Issue{ + 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, Issue{ + 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, validateGather(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 validateItems(s Source) []Issue { + var issues []Issue + itemIDs := s.Items.IDSet() + dropIDs := dropTableIDSet(s.DataDir) + + for id := range itemIDs { + def, err := s.Items.Load(id) + if err != nil { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Item %q: failed to load: %v", id, err), + }) + continue + } + if def.Name == "" { + issues = append(issues, Issue{ + Level: "WARN", + Type: "reference", + Message: fmt.Sprintf("Item %q: has no name", id), + }) + } + + if def.SearchTable != "" && !dropIDs[def.SearchTable] { + issues = append(issues, Issue{ + 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, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Item %q: search_misc_table %q does not exist", + id, def.SearchMiscTable), + }) + } + + if def.FarmProduct != "" && !itemIDs[def.FarmProduct] { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Item %q (seed): farm_product %q does not exist", + id, def.FarmProduct), + }) + } + } + + return issues +} + +func validateCraftBlocks(s Source) []Issue { + var issues []Issue + itemIDs := s.Items.IDSet() + objIDs := s.Objects.IDSet() + + allItems, err := s.Items.LoadAll() + if err != nil { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Failed to load items: %v", err), + }) + return issues + } + + for _, item := range allItems { + if len(item.Craft) == 0 { + continue + } + for ci := range item.Craft { + c := &item.Craft[ci] + + for _, e := range c.Consume { + for _, consumeItem := range e.Items { + if consumeItem != "" && !itemIDs[consumeItem] { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Item %q (craft: %s): consumes nonexistent item %q", + item.ID, c.Type, consumeItem), + }) + } + } + } + + if c.Fail != "" && !itemIDs[c.Fail] { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Item %q (craft: %s): fail product %q does not exist", + item.ID, c.Type, c.Fail), + }) + } + + for _, sid := range c.Station { + if sid != "" && !objIDs[sid] { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Item %q (craft: %s): station object %q does not exist", + item.ID, c.Type, sid), + }) + } + } + } + } + + return issues +} + +func validateDropTables(s Source) []Issue { + var issues []Issue + dropIDs := dropTableIDSet(s.DataDir) + itemIDs := s.Items.IDSet() + + for id := range dropIDs { + dt, err := behavior.LoadDropTable(s.DataDir, id) + if err != nil { + issues = append(issues, Issue{ + 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, Issue{ + 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, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Drop table %q: references nonexistent sub-table %q", + id, d.Table), + }) + } + } + } + + return issues +} + +func validateCourses(s Source) []Issue { + var issues []Issue + roomIndex := s.World.RoomIndex() + + for _, cv := range s.Courses { + if cv.StartRoom > 0 { + if _, ok := roomIndex[cv.StartRoom]; !ok { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Course %q: start_room %d does not exist", + cv.ID, cv.StartRoom), + }) + } + } + for _, rid := range cv.ObstacleRooms { + if _, ok := roomIndex[rid]; !ok { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Course %q: obstacle room %d does not exist", + cv.ID, rid), + }) + } + } + } + + return issues +} + +func validateRoomWiring(s Source) []Issue { + var issues []Issue + roomIndex := s.World.RoomIndex() + + sourceSet := make(map[int]bool) + for _, src := range s.CheckSources { + sourceSet[src] = true + } + + ignoreTo := make(map[int]bool) + for _, r := range s.IgnoreUnreachable { + ignoreTo[r] = true + } + + hasExitTo := make(map[int]bool) + for id := range roomIndex { + room, err := s.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, Issue{ + Level: "WARN", + Type: "integrity", + Message: fmt.Sprintf("Orphan rooms (no exit leads to them): %s", + strings.Join(strs, ", ")), + }) + } + + return issues +} + +func validateTechs(s Source) []Issue { + var issues []Issue + seen := make(map[string]bool) + for _, def := range s.Techs { + if def.ID == "" { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: "Tech has empty id", + }) + continue + } + if seen[def.ID] { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "duplicate", + Message: fmt.Sprintf("Duplicate tech %q", def.ID), + }) + } + seen[def.ID] = true + + if def.Name == "" { + issues = append(issues, Issue{ + Level: "WARN", + Type: "reference", + Message: fmt.Sprintf("Tech %q: has no name", def.ID), + }) + } + if def.Category != "" && !knownTechCategories[def.Category] { + issues = append(issues, Issue{ + Level: "WARN", + Type: "reference", + Message: fmt.Sprintf("Tech %q: unknown category %q", def.ID, def.Category), + }) + } + if def.DrainRate < 0 { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Tech %q: negative drain_rate %.2f", def.ID, def.DrainRate), + }) + } + } + + for id := range reservedTechIDs { + if !s.TechIDs[id] { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Reserved tech %q does not exist — required by code (damage protection / retribution)", id), + }) + } + } + + return issues +} + +func validateGather(prefix string, cfg *behavior.GatherConfig, itemIDs map[string]bool, dropIDs map[string]bool) []Issue { + var issues []Issue + + if cfg.Bait != "" && !itemIDs[cfg.Bait] { + issues = append(issues, Issue{ + 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, Issue{ + 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, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("%s: drop table %q does not exist", + prefix, d.Table), + }) + } + } + + return issues +} + +func validateTalkConfig(prefix string, cfg *behavior.TalkConfig, itemIDs map[string]bool, roomIndex map[int]bool) []Issue { + var issues []Issue + 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 *behavior.UseConfig, itemIDs map[string]bool) []Issue { + var issues []Issue + if cfg == nil { + return issues + } + + for itemID := range cfg.Consume { + if itemID != "" && !itemIDs[itemID] { + issues = append(issues, Issue{ + 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, Issue{ + 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 *behavior.NodeAction, itemIDs map[string]bool, roomIndex map[int]bool) []Issue { + var issues []Issue + if na == nil { + return issues + } + + if na.GiveItem != "" && !itemIDs[na.GiveItem] { + issues = append(issues, Issue{ + 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, Issue{ + 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, Issue{ + 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, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("%s: shop item %q does not exist", + prefix, si.ItemID), + }) + } + } + } + + return issues +} diff --git a/internal/validate/doc.go b/internal/validate/doc.go new file mode 100644 index 0000000..bbdf211 --- /dev/null +++ b/internal/validate/doc.go @@ -0,0 +1,6 @@ +// Package validate runs startup integrity checks across all YAML data files: +// duplicate IDs, referential integrity (rooms, mobs, objects, items, drops, +// courses, techs), and world wiring (orphan rooms). It imports only the data +// packages (world, object, item, behavior) and may be called directly in +// tests. +package validate diff --git a/internal/validate/validate.go b/internal/validate/validate.go new file mode 100644 index 0000000..5557f72 --- /dev/null +++ b/internal/validate/validate.go @@ -0,0 +1,162 @@ +// Package validate runs startup integrity checks across all data files: +// duplicate IDs, referential integrity (rooms, mobs, objects, items, drops, +// courses, techs), and world wiring (orphan rooms). It depends only on the +// data packages (world, object, item, behavior), never on the game +// orchestrator, so it can be reused and tested in isolation. +package validate + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/item" + "thehouseoficarus/internal/object" + "thehouseoficarus/internal/world" +) + +// Issue is a single validation finding. +type Issue struct { + Level string // "ERROR" or "WARN" + Type string // "duplicate", "reference", "integrity" + Message string +} + +// CourseView is the subset of a course definition needed for validation. +type CourseView struct { + ID string + StartRoom int + ObstacleRooms []int +} + +// TechView is the subset of a tech definition needed for validation. +type TechView struct { + ID string + Name string + Category string + DrainRate float64 +} + +// Source bundles everything the validator reads. The game package builds this +// from its stores and passes it to Run. +type Source struct { + DataDir string + Items *item.ItemStore + Objects *object.ObjectStore + Mobs *world.MobStore + World *world.World + Courses []CourseView + Techs []TechView + TechIDs map[string]bool + CheckSources []int + IgnoreUnreachable []int +} + +// Run executes all checks and returns the collected issues. +func Run(s Source) []Issue { + var issues []Issue + + for _, dir := range []struct{ sub, label string }{ + {"items", "item"}, {"rooms", "room"}, {"mobs", "mob"}, + {"objects", "object"}, {"drops", "drop table"}, {"courses", "course"}, + {"modules", "module"}, {"techs", "tech"}, {"help", "help topic"}, + } { + issues = append(issues, validateIDs(s.DataDir, dir.sub, dir.label)...) + } + + issues = append(issues, validateRooms(s)...) + issues = append(issues, validateMobs(s)...) + issues = append(issues, validateHazards(s)...) + issues = append(issues, validateObjects(s)...) + issues = append(issues, validateItems(s)...) + issues = append(issues, validateCraftBlocks(s)...) + issues = append(issues, validateDropTables(s)...) + issues = append(issues, validateCourses(s)...) + issues = append(issues, validateTechs(s)...) + issues = append(issues, validateRoomWiring(s)...) + + return issues +} + +func validateIDs(dataDir, subDir, label string) []Issue { + dir := filepath.Join(dataDir, subDir) + dups := behavior.CheckDuplicateIDs(dir) + var issues []Issue + 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, Issue{ + Level: "ERROR", + Type: "duplicate", + Message: fmt.Sprintf("Duplicate %s ID %q found in: %s", + label, d.ID, strings.Join(short, ", ")), + }) + } + return issues +} + +func dropTableIDSet(dataDir string) map[string]bool { + dir := filepath.Join(dataDir, "drops") + ids := make(map[string]bool) + behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error { + ids[id] = true + return nil + }) + return ids +} + +// LogIssues prints validation results to stderr, sorted with errors first. +func LogIssues(issues []Issue) { + 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) + } +} + +var knownTechCategories = map[string]bool{ + "accuracy": true, "strength": true, "defense": true, "ranged": true, + "science": true, "protection": true, "utility": true, "combo": true, +} + +// reservedTechIDs are tech IDs whose semantics are coupled to code logic and +// must exist in YAML (damage protection mapping + death retribution). +var reservedTechIDs = map[string]bool{ + "protect_melee": true, + "protect_ranged": true, + "protect_science": true, + "retribution": true, +} diff --git a/internal/world/hazard.go b/internal/world/hazard.go index 1361cff..ac2a6a6 100644 --- a/internal/world/hazard.go +++ b/internal/world/hazard.go @@ -5,8 +5,8 @@ import ( "os" "path/filepath" - "thehouseoficarus/internal/action" "gopkg.in/yaml.v3" + "thehouseoficarus/internal/behavior" ) // HazardDef describes an environmental threat attached to a room (a sandstorm, @@ -29,7 +29,7 @@ type HazardDef struct { MaxHit int `yaml:"max_hit"` Speed float64 `yaml:"speed"` // ticks between hazard rolls WarnMessage string `yaml:"warn_message"` - HitMessage string `yaml:"hit_message"` // accepts a single %d for damage + HitMessage string `yaml:"hit_message"` // accepts a single %d for damage MissMessage string `yaml:"miss_message"` RequiredItem string `yaml:"required_item"` // equipped item that negates the hazard } @@ -38,7 +38,7 @@ type HazardDef struct { func (w *World) LoadHazard(id string) (*HazardDef, error) { w.hazardMu.Lock() if w.hazardPathIndex == nil { - w.hazardPathIndex = action.BuildPathIndex(filepath.Join(w.dataDir, "hazards")) + w.hazardPathIndex = behavior.BuildPathIndex(filepath.Join(w.dataDir, "hazards")) } if def, ok := w.hazardDefs[id]; ok { w.hazardMu.Unlock() @@ -70,7 +70,7 @@ func (w *World) HazardIndex() map[string]bool { w.hazardMu.Lock() defer w.hazardMu.Unlock() if w.hazardPathIndex == nil { - w.hazardPathIndex = action.BuildPathIndex(filepath.Join(w.dataDir, "hazards")) + w.hazardPathIndex = behavior.BuildPathIndex(filepath.Join(w.dataDir, "hazards")) } ids := make(map[string]bool, len(w.hazardPathIndex)) for id := range w.hazardPathIndex { diff --git a/internal/world/mob.go b/internal/world/mob.go index 4864e5c..4c6becb 100644 --- a/internal/world/mob.go +++ b/internal/world/mob.go @@ -8,38 +8,37 @@ import ( "strings" "sync" - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/object" "gopkg.in/yaml.v3" + "thehouseoficarus/internal/behavior" ) type MobDropTable struct { - Remains string `yaml:"remains"` - Loot []action.DropEntry `yaml:"loot"` + Remains string `yaml:"remains"` + Loot []behavior.DropEntry `yaml:"loot"` } type MobDef struct { - ID string `yaml:"id"` - Name string `yaml:"name"` - Description string `yaml:"description"` - IdleDescriptions []string `yaml:"idle_descriptions"` - CombatDescriptions []string `yaml:"combat_descriptions"` - Attack int `yaml:"attack"` - Strength int `yaml:"strength"` - Defense int `yaml:"defense"` - HP int `yaml:"hp"` - Ranged int `yaml:"ranged"` - Science int `yaml:"science"` - Speed float64 `yaml:"speed"` - Aggressive bool `yaml:"aggressive"` - Protected bool `yaml:"protected"` - Unique bool `yaml:"unique"` - RespawnTicks float64 `yaml:"respawn_ticks"` + ID string `yaml:"id"` + Name string `yaml:"name"` + Description string `yaml:"description"` + IdleDescriptions []string `yaml:"idle_descriptions"` + CombatDescriptions []string `yaml:"combat_descriptions"` + Attack int `yaml:"attack"` + Strength int `yaml:"strength"` + Defense int `yaml:"defense"` + HP int `yaml:"hp"` + Ranged int `yaml:"ranged"` + Science int `yaml:"science"` + Speed float64 `yaml:"speed"` + Aggressive bool `yaml:"aggressive"` + Protected bool `yaml:"protected"` + Unique bool `yaml:"unique"` + RespawnTicks float64 `yaml:"respawn_ticks"` Drops MobDropTable `yaml:"drops"` - AttackBonus int `yaml:"attack_bonus"` - StrengthBonus int `yaml:"strength_bonus"` - AttackType string `yaml:"attack_type"` + AttackBonus int `yaml:"attack_bonus"` + StrengthBonus int `yaml:"strength_bonus"` + AttackType string `yaml:"attack_type"` StabDefense int `yaml:"stab_defense"` SlashDefense int `yaml:"slash_defense"` @@ -47,11 +46,11 @@ type MobDef struct { ScienceDefense int `yaml:"science_defense"` RangedDefense int `yaml:"ranged_defense"` - Weakness string `yaml:"weakness"` - StealTable string `yaml:"steal_table"` - StealLevel int `yaml:"steal_level"` - StealXP int `yaml:"steal_xp"` - StealSpeed float64 `yaml:"steal_speed"` + Weakness string `yaml:"weakness"` + StealTable string `yaml:"steal_table"` + StealLevel int `yaml:"steal_level"` + StealXP int `yaml:"steal_xp"` + StealSpeed float64 `yaml:"steal_speed"` AssassinLevel int `yaml:"assassin_level"` FinishingBlow string `yaml:"finishing_blow"` @@ -68,7 +67,7 @@ type MobDef struct { ProgressNoun string `yaml:"progress_noun"` // flavor noun, e.g. "construction" CompleteMessage string `yaml:"complete_message"` // shown on completion - Talk *action.TalkConfig `yaml:"talk,omitempty"` + Talk *behavior.TalkConfig `yaml:"talk,omitempty"` } func (d *MobDef) IsTalkable() bool { return d.Talk != nil } @@ -100,9 +99,9 @@ type MobInstance struct { WanderTickCounter int regenerateTick int - AttackBonus int - StrengthBonus int - AttackType string + AttackBonus int + StrengthBonus int + AttackType string StabDefense int SlashDefense int @@ -110,11 +109,11 @@ type MobInstance struct { ScienceDefense int RangedDefense int - Weakness string - StealTable string - StealLevel int - StealXP int - StealSpeed float64 + Weakness string + StealTable string + StealLevel int + StealXP int + StealSpeed float64 AssassinLevel int FinishingBlow string @@ -126,7 +125,7 @@ type MobInstance struct { ProgressNoun string CompleteMessage string - TalkConfig *action.TalkConfig + TalkConfig *behavior.TalkConfig } func (m *MobInstance) IsTalkable() bool { return m.TalkConfig != nil } @@ -135,48 +134,48 @@ func (m *MobInstance) IsTask() bool { return m.Kind == "task" } func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []int, wanderInterval float64) *MobInstance { return &MobInstance{ - InstanceID: instanceID, - DefID: def.ID, - Name: def.Name, - HP: def.HP, - MaxHP: def.HP, - Attack: def.Attack, - Strength: def.Strength, - Defense: def.Defense, - Ranged: def.Ranged, - Science: def.Science, - Speed: def.Speed, - Aggressive: def.Aggressive, - Protected: def.Protected, - Unique: def.Unique, - RespawnTicks: def.RespawnTicks, - RoomID: roomID, - HomeRoomID: roomID, - WanderRooms: wanderRooms, - WanderInterval: wanderInterval, - Drops: def.Drops, - AttackBonus: def.AttackBonus, - StrengthBonus: def.StrengthBonus, - AttackType: def.AttackType, - StabDefense: def.StabDefense, - SlashDefense: def.SlashDefense, - CrushDefense: def.CrushDefense, - ScienceDefense: def.ScienceDefense, - RangedDefense: def.RangedDefense, - Weakness: def.Weakness, - StealTable: def.StealTable, - StealLevel: def.StealLevel, - StealXP: def.StealXP, - StealSpeed: def.StealSpeed, - AssassinLevel: def.AssassinLevel, - FinishingBlow: def.FinishingBlow, - DamageWithout: def.DamageWithout, - Size: def.Size, - Kind: def.Kind, - Verb: def.Verb, - ProgressNoun: def.ProgressNoun, + InstanceID: instanceID, + DefID: def.ID, + Name: def.Name, + HP: def.HP, + MaxHP: def.HP, + Attack: def.Attack, + Strength: def.Strength, + Defense: def.Defense, + Ranged: def.Ranged, + Science: def.Science, + Speed: def.Speed, + Aggressive: def.Aggressive, + Protected: def.Protected, + Unique: def.Unique, + RespawnTicks: def.RespawnTicks, + RoomID: roomID, + HomeRoomID: roomID, + WanderRooms: wanderRooms, + WanderInterval: wanderInterval, + Drops: def.Drops, + AttackBonus: def.AttackBonus, + StrengthBonus: def.StrengthBonus, + AttackType: def.AttackType, + StabDefense: def.StabDefense, + SlashDefense: def.SlashDefense, + CrushDefense: def.CrushDefense, + ScienceDefense: def.ScienceDefense, + RangedDefense: def.RangedDefense, + Weakness: def.Weakness, + StealTable: def.StealTable, + StealLevel: def.StealLevel, + StealXP: def.StealXP, + StealSpeed: def.StealSpeed, + AssassinLevel: def.AssassinLevel, + FinishingBlow: def.FinishingBlow, + DamageWithout: def.DamageWithout, + Size: def.Size, + Kind: def.Kind, + Verb: def.Verb, + ProgressNoun: def.ProgressNoun, CompleteMessage: def.CompleteMessage, - TalkConfig: def.Talk, + TalkConfig: def.Talk, } } @@ -191,7 +190,7 @@ func (m *MobInstance) MatchQuality(input string) int { if strings.ToLower(m.Name) == lower { return MatchExact } - if object.WordPrefixMatch(input, m.Name) { + if behavior.WordPrefixMatch(input, m.Name) { return MatchPrefix } return MatchNone @@ -221,7 +220,7 @@ type MobStore struct { func NewMobStore(dataDir string) *MobStore { return &MobStore{ dataDir: dataDir, - pathIndex: action.BuildPathIndex(filepath.Join(dataDir, "mobs")), + pathIndex: behavior.BuildPathIndex(filepath.Join(dataDir, "mobs")), defs: make(map[string]*MobDef), instances: make(map[string]*MobInstance), } diff --git a/internal/world/objects.go b/internal/world/objects.go index a7e0605..849c47a 100644 --- a/internal/world/objects.go +++ b/internal/world/objects.go @@ -6,7 +6,7 @@ import ( "sort" "strings" - "thehouseoficarus/internal/object" + "thehouseoficarus/internal/behavior" ) type ObjState struct { @@ -25,9 +25,9 @@ type ObjState struct { SharedTimer int Quality float64 - SafespotLevel int + SafespotLevel int SafespotTicksAtLevel int - SafespotOccupants []string + SafespotOccupants []string } type ObjMove struct { @@ -187,11 +187,11 @@ func (w *World) FindObjInstances(roomID int, name string) []ObjState { } func wordMatchesObj(lower, defID, objName string, aliases []string) bool { - if object.WordPrefixMatch(lower, objName) { + if behavior.WordPrefixMatch(lower, objName) { return true } for _, alias := range aliases { - if object.WordPrefixMatch(lower, alias) { + if behavior.WordPrefixMatch(lower, alias) { return true } } diff --git a/internal/world/room.go b/internal/world/room.go index 2441c30..71a36ed 100644 --- a/internal/world/room.go +++ b/internal/world/room.go @@ -2,7 +2,7 @@ package world import ( "gopkg.in/yaml.v3" - "thehouseoficarus/internal/action" + "thehouseoficarus/internal/behavior" ) type ExitDir string @@ -17,12 +17,12 @@ const ( ) var ExitAliases = map[string]ExitDir{ - "n": North, - "s": South, - "e": East, - "w": West, - "u": Up, - "d": Down, + "n": North, + "s": South, + "e": East, + "w": West, + "u": Up, + "d": Down, } var OppositeExit = map[ExitDir]ExitDir{ @@ -45,11 +45,11 @@ type SpawnDef struct { } type ExitDef struct { - Room int `yaml:"room"` - Condition *action.Condition `yaml:"condition"` - BlockedMessage string `yaml:"blocked_message"` - SetFlags map[string]any `yaml:"set_flags"` - SetPlayerFlags map[string]any `yaml:"set_player_flags"` + Room int `yaml:"room"` + Condition *behavior.Condition `yaml:"condition"` + BlockedMessage string `yaml:"blocked_message"` + SetFlags map[string]any `yaml:"set_flags"` + SetPlayerFlags map[string]any `yaml:"set_player_flags"` } func (e *ExitDef) UnmarshalYAML(value *yaml.Node) error { @@ -68,24 +68,18 @@ func (e *ExitDef) UnmarshalYAML(value *yaml.Node) error { type Room struct { ID int `yaml:"id"` Name string `yaml:"name"` - Description string `yaml:"description"` - Descriptions []RoomDesc `yaml:"descriptions,omitempty"` + Description behavior.DescList `yaml:"description"` Exits map[ExitDir]ExitDef `yaml:"exits"` Objects []RoomObject `yaml:"objects"` - ItemSpawns []SpawnDef `yaml:"item_spawns"` + ItemSpawns []SpawnDef `yaml:"item_spawns"` Mobs []RoomMob `yaml:"mobs"` OnEnter []EnterStep `yaml:"on_enter"` Hazard string `yaml:"hazard"` } -type RoomDesc struct { - Text string `yaml:"text"` - Condition *action.Condition `yaml:"condition"` -} - type RoomMob struct { - ID string `yaml:"id"` - WanderRooms []int `yaml:"wander_rooms"` + ID string `yaml:"id"` + WanderRooms []int `yaml:"wander_rooms"` WanderInterval float64 `yaml:"wander_interval"` } @@ -103,11 +97,11 @@ func (rm *RoomMob) UnmarshalYAML(value *yaml.Node) error { } type EnterStep struct { - Message string `yaml:"message"` - Condition *action.Condition `yaml:"condition"` - Delay int `yaml:"delay"` - SetFlags map[string]any `yaml:"set_flags"` - SetPlayerFlags map[string]any `yaml:"set_player_flags"` + Message string `yaml:"message"` + Condition *behavior.Condition `yaml:"condition"` + Delay int `yaml:"delay"` + SetFlags map[string]any `yaml:"set_flags"` + SetPlayerFlags map[string]any `yaml:"set_player_flags"` } // IsTimed reports whether the step carries cutscene semantics: a tick delay or @@ -119,7 +113,7 @@ func (e EnterStep) IsTimed() bool { } type RoomObject struct { - ID string `yaml:"id"` - WanderRooms []int `yaml:"wander_rooms"` + ID string `yaml:"id"` + WanderRooms []int `yaml:"wander_rooms"` WanderInterval float64 `yaml:"wander_interval"` } diff --git a/internal/world/world.go b/internal/world/world.go index 8ed9a7c..ee06142 100644 --- a/internal/world/world.go +++ b/internal/world/world.go @@ -7,18 +7,18 @@ import ( "strings" "sync" - "thehouseoficarus/internal/action" "gopkg.in/yaml.v3" + "thehouseoficarus/internal/behavior" ) type World struct { - dataDir string - mu sync.Mutex - roomPathIndex map[int]string - groundItems map[int][]*groundEntry - seeded map[int]bool - objStates map[string]*ObjState - objMoves []ObjMove + dataDir string + mu sync.Mutex + roomPathIndex map[int]string + groundItems map[int][]*groundEntry + seeded map[int]bool + objStates map[string]*ObjState + objMoves []ObjMove hazardMu sync.Mutex hazardPathIndex map[string]string @@ -28,7 +28,7 @@ type World struct { func New(dataDir string) *World { return &World{ dataDir: dataDir, - roomPathIndex: action.BuildRoomIndex(filepath.Join(dataDir, "rooms")), + roomPathIndex: behavior.BuildRoomIndex(filepath.Join(dataDir, "rooms")), groundItems: make(map[int][]*groundEntry), seeded: make(map[int]bool), objStates: make(map[string]*ObjState), -- cgit v1.2.3