package player import ( "sort" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/item" ) type SkillName string const ( Accuracy SkillName = "accuracy" Strength SkillName = "strength" Defense SkillName = "defense" Hitpoints SkillName = "hitpoints" Ranged SkillName = "ranged" Science SkillName = "science" Technology SkillName = "technology" Fishing SkillName = "fishing" Cooking SkillName = "cooking" Woodcutting SkillName = "woodcutting" Firemaking SkillName = "firemaking" Mining SkillName = "mining" Smithing SkillName = "smithing" Crafting SkillName = "crafting" Fletching SkillName = "fletching" Pharmacy SkillName = "pharmacy" Thieving SkillName = "thieving" Agility SkillName = "agility" Construction SkillName = "construction" Scavenging SkillName = "scavenging" Hacking SkillName = "hacking" Assassin SkillName = "assassin" Farming SkillName = "farming" ) var AllSkills = []SkillName{ Accuracy, Strength, Defense, Hitpoints, Ranged, Science, Technology, Fishing, Cooking, Woodcutting, Firemaking, Mining, Smithing, Crafting, Fletching, Pharmacy, Thieving, Agility, Construction, Scavenging, Hacking, Assassin, Farming, } var SkillAbbr = map[SkillName]string{ Accuracy: "acc", Strength: "str", Defense: "def", Hitpoints: "hp", Ranged: "rng", Science: "sci", Technology: "tec", Fishing: "fsh", Cooking: "cok", Woodcutting: "wdc", Firemaking: "fmk", Mining: "min", Smithing: "smt", Crafting: "cft", Fletching: "flt", Pharmacy: "phr", Thieving: "thv", Agility: "agl", Construction: "con", Scavenging: "scv", Hacking: "hck", Assassin: "asm", Farming: "frm", } type AttackStyle string const ( Accurate AttackStyle = "accurate" Aggressive AttackStyle = "aggressive" Defensive AttackStyle = "defensive" Balanced AttackStyle = "balanced" ) type Skill struct { Name SkillName XP int } func (s Skill) Level() int { return LevelForXP(s.XP) } type InventorySlot struct { ItemID string `yaml:"item_id"` Quantity int `yaml:"quantity"` Quality int `yaml:"quality,omitempty"` MaxQuality int `yaml:"max_quality,omitempty"` } type OptionType int const ( OptBool OptionType = iota OptString OptInt ) type OptionDef struct { Name string Type OptionType Default any ValidValues []string Description string } var OptionDefs = []OptionDef{ {"description", OptBool, true, nil, "Long room descriptions when moving"}, {"tiny_map", OptString, "right", []string{"off", "right", "left"}, "Mini-map display (off/right/left)"}, {"xp_drops", OptBool, true, nil, "XP drop messages"}, {"exits", OptBool, true, nil, "Long exit display in look"}, {"mob_enter", OptBool, true, nil, "Messages when mobs enter the room"}, {"mob_leave", OptBool, true, nil, "Messages when mobs leave the room"}, {"mob_spawn", OptBool, true, nil, "Messages when mobs spawn in the area"}, {"reserve", OptBool, true, nil, "Show full reserved item details"}, {"depletion", OptBool, false, nil, "Show depletion and despawn timers"}, {"despawn", OptBool, false, nil, "Show ground item despawn timers"}, {"color", OptString, "xterm256", []string{"none", "ansi", "xterm256"}, "Color output mode"}, {"map_width", OptInt, 30, nil, "Map width for the map command"}, {"map_height", OptInt, 20, nil, "Map height for the map command"}, {"map_padding", OptString, "none", []string{"none", "x", "y", "xy"}, "Map output padding mode"}, {"automap", OptBool, false, nil, "Show map automatically after moving"}, {"show_queued_cmds", OptBool, false, nil, "Show confirmation messages for queued tick actions"}, {"wrap_width", OptInt, 80, nil, "Wrap all output to this many columns (minimum 80)"}, {"unicode", OptBool, true, nil, "Unicode box-drawing characters"}, {"card_size", OptInt, 9, nil, "Casino card size (1, 5, 7, or 9)"}, {"card_style", OptString, "dark", []string{"dark", "light"}, "Casino card style (dark or light)"}, {"visual_ticks", OptBool, false, nil, "Display a tick marker every game tick"}, {"visual_tick_count", OptInt, 0, nil, "Cycle length for tick counter (0 = no counter)"}, {"visual_tick_text", OptString, "Tick", nil, "Text displayed for visual ticks"}, {"fletch_all", OptBool, false, nil, "Auto-start fletching when only one product is possible"}, {"smith_all", OptBool, false, nil, "Auto-start smithing when only one product is possible"}, {"cook_all", OptBool, false, nil, "Auto-start cooking when only one product is possible"}, {"smelt_all", OptBool, false, nil, "Auto-start smelting when only one product is possible"}, {"mix_all", OptBool, false, nil, "Auto-start mixing when only one product is possible"}, {"construct_all", OptBool, false, nil, "Auto-start constructing when only one product is possible"}, {"craft_all", OptBool, false, nil, "Auto-start crafting when only one product is possible"}, {"danger_warning", OptBool, true, nil, "Confirm before entering a dangerous (hazardous) area"}, {"prompt_break", OptString, "on", []string{"on", "off"}, "Line break after prompt before output"}, } var optionByName map[string]*OptionDef func init() { optionByName = make(map[string]*OptionDef, len(OptionDefs)) for i := range OptionDefs { optionByName[OptionDefs[i].Name] = &OptionDefs[i] } } func GetOptionDef(name string) *OptionDef { return optionByName[name] } 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[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"` BankedCredits int `yaml:"banked_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"` PendingSequence *PendingSequence `yaml:"pending_sequence,omitempty"` RegenerateTick int AmmoQty int `yaml:"ammo_qty,omitempty"` Action *behavior.Action `yaml:"-"` BackgroundAction *behavior.Action `yaml:"-"` WalkSequence []string `yaml:"-"` ConsumeCooldown int `yaml:"-"` HomeTransportCooldown int `yaml:"-"` MoveTicks int `yaml:"-"` MoveDirection string `yaml:"-"` MoveTarget int `yaml:"-"` MovePendingTrigger *behavior.Trigger `yaml:"-"` MoveUsesEnergy bool `yaml:"-"` EscapeDir string `yaml:"-"` EscapeTarget int `yaml:"-"` EscapeTrigger *behavior.Trigger `yaml:"-"` EscapeIsWalk bool `yaml:"-"` EscapeFailCount int `yaml:"-"` VisualTickCurrent int `yaml:"-"` AutotriggerMod string `yaml:"-"` QueuedTrigger string `yaml:"-"` AttackTimer int `yaml:"-"` HazardTimer int `yaml:"-"` PendingDangerDir string `yaml:"-"` Battery float64 `yaml:"battery"` RunEnergy int `yaml:"run_energy,omitempty"` RunEnergyAccum int `yaml:"-"` 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"` GodMode bool `yaml:"-"` GodBackup map[SkillName]int `yaml:"-"` } type MapSymbolData struct { Char string `yaml:"char"` Color string `yaml:"color,omitempty"` } // PendingSequence is the persisted snapshot of an in-flight LOCKED trigger // sequence (Trigger.Lock == true). Unlocked sequences are never persisted. // On reconnect, the game rebuilds a sequence from this snapshot and resumes // it from the saved Wait offset. type PendingSequence struct { Key string `yaml:"key"` RoomID int `yaml:"room_id"` Steps []behavior.Step `yaml:"steps"` Wait int `yaml:"wait"` FlagVal any `yaml:"flag_val,omitempty"` } type PotionBuff struct { Stat string BonusPercent int TicksLeft int } func (p *Player) ClearMoveState() { p.MoveTicks = 0 p.MoveDirection = "" p.MoveTarget = 0 p.MovePendingTrigger = nil p.MoveUsesEnergy = false p.ClearEscape() } func (p *Player) ClearEscape() { p.EscapeDir = "" p.EscapeTarget = 0 p.EscapeTrigger = nil p.EscapeIsWalk = false } func (p *Player) OptionBool(name string) bool { def := GetOptionDef(name) if def == nil || def.Type != OptBool { return false } if p.Options == nil { return def.Default.(bool) } val, ok := p.Options[name] if !ok { return def.Default.(bool) } b, ok := val.(bool) if !ok { return def.Default.(bool) } return b } func (p *Player) OptionString(name string) string { def := GetOptionDef(name) if def == nil || def.Type != OptString { return "" } if p.Options == nil { return def.Default.(string) } val, ok := p.Options[name] if !ok { return def.Default.(string) } s, ok := val.(string) if !ok { return def.Default.(string) } return s } func (p *Player) OptionInt(name string) int { def := GetOptionDef(name) if def == nil || def.Type != OptInt { return 0 } if p.Options == nil { return def.Default.(int) } val, ok := p.Options[name] if !ok { return def.Default.(int) } switch v := val.(type) { case int: return v case int64: return int(v) case float64: return int(v) } return def.Default.(int) } func (p *Player) EnsureFlags() { if p.Flags == nil { p.Flags = make(map[string]any) } } func (p *Player) InvSlot(i int) *InventorySlot { if p.Inventory == nil { return nil } return p.Inventory[i] } func (p *Player) SetInvSlot(i int, slot *InventorySlot) { if p.Inventory == nil { p.Inventory = make(map[int]*InventorySlot) } if slot == nil { delete(p.Inventory, i) } else { p.Inventory[i] = slot } } func (p *Player) FirstFreeSlot() int { for i := 0; i < 28; i++ { if p.Inventory[i] == nil { return i } } return -1 } func (p *Player) FreeSlots() int { used := len(p.Inventory) return 28 - used } func New(name string) *Player { p := &Player{ Name: name, Skills: make(map[SkillName]int), Bank: make(map[int]*InventorySlot), Equipment: make(map[item.EquipSlot]string), AttackStyle: Accurate, Prompt: "> ", RoomID: 0, MapSymbols: make(map[int]MapSymbolData), Stats: PlayerStats{ MobKills: make(map[string]int), }, } for _, s := range AllSkills { p.Skills[s] = 0 } p.Skills[Hitpoints] = XPForLevel(10) p.HP = p.MaxHP() p.Battery = p.MaxBattery() p.RunEnergy = p.MaxRunEnergy() return p } func (p *Player) Level(s SkillName) int { return LevelForXP(p.Skills[s]) } func (p *Player) AddXP(s SkillName, amount int) { if p.Skills == nil { p.Skills = make(map[SkillName]int) } p.Skills[s] += amount } func (p *Player) AddSkillXP(s SkillName, amount int) int { oldLevel := p.Level(s) p.AddXP(s, amount) newLevel := p.Level(s) if newLevel > oldLevel { return newLevel } return 0 } func (p *Player) TotalLevel() int { total := 0 for _, s := range AllSkills { total += p.Level(s) } return total } func (p *Player) CombatLevel() int { base := 0.25 * float64(p.Level(Defense)+p.Level(Hitpoints)+p.Level(Technology)) att := float64(p.Level(Accuracy)) str := float64(p.Level(Strength)) if float64(p.Level(Ranged))*1.5 > att+str { base += 0.375 * float64(p.Level(Ranged)) } else { base += 0.25 * (att + str) } base += 0.125 * float64(p.Level(Science)) return int(base) } func (p *Player) MaxHP() int { return p.Level(Hitpoints) } const RegenTickInterval = 100 func (p *Player) StartRegen() { if p.RegenerateTick == 0 { p.RegenerateTick = RegenTickInterval } } func (p *Player) HasItem(itemID string) bool { for _, slot := range p.Inventory { if slot != nil && slot.ItemID == itemID && slot.Quantity > 0 { return true } } return false } // IsWielding reports whether the given item is currently held in one of the // player's weapon-hand equipment slots (main_hand or off_hand). It is the // "wielding" predicate used to gate on_kill interactions whose item_id names // a specific weapon — two-handed weapons occupy main_hand alone, while dual // wield / weapon + shield uses both hands. Armor, ammo, ring, and tool slots // do NOT count as "wielding". func (p *Player) IsWielding(itemID string) bool { if itemID == "" { return false } return p.Equipment[item.SlotMainHand] == itemID || p.Equipment[item.SlotOffHand] == itemID } func (p *Player) CountItem(itemID string) int { count := 0 for _, slot := range p.Inventory { if slot != nil && slot.ItemID == itemID { count += slot.Quantity } } return count } func (p *Player) RemoveItem(itemID string, qty int) bool { remaining := qty for i, slot := range p.Inventory { if slot == nil || slot.ItemID != itemID { continue } if slot.Quantity <= remaining { remaining -= slot.Quantity delete(p.Inventory, i) } else { slot.Quantity -= remaining remaining = 0 } if remaining <= 0 { return true } } return remaining <= 0 } func (p *Player) MaxBattery() float64 { return float64(p.Level(Technology)) } // MaxRunEnergy is 3x the player's Agility level (the OSRS-inspired cap). func (p *Player) MaxRunEnergy() int { return 3 * p.Level(Agility) } func (p *Player) HasRunEnergy() bool { return p.RunEnergy > 0 } // ClampRunEnergy caps current run energy to the player's current maximum. // Called whenever the Agility level can change (XP gains) since leveling up // raises the cap but should not auto-refill the missing energy. func (p *Player) ClampRunEnergy() { max := p.MaxRunEnergy() if p.RunEnergy > max { p.RunEnergy = max } } func (p *Player) HasActiveTech(techID string) bool { if p.ActiveTechs == nil { return false } return p.ActiveTechs[techID] } func (p *Player) ActivateTech(techID string) { if p.ActiveTechs == nil { p.ActiveTechs = make(map[string]bool) } if p.TechActivatedSinceTick == nil { p.TechActivatedSinceTick = make(map[string]bool) } p.ActiveTechs[techID] = true p.TechActivatedSinceTick[techID] = true } func (p *Player) DeactivateTech(techID string) { if p.ActiveTechs != nil { delete(p.ActiveTechs, techID) } } func (p *Player) DeactivateAllTechs() { p.ActiveTechs = nil p.TechActivatedSinceTick = nil } func (p *Player) BankSlot(i int) *InventorySlot { if p.Bank == nil { return nil } return p.Bank[i] } func (p *Player) SetBankSlot(i int, slot *InventorySlot) { if p.Bank == nil { p.Bank = make(map[int]*InventorySlot) } if slot == nil { delete(p.Bank, i) } else { p.Bank[i] = slot } } func (p *Player) NextBankSlot() int { if p.Bank == nil { return 0 } i := 0 for { if p.Bank[i] == nil { return i } i++ } } func (p *Player) BankSlots() []int { if p.Bank == nil { return nil } keys := make([]int, 0, len(p.Bank)) for k := range p.Bank { keys = append(keys, k) } sort.Ints(keys) return keys } func (p *Player) BankCount() int { return len(p.Bank) } func (p *Player) ActiveTechList() []string { var result []string for id := range p.ActiveTechs { result = append(result, id) } sort.Strings(result) return result }