From 0c048db2d57c58adc955474c0ba28a3488d2121c Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Wed, 30 Jul 2025 11:53:39 -0500 Subject: [PATCH] significant work on automatic conversion --- CLAUDE.md | 249 ++++++ internal/classes.cpp | 199 +++++ internal/classes.h | 119 +++ internal/entity/README.md | 167 ++++ internal/entity/entity.go | 668 +++++++++++++++ internal/entity/info_struct.go | 854 +++++++++++++++++++ internal/entity/spell_effects.go | 37 + internal/object/constants.go | 35 + internal/object/integration.go | 281 +++++++ internal/object/interfaces.go | 320 +++++++ internal/object/manager.go | 419 ++++++++++ internal/object/object.go | 603 ++++++++++++++ internal/races/constants.go | 90 ++ internal/races/integration.go | 291 +++++++ internal/races/manager.go | 380 +++++++++ internal/races/races.go | 387 +++++++++ internal/races/utils.go | 351 ++++++++ internal/spawn/README.md | 166 ++++ internal/spawn/spawn.go | 1156 ++++++++++++++++++++++++++ internal/spawn/spawn_lists.go | 446 ++++++++++ internal/spells/README.md | 68 ++ internal/spells/SPELL_PROCESS.md | 112 +++ internal/spells/constants.go | 140 ++++ internal/spells/process_constants.go | 304 +++++++ internal/spells/spell.go | 655 +++++++++++++++ internal/spells/spell_data.go | 434 ++++++++++ internal/spells/spell_effects.go | 621 ++++++++++++++ internal/spells/spell_manager.go | 597 +++++++++++++ internal/spells/spell_process.go | 591 +++++++++++++ internal/spells/spell_resources.go | 470 +++++++++++ internal/spells/spell_targeting.go | 442 ++++++++++ internal/titles/README.md | 226 +++++ internal/titles/constants.go | 106 +++ internal/titles/integration.go | 403 +++++++++ internal/titles/master_list.go | 420 ++++++++++ internal/titles/player_titles.go | 462 ++++++++++ internal/titles/title.go | 325 ++++++++ internal/titles/title_manager.go | 382 +++++++++ internal/trade/constants.go | 39 + internal/trade/manager.go | 378 +++++++++ internal/trade/trade.go | 487 +++++++++++ internal/trade/types.go | 188 +++++ internal/trade/utils.go | 207 +++++ 43 files changed, 15275 insertions(+) create mode 100644 CLAUDE.md create mode 100644 internal/classes.cpp create mode 100644 internal/classes.h create mode 100644 internal/entity/README.md create mode 100644 internal/entity/entity.go create mode 100644 internal/entity/info_struct.go create mode 100644 internal/entity/spell_effects.go create mode 100644 internal/object/constants.go create mode 100644 internal/object/integration.go create mode 100644 internal/object/interfaces.go create mode 100644 internal/object/manager.go create mode 100644 internal/object/object.go create mode 100644 internal/races/constants.go create mode 100644 internal/races/integration.go create mode 100644 internal/races/manager.go create mode 100644 internal/races/races.go create mode 100644 internal/races/utils.go create mode 100644 internal/spawn/README.md create mode 100644 internal/spawn/spawn.go create mode 100644 internal/spawn/spawn_lists.go create mode 100644 internal/spells/README.md create mode 100644 internal/spells/SPELL_PROCESS.md create mode 100644 internal/spells/constants.go create mode 100644 internal/spells/process_constants.go create mode 100644 internal/spells/spell.go create mode 100644 internal/spells/spell_data.go create mode 100644 internal/spells/spell_effects.go create mode 100644 internal/spells/spell_manager.go create mode 100644 internal/spells/spell_process.go create mode 100644 internal/spells/spell_resources.go create mode 100644 internal/spells/spell_targeting.go create mode 100644 internal/titles/README.md create mode 100644 internal/titles/constants.go create mode 100644 internal/titles/integration.go create mode 100644 internal/titles/master_list.go create mode 100644 internal/titles/player_titles.go create mode 100644 internal/titles/title.go create mode 100644 internal/titles/title_manager.go create mode 100644 internal/trade/constants.go create mode 100644 internal/trade/manager.go create mode 100644 internal/trade/trade.go create mode 100644 internal/trade/types.go create mode 100644 internal/trade/utils.go diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..deac5a4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,249 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +EQ2Go is a Go rewrite of the EverQuest II server emulator from C++ EQ2EMu. Implements core MMO server functionality with authentication, world simulation, and game mechanics using modern Go practices. + +## Development Commands + +### Building the Project +```bash +# Build login server +go build -o bin/login_server ./cmd/login_server + +# Build world server +go build -o bin/world_server ./cmd/world_server + +# Build both servers +go build ./cmd/... +``` + +### Running the Servers +```bash +# Run login server (requires login_config.json) +./bin/login_server + +# Run world server (creates world_config.json with defaults if missing) +./bin/world_server + +# With custom configuration +./bin/world_server -listen-port 9001 -web-port 8081 -log-level debug +``` + +### Testing +```bash +# Run all tests +go test ./... + +# Test specific packages +go test ./internal/udp +go test ./internal/packets/parser + +# Run tests with verbose output +go test -v ./... + +# Test with race detection +go test -race ./... +``` + +### Development Tools +```bash +# Format code +go fmt ./... + +# Run linter (if golangci-lint is installed) +golangci-lint run + +# Tidy modules +go mod tidy + +# Check for vulnerabilities +go run golang.org/x/vuln/cmd/govulncheck@latest ./... +``` + +## Architecture Overview + +### Core Components + +**Login Server** - Client authentication, character management, world server coordination +**World Server** - Game simulation engine, zones, NPCs, combat, quests, web admin interface + +**Shared Libraries:** +- `common/` - EQ2-specific data types and constants +- `database/` - SQLite wrapper with simplified query interface +- `udp/` - Custom UDP protocol with reliability layer +- `packets/` - XML-driven packet definition parser +- `achievements/` - Achievement system +- `spawn/` - Base game entity system (positioning, commands, scripting) +- `entity/` - Combat-capable entities with spell effects, stats, pet management +- `spells/` - Complete spell system with casting mechanics and processing +- `titles/` - Character title system with achievement integration +- `trade/` - Player trading system with item/coin exchange and validation +- `object/` - Interactive objects extending spawn system (merchants, transporters, devices, collectors) +- `races/` - Race system with all EQ2 races, alignment classification, stat modifiers, and entity integration + +### Network Protocol +EverQuest II UDP protocol with reliability layer, RC4 encryption, CRC validation, connection management, packet combining. + +### Database Layer +SQLite with simplified query interface, transactions, connection pooling, parameter binding. + +### Packet System +XML-driven packet definitions with version-specific formats, conditional fields, templates, dynamic arrays. + +## Key Files and Locations + +**Server Entry Points:** +- `cmd/login_server/main.go`: Login server startup and configuration +- `cmd/world_server/main.go`: World server startup with command-line options + +**Core Data Structures:** +- `internal/common/types.go`: EQ2-specific types (EQ2Color, EQ2Equipment, AppearanceData, etc.) + +**Network Implementation:** +- `internal/udp/server.go`: Multi-connection UDP server +- `internal/udp/connection.go`: Individual client connection handling +- `internal/udp/protocol.go`: Protocol packet types and constants + +**Database:** +- `internal/database/wrapper.go`: Simplified SQLite interface +- Database files: `login.db`, `world.db` (created automatically) + +**Spawn System:** +- `internal/spawn/spawn.go`: Base spawn entity with position, stats, commands, and scripting +- `internal/spawn/spawn_lists.go`: Spawn location and entry management +- `internal/spawn/README.md`: Comprehensive spawn system documentation + +**Entity System:** +- `internal/entity/entity.go`: Combat-capable spawn extension with spell casting and pet management +- `internal/entity/info_struct.go`: Comprehensive character statistics and information management +- `internal/entity/spell_effects.go`: DEPRECATED - now imports from spells package +- `internal/entity/README.md`: Complete entity system documentation + +**Spells System:** +- Core: `spell_data.go`, `spell.go`, `spell_effects.go`, `spell_manager.go`, `constants.go` +- Processing: `process_constants.go`, `spell_process.go`, `spell_targeting.go`, `spell_resources.go` +- Docs: `README.md`, `SPELL_PROCESS.md` + +**Titles System:** +- `internal/titles/title.go`: Core title data structures and player title management +- `internal/titles/constants.go`: Title system constants, categories, and rarity levels +- `internal/titles/master_list.go`: Global title registry and management with categorization +- `internal/titles/player_titles.go`: Individual player title collections and active title tracking +- `internal/titles/title_manager.go`: Central title system coordinator with background cleanup +- `internal/titles/integration.go`: Integration systems for earning titles through achievements, quests, PvP, events +- `internal/titles/README.md`: Complete title system documentation + +**Trade System:** +- `internal/trade/trade.go`: Core trade mechanics with two-party item/coin exchange +- `internal/trade/types.go`: Trade data structures, participants, and validation +- `internal/trade/constants.go`: Trade constants, error codes, and configuration +- `internal/trade/utils.go`: Coin calculations, validation helpers, and formatting +- `internal/trade/manager.go`: Trade service with high-level management and placeholders + +**Object System:** +- `internal/object/object.go`: Core object mechanics extending spawn functionality with interaction +- `internal/object/constants.go`: Object constants, spawn types, and interaction types +- `internal/object/manager.go`: Object manager with zone-based and type-based indexing +- `internal/object/integration.go`: Spawn system integration with ObjectSpawn wrapper +- `internal/object/interfaces.go`: Integration interfaces for trade/spell systems and entity adapters + +**Race System:** +- `internal/races/races.go`: Core race management with all 21 EQ2 races and alignment mapping +- `internal/races/constants.go`: Race IDs, names, and display constants +- `internal/races/utils.go`: Race utilities with stat modifiers, descriptions, and parsing +- `internal/races/integration.go`: Entity system integration with RaceAware interface +- `internal/races/manager.go`: High-level race management with statistics and command processing + +**Packet Definitions:** +- `internal/packets/xml/`: XML packet structure definitions +- `internal/packets/PARSER.md`: Packet definition language documentation +- `internal/packets/parser/`: Parser implementation for XML packet definitions + +## Configuration + +**Login Server**: Requires `login_config.json` with database and server settings + +**World Server**: Creates `world_config.json` with defaults: +```json +{ + "listen_addr": "0.0.0.0", + "listen_port": 9000, + "max_clients": 1000, + "web_port": 8080, + "database_path": "world.db", + "log_level": "info" +} +``` + +Command-line flags override JSON configuration. + +## Important Dependencies + +**Core Dependencies:** +- `zombiezen.com/go/sqlite`: Modern SQLite driver +- `golang.org/x/crypto`: Cryptographic functions + +**Module Information:** +- Go version: 1.24.5 +- Module name: `eq2emu` + +## Development Notes + +**Architecture Transition**: This Go implementation is based on the extensive C++ EQ2EMu codebase documented in `EQ2EMU_Architecture_White_Paper.md`. The Go version maintains compatibility with the original protocol while modernizing the implementation. + +**Packet Handling**: The XML packet definition system allows for easy addition of new packet types without code changes. See `internal/packets/PARSER.md` for syntax reference. + +**Database Schema**: Currently uses SQLite for development/testing. Production deployments may require migration to PostgreSQL or MySQL for better concurrent access. + +**Spawn System**: The spawn system (`internal/spawn/`) provides the foundation for all game entities. It's converted from the C++ EQ2EMu codebase with modern Go concurrency patterns. + +**Entity System**: The entity system (`internal/entity/`) extends spawns with combat capabilities, implementing the complete EverQuest II character statistics system, spell effect management, and pet systems. Key features include: +- **InfoStruct**: Complete character statistics (attributes, resistances, experience, currency, equipment data) +- **Entity**: Combat-capable spawn with spell casting, pet management, and bonus calculations +- **Thread Safety**: All systems use proper Go concurrency patterns with mutexes and atomic operations +- **Stat Calculations**: Dynamic stat calculations with bonuses from equipment, spells, and other sources +- **Pet Management**: Support for multiple pet types (summon, charm, deity, cosmetic) with proper ownership tracking + +**Spells System**: Complete spell management with spell definitions, effect system, and real-time casting engine. Features spell processing (50ms intervals), comprehensive targeting (self/single/group/AOE), resource management (power/health/concentration), cast/recast timers, interrupt handling, heroic opportunities, and thread-safe operations. + +**Titles System**: Character title management with prefix/suffix positioning, categories, rarity levels, achievement integration, and thread-safe operations. + +**Trade System**: Player-to-player trading with item/coin exchange, validation (no-trade, heirloom restrictions), slot management, acceptance workflow, bot trading support, and client version compatibility (6/12 slots). + +**Object System**: Interactive world objects extending spawn system with merchants, transporters, devices, and collectors. Features clickable interaction, command handling, device IDs, size randomization, transport/teleport support, and complete spawn integration with entity/item adapters for trade/spell system compatibility. + +**Race System**: Complete EverQuest II race management with all 21 races (Human through Aerakyn), alignment classification (good/evil/neutral), racial stat modifiers, starting locations, lore descriptions, and full entity system integration. Features randomization by alignment, compatibility checking, usage statistics, and RaceAware interface for seamless integration with existing systems. + +All systems are converted from C++ with TODO comments marking areas for future implementation (LUA integration, advanced mechanics, etc.). + +**Testing**: Focus testing on the UDP protocol layer and packet parsing, as these are critical for client compatibility. + +## Code Documentation Standards + +**Function Documentation**: All functions must have thorough documentation explaining their purpose, parameters, return values, and any important behavior. Do NOT follow the standard Go convention of starting comments with the function name - instead, write clear explanations of what the function does. + +**Examples:** + +Good: +```go +// Creates a new UDP server instance with the specified address and packet handler. +// The server will listen on the given address and route incoming packets to the handler. +// Returns an error if the address is invalid or the socket cannot be bound. +func NewServer(addr string, handler PacketHandler, config ...Config) (*Server, error) { +``` + +Poor (standard Go convention - avoid this): +```go +// NewServer creates a new UDP server instance with the specified address and packet handler. +func NewServer(addr string, handler PacketHandler, config ...Config) (*Server, error) { +``` + +**Additional Documentation Requirements:** +- Document all public types, constants, and variables +- Include examples in documentation for complex functions +- Explain any non-obvious algorithms or business logic +- Document error conditions and edge cases +- For packet-related code, reference the relevant XML definitions or protocol specifications \ No newline at end of file diff --git a/internal/classes.cpp b/internal/classes.cpp new file mode 100644 index 0000000..ba9d7fd --- /dev/null +++ b/internal/classes.cpp @@ -0,0 +1,199 @@ +/* + EQ2Emulator: Everquest II Server Emulator + Copyright (C) 2007 EQ2EMulator Development Team (http://www.eq2emulator.net) + + This file is part of EQ2Emulator. + + EQ2Emulator is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + EQ2Emulator is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with EQ2Emulator. If not, see . +*/ +#include "../common/debug.h" +#include "../common/Log.h" +#include "classes.h" +#include "../common/MiscFunctions.h" +#include + +Classes::Classes(){ + class_map["COMMONER"] = 0; + class_map["FIGHTER"] = 1; + class_map["WARRIOR"] = 2; + class_map["GUARDIAN"] = 3; + class_map["BERSERKER"] = 4; + class_map["BRAWLER"] = 5; + class_map["MONK"] = 6; + class_map["BRUISER"] = 7; + class_map["CRUSADER"] = 8; + class_map["SHADOWKNIGHT"] = 9; + class_map["PALADIN"] = 10; + class_map["PRIEST"] = 11; + class_map["CLERIC"] = 12; + class_map["TEMPLAR"] = 13; + class_map["INQUISITOR"] = 14; + class_map["DRUID"] = 15; + class_map["WARDEN"] = 16; + class_map["FURY"] = 17; + class_map["SHAMAN"] = 18; + class_map["MYSTIC"] = 19; + class_map["DEFILER"] = 20; + class_map["MAGE"] = 21; + class_map["SORCERER"] = 22; + class_map["WIZARD"] = 23; + class_map["WARLOCK"] = 24; + class_map["ENCHANTER"] = 25; + class_map["ILLUSIONIST"] = 26; + class_map["COERCER"] = 27; + class_map["SUMMONER"] = 28; + class_map["CONJUROR"] = 29; + class_map["NECROMANCER"] = 30; + class_map["SCOUT"] = 31; + class_map["ROGUE"] = 32; + class_map["SWASHBUCKLER"] = 33; + class_map["BRIGAND"] = 34; + class_map["BARD"] = 35; + class_map["TROUBADOR"] = 36; + class_map["DIRGE"] = 37; + class_map["PREDATOR"] = 38; + class_map["RANGER"] = 39; + class_map["ASSASSIN"] = 40; + class_map["ANIMALIST"] = 41; + class_map["BEASTLORD"] = 42; + class_map["SHAPER"] = 43; + class_map["CHANNELER"] = 44; + class_map["ARTISAN"] = 45; + class_map["CRAFTSMAN"] = 46; + class_map["PROVISIONER"] = 47; + class_map["WOODWORKER"] = 48; + class_map["CARPENTER"] = 49; + class_map["OUTFITTER"] = 50; + class_map["ARMORER"] = 51; + class_map["WEAPONSMITH"] = 52; + class_map["TAILOR"] = 53; + class_map["SCHOLAR"] = 54; + class_map["JEWELER"] = 55; + class_map["SAGE"] = 56; + class_map["ALCHEMIST"] = 57; +} + +int8 Classes::GetBaseClass(int8 class_id) { + int8 ret = 0; + if(class_id>=WARRIOR && class_id <= PALADIN) + ret = FIGHTER; + if((class_id>=CLERIC && class_id <= DEFILER) || (class_id == SHAPER || class_id == CHANNELER)) + ret = PRIEST; + if(class_id>=SORCERER && class_id <= NECROMANCER) + ret = MAGE; + if(class_id>=ROGUE && class_id <= BEASTLORD) + ret = SCOUT; + LogWrite(WORLD__DEBUG, 5, "World", "%s returning base class ID: %i", __FUNCTION__, ret); + return ret; +} + +int8 Classes::GetSecondaryBaseClass(int8 class_id){ + int8 ret = 0; + if(class_id==GUARDIAN || class_id == BERSERKER) + ret = WARRIOR; + if(class_id==MONK || class_id == BRUISER) + ret = BRAWLER; + if(class_id==SHADOWKNIGHT || class_id == PALADIN) + ret = CRUSADER; + if(class_id==TEMPLAR || class_id == INQUISITOR) + ret = CLERIC; + if(class_id==WARDEN || class_id == FURY) + ret = DRUID; + if(class_id==MYSTIC || class_id == DEFILER) + ret = SHAMAN; + if(class_id==WIZARD || class_id == WARLOCK) + ret = SORCERER; + if(class_id==ILLUSIONIST || class_id == COERCER) + ret = ENCHANTER; + if(class_id==CONJUROR || class_id == NECROMANCER) + ret = SUMMONER; + if(class_id==SWASHBUCKLER || class_id == BRIGAND) + ret = ROGUE; + if(class_id==TROUBADOR || class_id == DIRGE) + ret = BARD; + if(class_id==RANGER || class_id == ASSASSIN) + ret = PREDATOR; + if(class_id==BEASTLORD) + ret = ANIMALIST; + if(class_id == CHANNELER) + ret = SHAPER; + LogWrite(WORLD__DEBUG, 5, "World", "%s returning secondary class ID: %i", __FUNCTION__, ret); + return ret; +} + +int8 Classes::GetTSBaseClass(int8 class_id) { + int8 ret = 0; + if (class_id + 42 >= ARTISAN) + ret = ARTISAN - 44; + else + ret = class_id; + + LogWrite(WORLD__DEBUG, 5, "World", "%s returning base tradeskill class ID: %i", __FUNCTION__, ret); + return ret; +} + +int8 Classes::GetSecondaryTSBaseClass(int8 class_id) { + int8 ret = class_id + 42; + if (ret == ARTISAN) + ret = ARTISAN - 44; + else if (ret >= CRAFTSMAN && ret < OUTFITTER) + ret = CRAFTSMAN - 44; + else if (ret >= OUTFITTER && ret < SCHOLAR) + ret = OUTFITTER - 44; + else if (ret >= SCHOLAR) + ret = SCHOLAR - 44; + else + ret = class_id; + + LogWrite(WORLD__DEBUG, 5, "World", "%s returning secondary tradeskill class ID: %i", __FUNCTION__, ret); + return ret; +} + +sint8 Classes::GetClassID(const char* name){ + string class_name = string(name); + class_name = ToUpper(class_name); + if(class_map.count(class_name) == 1) { + LogWrite(WORLD__DEBUG, 5, "World", "%s returning class ID: %i for class name %s", __FUNCTION__, class_map[class_name], class_name.c_str()); + return class_map[class_name]; + } + LogWrite(WORLD__WARNING, 0, "World", "Could not find class_id in function: %s (return -1)", __FUNCTION__); + return -1; +} + +const char* Classes::GetClassName(int8 class_id){ + map::iterator itr; + for(itr = class_map.begin(); itr != class_map.end(); itr++){ + if(itr->second == class_id) { + LogWrite(WORLD__DEBUG, 5, "World", "%s returning class name: %s for class_id %i", __FUNCTION__, itr->first.c_str(), class_id); + return itr->first.c_str(); + } + } + LogWrite(WORLD__WARNING, 0, "World", "Could not find class name in function: %s (return 0)", __FUNCTION__); + return 0; +} + +string Classes::GetClassNameCase(int8 class_id) { + map::iterator itr; + for (itr = class_map.begin(); itr != class_map.end(); itr++){ + if (itr->second == class_id) { + string class_name = string(itr->first); + transform(itr->first.begin() + 1, itr->first.end(), class_name.begin() + 1, ::tolower); + class_name[0] = ::toupper(class_name[0]); + LogWrite(WORLD__DEBUG, 5, "World", "%s returning class name: %s for class_id %i", __FUNCTION__, class_name.c_str(), class_id); + return class_name; + } + } + LogWrite(WORLD__WARNING, 0, "World", "Could not find class name in function: %s (return blank)", __FUNCTION__); + return ""; +} diff --git a/internal/classes.h b/internal/classes.h new file mode 100644 index 0000000..58beefe --- /dev/null +++ b/internal/classes.h @@ -0,0 +1,119 @@ +/* + EQ2Emulator: Everquest II Server Emulator + Copyright (C) 2007 EQ2EMulator Development Team (http://www.eq2emulator.net) + + This file is part of EQ2Emulator. + + EQ2Emulator is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + EQ2Emulator is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with EQ2Emulator. If not, see . +*/ +#ifndef CLASSES_CH +#define CLASSES_CH +#include "../common/types.h" +#include +using namespace std; + +#define COMMONER 0 +#define FIGHTER 1 +#define WARRIOR 2 +#define GUARDIAN 3 +#define BERSERKER 4 +#define BRAWLER 5 +#define MONK 6 +#define BRUISER 7 +#define CRUSADER 8 +#define SHADOWKNIGHT 9 +#define PALADIN 10 +#define PRIEST 11 +#define CLERIC 12 +#define TEMPLAR 13 +#define INQUISITOR 14 +#define DRUID 15 +#define WARDEN 16 +#define FURY 17 +#define SHAMAN 18 +#define MYSTIC 19 +#define DEFILER 20 +#define MAGE 21 +#define SORCERER 22 +#define WIZARD 23 +#define WARLOCK 24 +#define ENCHANTER 25 +#define ILLUSIONIST 26 +#define COERCER 27 +#define SUMMONER 28 +#define CONJUROR 29 +#define NECROMANCER 30 +#define SCOUT 31 +#define ROGUE 32 +#define SWASHBUCKLER 33 +#define BRIGAND 34 +#define BARD 35 +#define TROUBADOR 36 +#define DIRGE 37 +#define PREDATOR 38 +#define RANGER 39 +#define ASSASSIN 40 +#define ANIMALIST 41 +#define BEASTLORD 42 +#define SHAPER 43 +#define CHANNELER 44 + +//Tradeskills +// 0 - transmuting/tinkering +#define ARTISAN 45 // 1 +#define CRAFTSMAN 46 // 2 +#define PROVISIONER 47 // 3 +#define WOODWORKER 48 // 4 +#define CARPENTER 49 // 5 +#define OUTFITTER 50 // 6 +#define ARMORER 51 // 7 +#define WEAPONSMITH 52 // 8 +#define TAILOR 53 // 9 +#define SCHOLAR 54 // 10 +#define JEWELER 55 // 11 +#define SAGE 56 // 12 +#define ALCHEMIST 57 // 13 +//43 - artisan + //44 - craftsman + //45 - provisioner + //46 - Woodworker + //47 - carpenter + //48 - armorer + //49 - weaponsmith + //50 - tailor + //51 - + //52 - jeweler + //53 - sage + //54 - alch +#define CLASSIC_MAX_ADVENTURE_CLASS 40 // there is a 41, but its 'scantestbase' +#define CLASSIC_MAX_TRADESKILL_CLASS 13 +#define MAX_CLASSES 58 + +class Classes { +public: + Classes(); + char* GetEQClassName(int8 class_, int8 level); + const char* GetClassName(int8 class_id); + string GetClassNameCase(int8 class_id); + sint8 GetClassID(const char* name); + int8 GetBaseClass(int8 class_id); + int8 GetSecondaryBaseClass(int8 class_id); + int8 GetTSBaseClass(int8 class_id); + int8 GetSecondaryTSBaseClass(int8 class_id); + +private: + map class_map; +}; +#endif + diff --git a/internal/entity/README.md b/internal/entity/README.md new file mode 100644 index 0000000..3551428 --- /dev/null +++ b/internal/entity/README.md @@ -0,0 +1,167 @@ +# Entity Package + +The Entity package provides the core combat and magic systems for EverQuest II server emulation. It extends the base Spawn system with combat capabilities, spell effects, and character statistics management. + +## Overview + +The Entity system is built on three main components: + +1. **InfoStruct** - Comprehensive character statistics and information +2. **SpellEffectManager** - Manages all spell effects, buffs, debuffs, and bonuses +3. **Entity** - Combat-capable spawn with spell casting and pet management + +## Architecture + +``` +Spawn (base class) + └── Entity (combat-capable) + ├── Player (player characters) + └── NPC (non-player characters) +``` + +## Core Components + +### InfoStruct + +Contains all character statistics including: +- Primary attributes (STR, STA, AGI, WIS, INT) +- Combat stats (attack, mitigation, avoidance) +- Resistances (heat, cold, magic, mental, divine, disease, poison) +- Experience points and currency +- Equipment and weapon information +- Group and encounter settings + +**Thread Safety**: All access methods use RWMutex for safe concurrent access. + +### SpellEffectManager + +Manages four types of spell effects: + +1. **Maintained Effects** - Buffs that consume concentration +2. **Spell Effects** - Temporary buffs/debuffs with durations +3. **Detrimental Effects** - Debuffs and harmful effects +4. **Bonus Values** - Stat modifications from various sources + +**Key Features**: +- Automatic expiration handling +- Control effect tracking (stun, root, mez, etc.) +- Bonus calculations with class/race/faction requirements +- Thread-safe effect management + +### Entity + +Combat-capable spawn that extends base Spawn functionality: + +**Combat Systems**: +- Health/Power/Savagery management +- Combat state tracking (in combat, casting) +- Damage resistance calculations +- Speed and movement modifiers + +**Magic Systems**: +- Spell effect application and removal +- Concentration-based maintained spells +- Bonus stat calculations +- Control effect immunity + +**Pet Systems**: +- Multiple pet types (summon, charm, deity, cosmetic) +- Pet ownership and dismissal +- Pet spell tracking + +## Usage Examples + +### Creating an Entity + +```go +entity := NewEntity() +entity.GetInfoStruct().SetName("TestEntity") +entity.GetInfoStruct().SetLevel(50) +entity.GetInfoStruct().SetStr(100.0) +``` + +### Managing Spell Effects + +```go +// Add a maintained spell (buff) +success := entity.AddMaintainedSpell("Heroic Strength", 12345, 300.0, 2) + +// Add a temporary effect +entity.AddSpellEffect(54321, casterID, 60.0) + +// Add a detrimental effect +entity.AddDetrimentalSpell(99999, attackerID, 30.0, 1) +``` + +### Stat Calculations + +```go +// Get effective stats (base + bonuses) +str := entity.GetStr() +sta := entity.GetSta() +primary := entity.GetPrimaryStat() + +// Recalculate all bonuses +entity.CalculateBonuses() +``` + +### Pet Management + +```go +// Set a summon pet +entity.SetPet(petEntity) + +// Check pet status +if entity.GetPet() != nil && !entity.IsPetDismissing() { + // Pet is active +} +``` + +## Constants and Enums + +### Pet Types +- `PetTypeSummon` - Summoned pets +- `PetTypeCharm` - Charmed creatures +- `PetTypeDeity` - Deity pets +- `PetTypeCosmetic` - Cosmetic pets + +### Control Effects +- `ControlEffectStun` - Cannot move or act +- `ControlEffectRoot` - Cannot move +- `ControlEffectMez` - Mesmerized +- `ControlEffectDaze` - Dazed +- `ControlEffectFear` - Feared +- `ControlEffectSlow` - Movement slowed +- `ControlEffectSnare` - Movement impaired +- `ControlEffectCharm` - Mind controlled + +## Thread Safety + +All Entity operations are thread-safe using: +- `sync.RWMutex` for read/write operations +- `sync.atomic` for simple state flags +- Separate mutexes for different subsystems to minimize lock contention + +## Integration with Spawn System + +The Entity extends the base Spawn class and requires: +- `spawn.NewSpawn()` for initialization +- Access to Spawn position and basic methods +- Integration with zone update systems + +## Future Extensions + +Areas marked with TODO comments for future implementation: +- Complete item and equipment systems +- Combat calculation methods +- Threat and hate management +- Group combat mechanics +- Spell casting systems +- LUA script integration + +## Files + +- `entity.go` - Main Entity class implementation +- `info_struct.go` - Character statistics and information +- `spell_effects.go` - Spell effect management system +- `README.md` - This documentation file \ No newline at end of file diff --git a/internal/entity/entity.go b/internal/entity/entity.go new file mode 100644 index 0000000..990ddce --- /dev/null +++ b/internal/entity/entity.go @@ -0,0 +1,668 @@ +package entity + +import ( + "math" + "sync" + "sync/atomic" + + "eq2emu/internal/common" + "eq2emu/internal/spawn" +) + +// Combat and pet types +const ( + PetTypeSummon = 1 + PetTypeCharm = 2 + PetTypeDeity = 3 + PetTypeCosmetic = 4 +) + +// Entity represents a combat-capable spawn (NPCs and Players) +// Extends the base Spawn with combat, magic, and equipment systems +type Entity struct { + *spawn.Spawn // Embedded spawn for basic functionality + + // Core entity information + infoStruct *InfoStruct // All entity statistics and information + + // Combat state + inCombat atomic.Bool // Whether currently in combat + casting atomic.Bool // Whether currently casting + maxSpeed float32 // Maximum movement speed + baseSpeed float32 // Base movement speed + speedMultiplier float32 // Speed multiplier + + // Position tracking for movement + lastX float32 // Last known X position + lastY float32 // Last known Y position + lastZ float32 // Last known Z position + lastHeading float32 // Last known heading + + // Regeneration + regenHpRate int16 // Health regeneration rate + regenPowerRate int16 // Power regeneration rate + + // Spell and effect management + spellEffectManager *SpellEffectManager + + // Pet system + pet *Entity // Summon pet + charmedPet *Entity // Charmed pet + deityPet *Entity // Deity pet + cosmeticPet *Entity // Cosmetic pet + owner int32 // Owner entity ID (if this is a pet) + petType int8 // Type of pet + petSpellID int32 // Spell ID used to create/control pet + petSpellTier int8 // Tier of pet spell + petDismissing atomic.Bool // Whether pet is being dismissed + + // Group and social + // TODO: Add GroupMemberInfo when group system is implemented + // groupMemberInfo *GroupMemberInfo + + // Trading + // TODO: Add Trade when trade system is implemented + // trade *Trade + + // Deity and alignment + deity int8 // Deity ID + + // Equipment and appearance + features common.CharFeatures // Character appearance features + equipment common.EQ2Equipment // Equipment appearance + + // Threat and hate management + threatTransferPercent float32 // Percentage of threat to transfer + + // Spell-related flags + hasSeeInvisSpell atomic.Bool // Has see invisible spell + hasSeeHideSpell atomic.Bool // Has see hidden spell + + // Proc system + // TODO: Add proc list when implemented + // procList []Proc + + // Thread safety + spellEffectMutex sync.RWMutex + maintainedMutex sync.RWMutex + detrimentalMutex sync.RWMutex + commandMutex sync.Mutex + bonusCalculationMutex sync.RWMutex +} + +// NewEntity creates a new Entity with default values +// Must be called by subclasses (NPC, Player) for proper initialization +func NewEntity() *Entity { + e := &Entity{ + Spawn: spawn.NewSpawn(), + infoStruct: NewInfoStruct(), + maxSpeed: 6.0, + baseSpeed: 0.0, + speedMultiplier: 1.0, + lastX: -1, + lastY: -1, + lastZ: -1, + lastHeading: -1, + regenHpRate: 0, + regenPowerRate: 0, + spellEffectManager: NewSpellEffectManager(), + pet: nil, + charmedPet: nil, + deityPet: nil, + cosmeticPet: nil, + owner: 0, + petType: 0, + petSpellID: 0, + petSpellTier: 0, + deity: 0, + threatTransferPercent: 0.0, + } + + // Initialize features and equipment + e.features = common.CharFeatures{} + e.equipment = common.EQ2Equipment{} + + // Set initial state + e.inCombat.Store(false) + e.casting.Store(false) + e.petDismissing.Store(false) + e.hasSeeInvisSpell.Store(false) + e.hasSeeHideSpell.Store(false) + + return e +} + +// IsEntity returns true (implements Spawn interface) +func (e *Entity) IsEntity() bool { + return true +} + +// GetInfoStruct returns the entity's info structure +func (e *Entity) GetInfoStruct() *InfoStruct { + return e.infoStruct +} + +// SetInfoStruct updates the entity's info structure +func (e *Entity) SetInfoStruct(info *InfoStruct) { + if info != nil { + e.infoStruct = info + } +} + +// GetClient returns the client for this entity (overridden by Player) +func (e *Entity) GetClient() interface{} { + return nil +} + +// Combat state methods + +// IsInCombat returns whether the entity is currently in combat +func (e *Entity) IsInCombat() bool { + return e.inCombat.Load() +} + +// SetInCombat updates the combat state +func (e *Entity) SetInCombat(inCombat bool) { + e.inCombat.Store(inCombat) +} + +// IsCasting returns whether the entity is currently casting +func (e *Entity) IsCasting() bool { + return e.casting.Load() +} + +// SetCasting updates the casting state +func (e *Entity) SetCasting(casting bool) { + e.casting.Store(casting) +} + +// Speed and movement methods + +// GetMaxSpeed returns the maximum movement speed +func (e *Entity) GetMaxSpeed() float32 { + return e.maxSpeed +} + +// SetMaxSpeed updates the maximum movement speed +func (e *Entity) SetMaxSpeed(speed float32) { + e.maxSpeed = speed +} + +// GetBaseSpeed returns the base movement speed +func (e *Entity) GetBaseSpeed() float32 { + return e.baseSpeed +} + +// SetBaseSpeed updates the base movement speed +func (e *Entity) SetBaseSpeed(speed float32) { + e.baseSpeed = speed +} + +// GetSpeedMultiplier returns the speed multiplier +func (e *Entity) GetSpeedMultiplier() float32 { + return e.speedMultiplier +} + +// SetSpeedMultiplier updates the speed multiplier +func (e *Entity) SetSpeedMultiplier(multiplier float32) { + e.speedMultiplier = multiplier +} + +// CalculateEffectiveSpeed calculates the current effective speed +func (e *Entity) CalculateEffectiveSpeed() float32 { + baseSpeed := e.baseSpeed + if baseSpeed <= 0 { + baseSpeed = e.maxSpeed + } + + return baseSpeed * e.speedMultiplier +} + +// Position tracking methods + +// GetLastPosition returns the last known position +func (e *Entity) GetLastPosition() (float32, float32, float32, float32) { + return e.lastX, e.lastY, e.lastZ, e.lastHeading +} + +// SetLastPosition updates the last known position +func (e *Entity) SetLastPosition(x, y, z, heading float32) { + e.lastX = x + e.lastY = y + e.lastZ = z + e.lastHeading = heading +} + +// HasMoved checks if the entity has moved since last position update +func (e *Entity) HasMoved() bool { + currentX := e.GetX() + currentY := e.GetY() + currentZ := e.GetZ() + currentHeading := e.GetHeading() + + const epsilon = 0.01 // Small threshold for floating point comparison + + return math.Abs(float64(currentX-e.lastX)) > epsilon || + math.Abs(float64(currentY-e.lastY)) > epsilon || + math.Abs(float64(currentZ-e.lastZ)) > epsilon || + math.Abs(float64(currentHeading-e.lastHeading)) > epsilon +} + +// Stat calculation methods + +// GetStr returns the effective strength stat +func (e *Entity) GetStr() int16 { + base := int16(e.infoStruct.GetStr()) + bonus := int16(e.spellEffectManager.GetBonusValue(1, 0, e.infoStruct.GetRace(), 0)) // Stat type 1 = STR + return base + bonus +} + +// GetSta returns the effective stamina stat +func (e *Entity) GetSta() int16 { + base := int16(e.infoStruct.GetSta()) + bonus := int16(e.spellEffectManager.GetBonusValue(2, 0, e.infoStruct.GetRace(), 0)) // Stat type 2 = STA + return base + bonus +} + +// GetAgi returns the effective agility stat +func (e *Entity) GetAgi() int16 { + base := int16(e.infoStruct.GetAgi()) + bonus := int16(e.spellEffectManager.GetBonusValue(3, 0, e.infoStruct.GetRace(), 0)) // Stat type 3 = AGI + return base + bonus +} + +// GetWis returns the effective wisdom stat +func (e *Entity) GetWis() int16 { + base := int16(e.infoStruct.GetWis()) + bonus := int16(e.spellEffectManager.GetBonusValue(4, 0, e.infoStruct.GetRace(), 0)) // Stat type 4 = WIS + return base + bonus +} + +// GetIntel returns the effective intelligence stat +func (e *Entity) GetIntel() int16 { + base := int16(e.infoStruct.GetIntel()) + bonus := int16(e.spellEffectManager.GetBonusValue(5, 0, e.infoStruct.GetRace(), 0)) // Stat type 5 = INT + return base + bonus +} + +// GetPrimaryStat returns the highest primary stat value +func (e *Entity) GetPrimaryStat() int16 { + str := e.GetStr() + sta := e.GetSta() + agi := e.GetAgi() + wis := e.GetWis() + intel := e.GetIntel() + + primary := str + if sta > primary { + primary = sta + } + if agi > primary { + primary = agi + } + if wis > primary { + primary = wis + } + if intel > primary { + primary = intel + } + + return primary +} + +// Resistance methods + +// GetHeatResistance returns heat resistance +func (e *Entity) GetHeatResistance() int16 { + return e.infoStruct.GetResistance("heat") +} + +// GetColdResistance returns cold resistance +func (e *Entity) GetColdResistance() int16 { + return e.infoStruct.GetResistance("cold") +} + +// GetMagicResistance returns magic resistance +func (e *Entity) GetMagicResistance() int16 { + return e.infoStruct.GetResistance("magic") +} + +// GetMentalResistance returns mental resistance +func (e *Entity) GetMentalResistance() int16 { + return e.infoStruct.GetResistance("mental") +} + +// GetDivineResistance returns divine resistance +func (e *Entity) GetDivineResistance() int16 { + return e.infoStruct.GetResistance("divine") +} + +// GetDiseaseResistance returns disease resistance +func (e *Entity) GetDiseaseResistance() int16 { + return e.infoStruct.GetResistance("disease") +} + +// GetPoisonResistance returns poison resistance +func (e *Entity) GetPoisonResistance() int16 { + return e.infoStruct.GetResistance("poison") +} + +// Spell effect management methods + +// AddMaintainedSpell adds a maintained spell effect +func (e *Entity) AddMaintainedSpell(name string, spellID int32, duration float32, concentration int8) bool { + // Check if we have enough concentration + if !e.infoStruct.AddConcentration(int16(concentration)) { + return false + } + + effect := NewMaintainedEffects(name, spellID, duration) + effect.ConcUsed = concentration + + e.maintainedMutex.Lock() + defer e.maintainedMutex.Unlock() + + if !e.spellEffectManager.AddMaintainedEffect(effect) { + // Failed to add, return concentration + e.infoStruct.RemoveConcentration(int16(concentration)) + return false + } + + return true +} + +// RemoveMaintainedSpell removes a maintained spell effect +func (e *Entity) RemoveMaintainedSpell(spellID int32) bool { + e.maintainedMutex.Lock() + defer e.maintainedMutex.Unlock() + + // Get the effect to check concentration usage + effect := e.spellEffectManager.GetMaintainedEffect(spellID) + if effect == nil { + return false + } + + // Return concentration + e.infoStruct.RemoveConcentration(int16(effect.ConcUsed)) + + return e.spellEffectManager.RemoveMaintainedEffect(spellID) +} + +// GetMaintainedSpell retrieves a maintained spell effect +func (e *Entity) GetMaintainedSpell(spellID int32) *MaintainedEffects { + e.maintainedMutex.RLock() + defer e.maintainedMutex.RUnlock() + + return e.spellEffectManager.GetMaintainedEffect(spellID) +} + +// AddSpellEffect adds a temporary spell effect +func (e *Entity) AddSpellEffect(spellID int32, casterID int32, duration float32) bool { + effect := NewSpellEffects(spellID, casterID, duration) + + e.spellEffectMutex.Lock() + defer e.spellEffectMutex.Unlock() + + return e.spellEffectManager.AddSpellEffect(effect) +} + +// RemoveSpellEffect removes a spell effect +func (e *Entity) RemoveSpellEffect(spellID int32) bool { + e.spellEffectMutex.Lock() + defer e.spellEffectMutex.Unlock() + + return e.spellEffectManager.RemoveSpellEffect(spellID) +} + +// AddDetrimentalSpell adds a detrimental effect +func (e *Entity) AddDetrimentalSpell(spellID int32, casterID int32, duration float32, detType int8) { + effect := NewDetrimentalEffects(spellID, casterID, duration) + effect.DetType = detType + + e.detrimentalMutex.Lock() + defer e.detrimentalMutex.Unlock() + + e.spellEffectManager.AddDetrimentalEffect(*effect) +} + +// RemoveDetrimentalSpell removes a detrimental effect +func (e *Entity) RemoveDetrimentalSpell(spellID int32, casterID int32) bool { + e.detrimentalMutex.Lock() + defer e.detrimentalMutex.Unlock() + + return e.spellEffectManager.RemoveDetrimentalEffect(spellID, casterID) +} + +// GetDetrimentalEffect retrieves a detrimental effect +func (e *Entity) GetDetrimentalEffect(spellID int32, casterID int32) *DetrimentalEffects { + e.detrimentalMutex.RLock() + defer e.detrimentalMutex.RUnlock() + + return e.spellEffectManager.GetDetrimentalEffect(spellID, casterID) +} + +// HasControlEffect checks if the entity has a specific control effect +func (e *Entity) HasControlEffect(controlType int8) bool { + return e.spellEffectManager.HasControlEffect(controlType) +} + +// Bonus system methods + +// AddSkillBonus adds a skill-related bonus +func (e *Entity) AddSkillBonus(spellID int32, skillID int32, value float32) { + bonus := NewBonusValues(spellID, int16(skillID+100), value) // Skill bonuses use type 100+ + e.spellEffectManager.AddBonus(bonus) +} + +// AddStatBonus adds a stat bonus +func (e *Entity) AddStatBonus(spellID int32, statType int16, value float32) { + bonus := NewBonusValues(spellID, statType, value) + e.spellEffectManager.AddBonus(bonus) +} + +// CalculateBonuses recalculates all stat bonuses and updates InfoStruct +func (e *Entity) CalculateBonuses() { + e.bonusCalculationMutex.Lock() + defer e.bonusCalculationMutex.Unlock() + + // Reset effects to base values + e.infoStruct.ResetEffects() + + entityClass := int64(1 << e.infoStruct.GetClass1()) // Convert class to bitmask + race := e.infoStruct.GetRace() + factionID := int32(e.GetFactionID()) + + // Apply stat bonuses + strBonus := e.spellEffectManager.GetBonusValue(1, entityClass, race, factionID) + staBonus := e.spellEffectManager.GetBonusValue(2, entityClass, race, factionID) + agiBonus := e.spellEffectManager.GetBonusValue(3, entityClass, race, factionID) + wisBonus := e.spellEffectManager.GetBonusValue(4, entityClass, race, factionID) + intelBonus := e.spellEffectManager.GetBonusValue(5, entityClass, race, factionID) + + // Update InfoStruct with bonuses + e.infoStruct.SetStr(e.infoStruct.GetStr() + strBonus) + e.infoStruct.SetSta(e.infoStruct.GetSta() + staBonus) + e.infoStruct.SetAgi(e.infoStruct.GetAgi() + agiBonus) + e.infoStruct.SetWis(e.infoStruct.GetWis() + wisBonus) + e.infoStruct.SetIntel(e.infoStruct.GetIntel() + intelBonus) + + // Apply resistance bonuses + heatBonus := int16(e.spellEffectManager.GetBonusValue(10, entityClass, race, factionID)) + coldBonus := int16(e.spellEffectManager.GetBonusValue(11, entityClass, race, factionID)) + magicBonus := int16(e.spellEffectManager.GetBonusValue(12, entityClass, race, factionID)) + mentalBonus := int16(e.spellEffectManager.GetBonusValue(13, entityClass, race, factionID)) + divineBonus := int16(e.spellEffectManager.GetBonusValue(14, entityClass, race, factionID)) + diseaseBonus := int16(e.spellEffectManager.GetBonusValue(15, entityClass, race, factionID)) + poisonBonus := int16(e.spellEffectManager.GetBonusValue(16, entityClass, race, factionID)) + + e.infoStruct.SetResistance("heat", e.infoStruct.GetResistance("heat")+heatBonus) + e.infoStruct.SetResistance("cold", e.infoStruct.GetResistance("cold")+coldBonus) + e.infoStruct.SetResistance("magic", e.infoStruct.GetResistance("magic")+magicBonus) + e.infoStruct.SetResistance("mental", e.infoStruct.GetResistance("mental")+mentalBonus) + e.infoStruct.SetResistance("divine", e.infoStruct.GetResistance("divine")+divineBonus) + e.infoStruct.SetResistance("disease", e.infoStruct.GetResistance("disease")+diseaseBonus) + e.infoStruct.SetResistance("poison", e.infoStruct.GetResistance("poison")+poisonBonus) +} + +// Pet management methods + +// GetPet returns the summon pet +func (e *Entity) GetPet() *Entity { + return e.pet +} + +// SetPet sets the summon pet +func (e *Entity) SetPet(pet *Entity) { + e.pet = pet + if pet != nil { + pet.owner = e.GetID() + pet.petType = PetTypeSummon + } +} + +// GetCharmedPet returns the charmed pet +func (e *Entity) GetCharmedPet() *Entity { + return e.charmedPet +} + +// SetCharmedPet sets the charmed pet +func (e *Entity) SetCharmedPet(pet *Entity) { + e.charmedPet = pet + if pet != nil { + pet.owner = e.GetID() + pet.petType = PetTypeCharm + } +} + +// GetDeityPet returns the deity pet +func (e *Entity) GetDeityPet() *Entity { + return e.deityPet +} + +// SetDeityPet sets the deity pet +func (e *Entity) SetDeityPet(pet *Entity) { + e.deityPet = pet + if pet != nil { + pet.owner = e.GetID() + pet.petType = PetTypeDeity + } +} + +// GetCosmeticPet returns the cosmetic pet +func (e *Entity) GetCosmeticPet() *Entity { + return e.cosmeticPet +} + +// SetCosmeticPet sets the cosmetic pet +func (e *Entity) SetCosmeticPet(pet *Entity) { + e.cosmeticPet = pet + if pet != nil { + pet.owner = e.GetID() + pet.petType = PetTypeCosmetic + } +} + +// GetOwner returns the owner entity ID (if this is a pet) +func (e *Entity) GetOwner() int32 { + return e.owner +} + +// GetPetType returns the type of pet this entity is +func (e *Entity) GetPetType() int8 { + return e.petType +} + +// IsPetDismissing returns whether the pet is being dismissed +func (e *Entity) IsPetDismissing() bool { + return e.petDismissing.Load() +} + +// SetPetDismissing sets whether the pet is being dismissed +func (e *Entity) SetPetDismissing(dismissing bool) { + e.petDismissing.Store(dismissing) +} + +// Utility methods + +// GetDeity returns the deity ID +func (e *Entity) GetDeity() int8 { + return e.deity +} + +// SetDeity updates the deity ID +func (e *Entity) SetDeity(deity int8) { + e.deity = deity +} + +// GetDodgeChance calculates the dodge chance (to be overridden by subclasses) +func (e *Entity) GetDodgeChance() float32 { + // Base implementation, should be overridden + return 5.0 // 5% base dodge chance +} + +// HasSeeInvisSpell returns whether the entity has see invisible +func (e *Entity) HasSeeInvisSpell() bool { + return e.hasSeeInvisSpell.Load() +} + +// SetSeeInvisSpell updates the see invisible state +func (e *Entity) SetSeeInvisSpell(hasSpell bool) { + e.hasSeeInvisSpell.Store(hasSpell) +} + +// HasSeeHideSpell returns whether the entity has see hidden +func (e *Entity) HasSeeHideSpell() bool { + return e.hasSeeHideSpell.Load() +} + +// SetSeeHideSpell updates the see hidden state +func (e *Entity) SetSeeHideSpell(hasSpell bool) { + e.hasSeeHideSpell.Store(hasSpell) +} + +// Cleanup methods + +// DeleteSpellEffects removes all spell effects +func (e *Entity) DeleteSpellEffects(removeClient bool) { + e.spellEffectMutex.Lock() + e.maintainedMutex.Lock() + e.detrimentalMutex.Lock() + defer e.spellEffectMutex.Unlock() + defer e.maintainedMutex.Unlock() + defer e.detrimentalMutex.Unlock() + + // Clear all maintained effects and return concentration + effects := e.spellEffectManager.GetAllMaintainedEffects() + for _, effect := range effects { + e.infoStruct.RemoveConcentration(int16(effect.ConcUsed)) + } + + e.spellEffectManager.ClearAllEffects() +} + +// RemoveSpells removes spell effects, optionally only unfriendly ones +func (e *Entity) RemoveSpells(unfriendlyOnly bool) { + // TODO: Implement when we can determine friendly vs unfriendly spells + if !unfriendlyOnly { + e.DeleteSpellEffects(false) + } +} + +// Update methods + +// ProcessEffects handles periodic effect processing +func (e *Entity) ProcessEffects() { + // Clean up expired effects + e.spellEffectManager.CleanupExpiredEffects() + + // TODO: Apply periodic effect damage/healing + // TODO: Handle effect-based stat changes +} + +// TODO: Additional methods to implement: +// - Combat calculation methods (damage, healing, etc.) +// - Equipment bonus application methods +// - Spell casting methods +// - Threat and hate management methods +// - Group combat methods +// - Many more combat and magic related methods diff --git a/internal/entity/info_struct.go b/internal/entity/info_struct.go new file mode 100644 index 0000000..4376e68 --- /dev/null +++ b/internal/entity/info_struct.go @@ -0,0 +1,854 @@ +package entity + +import ( + "sync" + "time" +) + +// RaceAlignment represents character alignment types +type RaceAlignment int + +const ( + AlignmentEvil RaceAlignment = 0 + AlignmentGood RaceAlignment = 1 +) + +// InfoStruct contains all character statistics and information +// This is a comprehensive structure that manages all entity stats, +// equipment bonuses, and various game mechanics data +type InfoStruct struct { + // Basic character information + name string + class1 int8 // Primary class + class2 int8 // Secondary class + class3 int8 // Tertiary class + race int8 // Character race + gender int8 // Character gender + level int16 // Current level + maxLevel int16 // Maximum achievable level + effectiveLevel int16 // Effective level for calculations + tradeskillLevel int16 // Tradeskill level + tradeskillMaxLevel int16 // Maximum tradeskill level + + // Concentration system (for maintaining spells) + curConcentration int16 // Current concentration used + maxConcentration int16 // Maximum concentration available + + // Combat statistics + curAttack int32 // Current attack rating + attackBase int32 // Base attack rating + curMitigation int32 // Current mitigation (damage reduction) + maxMitigation int32 // Maximum mitigation + mitigationBase int32 // Base mitigation + mitigationMod float32 // Mitigation modifier + + // Avoidance statistics + avoidanceDisplay int16 // Display value for avoidance + curAvoidance float32 // Current avoidance percentage + baseAvoidancePct int16 // Base avoidance percentage + avoidanceBase int16 // Base avoidance rating + maxAvoidance int16 // Maximum avoidance + parry float32 // Parry chance + parryBase float32 // Base parry + deflection int16 // Deflection rating + deflectionBase int16 // Base deflection + block int16 // Block rating + blockBase int16 // Base block + + // Primary attributes + str float32 // Strength + sta float32 // Stamina + agi float32 // Agility + wis float32 // Wisdom + intel float32 // Intelligence + strBase float32 // Base strength + staBase float32 // Base stamina + agiBase float32 // Base agility + wisBase float32 // Base wisdom + intelBase float32 // Base intelligence + + // Resistances + heat int16 // Heat resistance + cold int16 // Cold resistance + magic int16 // Magic resistance + mental int16 // Mental resistance + divine int16 // Divine resistance + disease int16 // Disease resistance + poison int16 // Poison resistance + heatBase int16 // Base heat resistance + coldBase int16 // Base cold resistance + magicBase int16 // Base magic resistance + mentalBase int16 // Base mental resistance + divineBase int16 // Base divine resistance + diseaseBase int16 // Base disease resistance + poisonBase int16 // Base poison resistance + elementalBase int16 // Base elemental resistance + noxiousBase int16 // Base noxious resistance + arcaneBase int16 // Base arcane resistance + + // Currency + coinCopper int32 // Copper coins + coinSilver int32 // Silver coins + coinGold int32 // Gold coins + coinPlat int32 // Platinum coins + bankCoinCopper int32 // Banked copper + bankCoinSilver int32 // Banked silver + bankCoinGold int32 // Banked gold + bankCoinPlat int32 // Banked platinum + statusPoints int32 // Status points + + // Character details + deity string // Deity name + weight int32 // Current weight + maxWeight int32 // Maximum carrying capacity + + // Tradeskill classes + tradeskillClass1 int8 // Primary tradeskill class + tradeskillClass2 int8 // Secondary tradeskill class + tradeskillClass3 int8 // Tertiary tradeskill class + + // Account and character age + accountAgeBase int16 // Base account age + accountAgeBonus [19]int8 // Account age bonuses + + // Combat and damage + absorb int32 // Damage absorption + + // Experience points + xp int32 // Current experience + xpNeeded int32 // Experience needed for next level + xpDebt float32 // Experience debt + xpYellow int32 // Yellow (bonus) experience + xpYellowVitalityBar int32 // Yellow vitality bar + xpBlueVitalityBar int32 // Blue vitality bar + xpBlue int32 // Blue (rested) experience + tsXp int32 // Tradeskill experience + tsXpNeeded int32 // Tradeskill experience needed + tradeskillExpYellow int32 // Tradeskill yellow experience + tradeskillExpBlue int32 // Tradeskill blue experience + xpVitality float32 // Experience vitality + tradeskillXpVitality float32 // Tradeskill experience vitality + + // Flags and states + flags int32 // General flags + flags2 int32 // Additional flags + + // Specialized mitigation + mitigationSkill1 int16 // Mitigation skill 1 + mitigationSkill2 int16 // Mitigation skill 2 + mitigationSkill3 int16 // Mitigation skill 3 + mitigationPve int16 // PvE mitigation + mitigationPvp int16 // PvP mitigation + + // Combat modifiers + abilityModifier int16 // Ability modifier + criticalMitigation int16 // Critical hit mitigation + blockChance int16 // Block chance + uncontestedParry int16 // Uncontested parry + uncontestedBlock int16 // Uncontested block + uncontestedDodge int16 // Uncontested dodge + uncontestedRiposte int16 // Uncontested riposte + critChance int16 // Critical hit chance + critBonus int16 // Critical hit bonus + potency int16 // Spell potency + hateMod int16 // Hate modifier + reuseSpeed int16 // Ability reuse speed + castingSpeed int16 // Spell casting speed + recoverySpeed int16 // Recovery speed + spellReuseSpeed int16 // Spell reuse speed + spellMultiAttack int16 // Spell multi-attack + + // Size and physical attributes + sizeMod float32 // Size modifier + ignoreSizeModCalc int8 // Ignore size modifier calculations + dps int16 // Damage per second + dpsMultiplier int16 // DPS multiplier + attackSpeed int16 // Attack speed + haste int16 // Haste + multiAttack int16 // Multi-attack chance + flurry int16 // Flurry chance + meleeAe int16 // Melee area effect + strikethrough int16 // Strikethrough + accuracy int16 // Accuracy + offensiveSpeed int16 // Offensive speed + + // Environmental + rain int16 // Rain resistance/affinity + wind int16 // Wind resistance/affinity + alignment int8 // Character alignment + + // Pet information + petId int32 // Pet ID + petName string // Pet name + petHealthPct float32 // Pet health percentage + petPowerPct float32 // Pet power percentage + petMovement int8 // Pet movement mode + petBehavior int8 // Pet behavior mode + + // Character abilities + vision int8 // Vision type + breatheUnderwater int8 // Can breathe underwater + biography string // Character biography + drunk int8 // Drunk level + + // Regeneration + powerRegen int16 // Power regeneration rate + hpRegen int16 // Health regeneration rate + powerRegenOverride int16 // Power regen override + hpRegenOverride int16 // Health regen override + + // Movement types + waterType int8 // Water movement type + flyingType int8 // Flying movement type + + // Special flags + noInterrupt int8 // Cannot be interrupted + interactionFlag int8 // Interaction flag + tag1 int8 // Tag 1 + mood int8 // Mood state + + // Weapon timing + rangeLastAttackTime int32 // Last ranged attack time + primaryLastAttackTime int32 // Last primary attack time + secondaryLastAttackTime int32 // Last secondary attack time + primaryAttackDelay int32 // Primary attack delay + secondaryAttackDelay int32 // Secondary attack delay + rangedAttackDelay int32 // Ranged attack delay + + // Weapon information + primaryWeaponType int8 // Primary weapon type + secondaryWeaponType int8 // Secondary weapon type + rangedWeaponType int8 // Ranged weapon type + primaryWeaponDmgLow int16 // Primary weapon min damage + primaryWeaponDmgHigh int16 // Primary weapon max damage + secondaryWeaponDmgLow int16 // Secondary weapon min damage + secondaryWeaponDmgHigh int16 // Secondary weapon max damage + rangedWeaponDmgLow int16 // Ranged weapon min damage + rangedWeaponDmgHigh int16 // Ranged weapon max damage + wieldType int8 // Weapon wield type + attackType int8 // Attack type + primaryWeaponDelay int16 // Primary weapon delay + secondaryWeaponDelay int16 // Secondary weapon delay + rangedWeaponDelay int16 // Ranged weapon delay + + // Weapon overrides + overridePrimaryWeapon int8 // Override primary weapon + overrideSecondaryWeapon int8 // Override secondary weapon + overrideRangedWeapon int8 // Override ranged weapon + + // NPC specific + friendlyTargetNpc int8 // Friendly target NPC flag + lastClaimTime int32 // Last claim time + + // Encounter system + engagedEncounter int8 // Engaged in encounter + lockableEncounter int8 // Encounter can be locked + + // Player flags + firstWorldLogin int8 // First world login flag + reloadPlayerSpells int8 // Reload player spells flag + + // Group settings + groupLootMethod int8 // Group loot method + groupLootItemsRarity int8 // Group loot rarity threshold + groupAutoSplit int8 // Auto-split loot + groupDefaultYell int8 // Default yell in group + groupAutolock int8 // Auto-lock group + groupLockMethod int8 // Group lock method + groupSoloAutolock int8 // Solo auto-lock + groupAutoLootMethod int8 // Auto-loot method + assistAutoAttack int8 // Assist auto-attack + + // Action state + actionState string // Current action state + + // Spell and ability reductions + spellMulticastChance int16 // Spell multicast chance + maxSpellReductionOverride int16 // Max spell reduction override + maxChaseDistance float32 // Maximum chase distance + + // Thread safety + mutex sync.RWMutex +} + +// NewInfoStruct creates a new InfoStruct with default values +// Initializes all fields to appropriate defaults for a new entity +func NewInfoStruct() *InfoStruct { + return &InfoStruct{ + name: "", + class1: 0, + class2: 0, + class3: 0, + race: 0, + gender: 0, + level: 0, + maxLevel: 0, + effectiveLevel: 0, + tradeskillLevel: 0, + tradeskillMaxLevel: 0, + curConcentration: 0, + maxConcentration: 5, // Default max concentration + curAttack: 0, + attackBase: 0, + curMitigation: 0, + maxMitigation: 0, + mitigationBase: 0, + mitigationMod: 0, + avoidanceDisplay: 0, + curAvoidance: 0.0, + baseAvoidancePct: 0, + avoidanceBase: 0, + maxAvoidance: 0, + parry: 0.0, + parryBase: 0.0, + deflection: 0, + deflectionBase: 0, + block: 0, + blockBase: 0, + str: 0.0, + sta: 0.0, + agi: 0.0, + wis: 0.0, + intel: 0.0, + strBase: 0.0, + staBase: 0.0, + agiBase: 0.0, + wisBase: 0.0, + intelBase: 0.0, + heat: 0, + cold: 0, + magic: 0, + mental: 0, + divine: 0, + disease: 0, + poison: 0, + heatBase: 0, + coldBase: 0, + magicBase: 0, + mentalBase: 0, + divineBase: 0, + diseaseBase: 0, + poisonBase: 0, + elementalBase: 0, + noxiousBase: 0, + arcaneBase: 0, + coinCopper: 0, + coinSilver: 0, + coinGold: 0, + coinPlat: 0, + bankCoinCopper: 0, + bankCoinSilver: 0, + bankCoinGold: 0, + bankCoinPlat: 0, + statusPoints: 0, + deity: "", + weight: 0, + maxWeight: 0, + tradeskillClass1: 0, + tradeskillClass2: 0, + tradeskillClass3: 0, + accountAgeBase: 0, + absorb: 0, + xp: 0, + xpNeeded: 0, + xpDebt: 0.0, + xpYellow: 0, + xpYellowVitalityBar: 0, + xpBlueVitalityBar: 0, + xpBlue: 0, + tsXp: 0, + tsXpNeeded: 0, + tradeskillExpYellow: 0, + tradeskillExpBlue: 0, + flags: 0, + flags2: 0, + xpVitality: 0, + tradeskillXpVitality: 0, + mitigationSkill1: 0, + mitigationSkill2: 0, + mitigationSkill3: 0, + mitigationPve: 0, + mitigationPvp: 0, + abilityModifier: 0, + criticalMitigation: 0, + blockChance: 0, + uncontestedParry: 0, + uncontestedBlock: 0, + uncontestedDodge: 0, + uncontestedRiposte: 0, + critChance: 0, + critBonus: 0, + potency: 0, + hateMod: 0, + reuseSpeed: 0, + castingSpeed: 0, + recoverySpeed: 0, + spellReuseSpeed: 0, + spellMultiAttack: 0, + sizeMod: 0.0, + ignoreSizeModCalc: 0, + dps: 0, + dpsMultiplier: 0, + attackSpeed: 0, + haste: 0, + multiAttack: 0, + flurry: 0, + meleeAe: 0, + strikethrough: 0, + accuracy: 0, + offensiveSpeed: 0, + rain: 0, + wind: 0, + alignment: 0, + petId: 0, + petName: "", + petHealthPct: 0.0, + petPowerPct: 0.0, + petMovement: 0, + petBehavior: 0, + vision: 0, + breatheUnderwater: 0, + biography: "", + drunk: 0, + powerRegen: 0, + hpRegen: 0, + powerRegenOverride: 0, + hpRegenOverride: 0, + waterType: 0, + flyingType: 0, + noInterrupt: 0, + interactionFlag: 0, + tag1: 0, + mood: 0, + rangeLastAttackTime: 0, + primaryLastAttackTime: 0, + secondaryLastAttackTime: 0, + primaryAttackDelay: 0, + secondaryAttackDelay: 0, + rangedAttackDelay: 0, + primaryWeaponType: 0, + secondaryWeaponType: 0, + rangedWeaponType: 0, + primaryWeaponDmgLow: 0, + primaryWeaponDmgHigh: 0, + secondaryWeaponDmgLow: 0, + secondaryWeaponDmgHigh: 0, + rangedWeaponDmgLow: 0, + rangedWeaponDmgHigh: 0, + wieldType: 0, + attackType: 0, + primaryWeaponDelay: 0, + secondaryWeaponDelay: 0, + rangedWeaponDelay: 0, + overridePrimaryWeapon: 0, + overrideSecondaryWeapon: 0, + overrideRangedWeapon: 0, + friendlyTargetNpc: 0, + lastClaimTime: 0, + engagedEncounter: 0, + lockableEncounter: 1, + firstWorldLogin: 0, + reloadPlayerSpells: 0, + groupLootMethod: 1, + groupLootItemsRarity: 0, + groupAutoSplit: 1, + groupDefaultYell: 1, + groupAutolock: 0, + groupLockMethod: 0, + groupSoloAutolock: 0, + groupAutoLootMethod: 0, + assistAutoAttack: 0, + actionState: "", + spellMulticastChance: 0, + maxSpellReductionOverride: 0, + maxChaseDistance: 0.0, + } +} + +// GetName returns the entity's name +func (info *InfoStruct) GetName() string { + info.mutex.RLock() + defer info.mutex.RUnlock() + return info.name +} + +// SetName updates the entity's name +func (info *InfoStruct) SetName(name string) { + info.mutex.Lock() + defer info.mutex.Unlock() + info.name = name +} + +// GetLevel returns the entity's level +func (info *InfoStruct) GetLevel() int16 { + info.mutex.RLock() + defer info.mutex.RUnlock() + return info.level +} + +// SetLevel updates the entity's level +func (info *InfoStruct) SetLevel(level int16) { + info.mutex.Lock() + defer info.mutex.Unlock() + info.level = level +} + +// GetEffectiveLevel returns the entity's effective level for calculations +func (info *InfoStruct) GetEffectiveLevel() int16 { + info.mutex.RLock() + defer info.mutex.RUnlock() + return info.effectiveLevel +} + +// SetEffectiveLevel updates the entity's effective level +func (info *InfoStruct) SetEffectiveLevel(level int16) { + info.mutex.Lock() + defer info.mutex.Unlock() + info.effectiveLevel = level +} + +// GetClass1 returns the primary class +func (info *InfoStruct) GetClass1() int8 { + info.mutex.RLock() + defer info.mutex.RUnlock() + return info.class1 +} + +// SetClass1 updates the primary class +func (info *InfoStruct) SetClass1(class int8) { + info.mutex.Lock() + defer info.mutex.Unlock() + info.class1 = class +} + +// GetRace returns the entity's race +func (info *InfoStruct) GetRace() int8 { + info.mutex.RLock() + defer info.mutex.RUnlock() + return info.race +} + +// SetRace updates the entity's race +func (info *InfoStruct) SetRace(race int8) { + info.mutex.Lock() + defer info.mutex.Unlock() + info.race = race +} + +// GetGender returns the entity's gender +func (info *InfoStruct) GetGender() int8 { + info.mutex.RLock() + defer info.mutex.RUnlock() + return info.gender +} + +// SetGender updates the entity's gender +func (info *InfoStruct) SetGender(gender int8) { + info.mutex.Lock() + defer info.mutex.Unlock() + info.gender = gender +} + +// GetStr returns the strength stat +func (info *InfoStruct) GetStr() float32 { + info.mutex.RLock() + defer info.mutex.RUnlock() + return info.str +} + +// SetStr updates the strength stat +func (info *InfoStruct) SetStr(str float32) { + info.mutex.Lock() + defer info.mutex.Unlock() + info.str = str +} + +// GetSta returns the stamina stat +func (info *InfoStruct) GetSta() float32 { + info.mutex.RLock() + defer info.mutex.RUnlock() + return info.sta +} + +// SetSta updates the stamina stat +func (info *InfoStruct) SetSta(sta float32) { + info.mutex.Lock() + defer info.mutex.Unlock() + info.sta = sta +} + +// GetAgi returns the agility stat +func (info *InfoStruct) GetAgi() float32 { + info.mutex.RLock() + defer info.mutex.RUnlock() + return info.agi +} + +// SetAgi updates the agility stat +func (info *InfoStruct) SetAgi(agi float32) { + info.mutex.Lock() + defer info.mutex.Unlock() + info.agi = agi +} + +// GetWis returns the wisdom stat +func (info *InfoStruct) GetWis() float32 { + info.mutex.RLock() + defer info.mutex.RUnlock() + return info.wis +} + +// SetWis updates the wisdom stat +func (info *InfoStruct) SetWis(wis float32) { + info.mutex.Lock() + defer info.mutex.Unlock() + info.wis = wis +} + +// GetIntel returns the intelligence stat +func (info *InfoStruct) GetIntel() float32 { + info.mutex.RLock() + defer info.mutex.RUnlock() + return info.intel +} + +// SetIntel updates the intelligence stat +func (info *InfoStruct) SetIntel(intel float32) { + info.mutex.Lock() + defer info.mutex.Unlock() + info.intel = intel +} + +// GetMaxConcentration returns the maximum concentration +func (info *InfoStruct) GetMaxConcentration() int16 { + info.mutex.RLock() + defer info.mutex.RUnlock() + return info.maxConcentration +} + +// SetMaxConcentration updates the maximum concentration +func (info *InfoStruct) SetMaxConcentration(maxConcentration int16) { + info.mutex.Lock() + defer info.mutex.Unlock() + info.maxConcentration = maxConcentration +} + +// GetCurConcentration returns the current concentration used +func (info *InfoStruct) GetCurConcentration() int16 { + info.mutex.RLock() + defer info.mutex.RUnlock() + return info.curConcentration +} + +// SetCurConcentration updates the current concentration used +func (info *InfoStruct) SetCurConcentration(curConcentration int16) { + info.mutex.Lock() + defer info.mutex.Unlock() + info.curConcentration = curConcentration +} + +// AddConcentration adds to the current concentration used +func (info *InfoStruct) AddConcentration(amount int16) bool { + info.mutex.Lock() + defer info.mutex.Unlock() + + if info.curConcentration+amount > info.maxConcentration { + return false // Not enough concentration available + } + + info.curConcentration += amount + return true +} + +// RemoveConcentration removes from the current concentration used +func (info *InfoStruct) RemoveConcentration(amount int16) { + info.mutex.Lock() + defer info.mutex.Unlock() + + info.curConcentration -= amount + if info.curConcentration < 0 { + info.curConcentration = 0 + } +} + +// GetCoins returns total coin value in copper +func (info *InfoStruct) GetCoins() int32 { + info.mutex.RLock() + defer info.mutex.RUnlock() + + return info.coinCopper + (info.coinSilver * 100) + + (info.coinGold * 10000) + (info.coinPlat * 1000000) +} + +// AddCoins adds coins to the entity (automatically distributes to appropriate denominations) +func (info *InfoStruct) AddCoins(copperAmount int32) { + info.mutex.Lock() + defer info.mutex.Unlock() + + totalCopper := info.coinCopper + copperAmount + + // Convert to higher denominations + info.coinPlat += totalCopper / 1000000 + totalCopper %= 1000000 + + info.coinGold += totalCopper / 10000 + totalCopper %= 10000 + + info.coinSilver += totalCopper / 100 + info.coinCopper = totalCopper % 100 +} + +// RemoveCoins removes coins from the entity +func (info *InfoStruct) RemoveCoins(copperAmount int32) bool { + info.mutex.Lock() + defer info.mutex.Unlock() + + totalCopper := info.coinCopper + (info.coinSilver * 100) + + (info.coinGold * 10000) + (info.coinPlat * 1000000) + + if totalCopper < copperAmount { + return false // Not enough coins + } + + totalCopper -= copperAmount + + // Redistribute + info.coinPlat = totalCopper / 1000000 + totalCopper %= 1000000 + + info.coinGold = totalCopper / 10000 + totalCopper %= 10000 + + info.coinSilver = totalCopper / 100 + info.coinCopper = totalCopper % 100 + + return true +} + +// GetResistance returns the resistance value for a specific type +func (info *InfoStruct) GetResistance(resistType string) int16 { + info.mutex.RLock() + defer info.mutex.RUnlock() + + switch resistType { + case "heat": + return info.heat + case "cold": + return info.cold + case "magic": + return info.magic + case "mental": + return info.mental + case "divine": + return info.divine + case "disease": + return info.disease + case "poison": + return info.poison + default: + return 0 + } +} + +// SetResistance updates a resistance value +func (info *InfoStruct) SetResistance(resistType string, value int16) { + info.mutex.Lock() + defer info.mutex.Unlock() + + switch resistType { + case "heat": + info.heat = value + case "cold": + info.cold = value + case "magic": + info.magic = value + case "mental": + info.mental = value + case "divine": + info.divine = value + case "disease": + info.disease = value + case "poison": + info.poison = value + } +} + +// ResetEffects resets all temporary effects and bonuses +// This method should be called when recalculating all bonuses +func (info *InfoStruct) ResetEffects() { + info.mutex.Lock() + defer info.mutex.Unlock() + + // Reset stats to base values + info.str = info.strBase + info.sta = info.staBase + info.agi = info.agiBase + info.wis = info.wisBase + info.intel = info.intelBase + + info.heat = info.heatBase + info.cold = info.coldBase + info.magic = info.magicBase + info.mental = info.mentalBase + info.divine = info.divineBase + info.disease = info.diseaseBase + info.poison = info.poisonBase + + info.parry = info.parryBase + info.deflection = info.deflectionBase + info.block = info.blockBase + + // Reset combat stats to base + info.curAttack = info.attackBase + info.curMitigation = info.mitigationBase + info.curAvoidance = float32(info.baseAvoidancePct) +} + +// CalculatePrimaryStat returns the highest primary stat value +func (info *InfoStruct) CalculatePrimaryStat() float32 { + info.mutex.RLock() + defer info.mutex.RUnlock() + + primary := info.str + if info.sta > primary { + primary = info.sta + } + if info.agi > primary { + primary = info.agi + } + if info.wis > primary { + primary = info.wis + } + if info.intel > primary { + primary = info.intel + } + + return primary +} + +// Clone creates a deep copy of the InfoStruct +func (info *InfoStruct) Clone() *InfoStruct { + info.mutex.RLock() + defer info.mutex.RUnlock() + + clone := &InfoStruct{} + *clone = *info // Copy all fields + + // Copy the account age bonus array + copy(clone.accountAgeBonus[:], info.accountAgeBonus[:]) + + return clone +} + +// GetUptime returns how long the entity has been active (for players, time logged in) +func (info *InfoStruct) GetUptime() time.Duration { + // TODO: Implement when we have login tracking + return time.Duration(0) +} + +// TODO: Additional methods to implement: +// - Experience calculation methods +// - Weapon damage calculation methods +// - Spell-related stat methods +// - Group and encounter methods +// - Equipment bonus application methods diff --git a/internal/entity/spell_effects.go b/internal/entity/spell_effects.go new file mode 100644 index 0000000..58463aa --- /dev/null +++ b/internal/entity/spell_effects.go @@ -0,0 +1,37 @@ +package entity + +// DEPRECATED: This file now imports spell effect structures from the spells package. +// The spell effect management has been moved to internal/spells for better organization. + +import ( + "eq2emu/internal/spells" +) + +// Re-export spell effect types for backward compatibility +// These will eventually be removed in favor of direct imports from spells package + +type BonusValues = spells.BonusValues +type MaintainedEffects = spells.MaintainedEffects +type SpellEffects = spells.SpellEffects +type DetrimentalEffects = spells.DetrimentalEffects +type SpellEffectManager = spells.SpellEffectManager + +// Re-export constructor functions +var NewBonusValues = spells.NewBonusValues +var NewMaintainedEffects = spells.NewMaintainedEffects +var NewSpellEffects = spells.NewSpellEffects +var NewDetrimentalEffects = spells.NewDetrimentalEffects +var NewSpellEffectManager = spells.NewSpellEffectManager + +// Re-export constants +const ( + ControlEffectStun = spells.ControlEffectStun + ControlEffectRoot = spells.ControlEffectRoot + ControlEffectMez = spells.ControlEffectMez + ControlEffectDaze = spells.ControlEffectDaze + ControlEffectFear = spells.ControlEffectFear + ControlEffectSlow = spells.ControlEffectSlow + ControlEffectSnare = spells.ControlEffectSnare + ControlEffectCharm = spells.ControlEffectCharm + ControlMaxEffects = spells.ControlMaxEffects +) \ No newline at end of file diff --git a/internal/object/constants.go b/internal/object/constants.go new file mode 100644 index 0000000..a3c5b93 --- /dev/null +++ b/internal/object/constants.go @@ -0,0 +1,35 @@ +package object + +// Object constants converted from C++ Object.cpp +const ( + // Object spawn type (from C++ constructor) + ObjectSpawnType = 2 + + // Object appearance defaults (from C++ constructor) + ObjectActivityStatus = 64 // Default activity status + ObjectPosState = 1 // Default position state + ObjectDifficulty = 0 // Default difficulty + + // Object interaction constants + ObjectShowCommandIcon = 1 // Show command icon when interactable +) + +// Object device ID constants +const ( + DeviceIDNone = 0 // No device ID assigned + DeviceIDDefault = 0 // Default device ID +) + +// Object state constants +const ( + ObjectStateInactive = 0 // Object is inactive + ObjectStateActive = 1 // Object is active and usable +) + +// Object interaction types +const ( + InteractionTypeNone = 0 // No interaction available + InteractionTypeCommand = 1 // Command-based interaction + InteractionTypeTransport = 2 // Transport/teleport interaction + InteractionTypeDevice = 3 // Device-based interaction +) \ No newline at end of file diff --git a/internal/object/integration.go b/internal/object/integration.go new file mode 100644 index 0000000..1366ed6 --- /dev/null +++ b/internal/object/integration.go @@ -0,0 +1,281 @@ +package object + +import ( + "fmt" + + "eq2emu/internal/spawn" + "eq2emu/internal/common" +) + +// ObjectSpawn represents an object that extends spawn functionality +// This properly integrates with the existing spawn system +type ObjectSpawn struct { + *spawn.Spawn // Embed the spawn functionality + + // Object-specific properties + clickable bool // Whether the object can be clicked/interacted with + deviceID int8 // Device ID for interactive objects +} + +// NewObjectSpawn creates a new object spawn with default values +func NewObjectSpawn() *ObjectSpawn { + // Create base spawn + baseSpawn := spawn.NewSpawn() + + // Set object-specific spawn properties + baseSpawn.SetSpawnType(ObjectSpawnType) + + // Set object appearance defaults + appearance := baseSpawn.GetAppearance() + appearance.ActivityStatus = ObjectActivityStatus + appearance.Pos.State = ObjectPosState + appearance.Difficulty = ObjectDifficulty + baseSpawn.SetAppearance(appearance) + + return &ObjectSpawn{ + Spawn: baseSpawn, + clickable: false, + deviceID: DeviceIDNone, + } +} + +// SetClickable sets whether the object can be clicked +func (os *ObjectSpawn) SetClickable(clickable bool) { + os.clickable = clickable +} + +// IsClickable returns whether the object can be clicked +func (os *ObjectSpawn) IsClickable() bool { + return os.clickable +} + +// SetDeviceID sets the device ID for interactive objects +func (os *ObjectSpawn) SetDeviceID(deviceID int8) { + os.deviceID = deviceID +} + +// GetDeviceID returns the device ID +func (os *ObjectSpawn) GetDeviceID() int8 { + return os.deviceID +} + +// IsObject always returns true for object spawns +func (os *ObjectSpawn) IsObject() bool { + return true +} + +// Copy creates a deep copy of the object spawn +func (os *ObjectSpawn) Copy() *ObjectSpawn { + // Copy base spawn + newSpawn := os.Spawn.Copy() + + // Create new object spawn + newObjectSpawn := &ObjectSpawn{ + Spawn: newSpawn, + clickable: os.clickable, + deviceID: os.deviceID, + } + + return newObjectSpawn +} + +// HandleUse processes object interaction by a client +func (os *ObjectSpawn) HandleUse(clientID int32, command string) error { + // Use the base object's HandleUse logic but with spawn integration + object := &Object{} + + // Copy relevant properties for handling + object.clickable = os.clickable + object.deviceID = os.deviceID + object.transporterID = os.GetTransporterID() + object.appearanceShowCommandIcon = int8(0) + if os.GetAppearance().ShowCommandIcon == 1 { + object.appearanceShowCommandIcon = ObjectShowCommandIcon + } + + // TODO: Copy command lists when they're integrated with spawn system + + return object.HandleUse(clientID, command) +} + +// SetShowCommandIcon sets whether to show the command icon +func (os *ObjectSpawn) SetShowCommandIcon(show bool) { + appearance := os.GetAppearance() + if show { + appearance.ShowCommandIcon = ObjectShowCommandIcon + } else { + appearance.ShowCommandIcon = 0 + } + os.SetAppearance(appearance) +} + +// ShowsCommandIcon returns whether the command icon is shown +func (os *ObjectSpawn) ShowsCommandIcon() bool { + return os.GetAppearance().ShowCommandIcon == ObjectShowCommandIcon +} + +// GetObjectInfo returns comprehensive information about the object spawn +func (os *ObjectSpawn) GetObjectInfo() map[string]interface{} { + info := make(map[string]interface{}) + + // Add spawn info + info["spawn_id"] = os.GetID() + info["database_id"] = os.GetDatabaseID() + info["zone_name"] = os.GetZoneName() + info["spawn_type"] = os.GetSpawnType() + info["size"] = os.GetSize() + + // Add object-specific info + info["clickable"] = os.clickable + info["device_id"] = os.deviceID + info["shows_command_icon"] = os.ShowsCommandIcon() + info["transporter_id"] = os.GetTransporterID() + info["merchant_id"] = os.GetMerchantID() + info["is_collector"] = os.IsCollector() + + // Add position info + appearance := os.GetAppearance() + info["x"] = appearance.Pos.X + info["y"] = appearance.Pos.Y + info["z"] = appearance.Pos.Z + info["heading"] = appearance.Pos.Dir1 + + return info +} + +// ObjectSpawnManager manages object spawns specifically +type ObjectSpawnManager struct { + spawnManager *spawn.SpawnManager // Reference to global spawn manager + objects map[int32]*ObjectSpawn // Object spawns by spawn ID +} + +// NewObjectSpawnManager creates a new object spawn manager +func NewObjectSpawnManager(spawnManager *spawn.SpawnManager) *ObjectSpawnManager { + return &ObjectSpawnManager{ + spawnManager: spawnManager, + objects: make(map[int32]*ObjectSpawn), + } +} + +// AddObjectSpawn adds an object spawn to both the object and spawn managers +func (osm *ObjectSpawnManager) AddObjectSpawn(objectSpawn *ObjectSpawn) error { + // Add to spawn manager first + if err := osm.spawnManager.AddSpawn(objectSpawn.Spawn); err != nil { + return err + } + + // Add to object tracking + osm.objects[objectSpawn.GetID()] = objectSpawn + + return nil +} + +// RemoveObjectSpawn removes an object spawn from both managers +func (osm *ObjectSpawnManager) RemoveObjectSpawn(spawnID int32) error { + // Remove from object tracking + delete(osm.objects, spawnID) + + // Remove from spawn manager + return osm.spawnManager.RemoveSpawn(spawnID) +} + +// GetObjectSpawn retrieves an object spawn by ID +func (osm *ObjectSpawnManager) GetObjectSpawn(spawnID int32) *ObjectSpawn { + return osm.objects[spawnID] +} + +// GetObjectSpawnsByZone returns all object spawns in a zone +func (osm *ObjectSpawnManager) GetObjectSpawnsByZone(zoneName string) []*ObjectSpawn { + result := make([]*ObjectSpawn, 0) + + // Get all spawns in zone and filter for objects + spawns := osm.spawnManager.GetSpawnsByZone(zoneName) + for _, spawn := range spawns { + if spawn.GetSpawnType() == ObjectSpawnType { + if objectSpawn, exists := osm.objects[spawn.GetID()]; exists { + result = append(result, objectSpawn) + } + } + } + + return result +} + +// GetInteractiveObjectSpawns returns all interactive object spawns +func (osm *ObjectSpawnManager) GetInteractiveObjectSpawns() []*ObjectSpawn { + result := make([]*ObjectSpawn, 0) + + for _, objectSpawn := range osm.objects { + if objectSpawn.IsClickable() || objectSpawn.ShowsCommandIcon() { + result = append(result, objectSpawn) + } + } + + return result +} + +// ProcessObjectInteraction handles interaction with an object spawn +func (osm *ObjectSpawnManager) ProcessObjectInteraction(spawnID, clientID int32, command string) error { + objectSpawn := osm.GetObjectSpawn(spawnID) + if objectSpawn == nil { + return fmt.Errorf("object spawn %d not found", spawnID) + } + + return objectSpawn.HandleUse(clientID, command) +} + +// ConvertSpawnToObject converts a regular spawn to an object spawn (if applicable) +func ConvertSpawnToObject(spawn *spawn.Spawn) *ObjectSpawn { + if spawn.GetSpawnType() != ObjectSpawnType { + return nil + } + + objectSpawn := &ObjectSpawn{ + Spawn: spawn, + clickable: false, // Default, should be loaded from data + deviceID: DeviceIDNone, + } + + // Set clickable based on appearance flags or other indicators + appearance := spawn.GetAppearance() + if appearance.ShowCommandIcon == ObjectShowCommandIcon { + objectSpawn.clickable = true + } + + return objectSpawn +} + +// LoadObjectSpawnFromData loads object spawn data from database/config +// This would be called when loading spawns from the database +func LoadObjectSpawnFromData(spawnData map[string]interface{}) *ObjectSpawn { + objectSpawn := NewObjectSpawn() + + // Load basic spawn data + if databaseID, ok := spawnData["database_id"].(int32); ok { + objectSpawn.SetDatabaseID(databaseID) + } + + if zoneName, ok := spawnData["zone"].(string); ok { + objectSpawn.SetZoneName(zoneName) + } + + // Load object-specific data + if clickable, ok := spawnData["clickable"].(bool); ok { + objectSpawn.SetClickable(clickable) + } + + if deviceID, ok := spawnData["device_id"].(int8); ok { + objectSpawn.SetDeviceID(deviceID) + } + + // Load position data + if x, ok := spawnData["x"].(float32); ok { + appearance := objectSpawn.GetAppearance() + appearance.Pos.X = x + objectSpawn.SetAppearance(appearance) + } + + // TODO: Load other properties as needed + + return objectSpawn +} \ No newline at end of file diff --git a/internal/object/interfaces.go b/internal/object/interfaces.go new file mode 100644 index 0000000..d398106 --- /dev/null +++ b/internal/object/interfaces.go @@ -0,0 +1,320 @@ +package object + +import ( + "time" +) + +// SpawnInterface defines the interface that spawn-based objects must implement +// This allows objects to integrate with systems expecting spawn entities +type SpawnInterface interface { + // Basic identification + GetID() int32 + GetDatabaseID() int32 + + // Zone and positioning + GetZoneName() string + SetZoneName(string) + GetX() float32 + GetY() float32 + GetZ() float32 + GetHeading() float32 + + // Spawn properties + GetSpawnType() int8 + SetSpawnType(int8) + GetSize() int16 + SetSize(int16) + + // State flags + IsAlive() bool + SetAlive(bool) + IsRunning() bool + SetRunning(bool) + + // Entity properties for spell/trade integration + GetFactionID() int32 + SetFactionID(int32) + GetTarget() int32 + SetTarget(int32) +} + +// ObjectInterface defines the interface for interactive objects +type ObjectInterface interface { + SpawnInterface + + // Object-specific properties + IsObject() bool + IsClickable() bool + SetClickable(bool) + GetDeviceID() int8 + SetDeviceID(int8) + + // Interaction + HandleUse(clientID int32, command string) error + ShowsCommandIcon() bool + SetShowCommandIcon(bool) + + // Merchant functionality + GetMerchantID() int32 + SetMerchantID(int32) + GetMerchantType() int8 + SetMerchantType(int8) + IsCollector() bool + SetCollector(bool) + + // Transport functionality + GetTransporterID() int32 + SetTransporterID(int32) + + // Copying + Copy() ObjectInterface +} + +// EntityInterface defines the interface for entities in trade/spell systems +// ObjectSpawn implements this to integrate with existing systems +type EntityInterface interface { + GetID() int32 + GetName() string + IsPlayer() bool + IsBot() bool + HasCoins(amount int64) bool + GetClientVersion() int32 +} + +// ItemInterface defines the interface for items that objects might provide +// This allows objects to act as item sources (merchants, containers, etc.) +type ItemInterface interface { + GetID() int32 + GetName() string + GetQuantity() int32 + GetIcon(version int32) int32 + IsNoTrade() bool + IsHeirloom() bool + IsAttuned() bool + GetCreationTime() time.Time + GetGroupCharacterIDs() []int32 +} + +// ObjectSpawnAsEntity is an adapter that makes ObjectSpawn implement EntityInterface +// This allows objects to be used in trade and spell systems +type ObjectSpawnAsEntity struct { + *ObjectSpawn + name string + isPlayer bool + isBot bool + coinsAmount int64 + clientVersion int32 +} + +// NewObjectSpawnAsEntity creates an entity adapter for an object spawn +func NewObjectSpawnAsEntity(objectSpawn *ObjectSpawn) *ObjectSpawnAsEntity { + return &ObjectSpawnAsEntity{ + ObjectSpawn: objectSpawn, + name: "Object", // Default name, should be loaded from data + isPlayer: false, + isBot: false, + coinsAmount: 0, + clientVersion: 1000, + } +} + +// EntityInterface implementation for ObjectSpawnAsEntity + +// GetName returns the object's name +func (osae *ObjectSpawnAsEntity) GetName() string { + return osae.name +} + +// SetName sets the object's name +func (osae *ObjectSpawnAsEntity) SetName(name string) { + osae.name = name +} + +// IsPlayer returns false for objects +func (osae *ObjectSpawnAsEntity) IsPlayer() bool { + return osae.isPlayer +} + +// IsBot returns whether this object behaves like a bot +func (osae *ObjectSpawnAsEntity) IsBot() bool { + return osae.isBot +} + +// SetBot sets whether this object behaves like a bot +func (osae *ObjectSpawnAsEntity) SetBot(isBot bool) { + osae.isBot = isBot +} + +// HasCoins returns whether the object has sufficient coins (for merchant objects) +func (osae *ObjectSpawnAsEntity) HasCoins(amount int64) bool { + return osae.coinsAmount >= amount +} + +// SetCoins sets the object's coin amount (for merchant objects) +func (osae *ObjectSpawnAsEntity) SetCoins(amount int64) { + osae.coinsAmount = amount +} + +// GetClientVersion returns the client version (default for objects) +func (osae *ObjectSpawnAsEntity) GetClientVersion() int32 { + return osae.clientVersion +} + +// SetClientVersion sets the client version +func (osae *ObjectSpawnAsEntity) SetClientVersion(version int32) { + osae.clientVersion = version +} + +// ObjectItem represents an item provided by an object (merchants, containers, etc.) +type ObjectItem struct { + id int32 + name string + quantity int32 + iconID int32 + noTrade bool + heirloom bool + attuned bool + creationTime time.Time + groupCharacterIDs []int32 +} + +// NewObjectItem creates a new object item +func NewObjectItem(id int32, name string, quantity int32) *ObjectItem { + return &ObjectItem{ + id: id, + name: name, + quantity: quantity, + iconID: 0, + noTrade: false, + heirloom: false, + attuned: false, + creationTime: time.Now(), + groupCharacterIDs: make([]int32, 0), + } +} + +// ItemInterface implementation for ObjectItem + +// GetID returns the item ID +func (oi *ObjectItem) GetID() int32 { + return oi.id +} + +// GetName returns the item name +func (oi *ObjectItem) GetName() string { + return oi.name +} + +// GetQuantity returns the item quantity +func (oi *ObjectItem) GetQuantity() int32 { + return oi.quantity +} + +// SetQuantity sets the item quantity +func (oi *ObjectItem) SetQuantity(quantity int32) { + oi.quantity = quantity +} + +// GetIcon returns the item icon ID +func (oi *ObjectItem) GetIcon(version int32) int32 { + return oi.iconID +} + +// SetIcon sets the item icon ID +func (oi *ObjectItem) SetIcon(iconID int32) { + oi.iconID = iconID +} + +// IsNoTrade returns whether the item is no-trade +func (oi *ObjectItem) IsNoTrade() bool { + return oi.noTrade +} + +// SetNoTrade sets whether the item is no-trade +func (oi *ObjectItem) SetNoTrade(noTrade bool) { + oi.noTrade = noTrade +} + +// IsHeirloom returns whether the item is heirloom +func (oi *ObjectItem) IsHeirloom() bool { + return oi.heirloom +} + +// SetHeirloom sets whether the item is heirloom +func (oi *ObjectItem) SetHeirloom(heirloom bool) { + oi.heirloom = heirloom +} + +// IsAttuned returns whether the item is attuned +func (oi *ObjectItem) IsAttuned() bool { + return oi.attuned +} + +// SetAttuned sets whether the item is attuned +func (oi *ObjectItem) SetAttuned(attuned bool) { + oi.attuned = attuned +} + +// GetCreationTime returns when the item was created +func (oi *ObjectItem) GetCreationTime() time.Time { + return oi.creationTime +} + +// SetCreationTime sets when the item was created +func (oi *ObjectItem) SetCreationTime(creationTime time.Time) { + oi.creationTime = creationTime +} + +// GetGroupCharacterIDs returns the group character IDs for heirloom sharing +func (oi *ObjectItem) GetGroupCharacterIDs() []int32 { + return oi.groupCharacterIDs +} + +// SetGroupCharacterIDs sets the group character IDs for heirloom sharing +func (oi *ObjectItem) SetGroupCharacterIDs(characterIDs []int32) { + oi.groupCharacterIDs = make([]int32, len(characterIDs)) + copy(oi.groupCharacterIDs, characterIDs) +} + +// Utility functions for integration + +// CreateMerchantObjectSpawn creates an object spawn configured as a merchant +func CreateMerchantObjectSpawn(merchantID int32, merchantType int8) *ObjectSpawn { + objectSpawn := NewObjectSpawn() + objectSpawn.SetMerchantID(merchantID) + objectSpawn.SetMerchantType(merchantType) + objectSpawn.SetClickable(true) + objectSpawn.SetShowCommandIcon(true) + + return objectSpawn +} + +// CreateTransportObjectSpawn creates an object spawn configured as a transporter +func CreateTransportObjectSpawn(transporterID int32) *ObjectSpawn { + objectSpawn := NewObjectSpawn() + objectSpawn.SetTransporterID(transporterID) + objectSpawn.SetClickable(true) + objectSpawn.SetShowCommandIcon(true) + + return objectSpawn +} + +// CreateDeviceObjectSpawn creates an object spawn configured as an interactive device +func CreateDeviceObjectSpawn(deviceID int8) *ObjectSpawn { + objectSpawn := NewObjectSpawn() + objectSpawn.SetDeviceID(deviceID) + objectSpawn.SetClickable(true) + objectSpawn.SetShowCommandIcon(true) + + return objectSpawn +} + +// CreateCollectorObjectSpawn creates an object spawn configured as a collector +func CreateCollectorObjectSpawn() *ObjectSpawn { + objectSpawn := NewObjectSpawn() + objectSpawn.SetCollector(true) + objectSpawn.SetClickable(true) + objectSpawn.SetShowCommandIcon(true) + + return objectSpawn +} \ No newline at end of file diff --git a/internal/object/manager.go b/internal/object/manager.go new file mode 100644 index 0000000..c740088 --- /dev/null +++ b/internal/object/manager.go @@ -0,0 +1,419 @@ +package object + +import ( + "fmt" + "sync" +) + +// ObjectManager manages all objects in the game world +type ObjectManager struct { + objects map[int32]*Object // Objects by database ID + + // Zone-based indexing + objectsByZone map[string][]*Object // Objects grouped by zone + + // Type-based indexing + interactiveObjects []*Object // Objects that can be interacted with + transportObjects []*Object // Objects that provide transport + merchantObjects []*Object // Objects that are merchants + collectorObjects []*Object // Objects that are collectors + + // Thread safety + mutex sync.RWMutex +} + +// NewObjectManager creates a new object manager +func NewObjectManager() *ObjectManager { + return &ObjectManager{ + objects: make(map[int32]*Object), + objectsByZone: make(map[string][]*Object), + interactiveObjects: make([]*Object, 0), + transportObjects: make([]*Object, 0), + merchantObjects: make([]*Object, 0), + collectorObjects: make([]*Object, 0), + } +} + +// AddObject adds an object to the manager +func (om *ObjectManager) AddObject(object *Object) error { + if object == nil { + return fmt.Errorf("cannot add nil object") + } + + om.mutex.Lock() + defer om.mutex.Unlock() + + databaseID := object.GetDatabaseID() + if databaseID == 0 { + return fmt.Errorf("object must have a valid database ID") + } + + // Check if object already exists + if _, exists := om.objects[databaseID]; exists { + return fmt.Errorf("object with database ID %d already exists", databaseID) + } + + // Add to main collection + om.objects[databaseID] = object + + // Add to zone collection + zoneName := object.GetZoneName() + if zoneName != "" { + om.objectsByZone[zoneName] = append(om.objectsByZone[zoneName], object) + } + + // Add to type-based collections + om.updateObjectIndices(object, true) + + return nil +} + +// RemoveObject removes an object from the manager +func (om *ObjectManager) RemoveObject(databaseID int32) error { + om.mutex.Lock() + defer om.mutex.Unlock() + + object, exists := om.objects[databaseID] + if !exists { + return fmt.Errorf("object with database ID %d not found", databaseID) + } + + // Remove from main collection + delete(om.objects, databaseID) + + // Remove from zone collection + zoneName := object.GetZoneName() + if zoneName != "" { + if zoneObjects, exists := om.objectsByZone[zoneName]; exists { + for i, obj := range zoneObjects { + if obj.GetDatabaseID() == databaseID { + om.objectsByZone[zoneName] = append(zoneObjects[:i], zoneObjects[i+1:]...) + break + } + } + + // Clean up empty zone collection + if len(om.objectsByZone[zoneName]) == 0 { + delete(om.objectsByZone, zoneName) + } + } + } + + // Remove from type-based collections + om.updateObjectIndices(object, false) + + return nil +} + +// GetObject retrieves an object by database ID +func (om *ObjectManager) GetObject(databaseID int32) *Object { + om.mutex.RLock() + defer om.mutex.RUnlock() + + return om.objects[databaseID] +} + +// GetObjectsByZone retrieves all objects in a specific zone +func (om *ObjectManager) GetObjectsByZone(zoneName string) []*Object { + om.mutex.RLock() + defer om.mutex.RUnlock() + + if objects, exists := om.objectsByZone[zoneName]; exists { + // Return a copy to prevent external modification + result := make([]*Object, len(objects)) + copy(result, objects) + return result + } + + return make([]*Object, 0) +} + +// GetInteractiveObjects returns all objects that can be interacted with +func (om *ObjectManager) GetInteractiveObjects() []*Object { + om.mutex.RLock() + defer om.mutex.RUnlock() + + result := make([]*Object, len(om.interactiveObjects)) + copy(result, om.interactiveObjects) + return result +} + +// GetTransportObjects returns all objects that provide transport +func (om *ObjectManager) GetTransportObjects() []*Object { + om.mutex.RLock() + defer om.mutex.RUnlock() + + result := make([]*Object, len(om.transportObjects)) + copy(result, om.transportObjects) + return result +} + +// GetMerchantObjects returns all merchant objects +func (om *ObjectManager) GetMerchantObjects() []*Object { + om.mutex.RLock() + defer om.mutex.RUnlock() + + result := make([]*Object, len(om.merchantObjects)) + copy(result, om.merchantObjects) + return result +} + +// GetCollectorObjects returns all collector objects +func (om *ObjectManager) GetCollectorObjects() []*Object { + om.mutex.RLock() + defer om.mutex.RUnlock() + + result := make([]*Object, len(om.collectorObjects)) + copy(result, om.collectorObjects) + return result +} + +// GetObjectCount returns the total number of objects +func (om *ObjectManager) GetObjectCount() int { + om.mutex.RLock() + defer om.mutex.RUnlock() + + return len(om.objects) +} + +// GetZoneCount returns the number of zones with objects +func (om *ObjectManager) GetZoneCount() int { + om.mutex.RLock() + defer om.mutex.RUnlock() + + return len(om.objectsByZone) +} + +// GetObjectsByType returns objects filtered by specific criteria +func (om *ObjectManager) GetObjectsByType(objectType string) []*Object { + om.mutex.RLock() + defer om.mutex.RUnlock() + + switch objectType { + case "interactive": + result := make([]*Object, len(om.interactiveObjects)) + copy(result, om.interactiveObjects) + return result + case "transport": + result := make([]*Object, len(om.transportObjects)) + copy(result, om.transportObjects) + return result + case "merchant": + result := make([]*Object, len(om.merchantObjects)) + copy(result, om.merchantObjects) + return result + case "collector": + result := make([]*Object, len(om.collectorObjects)) + copy(result, om.collectorObjects) + return result + default: + return make([]*Object, 0) + } +} + +// FindObjectsInZone finds objects in a zone matching specific criteria +func (om *ObjectManager) FindObjectsInZone(zoneName string, filter func(*Object) bool) []*Object { + om.mutex.RLock() + defer om.mutex.RUnlock() + + zoneObjects, exists := om.objectsByZone[zoneName] + if !exists { + return make([]*Object, 0) + } + + result := make([]*Object, 0) + for _, obj := range zoneObjects { + if filter == nil || filter(obj) { + result = append(result, obj) + } + } + + return result +} + +// FindObjectByName finds the first object with a matching name (placeholder) +func (om *ObjectManager) FindObjectByName(name string) *Object { + om.mutex.RLock() + defer om.mutex.RUnlock() + + // TODO: Implement name searching when spawn name system is integrated + // For now, return nil + return nil +} + +// UpdateObject updates an existing object's properties +func (om *ObjectManager) UpdateObject(databaseID int32, updateFn func(*Object)) error { + om.mutex.Lock() + defer om.mutex.Unlock() + + object, exists := om.objects[databaseID] + if !exists { + return fmt.Errorf("object with database ID %d not found", databaseID) + } + + // Store old zone for potential reindexing + oldZone := object.GetZoneName() + + // Apply updates + updateFn(object) + + // Check if zone changed and reindex if necessary + newZone := object.GetZoneName() + if oldZone != newZone { + // Remove from old zone + if oldZone != "" { + if zoneObjects, exists := om.objectsByZone[oldZone]; exists { + for i, obj := range zoneObjects { + if obj.GetDatabaseID() == databaseID { + om.objectsByZone[oldZone] = append(zoneObjects[:i], zoneObjects[i+1:]...) + break + } + } + + // Clean up empty zone collection + if len(om.objectsByZone[oldZone]) == 0 { + delete(om.objectsByZone, oldZone) + } + } + } + + // Add to new zone + if newZone != "" { + om.objectsByZone[newZone] = append(om.objectsByZone[newZone], object) + } + } + + // Update type-based indices + om.rebuildIndicesForObject(object) + + return nil +} + +// ClearZone removes all objects from a specific zone +func (om *ObjectManager) ClearZone(zoneName string) int { + om.mutex.Lock() + defer om.mutex.Unlock() + + zoneObjects, exists := om.objectsByZone[zoneName] + if !exists { + return 0 + } + + count := len(zoneObjects) + + // Remove objects from main collection and indices + for _, obj := range zoneObjects { + databaseID := obj.GetDatabaseID() + delete(om.objects, databaseID) + om.updateObjectIndices(obj, false) + } + + // Clear zone collection + delete(om.objectsByZone, zoneName) + + return count +} + +// GetStatistics returns statistics about objects in the manager +func (om *ObjectManager) GetStatistics() map[string]interface{} { + om.mutex.RLock() + defer om.mutex.RUnlock() + + stats := make(map[string]interface{}) + stats["total_objects"] = len(om.objects) + stats["zones_with_objects"] = len(om.objectsByZone) + stats["interactive_objects"] = len(om.interactiveObjects) + stats["transport_objects"] = len(om.transportObjects) + stats["merchant_objects"] = len(om.merchantObjects) + stats["collector_objects"] = len(om.collectorObjects) + + // Zone breakdown + zoneStats := make(map[string]int) + for zoneName, objects := range om.objectsByZone { + zoneStats[zoneName] = len(objects) + } + stats["objects_by_zone"] = zoneStats + + return stats +} + +// Shutdown clears all objects and prepares for shutdown +func (om *ObjectManager) Shutdown() { + om.mutex.Lock() + defer om.mutex.Unlock() + + om.objects = make(map[int32]*Object) + om.objectsByZone = make(map[string][]*Object) + om.interactiveObjects = make([]*Object, 0) + om.transportObjects = make([]*Object, 0) + om.merchantObjects = make([]*Object, 0) + om.collectorObjects = make([]*Object, 0) +} + +// Private helper methods + +// updateObjectIndices updates type-based indices for an object +func (om *ObjectManager) updateObjectIndices(object *Object, add bool) { + if add { + // Add to type-based collections + if object.IsClickable() || len(object.GetPrimaryCommands()) > 0 || len(object.GetSecondaryCommands()) > 0 { + om.interactiveObjects = append(om.interactiveObjects, object) + } + + if object.GetTransporterID() > 0 { + om.transportObjects = append(om.transportObjects, object) + } + + if object.GetMerchantID() > 0 { + om.merchantObjects = append(om.merchantObjects, object) + } + + if object.IsCollector() { + om.collectorObjects = append(om.collectorObjects, object) + } + } else { + // Remove from type-based collections + databaseID := object.GetDatabaseID() + + om.interactiveObjects = removeObjectFromSlice(om.interactiveObjects, databaseID) + om.transportObjects = removeObjectFromSlice(om.transportObjects, databaseID) + om.merchantObjects = removeObjectFromSlice(om.merchantObjects, databaseID) + om.collectorObjects = removeObjectFromSlice(om.collectorObjects, databaseID) + } +} + +// rebuildIndicesForObject rebuilds type-based indices for an object (used after updates) +func (om *ObjectManager) rebuildIndicesForObject(object *Object) { + databaseID := object.GetDatabaseID() + + // Remove from all type-based collections + om.interactiveObjects = removeObjectFromSlice(om.interactiveObjects, databaseID) + om.transportObjects = removeObjectFromSlice(om.transportObjects, databaseID) + om.merchantObjects = removeObjectFromSlice(om.merchantObjects, databaseID) + om.collectorObjects = removeObjectFromSlice(om.collectorObjects, databaseID) + + // Re-add based on current properties + om.updateObjectIndices(object, true) +} + +// removeObjectFromSlice removes an object from a slice by database ID +func removeObjectFromSlice(slice []*Object, databaseID int32) []*Object { + for i, obj := range slice { + if obj.GetDatabaseID() == databaseID { + return append(slice[:i], slice[i+1:]...) + } + } + return slice +} + +// Global object manager instance +var globalObjectManager *ObjectManager +var initObjectManagerOnce sync.Once + +// GetGlobalObjectManager returns the global object manager (singleton) +func GetGlobalObjectManager() *ObjectManager { + initObjectManagerOnce.Do(func() { + globalObjectManager = NewObjectManager() + }) + return globalObjectManager +} \ No newline at end of file diff --git a/internal/object/object.go b/internal/object/object.go new file mode 100644 index 0000000..27df4fe --- /dev/null +++ b/internal/object/object.go @@ -0,0 +1,603 @@ +package object + +import ( + "fmt" + "math/rand" + "strings" + "sync" +) + +// Object represents a game object that extends spawn functionality +// Converted from C++ Object class +type Object struct { + // Embed spawn functionality - TODO: Use actual spawn.Spawn when integrated + // spawn.Spawn + + // Object-specific properties + clickable bool // Whether the object can be clicked/interacted with + zoneName string // Name of the zone this object belongs to + deviceID int8 // Device ID for interactive objects + + // Inherited spawn properties (placeholder until spawn integration) + databaseID int32 + size int16 + sizeOffset int8 + merchantID int32 + merchantType int8 + merchantMinLevel int8 + merchantMaxLevel int8 + isCollector bool + factionID int32 + totalHP int32 + totalPower int32 + currentHP int32 + currentPower int32 + transporterID int32 + soundsDisabled bool + omittedByDBFlag bool + lootTier int8 + lootDropType int8 + spawnScript string + spawnScriptSetDB bool + primaryCommandListID int32 + secondaryCommandListID int32 + + // Appearance data placeholder - TODO: Use actual appearance struct + appearanceActivityStatus int8 + appearancePosState int8 + appearanceDifficulty int8 + appearanceShowCommandIcon int8 + + // Command lists - TODO: Use actual command structures + primaryCommands []string + secondaryCommands []string + + // Thread safety + mutex sync.RWMutex +} + +// NewObject creates a new object with default values +// Converted from C++ Object::Object constructor +func NewObject() *Object { + return &Object{ + clickable: false, + zoneName: "", + deviceID: DeviceIDNone, + appearanceActivityStatus: ObjectActivityStatus, + appearancePosState: ObjectPosState, + appearanceDifficulty: ObjectDifficulty, + appearanceShowCommandIcon: 0, + primaryCommands: make([]string, 0), + secondaryCommands: make([]string, 0), + } +} + +// SetClickable sets whether the object can be clicked +func (o *Object) SetClickable(clickable bool) { + o.mutex.Lock() + defer o.mutex.Unlock() + o.clickable = clickable +} + +// IsClickable returns whether the object can be clicked +func (o *Object) IsClickable() bool { + o.mutex.RLock() + defer o.mutex.RUnlock() + return o.clickable +} + +// SetZone sets the zone name for this object +// Converted from C++ Object::SetZone +func (o *Object) SetZone(zoneName string) { + o.mutex.Lock() + defer o.mutex.Unlock() + o.zoneName = zoneName +} + +// GetZoneName returns the zone name for this object +func (o *Object) GetZoneName() string { + o.mutex.RLock() + defer o.mutex.RUnlock() + return o.zoneName +} + +// SetDeviceID sets the device ID for interactive objects +// Converted from C++ Object::SetDeviceID +func (o *Object) SetDeviceID(deviceID int8) { + o.mutex.Lock() + defer o.mutex.Unlock() + o.deviceID = deviceID +} + +// GetDeviceID returns the device ID +// Converted from C++ Object::GetDeviceID +func (o *Object) GetDeviceID() int8 { + o.mutex.RLock() + defer o.mutex.RUnlock() + return o.deviceID +} + +// IsObject always returns true for Object instances +// Converted from C++ Object::IsObject +func (o *Object) IsObject() bool { + return true +} + +// Copy creates a deep copy of the object +// Converted from C++ Object::Copy +func (o *Object) Copy() *Object { + o.mutex.RLock() + defer o.mutex.RUnlock() + + newObject := NewObject() + + // Copy basic properties + newObject.clickable = o.clickable + newObject.zoneName = o.zoneName + newObject.deviceID = o.deviceID + newObject.databaseID = o.databaseID + newObject.merchantID = o.merchantID + newObject.merchantType = o.merchantType + newObject.merchantMinLevel = o.merchantMinLevel + newObject.merchantMaxLevel = o.merchantMaxLevel + newObject.isCollector = o.isCollector + newObject.factionID = o.factionID + newObject.totalHP = o.totalHP + newObject.totalPower = o.totalPower + newObject.currentHP = o.currentHP + newObject.currentPower = o.currentPower + newObject.transporterID = o.transporterID + newObject.soundsDisabled = o.soundsDisabled + newObject.omittedByDBFlag = o.omittedByDBFlag + newObject.lootTier = o.lootTier + newObject.lootDropType = o.lootDropType + newObject.spawnScript = o.spawnScript + newObject.spawnScriptSetDB = o.spawnScriptSetDB + newObject.primaryCommandListID = o.primaryCommandListID + newObject.secondaryCommandListID = o.secondaryCommandListID + + // Copy appearance data + newObject.appearanceActivityStatus = o.appearanceActivityStatus + newObject.appearancePosState = o.appearancePosState + newObject.appearanceDifficulty = o.appearanceDifficulty + newObject.appearanceShowCommandIcon = o.appearanceShowCommandIcon + + // Handle size with random offset (from C++ logic) + if o.sizeOffset > 0 { + offset := o.sizeOffset + 1 + tmpSize := int32(o.size) + (rand.Int31n(int32(offset)) - rand.Int31n(int32(offset))) + if tmpSize < 0 { + tmpSize = 1 + } else if tmpSize >= 0xFFFF { + tmpSize = 0xFFFF + } + newObject.size = int16(tmpSize) + } else { + newObject.size = o.size + } + + // Copy command lists + newObject.primaryCommands = make([]string, len(o.primaryCommands)) + copy(newObject.primaryCommands, o.primaryCommands) + newObject.secondaryCommands = make([]string, len(o.secondaryCommands)) + copy(newObject.secondaryCommands, o.secondaryCommands) + + return newObject +} + +// HandleUse processes object interaction by a client +// Converted from C++ Object::HandleUse +func (o *Object) HandleUse(clientID int32, command string) error { + o.mutex.RLock() + defer o.mutex.RUnlock() + + // TODO: Implement transport destination handling when zone system is available + // This would check for transporter ID and process teleportation + if o.transporterID > 0 { + // Handle transport logic + return o.handleTransport(clientID) + } + + // Handle command-based interaction + if len(command) > 0 && o.appearanceShowCommandIcon == ObjectShowCommandIcon { + return o.handleCommand(clientID, command) + } + + return fmt.Errorf("object is not interactive") +} + +// handleTransport processes transport/teleport functionality +func (o *Object) handleTransport(clientID int32) error { + // TODO: Implement when zone and transport systems are available + // This would: + // 1. Get transport destinations for this object + // 2. Present options to the client + // 3. Process teleportation request + + return fmt.Errorf("transport system not yet implemented") +} + +// handleCommand processes command-based object interaction +func (o *Object) handleCommand(clientID int32, command string) error { + // TODO: Implement when entity command system is available + // This would: + // 1. Find the entity command by name + // 2. Validate client permissions + // 3. Execute the command + + command = strings.TrimSpace(strings.ToLower(command)) + + // Check if command exists in primary or secondary commands + for _, cmd := range o.primaryCommands { + if strings.ToLower(cmd) == command { + return o.executeCommand(clientID, cmd) + } + } + + for _, cmd := range o.secondaryCommands { + if strings.ToLower(cmd) == command { + return o.executeCommand(clientID, cmd) + } + } + + return fmt.Errorf("command '%s' not found", command) +} + +// executeCommand executes a specific command for a client +func (o *Object) executeCommand(clientID int32, command string) error { + // TODO: Implement actual command execution when command system is available + // For now, just return success for valid commands + return nil +} + +// Serialize returns packet data for this object +// Converted from C++ Object::serialize +func (o *Object) Serialize(playerID int32, version int16) ([]byte, error) { + // TODO: Implement actual serialization when packet system is available + // This would call the spawn serialization method + return nil, fmt.Errorf("serialization not yet implemented") +} + +// Getter/Setter methods for inherited spawn properties + +// SetCollector sets whether this object is a collector +func (o *Object) SetCollector(isCollector bool) { + o.mutex.Lock() + defer o.mutex.Unlock() + o.isCollector = isCollector +} + +// IsCollector returns whether this object is a collector +func (o *Object) IsCollector() bool { + o.mutex.RLock() + defer o.mutex.RUnlock() + return o.isCollector +} + +// SetMerchantID sets the merchant ID +func (o *Object) SetMerchantID(merchantID int32) { + o.mutex.Lock() + defer o.mutex.Unlock() + o.merchantID = merchantID +} + +// GetMerchantID returns the merchant ID +func (o *Object) GetMerchantID() int32 { + o.mutex.RLock() + defer o.mutex.RUnlock() + return o.merchantID +} + +// SetMerchantType sets the merchant type +func (o *Object) SetMerchantType(merchantType int8) { + o.mutex.Lock() + defer o.mutex.Unlock() + o.merchantType = merchantType +} + +// GetMerchantType returns the merchant type +func (o *Object) GetMerchantType() int8 { + o.mutex.RLock() + defer o.mutex.RUnlock() + return o.merchantType +} + +// SetMerchantLevelRange sets the merchant level range +func (o *Object) SetMerchantLevelRange(minLevel, maxLevel int8) { + o.mutex.Lock() + defer o.mutex.Unlock() + o.merchantMinLevel = minLevel + o.merchantMaxLevel = maxLevel +} + +// GetMerchantMinLevel returns the minimum merchant level +func (o *Object) GetMerchantMinLevel() int8 { + o.mutex.RLock() + defer o.mutex.RUnlock() + return o.merchantMinLevel +} + +// GetMerchantMaxLevel returns the maximum merchant level +func (o *Object) GetMerchantMaxLevel() int8 { + o.mutex.RLock() + defer o.mutex.RUnlock() + return o.merchantMaxLevel +} + +// SetSize sets the object size +func (o *Object) SetSize(size int16) { + o.mutex.Lock() + defer o.mutex.Unlock() + o.size = size +} + +// GetSize returns the object size +func (o *Object) GetSize() int16 { + o.mutex.RLock() + defer o.mutex.RUnlock() + return o.size +} + +// SetSizeOffset sets the size randomization offset +func (o *Object) SetSizeOffset(offset int8) { + o.mutex.Lock() + defer o.mutex.Unlock() + o.sizeOffset = offset +} + +// GetSizeOffset returns the size randomization offset +func (o *Object) GetSizeOffset() int8 { + o.mutex.RLock() + defer o.mutex.RUnlock() + return o.sizeOffset +} + +// SetPrimaryCommands sets the primary command list +func (o *Object) SetPrimaryCommands(commands []string) { + o.mutex.Lock() + defer o.mutex.Unlock() + o.primaryCommands = make([]string, len(commands)) + copy(o.primaryCommands, commands) +} + +// GetPrimaryCommands returns the primary command list +func (o *Object) GetPrimaryCommands() []string { + o.mutex.RLock() + defer o.mutex.RUnlock() + commands := make([]string, len(o.primaryCommands)) + copy(commands, o.primaryCommands) + return commands +} + +// SetSecondaryCommands sets the secondary command list +func (o *Object) SetSecondaryCommands(commands []string) { + o.mutex.Lock() + defer o.mutex.Unlock() + o.secondaryCommands = make([]string, len(commands)) + copy(o.secondaryCommands, commands) +} + +// GetSecondaryCommands returns the secondary command list +func (o *Object) GetSecondaryCommands() []string { + o.mutex.RLock() + defer o.mutex.RUnlock() + commands := make([]string, len(o.secondaryCommands)) + copy(commands, o.secondaryCommands) + return commands +} + +// SetDatabaseID sets the database ID +func (o *Object) SetDatabaseID(id int32) { + o.mutex.Lock() + defer o.mutex.Unlock() + o.databaseID = id +} + +// GetDatabaseID returns the database ID +func (o *Object) GetDatabaseID() int32 { + o.mutex.RLock() + defer o.mutex.RUnlock() + return o.databaseID +} + +// SetFactionID sets the faction ID +func (o *Object) SetFactionID(factionID int32) { + o.mutex.Lock() + defer o.mutex.Unlock() + o.factionID = factionID +} + +// GetFactionID returns the faction ID +func (o *Object) GetFactionID() int32 { + o.mutex.RLock() + defer o.mutex.RUnlock() + return o.factionID +} + +// SetTotalHP sets the total hit points +func (o *Object) SetTotalHP(hp int32) { + o.mutex.Lock() + defer o.mutex.Unlock() + o.totalHP = hp +} + +// GetTotalHP returns the total hit points +func (o *Object) GetTotalHP() int32 { + o.mutex.RLock() + defer o.mutex.RUnlock() + return o.totalHP +} + +// SetTotalPower sets the total power +func (o *Object) SetTotalPower(power int32) { + o.mutex.Lock() + defer o.mutex.Unlock() + o.totalPower = power +} + +// GetTotalPower returns the total power +func (o *Object) GetTotalPower() int32 { + o.mutex.RLock() + defer o.mutex.RUnlock() + return o.totalPower +} + +// SetHP sets the current hit points +func (o *Object) SetHP(hp int32) { + o.mutex.Lock() + defer o.mutex.Unlock() + o.currentHP = hp +} + +// GetHP returns the current hit points +func (o *Object) GetHP() int32 { + o.mutex.RLock() + defer o.mutex.RUnlock() + return o.currentHP +} + +// SetPower sets the current power +func (o *Object) SetPower(power int32) { + o.mutex.Lock() + defer o.mutex.Unlock() + o.currentPower = power +} + +// GetPower returns the current power +func (o *Object) GetPower() int32 { + o.mutex.RLock() + defer o.mutex.RUnlock() + return o.currentPower +} + +// SetTransporterID sets the transporter ID +func (o *Object) SetTransporterID(transporterID int32) { + o.mutex.Lock() + defer o.mutex.Unlock() + o.transporterID = transporterID +} + +// GetTransporterID returns the transporter ID +func (o *Object) GetTransporterID() int32 { + o.mutex.RLock() + defer o.mutex.RUnlock() + return o.transporterID +} + +// SetSoundsDisabled sets whether sounds are disabled +func (o *Object) SetSoundsDisabled(disabled bool) { + o.mutex.Lock() + defer o.mutex.Unlock() + o.soundsDisabled = disabled +} + +// IsSoundsDisabled returns whether sounds are disabled +func (o *Object) IsSoundsDisabled() bool { + o.mutex.RLock() + defer o.mutex.RUnlock() + return o.soundsDisabled +} + +// SetOmittedByDBFlag sets the omitted by DB flag +func (o *Object) SetOmittedByDBFlag(omitted bool) { + o.mutex.Lock() + defer o.mutex.Unlock() + o.omittedByDBFlag = omitted +} + +// IsOmittedByDBFlag returns the omitted by DB flag +func (o *Object) IsOmittedByDBFlag() bool { + o.mutex.RLock() + defer o.mutex.RUnlock() + return o.omittedByDBFlag +} + +// SetLootTier sets the loot tier +func (o *Object) SetLootTier(tier int8) { + o.mutex.Lock() + defer o.mutex.Unlock() + o.lootTier = tier +} + +// GetLootTier returns the loot tier +func (o *Object) GetLootTier() int8 { + o.mutex.RLock() + defer o.mutex.RUnlock() + return o.lootTier +} + +// SetLootDropType sets the loot drop type +func (o *Object) SetLootDropType(dropType int8) { + o.mutex.Lock() + defer o.mutex.Unlock() + o.lootDropType = dropType +} + +// GetLootDropType returns the loot drop type +func (o *Object) GetLootDropType() int8 { + o.mutex.RLock() + defer o.mutex.RUnlock() + return o.lootDropType +} + +// SetSpawnScript sets the spawn script +func (o *Object) SetSpawnScript(script string) { + o.mutex.Lock() + defer o.mutex.Unlock() + o.spawnScript = script + o.spawnScriptSetDB = len(script) > 0 +} + +// GetSpawnScript returns the spawn script +func (o *Object) GetSpawnScript() string { + o.mutex.RLock() + defer o.mutex.RUnlock() + return o.spawnScript +} + +// GetSpawnScriptSetDB returns whether spawn script is set from DB +func (o *Object) GetSpawnScriptSetDB() bool { + o.mutex.RLock() + defer o.mutex.RUnlock() + return o.spawnScriptSetDB +} + +// SetShowCommandIcon sets whether to show the command icon +func (o *Object) SetShowCommandIcon(show bool) { + o.mutex.Lock() + defer o.mutex.Unlock() + if show { + o.appearanceShowCommandIcon = ObjectShowCommandIcon + } else { + o.appearanceShowCommandIcon = 0 + } +} + +// ShowsCommandIcon returns whether the command icon is shown +func (o *Object) ShowsCommandIcon() bool { + o.mutex.RLock() + defer o.mutex.RUnlock() + return o.appearanceShowCommandIcon == ObjectShowCommandIcon +} + +// GetObjectInfo returns comprehensive information about the object +func (o *Object) GetObjectInfo() map[string]interface{} { + o.mutex.RLock() + defer o.mutex.RUnlock() + + info := make(map[string]interface{}) + info["clickable"] = o.clickable + info["zone_name"] = o.zoneName + info["device_id"] = o.deviceID + info["database_id"] = o.databaseID + info["size"] = o.size + info["merchant_id"] = o.merchantID + info["transporter_id"] = o.transporterID + info["is_collector"] = o.isCollector + info["sounds_disabled"] = o.soundsDisabled + info["primary_commands"] = len(o.primaryCommands) + info["secondary_commands"] = len(o.secondaryCommands) + info["shows_command_icon"] = o.ShowsCommandIcon() + + return info +} \ No newline at end of file diff --git a/internal/races/constants.go b/internal/races/constants.go new file mode 100644 index 0000000..24e7047 --- /dev/null +++ b/internal/races/constants.go @@ -0,0 +1,90 @@ +package races + +// Race ID constants converted from C++ races.h +const ( + RaceBarbarian = 0 + RaceDarkElf = 1 + RaceDwarf = 2 + RaceErudite = 3 + RaceFroglok = 4 + RaceGnome = 5 + RaceHalfElf = 6 + RaceHalfling = 7 + RaceHighElf = 8 + RaceHuman = 9 + RaceIksar = 10 + RaceKerra = 11 + RaceOgre = 12 + RaceRatonga = 13 + RaceTroll = 14 + RaceWoodElf = 15 + RaceFae = 16 + RaceArasai = 17 + RaceSarnak = 18 + RaceVampire = 19 + RaceAerakyn = 20 +) + +// Maximum race ID for validation +const ( + MaxRaceID = 20 + MinRaceID = 0 + DefaultRaceID = RaceHuman // Default to Human if lookup fails +) + +// Race alignment types +const ( + AlignmentGood = "good" + AlignmentEvil = "evil" + AlignmentNeutral = "neutral" +) + +// Race name constants for lookup (uppercase keys from C++) +const ( + RaceNameBarbarian = "BARBARIAN" + RaceNameDarkElf = "DARKELF" + RaceNameDwarf = "DWARF" + RaceNameErudite = "ERUDITE" + RaceNameFroglok = "FROGLOK" + RaceNameGnome = "GNOME" + RaceNameHalfElf = "HALFELF" + RaceNameHalfling = "HALFLING" + RaceNameHighElf = "HIGHELF" + RaceNameHuman = "HUMAN" + RaceNameIksar = "IKSAR" + RaceNameKerra = "KERRA" + RaceNameOgre = "OGRE" + RaceNameRatonga = "RATONGA" + RaceNameTroll = "TROLL" + RaceNameWoodElf = "WOODELF" + RaceNameFaeLight = "FAE_LIGHT" + RaceNameFaeDark = "FAE_DARK" + RaceNameSarnak = "SARNAK" + RaceNameVampire = "VAMPIRE" + RaceNameAerakyn = "AERAKYN" +) + +// Race display names (proper case from C++) +const ( + DisplayNameBarbarian = "Barbarian" + DisplayNameDarkElf = "Dark Elf" + DisplayNameDwarf = "Dwarf" + DisplayNameErudite = "Erudite" + DisplayNameFroglok = "Froglok" + DisplayNameGnome = "Gnome" + DisplayNameHalfElf = "Half Elf" + DisplayNameHalfling = "Halfling" + DisplayNameHighElf = "High Elf" + DisplayNameHuman = "Human" + DisplayNameIksar = "Iksar" + DisplayNameKerra = "Kerra" + DisplayNameOgre = "Ogre" + DisplayNameRatonga = "Ratonga" + DisplayNameTroll = "Troll" + DisplayNameWoodElf = "Wood Elf" + DisplayNameFae = "Fae" + DisplayNameArasai = "Arasai" + DisplayNameSarnak = "Sarnak" + DisplayNameVampire = "Vampire" + DisplayNameAerakyn = "Aerakyn" +) \ No newline at end of file diff --git a/internal/races/integration.go b/internal/races/integration.go new file mode 100644 index 0000000..967fdf2 --- /dev/null +++ b/internal/races/integration.go @@ -0,0 +1,291 @@ +package races + +import ( + "fmt" +) + +// RaceAware interface for entities that have race information +type RaceAware interface { + GetRace() int8 + SetRace(int8) +} + +// EntityWithRace interface extends RaceAware with additional entity properties +type EntityWithRace interface { + RaceAware + GetID() int32 + GetName() string +} + +// RaceIntegration provides race-related functionality for other systems +type RaceIntegration struct { + races *Races + utils *RaceUtils +} + +// NewRaceIntegration creates a new race integration helper +func NewRaceIntegration() *RaceIntegration { + return &RaceIntegration{ + races: GetGlobalRaces(), + utils: NewRaceUtils(), + } +} + +// ValidateEntityRace validates an entity's race and provides detailed information +func (ri *RaceIntegration) ValidateEntityRace(entity RaceAware) (bool, string, map[string]interface{}) { + raceID := entity.GetRace() + + if !ri.races.IsValidRaceID(raceID) { + return false, fmt.Sprintf("Invalid race ID: %d", raceID), nil + } + + raceInfo := ri.races.GetRaceInfo(raceID) + return true, "Valid race", raceInfo +} + +// ApplyRacialBonuses applies racial stat bonuses to an entity +// This would integrate with the stat system when available +func (ri *RaceIntegration) ApplyRacialBonuses(entity RaceAware, stats map[string]*int16) { + raceID := entity.GetRace() + if !ri.races.IsValidRaceID(raceID) { + return + } + + // Get racial modifiers + modifiers := ri.utils.GetRaceStatModifiers(raceID) + + // Apply modifiers to stats + for statName, modifier := range modifiers { + if statPtr, exists := stats[statName]; exists && statPtr != nil { + *statPtr += int16(modifier) + } + } +} + +// GetEntityRaceInfo returns comprehensive race information for an entity +func (ri *RaceIntegration) GetEntityRaceInfo(entity EntityWithRace) map[string]interface{} { + info := make(map[string]interface{}) + + // Basic entity info + info["entity_id"] = entity.GetID() + info["entity_name"] = entity.GetName() + + // Race information + raceID := entity.GetRace() + raceInfo := ri.races.GetRaceInfo(raceID) + info["race"] = raceInfo + + // Additional race-specific info + info["description"] = ri.utils.GetRaceDescription(raceID) + info["starting_location"] = ri.utils.GetRaceStartingLocation(raceID) + info["stat_modifiers"] = ri.utils.GetRaceStatModifiers(raceID) + info["aliases"] = ri.utils.GetRaceAliases(raceID) + info["compatible_races"] = ri.utils.GetCompatibleRaces(raceID) + + return info +} + +// ChangeEntityRace changes an entity's race with validation +func (ri *RaceIntegration) ChangeEntityRace(entity RaceAware, newRaceID int8) error { + if !ri.races.IsValidRaceID(newRaceID) { + return fmt.Errorf("invalid race ID: %d", newRaceID) + } + + oldRaceID := entity.GetRace() + + // Validate the race transition + if valid, reason := ri.utils.ValidateRaceTransition(oldRaceID, newRaceID); !valid { + return fmt.Errorf("race change not allowed: %s", reason) + } + + // Perform the race change + entity.SetRace(newRaceID) + + return nil +} + +// GetRandomRaceForEntity returns a random race appropriate for an entity +func (ri *RaceIntegration) GetRandomRaceForEntity(alignment string) int8 { + return ri.utils.GetRandomRaceByAlignment(alignment) +} + +// CheckRaceCompatibility checks if two entities' races are compatible +func (ri *RaceIntegration) CheckRaceCompatibility(entity1, entity2 RaceAware) bool { + race1 := entity1.GetRace() + race2 := entity2.GetRace() + + if !ri.races.IsValidRaceID(race1) || !ri.races.IsValidRaceID(race2) { + return false + } + + // Same race is always compatible + if race1 == race2 { + return true + } + + // Check alignment compatibility + alignment1 := ri.races.GetRaceAlignment(race1) + alignment2 := ri.races.GetRaceAlignment(race2) + + // Neutral races are compatible with everyone + if alignment1 == AlignmentNeutral || alignment2 == AlignmentNeutral { + return true + } + + // Same alignment races are compatible + return alignment1 == alignment2 +} + +// FormatEntityRace returns a formatted race name for an entity +func (ri *RaceIntegration) FormatEntityRace(entity RaceAware, format string) string { + raceID := entity.GetRace() + return ri.utils.FormatRaceName(raceID, format) +} + +// GetEntityRaceAlignment returns an entity's race alignment +func (ri *RaceIntegration) GetEntityRaceAlignment(entity RaceAware) string { + raceID := entity.GetRace() + return ri.races.GetRaceAlignment(raceID) +} + +// IsEntityGoodRace checks if an entity is a good-aligned race +func (ri *RaceIntegration) IsEntityGoodRace(entity RaceAware) bool { + raceID := entity.GetRace() + return ri.races.IsGoodRace(raceID) +} + +// IsEntityEvilRace checks if an entity is an evil-aligned race +func (ri *RaceIntegration) IsEntityEvilRace(entity RaceAware) bool { + raceID := entity.GetRace() + return ri.races.IsEvilRace(raceID) +} + +// IsEntityNeutralRace checks if an entity is a neutral race +func (ri *RaceIntegration) IsEntityNeutralRace(entity RaceAware) bool { + raceID := entity.GetRace() + return ri.races.IsNeutralRace(raceID) +} + +// GetEntitiesByRace filters entities by race +func (ri *RaceIntegration) GetEntitiesByRace(entities []RaceAware, raceID int8) []RaceAware { + result := make([]RaceAware, 0) + + for _, entity := range entities { + if entity.GetRace() == raceID { + result = append(result, entity) + } + } + + return result +} + +// GetEntitiesByAlignment filters entities by race alignment +func (ri *RaceIntegration) GetEntitiesByAlignment(entities []RaceAware, alignment string) []RaceAware { + result := make([]RaceAware, 0) + + for _, entity := range entities { + entityAlignment := ri.GetEntityRaceAlignment(entity) + if entityAlignment == alignment { + result = append(result, entity) + } + } + + return result +} + +// ValidateRaceForClass checks if a race/class combination is valid +func (ri *RaceIntegration) ValidateRaceForClass(raceID, classID int8) (bool, string) { + if !ri.races.IsValidRaceID(raceID) { + return false, "Invalid race" + } + + // Use the utility function (which currently allows all combinations) + if ri.utils.ValidateRaceForClass(raceID, classID) { + return true, "" + } + + raceName := ri.races.GetRaceNameCase(raceID) + return false, fmt.Sprintf("Race %s cannot be class %d", raceName, classID) +} + +// GetRaceStartingStats returns the starting stats for a race +func (ri *RaceIntegration) GetRaceStartingStats(raceID int8) map[string]int16 { + baseStats := map[string]int16{ + "strength": 50, + "stamina": 50, + "agility": 50, + "wisdom": 50, + "intelligence": 50, + } + + // Apply racial modifiers + modifiers := ri.utils.GetRaceStatModifiers(raceID) + for statName, modifier := range modifiers { + if baseStat, exists := baseStats[statName]; exists { + baseStats[statName] = baseStat + int16(modifier) + } + } + + return baseStats +} + +// CreateRaceSpecificEntity creates entity data with race-specific properties +func (ri *RaceIntegration) CreateRaceSpecificEntity(raceID int8) map[string]interface{} { + if !ri.races.IsValidRaceID(raceID) { + return nil + } + + entityData := make(map[string]interface{}) + + // Basic race info + entityData["race_id"] = raceID + entityData["race_name"] = ri.races.GetRaceNameCase(raceID) + entityData["alignment"] = ri.races.GetRaceAlignment(raceID) + + // Starting stats + entityData["starting_stats"] = ri.GetRaceStartingStats(raceID) + + // Starting location + entityData["starting_location"] = ri.utils.GetRaceStartingLocation(raceID) + + // Race description + entityData["description"] = ri.utils.GetRaceDescription(raceID) + + return entityData +} + +// GetRaceSelectionData returns data for race selection UI +func (ri *RaceIntegration) GetRaceSelectionData() map[string]interface{} { + data := make(map[string]interface{}) + + // All available races + allRaces := ri.races.GetAllRaces() + raceList := make([]map[string]interface{}, 0, len(allRaces)) + + for raceID, friendlyName := range allRaces { + raceData := map[string]interface{}{ + "id": raceID, + "name": friendlyName, + "alignment": ri.races.GetRaceAlignment(raceID), + "description": ri.utils.GetRaceDescription(raceID), + "stats": ri.GetRaceStartingStats(raceID), + } + raceList = append(raceList, raceData) + } + + data["races"] = raceList + data["statistics"] = ri.utils.GetRaceStatistics() + + return data +} + +// Global race integration instance +var globalRaceIntegration *RaceIntegration + +// GetGlobalRaceIntegration returns the global race integration helper +func GetGlobalRaceIntegration() *RaceIntegration { + if globalRaceIntegration == nil { + globalRaceIntegration = NewRaceIntegration() + } + return globalRaceIntegration +} \ No newline at end of file diff --git a/internal/races/manager.go b/internal/races/manager.go new file mode 100644 index 0000000..0e95e4f --- /dev/null +++ b/internal/races/manager.go @@ -0,0 +1,380 @@ +package races + +import ( + "fmt" + "sync" +) + +// RaceManager provides high-level race management functionality +type RaceManager struct { + races *Races + utils *RaceUtils + integration *RaceIntegration + + // Statistics tracking + raceUsageStats map[int8]int32 // Track how often each race is used + + // Thread safety + mutex sync.RWMutex +} + +// NewRaceManager creates a new race manager +func NewRaceManager() *RaceManager { + return &RaceManager{ + races: GetGlobalRaces(), + utils: NewRaceUtils(), + integration: NewRaceIntegration(), + raceUsageStats: make(map[int8]int32), + } +} + +// RegisterRaceUsage tracks race usage for statistics +func (rm *RaceManager) RegisterRaceUsage(raceID int8) { + if !rm.races.IsValidRaceID(raceID) { + return + } + + rm.mutex.Lock() + defer rm.mutex.Unlock() + + rm.raceUsageStats[raceID]++ +} + +// GetRaceUsageStats returns race usage statistics +func (rm *RaceManager) GetRaceUsageStats() map[int8]int32 { + rm.mutex.RLock() + defer rm.mutex.RUnlock() + + // Return a copy to prevent external modification + stats := make(map[int8]int32) + for raceID, count := range rm.raceUsageStats { + stats[raceID] = count + } + + return stats +} + +// GetMostPopularRace returns the most frequently used race +func (rm *RaceManager) GetMostPopularRace() (int8, int32) { + rm.mutex.RLock() + defer rm.mutex.RUnlock() + + var mostPopularRace int8 = -1 + var maxUsage int32 = 0 + + for raceID, usage := range rm.raceUsageStats { + if usage > maxUsage { + maxUsage = usage + mostPopularRace = raceID + } + } + + return mostPopularRace, maxUsage +} + +// GetLeastPopularRace returns the least frequently used race +func (rm *RaceManager) GetLeastPopularRace() (int8, int32) { + rm.mutex.RLock() + defer rm.mutex.RUnlock() + + var leastPopularRace int8 = -1 + var minUsage int32 = -1 + + for raceID, usage := range rm.raceUsageStats { + if minUsage == -1 || usage < minUsage { + minUsage = usage + leastPopularRace = raceID + } + } + + return leastPopularRace, minUsage +} + +// ResetUsageStats clears all usage statistics +func (rm *RaceManager) ResetUsageStats() { + rm.mutex.Lock() + defer rm.mutex.Unlock() + + rm.raceUsageStats = make(map[int8]int32) +} + +// ProcessRaceCommand handles race-related commands +func (rm *RaceManager) ProcessRaceCommand(command string, args []string) (string, error) { + switch command { + case "list": + return rm.handleListCommand(args) + case "info": + return rm.handleInfoCommand(args) + case "random": + return rm.handleRandomCommand(args) + case "stats": + return rm.handleStatsCommand(args) + case "search": + return rm.handleSearchCommand(args) + default: + return "", fmt.Errorf("unknown race command: %s", command) + } +} + +// handleListCommand lists races by criteria +func (rm *RaceManager) handleListCommand(args []string) (string, error) { + if len(args) == 0 { + // List all races + allRaces := rm.races.GetAllRaces() + result := "All Races:\n" + for raceID, friendlyName := range allRaces { + alignment := rm.races.GetRaceAlignment(raceID) + result += fmt.Sprintf("%d: %s (%s)\n", raceID, friendlyName, alignment) + } + return result, nil + } + + // List races by alignment + alignment := args[0] + raceIDs := rm.races.GetRacesByAlignment(alignment) + + if len(raceIDs) == 0 { + return fmt.Sprintf("No races found for alignment: %s", alignment), nil + } + + result := fmt.Sprintf("%s Races:\n", alignment) + for _, raceID := range raceIDs { + friendlyName := rm.races.GetRaceNameCase(raceID) + result += fmt.Sprintf("%d: %s\n", raceID, friendlyName) + } + + return result, nil +} + +// handleInfoCommand provides detailed information about a race +func (rm *RaceManager) handleInfoCommand(args []string) (string, error) { + if len(args) == 0 { + return "", fmt.Errorf("race name or ID required") + } + + // Try to parse as race name or ID + raceID := rm.utils.ParseRaceName(args[0]) + if raceID == -1 { + return fmt.Sprintf("Invalid race: %s", args[0]), nil + } + + raceInfo := rm.races.GetRaceInfo(raceID) + if !raceInfo["valid"].(bool) { + return fmt.Sprintf("Invalid race ID: %d", raceID), nil + } + + result := fmt.Sprintf("Race Information:\n") + result += fmt.Sprintf("ID: %d\n", raceID) + result += fmt.Sprintf("Name: %s\n", raceInfo["display_name"]) + result += fmt.Sprintf("Alignment: %s\n", raceInfo["alignment"]) + result += fmt.Sprintf("Description: %s\n", rm.utils.GetRaceDescription(raceID)) + result += fmt.Sprintf("Starting Location: %s\n", rm.utils.GetRaceStartingLocation(raceID)) + + // Add stat modifiers + modifiers := rm.utils.GetRaceStatModifiers(raceID) + if len(modifiers) > 0 { + result += "Stat Modifiers:\n" + for stat, modifier := range modifiers { + sign := "+" + if modifier < 0 { + sign = "" + } + result += fmt.Sprintf(" %s: %s%d\n", stat, sign, modifier) + } + } + + // Add usage statistics if available + rm.mutex.RLock() + usage, hasUsage := rm.raceUsageStats[raceID] + rm.mutex.RUnlock() + + if hasUsage { + result += fmt.Sprintf("Usage Count: %d\n", usage) + } + + return result, nil +} + +// handleRandomCommand generates random races +func (rm *RaceManager) handleRandomCommand(args []string) (string, error) { + alignment := AlignmentNeutral + if len(args) > 0 { + alignment = args[0] + } + + raceID := rm.utils.GetRandomRaceByAlignment(alignment) + if raceID == -1 { + return "Failed to generate random race", nil + } + + friendlyName := rm.races.GetRaceNameCase(raceID) + raceAlignment := rm.races.GetRaceAlignment(raceID) + + return fmt.Sprintf("Random %s Race: %s (ID: %d)", raceAlignment, friendlyName, raceID), nil +} + +// handleStatsCommand shows race system statistics +func (rm *RaceManager) handleStatsCommand(args []string) (string, error) { + systemStats := rm.utils.GetRaceStatistics() + usageStats := rm.GetRaceUsageStats() + + result := "Race System Statistics:\n" + result += fmt.Sprintf("Total Races: %d\n", systemStats["total_races"]) + result += fmt.Sprintf("Good Races: %d\n", systemStats["good_races"]) + result += fmt.Sprintf("Evil Races: %d\n", systemStats["evil_races"]) + result += fmt.Sprintf("Neutral Races: %d\n", systemStats["neutral_races"]) + + if len(usageStats) > 0 { + result += "\nUsage Statistics:\n" + mostPopular, maxUsage := rm.GetMostPopularRace() + leastPopular, minUsage := rm.GetLeastPopularRace() + + if mostPopular != -1 { + mostPopularName := rm.races.GetRaceNameCase(mostPopular) + result += fmt.Sprintf("Most Popular: %s (%d uses)\n", mostPopularName, maxUsage) + } + + if leastPopular != -1 { + leastPopularName := rm.races.GetRaceNameCase(leastPopular) + result += fmt.Sprintf("Least Popular: %s (%d uses)\n", leastPopularName, minUsage) + } + } + + return result, nil +} + +// handleSearchCommand searches for races by pattern +func (rm *RaceManager) handleSearchCommand(args []string) (string, error) { + if len(args) == 0 { + return "", fmt.Errorf("search pattern required") + } + + pattern := args[0] + matchingRaces := rm.utils.GetRacesByPattern(pattern) + + if len(matchingRaces) == 0 { + return fmt.Sprintf("No races found matching pattern: %s", pattern), nil + } + + result := fmt.Sprintf("Races matching '%s':\n", pattern) + for _, raceID := range matchingRaces { + friendlyName := rm.races.GetRaceNameCase(raceID) + alignment := rm.races.GetRaceAlignment(raceID) + result += fmt.Sprintf("%d: %s (%s)\n", raceID, friendlyName, alignment) + } + + return result, nil +} + +// ValidateEntityRaces validates races for a collection of entities +func (rm *RaceManager) ValidateEntityRaces(entities []RaceAware) map[string]interface{} { + validationResults := make(map[string]interface{}) + + validCount := 0 + invalidCount := 0 + raceDistribution := make(map[int8]int) + + for i, entity := range entities { + raceID := entity.GetRace() + isValid := rm.races.IsValidRaceID(raceID) + + if isValid { + validCount++ + raceDistribution[raceID]++ + } else { + invalidCount++ + } + + // Track invalid entities + if !isValid { + if validationResults["invalid_entities"] == nil { + validationResults["invalid_entities"] = make([]map[string]interface{}, 0) + } + + invalidList := validationResults["invalid_entities"].([]map[string]interface{}) + invalidList = append(invalidList, map[string]interface{}{ + "index": i, + "race_id": raceID, + }) + validationResults["invalid_entities"] = invalidList + } + } + + validationResults["total_entities"] = len(entities) + validationResults["valid_count"] = validCount + validationResults["invalid_count"] = invalidCount + validationResults["race_distribution"] = raceDistribution + + return validationResults +} + +// GetRaceRecommendations returns race recommendations for character creation +func (rm *RaceManager) GetRaceRecommendations(preferences map[string]interface{}) []int8 { + recommendations := make([]int8, 0) + + // Check for alignment preference + if alignment, exists := preferences["alignment"]; exists { + if alignmentStr, ok := alignment.(string); ok { + raceIDs := rm.races.GetRacesByAlignment(alignmentStr) + recommendations = append(recommendations, raceIDs...) + } + } + + // Check for specific stat preferences + if preferredStats, exists := preferences["preferred_stats"]; exists { + if stats, ok := preferredStats.([]string); ok { + allRaces := rm.races.GetAllRaces() + + for raceID := range allRaces { + modifiers := rm.utils.GetRaceStatModifiers(raceID) + + // Check if this race has bonuses in preferred stats + hasPreferredBonus := false + for _, preferredStat := range stats { + if modifier, exists := modifiers[preferredStat]; exists && modifier > 0 { + hasPreferredBonus = true + break + } + } + + if hasPreferredBonus { + recommendations = append(recommendations, raceID) + } + } + } + } + + // If no specific preferences, recommend popular races + if len(recommendations) == 0 { + // Get usage stats and recommend most popular races + usageStats := rm.GetRaceUsageStats() + if len(usageStats) > 0 { + // Sort by usage and take top races + // For simplicity, just return all races with usage > 0 + for raceID, usage := range usageStats { + if usage > 0 { + recommendations = append(recommendations, raceID) + } + } + } + + // If still no recommendations, return a default set + if len(recommendations) == 0 { + recommendations = []int8{RaceHuman, RaceHighElf, RaceDwarf, RaceDarkElf} + } + } + + return recommendations +} + +// Global race manager instance +var globalRaceManager *RaceManager +var initRaceManagerOnce sync.Once + +// GetGlobalRaceManager returns the global race manager (singleton) +func GetGlobalRaceManager() *RaceManager { + initRaceManagerOnce.Do(func() { + globalRaceManager = NewRaceManager() + }) + return globalRaceManager +} \ No newline at end of file diff --git a/internal/races/races.go b/internal/races/races.go new file mode 100644 index 0000000..6481b31 --- /dev/null +++ b/internal/races/races.go @@ -0,0 +1,387 @@ +package races + +import ( + "math/rand" + "strings" + "sync" +) + +// Races manages race information and lookups +// Converted from C++ Races class +type Races struct { + // Race name to ID mapping (uppercase keys) + raceMap map[string]int8 + + // ID to friendly name mapping + friendlyNameMap map[int8]string + + // Alignment-based race lists for randomization + goodRaces []string + evilRaces []string + + // Thread safety + mutex sync.RWMutex +} + +// NewRaces creates a new races manager with all EQ2 races +// Converted from C++ Races::Races constructor +func NewRaces() *Races { + races := &Races{ + raceMap: make(map[string]int8), + friendlyNameMap: make(map[int8]string), + goodRaces: make([]string, 0), + evilRaces: make([]string, 0), + } + + races.initializeRaces() + return races +} + +// initializeRaces sets up all race mappings +func (r *Races) initializeRaces() { + // Initialize race name to ID mappings (from C++ constructor) + r.raceMap[RaceNameBarbarian] = RaceBarbarian + r.raceMap[RaceNameDarkElf] = RaceDarkElf + r.raceMap[RaceNameDwarf] = RaceDwarf + r.raceMap[RaceNameErudite] = RaceErudite + r.raceMap[RaceNameFroglok] = RaceFroglok + r.raceMap[RaceNameGnome] = RaceGnome + r.raceMap[RaceNameHalfElf] = RaceHalfElf + r.raceMap[RaceNameHalfling] = RaceHalfling + r.raceMap[RaceNameHighElf] = RaceHighElf + r.raceMap[RaceNameHuman] = RaceHuman + r.raceMap[RaceNameIksar] = RaceIksar + r.raceMap[RaceNameKerra] = RaceKerra + r.raceMap[RaceNameOgre] = RaceOgre + r.raceMap[RaceNameRatonga] = RaceRatonga + r.raceMap[RaceNameTroll] = RaceTroll + r.raceMap[RaceNameWoodElf] = RaceWoodElf + r.raceMap[RaceNameFaeLight] = RaceFae + r.raceMap[RaceNameFaeDark] = RaceArasai + r.raceMap[RaceNameSarnak] = RaceSarnak + r.raceMap[RaceNameVampire] = RaceVampire + r.raceMap[RaceNameAerakyn] = RaceAerakyn + + // Initialize friendly display names (from C++ constructor) + r.friendlyNameMap[RaceBarbarian] = DisplayNameBarbarian + r.friendlyNameMap[RaceDarkElf] = DisplayNameDarkElf + r.friendlyNameMap[RaceDwarf] = DisplayNameDwarf + r.friendlyNameMap[RaceErudite] = DisplayNameErudite + r.friendlyNameMap[RaceFroglok] = DisplayNameFroglok + r.friendlyNameMap[RaceGnome] = DisplayNameGnome + r.friendlyNameMap[RaceHalfElf] = DisplayNameHalfElf + r.friendlyNameMap[RaceHalfling] = DisplayNameHalfling + r.friendlyNameMap[RaceHighElf] = DisplayNameHighElf + r.friendlyNameMap[RaceHuman] = DisplayNameHuman + r.friendlyNameMap[RaceIksar] = DisplayNameIksar + r.friendlyNameMap[RaceKerra] = DisplayNameKerra + r.friendlyNameMap[RaceOgre] = DisplayNameOgre + r.friendlyNameMap[RaceRatonga] = DisplayNameRatonga + r.friendlyNameMap[RaceTroll] = DisplayNameTroll + r.friendlyNameMap[RaceWoodElf] = DisplayNameWoodElf + r.friendlyNameMap[RaceFae] = DisplayNameFae + r.friendlyNameMap[RaceArasai] = DisplayNameArasai + r.friendlyNameMap[RaceSarnak] = DisplayNameSarnak + r.friendlyNameMap[RaceVampire] = DisplayNameVampire + r.friendlyNameMap[RaceAerakyn] = DisplayNameAerakyn + + // Initialize good races (from C++ race_map_good) + // "Neutral" races appear in both lists for /randomize functionality + r.goodRaces = []string{ + RaceNameDwarf, // 0 + RaceNameFaeLight, // 1 + RaceNameFroglok, // 2 + RaceNameHalfling, // 3 + RaceNameHighElf, // 4 + RaceNameWoodElf, // 5 + RaceNameBarbarian, // 6 (neutral) + RaceNameErudite, // 7 (neutral) + RaceNameGnome, // 8 (neutral) + RaceNameHalfElf, // 9 (neutral) + RaceNameHuman, // 10 (neutral) + RaceNameKerra, // 11 (neutral) + RaceNameVampire, // 12 (neutral) + RaceNameAerakyn, // 13 (neutral) + } + + // Initialize evil races (from C++ race_map_evil) + r.evilRaces = []string{ + RaceNameFaeDark, // 0 + RaceNameDarkElf, // 1 + RaceNameIksar, // 2 + RaceNameOgre, // 3 + RaceNameRatonga, // 4 + RaceNameSarnak, // 5 + RaceNameTroll, // 6 + RaceNameBarbarian, // 7 (neutral) + RaceNameErudite, // 8 (neutral) + RaceNameGnome, // 9 (neutral) + RaceNameHalfElf, // 10 (neutral) + RaceNameHuman, // 11 (neutral) + RaceNameKerra, // 12 (neutral) + RaceNameVampire, // 13 (neutral) + RaceNameAerakyn, // 14 (neutral) + } +} + +// GetRaceID returns the race ID for a given race name +// Converted from C++ Races::GetRaceID +func (r *Races) GetRaceID(name string) int8 { + r.mutex.RLock() + defer r.mutex.RUnlock() + + raceName := strings.ToUpper(strings.TrimSpace(name)) + if raceID, exists := r.raceMap[raceName]; exists { + return raceID + } + + return -1 // Invalid race +} + +// GetRaceName returns the uppercase race name for a given ID +// Converted from C++ Races::GetRaceName +func (r *Races) GetRaceName(raceID int8) string { + r.mutex.RLock() + defer r.mutex.RUnlock() + + // Search through race map to find the name + for name, id := range r.raceMap { + if id == raceID { + return name + } + } + + return "" // Invalid race ID +} + +// GetRaceNameCase returns the friendly display name for a given race ID +// Converted from C++ Races::GetRaceNameCase +func (r *Races) GetRaceNameCase(raceID int8) string { + r.mutex.RLock() + defer r.mutex.RUnlock() + + if friendlyName, exists := r.friendlyNameMap[raceID]; exists { + return friendlyName + } + + return "" // Invalid race ID +} + +// GetRandomGoodRace returns a random good-aligned race ID +// Converted from C++ Races::GetRaceNameGood +func (r *Races) GetRandomGoodRace() int8 { + r.mutex.RLock() + defer r.mutex.RUnlock() + + if len(r.goodRaces) == 0 { + return DefaultRaceID + } + + randomIndex := rand.Intn(len(r.goodRaces)) + raceName := r.goodRaces[randomIndex] + + if raceID, exists := r.raceMap[raceName]; exists { + return raceID + } + + return DefaultRaceID // Default to Human if error +} + +// GetRandomEvilRace returns a random evil-aligned race ID +// Converted from C++ Races::GetRaceNameEvil +func (r *Races) GetRandomEvilRace() int8 { + r.mutex.RLock() + defer r.mutex.RUnlock() + + if len(r.evilRaces) == 0 { + return DefaultRaceID + } + + randomIndex := rand.Intn(len(r.evilRaces)) + raceName := r.evilRaces[randomIndex] + + if raceID, exists := r.raceMap[raceName]; exists { + return raceID + } + + return DefaultRaceID // Default to Human if error +} + +// IsValidRaceID checks if a race ID is valid +func (r *Races) IsValidRaceID(raceID int8) bool { + return raceID >= MinRaceID && raceID <= MaxRaceID +} + +// GetAllRaces returns all race IDs and their friendly names +func (r *Races) GetAllRaces() map[int8]string { + r.mutex.RLock() + defer r.mutex.RUnlock() + + result := make(map[int8]string) + for raceID, friendlyName := range r.friendlyNameMap { + result[raceID] = friendlyName + } + + return result +} + +// GetRacesByAlignment returns races filtered by alignment +func (r *Races) GetRacesByAlignment(alignment string) []int8 { + r.mutex.RLock() + defer r.mutex.RUnlock() + + var raceNames []string + + switch strings.ToLower(alignment) { + case AlignmentGood: + raceNames = r.goodRaces + case AlignmentEvil: + raceNames = r.evilRaces + default: + // Return all races for neutral or unknown alignment + result := make([]int8, 0, len(r.friendlyNameMap)) + for raceID := range r.friendlyNameMap { + result = append(result, raceID) + } + return result + } + + result := make([]int8, 0, len(raceNames)) + for _, raceName := range raceNames { + if raceID, exists := r.raceMap[raceName]; exists { + result = append(result, raceID) + } + } + + return result +} + +// IsGoodRace checks if a race is considered good-aligned +func (r *Races) IsGoodRace(raceID int8) bool { + r.mutex.RLock() + defer r.mutex.RUnlock() + + raceName := "" + for name, id := range r.raceMap { + if id == raceID { + raceName = name + break + } + } + + if raceName == "" { + return false + } + + for _, goodRace := range r.goodRaces { + if goodRace == raceName { + return true + } + } + + return false +} + +// IsEvilRace checks if a race is considered evil-aligned +func (r *Races) IsEvilRace(raceID int8) bool { + r.mutex.RLock() + defer r.mutex.RUnlock() + + raceName := "" + for name, id := range r.raceMap { + if id == raceID { + raceName = name + break + } + } + + if raceName == "" { + return false + } + + for _, evilRace := range r.evilRaces { + if evilRace == raceName { + return true + } + } + + return false +} + +// IsNeutralRace checks if a race appears in both good and evil lists (neutral) +func (r *Races) IsNeutralRace(raceID int8) bool { + return r.IsGoodRace(raceID) && r.IsEvilRace(raceID) +} + +// GetRaceAlignment returns the primary alignment of a race +func (r *Races) GetRaceAlignment(raceID int8) string { + if r.IsNeutralRace(raceID) { + return AlignmentNeutral + } else if r.IsGoodRace(raceID) { + return AlignmentGood + } else if r.IsEvilRace(raceID) { + return AlignmentEvil + } + + return AlignmentNeutral // Default for invalid races +} + +// GetRaceCount returns the total number of races +func (r *Races) GetRaceCount() int { + r.mutex.RLock() + defer r.mutex.RUnlock() + + return len(r.friendlyNameMap) +} + +// GetGoodRaceCount returns the number of good-aligned races +func (r *Races) GetGoodRaceCount() int { + r.mutex.RLock() + defer r.mutex.RUnlock() + + return len(r.goodRaces) +} + +// GetEvilRaceCount returns the number of evil-aligned races +func (r *Races) GetEvilRaceCount() int { + r.mutex.RLock() + defer r.mutex.RUnlock() + + return len(r.evilRaces) +} + +// GetRaceInfo returns comprehensive information about a race +func (r *Races) GetRaceInfo(raceID int8) map[string]interface{} { + r.mutex.RLock() + defer r.mutex.RUnlock() + + info := make(map[string]interface{}) + + if !r.IsValidRaceID(raceID) { + info["valid"] = false + return info + } + + info["valid"] = true + info["race_id"] = raceID + info["name"] = r.GetRaceName(raceID) + info["display_name"] = r.GetRaceNameCase(raceID) + info["alignment"] = r.GetRaceAlignment(raceID) + info["is_good"] = r.IsGoodRace(raceID) + info["is_evil"] = r.IsEvilRace(raceID) + info["is_neutral"] = r.IsNeutralRace(raceID) + + return info +} + +// Global races instance +var globalRaces *Races +var initRacesOnce sync.Once + +// GetGlobalRaces returns the global races manager (singleton) +func GetGlobalRaces() *Races { + initRacesOnce.Do(func() { + globalRaces = NewRaces() + }) + return globalRaces +} \ No newline at end of file diff --git a/internal/races/utils.go b/internal/races/utils.go new file mode 100644 index 0000000..f80d323 --- /dev/null +++ b/internal/races/utils.go @@ -0,0 +1,351 @@ +package races + +import ( + "fmt" + "math/rand" + "strings" +) + +// RaceUtils provides utility functions for race operations +type RaceUtils struct { + races *Races +} + +// NewRaceUtils creates a new race utilities instance +func NewRaceUtils() *RaceUtils { + return &RaceUtils{ + races: GetGlobalRaces(), + } +} + +// ParseRaceName attempts to parse a race name from various input formats +func (ru *RaceUtils) ParseRaceName(input string) int8 { + if input == "" { + return -1 + } + + // Try direct lookup first + raceID := ru.races.GetRaceID(input) + if raceID != -1 { + return raceID + } + + // Try with common variations + variations := []string{ + strings.ToUpper(input), + strings.ReplaceAll(strings.ToUpper(input), " ", ""), + strings.ReplaceAll(strings.ToUpper(input), "_", ""), + strings.ReplaceAll(strings.ToUpper(input), "-", ""), + } + + for _, variation := range variations { + if raceID := ru.races.GetRaceID(variation); raceID != -1 { + return raceID + } + } + + // Try matching against friendly names (case insensitive) + inputLower := strings.ToLower(input) + allRaces := ru.races.GetAllRaces() + for raceID, friendlyName := range allRaces { + if strings.ToLower(friendlyName) == inputLower { + return raceID + } + } + + return -1 // Not found +} + +// FormatRaceName returns a properly formatted race name +func (ru *RaceUtils) FormatRaceName(raceID int8, format string) string { + switch strings.ToLower(format) { + case "display", "friendly", "proper": + return ru.races.GetRaceNameCase(raceID) + case "upper", "uppercase": + return ru.races.GetRaceName(raceID) + case "lower", "lowercase": + return strings.ToLower(ru.races.GetRaceName(raceID)) + default: + return ru.races.GetRaceNameCase(raceID) // Default to friendly name + } +} + +// GetRandomRaceByAlignment returns a random race for the specified alignment +func (ru *RaceUtils) GetRandomRaceByAlignment(alignment string) int8 { + switch strings.ToLower(alignment) { + case AlignmentGood: + return ru.races.GetRandomGoodRace() + case AlignmentEvil: + return ru.races.GetRandomEvilRace() + default: + // For neutral or any other alignment, pick from all races + allRaces := ru.races.GetAllRaces() + if len(allRaces) == 0 { + return DefaultRaceID + } + + // Convert map to slice for random selection + raceIDs := make([]int8, 0, len(allRaces)) + for raceID := range allRaces { + raceIDs = append(raceIDs, raceID) + } + + return raceIDs[rand.Intn(len(raceIDs))] + } +} + +// ValidateRaceForClass checks if a race is valid for a specific class +// This is a placeholder for future class-race restrictions +func (ru *RaceUtils) ValidateRaceForClass(raceID, classID int8) bool { + // TODO: Implement class-race restrictions when class system is available + // For now, all races can be all classes + return ru.races.IsValidRaceID(raceID) +} + +// GetRaceDescription returns a description of the race +func (ru *RaceUtils) GetRaceDescription(raceID int8) string { + // This would typically come from a database or configuration + // For now, provide basic descriptions based on race + + switch raceID { + case RaceHuman: + return "Versatile and adaptable, humans are found throughout Norrath." + case RaceBarbarian: + return "Hardy warriors from the frozen lands of Everfrost." + case RaceDarkElf: + return "Cunning and magical, the dark elves hail from Neriak." + case RaceDwarf: + return "Stout and strong, dwarves are master craftsmen and fighters." + case RaceErudite: + return "Intelligent and scholarly, erudites value knowledge above all." + case RaceFroglok: + return "Honorable amphibians who have overcome great adversity." + case RaceGnome: + return "Small but ingenious, gnomes are masters of tinkering and magic." + case RaceHalfElf: + return "Caught between two worlds, half elves combine human adaptability with elven grace." + case RaceHalfling: + return "Small and nimble, halflings are natural rogues and adventurers." + case RaceHighElf: + return "Noble and proud, high elves are paragons of magical prowess." + case RaceIksar: + return "Ancient lizardfolk with a proud warrior tradition." + case RaceKerra: + return "Feline humanoids known for their agility and curiosity." + case RaceOgre: + return "Massive and powerful, ogres are fearsome warriors." + case RaceRatonga: + return "Clever rodent-folk who excel at stealth and cunning." + case RaceTroll: + return "Large and brutish, trolls possess incredible strength and regeneration." + case RaceWoodElf: + return "Forest dwellers with unmatched skill in archery and nature magic." + case RaceFae: + return "Magical fairy-folk with wings and a connection to nature." + case RaceArasai: + return "Dark counterparts to the Fae, corrupted by shadow magic." + case RaceSarnak: + return "Draconic humanoids with scales and ancient wisdom." + case RaceVampire: + return "Undead beings with supernatural powers and bloodthirst." + case RaceAerakyn: + return "Dragon-blooded humanoids with draconic heritage and flight." + default: + return "An unknown race with mysterious origins." + } +} + +// GetRaceStatModifiers returns racial stat modifiers for character creation +// This is a placeholder for future stat system integration +func (ru *RaceUtils) GetRaceStatModifiers(raceID int8) map[string]int8 { + // TODO: Implement racial stat modifiers when stat system is available + // This would typically come from database or configuration files + + modifiers := make(map[string]int8) + + // Example modifiers (these would need to be balanced and come from data) + switch raceID { + case RaceBarbarian: + modifiers["strength"] = 2 + modifiers["stamina"] = 1 + modifiers["intelligence"] = -1 + case RaceDarkElf: + modifiers["intelligence"] = 2 + modifiers["agility"] = 1 + modifiers["wisdom"] = -1 + case RaceDwarf: + modifiers["stamina"] = 2 + modifiers["strength"] = 1 + modifiers["agility"] = -1 + case RaceErudite: + modifiers["intelligence"] = 3 + modifiers["wisdom"] = 1 + modifiers["strength"] = -2 + case RaceGnome: + modifiers["intelligence"] = 2 + modifiers["agility"] = 1 + modifiers["strength"] = -2 + case RaceHighElf: + modifiers["intelligence"] = 2 + modifiers["wisdom"] = 1 + modifiers["stamina"] = -1 + case RaceOgre: + modifiers["strength"] = 3 + modifiers["stamina"] = 2 + modifiers["intelligence"] = -2 + modifiers["agility"] = -1 + case RaceTroll: + modifiers["strength"] = 2 + modifiers["stamina"] = 3 + modifiers["intelligence"] = -2 + modifiers["wisdom"] = -1 + default: + // Humans and other races have no modifiers (balanced) + break + } + + return modifiers +} + +// GetRaceStartingLocation returns the starting city/zone for a race +func (ru *RaceUtils) GetRaceStartingLocation(raceID int8) string { + // TODO: This would typically come from database configuration + switch raceID { + case RaceHuman, RaceBarbarian, RaceErudite, RaceKerra: + return "Qeynos" + case RaceDarkElf, RaceOgre, RaceRatonga, RaceTroll: + return "Freeport" + case RaceDwarf, RaceGnome, RaceHalfling: + return "New Halas" + case RaceHighElf, RaceWoodElf, RaceHalfElf: + return "Kelethin" + case RaceIksar, RaceSarnak: + return "Gorowyn" + case RaceFroglok: + return "Temple of Cazic-Thule" + case RaceFae: + return "Greater Faydark" + case RaceArasai: + return "Darklight Wood" + case RaceVampire: + return "Neriak" + case RaceAerakyn: + return "Draconic Starting Area" + default: + return "Qeynos" // Default starting location + } +} + +// GetCompatibleRaces returns races that can interact peacefully +func (ru *RaceUtils) GetCompatibleRaces(raceID int8) []int8 { + // This is based on lore and alignment - races of similar alignment are generally compatible + alignment := ru.races.GetRaceAlignment(raceID) + return ru.races.GetRacesByAlignment(alignment) +} + +// FormatRaceList returns a formatted string of race names +func (ru *RaceUtils) FormatRaceList(raceIDs []int8, separator string) string { + if len(raceIDs) == 0 { + return "" + } + + names := make([]string, len(raceIDs)) + for i, raceID := range raceIDs { + names[i] = ru.races.GetRaceNameCase(raceID) + } + + return strings.Join(names, separator) +} + +// GetRacesByPattern returns races matching a name pattern +func (ru *RaceUtils) GetRacesByPattern(pattern string) []int8 { + pattern = strings.ToLower(pattern) + result := make([]int8, 0) + + allRaces := ru.races.GetAllRaces() + for raceID, friendlyName := range allRaces { + if strings.Contains(strings.ToLower(friendlyName), pattern) { + result = append(result, raceID) + } + } + + return result +} + +// ValidateRaceTransition checks if a race change is allowed +func (ru *RaceUtils) ValidateRaceTransition(fromRaceID, toRaceID int8) (bool, string) { + if !ru.races.IsValidRaceID(fromRaceID) { + return false, "Invalid source race" + } + + if !ru.races.IsValidRaceID(toRaceID) { + return false, "Invalid target race" + } + + if fromRaceID == toRaceID { + return false, "Cannot change to the same race" + } + + // TODO: Implement specific race change restrictions when needed + // For now, allow all transitions + return true, "" +} + +// GetRaceAliases returns common aliases for a race +func (ru *RaceUtils) GetRaceAliases(raceID int8) []string { + aliases := make([]string, 0) + + switch raceID { + case RaceDarkElf: + aliases = append(aliases, "DE", "Dark Elf", "Teir'Dal") + case RaceHighElf: + aliases = append(aliases, "HE", "High Elf", "Koada'Dal") + case RaceWoodElf: + aliases = append(aliases, "WE", "Wood Elf", "Feir'Dal") + case RaceHalfElf: + aliases = append(aliases, "Half-Elf", "Half Elf", "Ayr'Dal") + case RaceFae: + aliases = append(aliases, "Fairy", "Pixie") + case RaceArasai: + aliases = append(aliases, "Dark Fae", "Shadow Fae") + case RaceVampire: + aliases = append(aliases, "Vamp", "Undead") + case RaceAerakyn: + aliases = append(aliases, "Dragon-kin", "Dragonborn") + } + + // Always include the official names + aliases = append(aliases, ru.races.GetRaceName(raceID)) + aliases = append(aliases, ru.races.GetRaceNameCase(raceID)) + + return aliases +} + +// GetRaceStatistics returns statistics about the race system +func (ru *RaceUtils) GetRaceStatistics() map[string]interface{} { + stats := make(map[string]interface{}) + + stats["total_races"] = ru.races.GetRaceCount() + stats["good_races"] = ru.races.GetGoodRaceCount() + stats["evil_races"] = ru.races.GetEvilRaceCount() + + // Count neutral races (appear in both lists) + neutralCount := 0 + allRaces := ru.races.GetAllRaces() + for raceID := range allRaces { + if ru.races.IsNeutralRace(raceID) { + neutralCount++ + } + } + stats["neutral_races"] = neutralCount + + // Race distribution by alignment + alignmentDistribution := make(map[string][]string) + for raceID, friendlyName := range allRaces { + alignment := ru.races.GetRaceAlignment(raceID) + alignmentDistribution[alignment] = append(alignmentDistribution[alignment], friendlyName) + } + stats["alignment_distribution"] = alignmentDistribution + + return stats +} \ No newline at end of file diff --git a/internal/spawn/README.md b/internal/spawn/README.md new file mode 100644 index 0000000..118bcf2 --- /dev/null +++ b/internal/spawn/README.md @@ -0,0 +1,166 @@ +# Spawn System + +This package implements the EverQuest II spawn system, converted from the original C++ codebase to Go. + +## Overview + +The spawn system manages all entities that can appear in the game world, including NPCs, objects, widgets, signs, and ground spawns. It handles their positioning, movement, combat states, and interactions. + +## Key Components + +### Spawn (`spawn.go`) + +The `Spawn` struct is the base class for all entities in the game world. It provides: + +- **Basic Properties**: ID, name, level, position, heading +- **State Management**: Health, power, alive status, combat state +- **Movement System**: Position tracking, movement queues, pathfinding +- **Command System**: Interactive commands players can use +- **Loot System**: Item drops, coin rewards, loot distribution +- **Scripting Integration**: Lua script support for dynamic behavior +- **Thread Safety**: Atomic operations and mutexes for concurrent access + +Key features: +- Thread-safe operations using atomic values and mutexes +- Extensible design allowing subclasses (NPC, Player, Object, etc.) +- Comprehensive state tracking with change notifications +- Support for temporary variables for Lua scripting +- Equipment and appearance management +- Group and faction relationships + +### Spawn Lists (`spawn_lists.go`) + +Manages spawn locations and spawn entries: + +- **SpawnEntry**: Defines what can spawn at a location with configuration +- **SpawnLocation**: Represents a point in the world where spawns appear +- **SpawnLocationManager**: Manages collections of spawn locations + +Features: +- Randomized spawn selection based on percentages +- Position offsets for spawn variety +- Respawn timing with random offsets +- Stat overrides for spawn customization +- Grid-based location management + +## Architecture Notes + +### Thread Safety + +The spawn system is designed for high-concurrency access: +- Atomic values for frequently-accessed state flags +- Read-write mutexes for complex data structures +- Separate mutexes for different subsystems to minimize contention + +### Memory Management + +Go's garbage collector handles memory management, but the system includes: +- Proper cleanup methods for complex structures +- Resource pooling where appropriate +- Efficient data structures to minimize allocations + +### Extensibility + +The base Spawn struct is designed to be extended: +- Virtual method patterns using interfaces +- Type checking methods (IsNPC, IsPlayer, etc.) +- Extensible command and scripting systems + +## TODO Items + +Many features are marked with TODO comments for future implementation: + +### High Priority +- **Zone System Integration**: Zone server references and notifications +- **Client System**: Player client connections and packet handling +- **Item System**: Complete item and equipment implementation +- **Combat System**: Damage calculation and combat mechanics + +### Medium Priority +- **Lua Scripting**: Full Lua integration for spawn behavior +- **Movement System**: Pathfinding and advanced movement +- **Quest System**: Quest requirement checking and progression +- **Map System**: Collision detection and height maps + +### Low Priority +- **Region System**: Area-based effects and triggers +- **Housing System**: Player housing integration +- **Transportation**: Mounts and vehicles +- **Advanced Physics**: Knockback and projectile systems + +## Usage Examples + +### Creating a Basic Spawn + +```go +spawn := NewSpawn() +spawn.SetName("a goblin warrior") +spawn.SetLevel(10) +spawn.SetX(100.0) +spawn.SetY(0.0) +spawn.SetZ(200.0) +spawn.SetTotalHP(500) +spawn.SetHP(500) +``` + +### Managing Spawn Locations + +```go +manager := NewSpawnLocationManager() + +location := NewSpawnLocation() +location.SetPosition(100.0, 0.0, 200.0) +location.SetOffsets(5.0, 0.0, 5.0) // 5 unit random offset + +entry := NewSpawnEntry() +entry.SpawnID = 12345 +entry.SpawnPercentage = 75.0 +entry.Respawn = 300 // 5 minutes + +location.AddSpawnEntry(entry) +manager.AddLocation(1001, location) +``` + +### Adding Commands + +```go +spawn.AddPrimaryEntityCommand( + "hail", // command name + 10.0, // max distance + "hail_npc", // internal command + "Too far away", // error message + 0, // cast time + 0, // spell visual + true, // default allow +) +``` + +## Integration Points + +The spawn system integrates with several other systems: + +- **Database**: Loading and saving spawn data +- **Network**: Serializing spawn data for clients +- **Zone**: Spawn management within zones +- **Combat**: Damage and death handling +- **Scripting**: Lua event handling +- **Items**: Equipment and loot management + +## Performance Considerations + +- Position updates use atomic operations to minimize locking +- Command lists are copied on access to prevent race conditions +- Spawn changes are batched and sent efficiently to clients +- Memory usage is optimized for large numbers of concurrent spawns + +## Migration from C++ + +This Go implementation maintains compatibility with the original C++ EQ2EMu spawn system while modernizing the architecture: + +- Converted C-style arrays to Go slices +- Replaced manual memory management with garbage collection +- Used Go's concurrency primitives instead of platform-specific threading +- Maintained the same packet structures and network protocol +- Preserved the same database schema and data relationships + +The API surface remains similar to ease porting of existing scripts and configurations. \ No newline at end of file diff --git a/internal/spawn/spawn.go b/internal/spawn/spawn.go new file mode 100644 index 0000000..747be14 --- /dev/null +++ b/internal/spawn/spawn.go @@ -0,0 +1,1156 @@ +package spawn + +import ( + "math" + "sync" + "sync/atomic" + "time" + + "eq2emu/internal/common" +) + +// Damage packet constants +const ( + DamagePacketTypeSiphonSpell = 0x41 + DamagePacketTypeSiphonSpell2 = 0x49 + DamagePacketTypeMultipleDamage = 0x80 + DamagePacketTypeSimpleDamage = 0xC0 + DamagePacketTypeSpellDamage = 0xC1 + DamagePacketTypeSimpleCritDmg = 0xC4 + DamagePacketTypeSpellCritDmg = 0xC5 + DamagePacketTypeSpellDamage2 = 0xC8 + DamagePacketTypeSpellDamage3 = 0xC9 + DamagePacketTypeRangeDamage = 0xE2 + DamagePacketTypeRangeSpellDmg = 0xE3 + DamagePacketTypeRangeSpellDmg2 = 0xEA +) + +// Damage packet results +const ( + DamagePacketResultNoDamage = 0 + DamagePacketResultSuccessful = 1 + DamagePacketResultMiss = 4 + DamagePacketResultDodge = 8 + DamagePacketResultParry = 12 + DamagePacketResultRiposte = 16 + DamagePacketResultBlock = 20 + DamagePacketResultDeathBlow = 24 + DamagePacketResultInvulnerable = 28 + DamagePacketResultResist = 36 + DamagePacketResultReflect = 40 + DamagePacketResultImmune = 44 + DamagePacketResultDeflect = 48 + DamagePacketResultCounter = 52 + DamagePacketResultFocus = 56 + DamagePacketResultCounterStrike = 60 + DamagePacketResultBash = 64 +) + +// Damage types +const ( + DamagePacketDamageTypeSlash = 0 + DamagePacketDamageTypeCrush = 1 + DamagePacketDamageTypePierce = 2 + DamagePacketDamageTypeHeat = 3 + DamagePacketDamageTypeCold = 4 + DamagePacketDamageTypeMagic = 5 + DamagePacketDamageTypeMental = 6 + DamagePacketDamageTypeDivine = 7 + DamagePacketDamageTypeDisease = 8 + DamagePacketDamageTypePoison = 9 + DamagePacketDamageTypeDrown = 10 + DamagePacketDamageTypeFalling = 11 + DamagePacketDamageTypePain = 12 + DamagePacketDamageTypeHit = 13 + DamagePacketDamageTypeFocus = 14 +) + +// Activity status flags +const ( + ActivityStatusRoleplaying = 1 + ActivityStatusAnonymous = 2 + ActivityStatusLinkdead = 4 + ActivityStatusCamping = 8 + ActivityStatusLFG = 16 + ActivityStatusLFW = 32 + ActivityStatusSolid = 64 + ActivityStatusImmunityGained = 8192 + ActivityStatusImmunityRemaining = 16384 + ActivityStatusAFK = 32768 +) + +// Position states +const ( + PosStateKneeling = 64 + PosStateSolid = 128 + PosStateNotargetCursor = 256 + PosStateCrouching = 512 +) + +// Encounter states +const ( + EncounterStateNone = 0 + EncounterStateAvailable = 1 + EncounterStateBroken = 2 + EncounterStateLocked = 3 + EncounterStateOvermatched = 4 + EncounterStateNoReward = 5 +) + +// Loot methods and modes +type GroupLootMethod int + +const ( + MethodLeader GroupLootMethod = 0 + MethodFFA GroupLootMethod = 1 + MethodLotto GroupLootMethod = 2 + MethodNeedBeforeGreed GroupLootMethod = 3 + MethodRoundRobin GroupLootMethod = 4 +) + +type AutoLootMode int + +const ( + AutoLootDisabled = 0 + AutoLootAccept = 1 + AutoLootDecline = 2 +) + +type LootTier int + +const ( + ItemsAll LootTier = 0 + ItemsTreasuredPlus LootTier = 1 + ItemsLegendaryPlus LootTier = 2 + ItemsFabledPlus LootTier = 3 +) + +// SpawnProximityType defines types of spawn proximity tracking +type SpawnProximityType int + +const ( + SpawnProximityDatabaseID = 0 + SpawnProximityLocationID = 1 +) + +// BasicInfoStruct contains basic spawn statistics +type BasicInfoStruct struct { + CurHP int32 + MaxHP int32 + HPBase int32 + HPBaseInstance int32 + CurPower int32 + MaxPower int32 + PowerBase int32 + PowerBaseInstance int32 + CurSavagery int32 + MaxSavagery int32 + SavageryBase int32 + CurDissonance int32 + MaxDissonance int32 + DissonanceBase int32 + AssignedAA int16 + UnassignedAA int16 + TradeskillAA int16 + UnassignedTradeskillAA int16 + PrestigeAA int16 + UnassignedPrestigeAA int16 + TradeskillPrestigeAA int16 + UnassignedTradeskillPrestigeAA int16 + AAXPRewards int32 +} + +// MovementLocation represents a point in a movement path +type MovementLocation struct { + X float32 + Y float32 + Z float32 + Speed float32 + Attackable bool + LuaFunction string + Mapped bool + GridID int32 + Stage int8 + ResetHPOnRunback bool + UseNavPath bool +} + +// MovementData contains movement configuration +type MovementData struct { + X float32 + Y float32 + Z float32 + Speed float32 + Delay int32 + LuaFunction string + Heading float32 + UseMovementLocationHeading bool + UseNavPath bool +} + +// SpawnUpdate tracks what aspects of a spawn need updating +type SpawnUpdate struct { + SpawnID int32 + InfoChanged bool + VisChanged bool + PosChanged bool + // TODO: Add Client reference when client system is implemented + // Client *Client +} + +// SpawnData contains spawn packet data +type SpawnData struct { + // TODO: Add Spawn reference when implemented + // Spawn *Spawn + Data []byte + Size int32 +} + +// TimedGridData tracks movement through grid system +type TimedGridData struct { + Timestamp int32 + GridID int32 + X float32 + Y float32 + Z float32 + OffsetY float32 + ZoneGroundY float32 + NPCSave bool + WidgetID int32 +} + +// EntityCommand represents an interactable command +type EntityCommand struct { + Name string + Distance float32 + Command string + ErrorText string + CastTime int16 + SpellVisual int32 + AllowOrDeny map[int32]bool // player IDs and whether they're allowed + DefaultAllowList bool // if false, it's a deny list +} + +// SpawnProximity tracks proximity-based events +type SpawnProximity struct { + X float32 + Y float32 + Z float32 + SpawnValue int32 + SpawnType int8 + Distance float32 + InRangeLuaFunction string + LeavingRangeLuaFunction string + SpawnsInProximity map[int32]bool +} + +var nextSpawnID int32 = 1 + +// NextID generates the next unique spawn ID +// Handles special cases like avoiding IDs ending in 255 and wraparound +func NextID() int32 { + for { + id := atomic.AddInt32(&nextSpawnID, 1) + + // Handle wraparound + if id == 0xFFFFFFFE { + atomic.StoreInt32(&nextSpawnID, 1) + continue + } + + // Avoid IDs ending in 255 to prevent client confusion/crashes + if (id-255)%256 == 0 { + continue + } + + return id + } +} + +// Spawn represents a game entity that can appear in the world +type Spawn struct { + // Basic identification + id int32 + databaseID int32 + + // Appearance and positioning + appearance common.AppearanceData + size int16 + sizeOffset int8 + + // State flags (using atomic for thread safety) + changed atomic.Bool + positionChanged atomic.Bool + infoChanged atomic.Bool + visChanged atomic.Bool + isRunning atomic.Bool + sizeChanged atomic.Bool + following atomic.Bool + isAlive atomic.Bool + deletedSpawn atomic.Bool + resetMovement atomic.Bool + knockedBack atomic.Bool + + // Game state + basicInfo BasicInfoStruct + factionID int32 + spawnType int8 + target int32 + lastAttacker int32 + + // Commands and interaction + primaryCommandListID int32 + secondaryCommandListID int32 + primaryCommandList []*EntityCommand + secondaryCommandList []*EntityCommand + + // Movement and positioning + lastMovementUpdate int32 + lastLocationUpdate int32 + lastGridUpdate int32 + forceMapCheck bool + movementLocations []*MovementLocation + movementLoop []*MovementData + movementIndex int16 + runningTo int32 + invulnerable bool + + // Group and spawn relationships + groupID int32 + spawnGroupList []*Spawn + + // Location and respawn data + spawnLocationID int32 + spawnEntryID int32 + spawnLocationSpawnsID int32 + respawn int32 + respawnOffsetLow int32 + respawnOffsetHigh int32 + duplicatedSpawn bool + expireTime int32 + expireOffset int32 + xOffset float32 + yOffset float32 + zOffset float32 + deviation int32 + + // Loot system + lootItems []*Item // TODO: Define Item type + lootCoins int32 + lootGroupID int32 + lootMethod GroupLootMethod + lootRarity int8 + looterSpawnID int32 + lootComplete map[int32]bool + isLootComplete bool + isLootDispensed bool + lootName string + lootTier int32 + lootDropType int32 + trapTriggered bool + trapState int32 + chestDropTime int32 + trapOpenedTime int32 + + // Merchant data + merchantID int32 + merchantType int8 + merchantMinLevel int32 + merchantMaxLevel int32 + isCollector bool + + // Transportation + transporterID int32 + isTransportSpawn bool + railID int64 + railPassengers map[int32]bool + + // Scripting and AI + spawnScript string + spawnScriptSetDB bool + questIDs []int32 + + // Quest and access requirements + hasQuestsRequired bool + hasHistoryRequired bool + reqQuestsPrivate bool + reqQuestsOverride int16 + reqQuestsContinuedAccess bool + requiredQuests map[int32][]int16 + // TODO: Add LUAHistory type + // requiredHistory map[int32]*LUAHistory + allowedAccess map[int32]int8 + + // Visual and display state + tmpVisualState int + tmpActionState int + illusionModel int16 + + // Items and pickups + pickupItemID int32 + pickupUniqueItemID int32 + houseCharacterID int32 + + // Proximity tracking + hasSpawnProximities bool + spawnProximities []*SpawnProximity + + // Creature flags + isFlyingCreature bool + isWaterCreature bool + isPet bool + scaredByStrongPlayers bool + + // Following system + followTarget int32 + followDistance int32 + + // Temporary variables (for Lua scripting) + tempVariableTypes map[string]int8 + tempVariables map[string]string + tempVariableSpawn map[string]int32 + // TODO: Add other temp variable types when systems are implemented + // tempVariableZone map[string]*ZoneServer + // tempVariableItem map[string]*Item + // tempVariableQuest map[string]*Quest + + // Animation and timing + spawnAnim int32 + addedToWorldTimestamp int32 + spawnAnimLeeway int16 + lastHeadingAngle float32 + + // Region and mapping + // TODO: Add RegionMap and Map types + // regionMap *RegionMap + // currentMap *Map + // regions map[map[*RegionNode]*ZBSPNode]*RegionStatus + establishedGridID map[int32]*TimedGridData + ignoredWidgets map[int32]bool + triggerWidgetID int32 + + // Equipment + // TODO: Add EquipmentItemList type + // equipmentList *EquipmentItemList + // appearanceEquipmentList *EquipmentItemList + + // Zone reference + // TODO: Add ZoneServer reference when implemented + // zone *ZoneServer + + // Knockback physics + knockedBackTimeStep float32 + knockedBackHDistance float32 + knockedBackVDistance float32 + knockedBackDuration float32 + knockedBackStartX float32 + knockedBackStartY float32 + knockedBackStartZ float32 + knockedBackEndTime int32 + knockedAngle float32 + // TODO: Add glm::vec3 equivalent + // knockedVelocity Vec3 + + // Database omission flag + isOmittedByDBFlag bool + + // Sound settings + disableSounds bool + + // Thread safety + updateMutex sync.RWMutex + spawnMutex sync.RWMutex + lootItemsMutex sync.RWMutex + commandMutex sync.Mutex + regionMutex sync.Mutex + railMutex sync.Mutex + gridMutex sync.Mutex + movementMutex sync.RWMutex + requiredQuestsMutex sync.RWMutex + requiredHistoryMutex sync.RWMutex + ignoredWidgetsMutex sync.RWMutex +} + +// NewSpawn creates a new spawn instance with default values +// Initializes all fields to appropriate defaults matching the C++ constructor +func NewSpawn() *Spawn { + s := &Spawn{ + id: NextID(), + size: 32, + sizeOffset: 0, + factionID: 0, + spawnType: 0, + target: 0, + lastAttacker: 0, + primaryCommandListID: 0, + secondaryCommandListID: 0, + primaryCommandList: make([]*EntityCommand, 0), + secondaryCommandList: make([]*EntityCommand, 0), + groupID: 0, + spawnLocationID: 0, + spawnEntryID: 0, + spawnLocationSpawnsID: 0, + respawn: 0, + respawnOffsetLow: 0, + respawnOffsetHigh: 0, + duplicatedSpawn: true, + expireTime: 0, + expireOffset: 0, + xOffset: 0, + yOffset: 0, + zOffset: 0, + deviation: 0, + lootItems: make([]*Item, 0), + lootCoins: 0, + lootGroupID: 0, + lootMethod: MethodFFA, + lootRarity: 0, + looterSpawnID: 0, + lootComplete: make(map[int32]bool), + isLootComplete: false, + isLootDispensed: false, + lootTier: 0, + lootDropType: 0, + trapTriggered: false, + trapState: 0, + chestDropTime: 0, + trapOpenedTime: 0, + merchantID: 0, + merchantType: 0, + merchantMinLevel: 0, + merchantMaxLevel: 0, + isCollector: false, + transporterID: 0, + isTransportSpawn: false, + railID: 0, + railPassengers: make(map[int32]bool), + questIDs: make([]int32, 0), + hasQuestsRequired: false, + hasHistoryRequired: false, + reqQuestsPrivate: false, + reqQuestsOverride: 0, + reqQuestsContinuedAccess: false, + requiredQuests: make(map[int32][]int16), + allowedAccess: make(map[int32]int8), + tmpVisualState: -1, + tmpActionState: -1, + illusionModel: 0, + pickupItemID: 0, + pickupUniqueItemID: 0, + houseCharacterID: 0, + hasSpawnProximities: false, + spawnProximities: make([]*SpawnProximity, 0), + isFlyingCreature: false, + isWaterCreature: false, + isPet: false, + scaredByStrongPlayers: false, + followTarget: 0, + followDistance: 0, + tempVariableTypes: make(map[string]int8), + tempVariables: make(map[string]string), + tempVariableSpawn: make(map[string]int32), + spawnAnim: 0, + addedToWorldTimestamp: 0, + spawnAnimLeeway: 0, + lastHeadingAngle: 0.0, + lastMovementUpdate: int32(time.Now().Unix()), + lastLocationUpdate: 0, + lastGridUpdate: 0, + forceMapCheck: false, + movementLocations: make([]*MovementLocation, 0), + movementLoop: make([]*MovementData, 0), + movementIndex: 0, + runningTo: 0, + invulnerable: false, + spawnGroupList: make([]*Spawn, 0), + establishedGridID: make(map[int32]*TimedGridData), + ignoredWidgets: make(map[int32]bool), + triggerWidgetID: 0, + isOmittedByDBFlag: false, + disableSounds: false, + } + + // Initialize appearance with defaults + s.appearance.Pos.State = 0x4080 + s.appearance.Difficulty = 6 + s.appearance.Pos.CollisionRadius = 32 + s.appearance.Pos.Speed1 = 0 + + // Set alive state + s.isAlive.Store(true) + + // Initialize encounter state + s.SetLockedNoLoot(EncounterStateAvailable) + + return s +} + +// GetID returns the spawn's unique identifier +func (s *Spawn) GetID() int32 { + return s.id +} + +// SetID updates the spawn's unique identifier +func (s *Spawn) SetID(id int32) { + s.id = id +} + +// GetDatabaseID returns the database ID for this spawn +func (s *Spawn) GetDatabaseID() int32 { + return s.databaseID +} + +// SetDatabaseID updates the database ID for this spawn +func (s *Spawn) SetDatabaseID(id int32) { + s.databaseID = id +} + +// GetName returns the spawn's display name +func (s *Spawn) GetName() string { + return string(s.appearance.Name[:]) +} + +// SetName updates the spawn's display name and marks info as changed +func (s *Spawn) SetName(name string) { + s.updateMutex.Lock() + defer s.updateMutex.Unlock() + + copy(s.appearance.Name[:], name) + s.infoChanged.Store(true) + s.changed.Store(true) + s.addChangedZoneSpawn() +} + +// GetLevel returns the spawn's level +func (s *Spawn) GetLevel() int16 { + return s.appearance.Level +} + +// SetLevel updates the spawn's level and marks info as changed +func (s *Spawn) SetLevel(level int16) { + s.updateMutex.Lock() + defer s.updateMutex.Unlock() + + s.appearance.Level = level + s.infoChanged.Store(true) + s.changed.Store(true) + s.addChangedZoneSpawn() +} + +// GetX returns the spawn's X coordinate +func (s *Spawn) GetX() float32 { + return s.appearance.Pos.X +} + +// SetX updates the spawn's X coordinate and marks position as changed +func (s *Spawn) SetX(x float32) { + s.updateMutex.Lock() + defer s.updateMutex.Unlock() + + s.appearance.Pos.X = x + s.positionChanged.Store(true) + s.infoChanged.Store(true) + s.visChanged.Store(true) + s.changed.Store(true) + s.addChangedZoneSpawn() +} + +// GetY returns the spawn's Y coordinate +func (s *Spawn) GetY() float32 { + return s.appearance.Pos.Y +} + +// SetY updates the spawn's Y coordinate with optional Y map fixing +func (s *Spawn) SetY(y float32, disableYMapFix bool) { + s.updateMutex.Lock() + defer s.updateMutex.Unlock() + + s.appearance.Pos.Y = y + s.positionChanged.Store(true) + s.infoChanged.Store(true) + s.visChanged.Store(true) + s.changed.Store(true) + s.addChangedZoneSpawn() + + // TODO: Implement Y map fixing when map system is available + if !disableYMapFix { + // FixZ() would be called here + } +} + +// GetZ returns the spawn's Z coordinate +func (s *Spawn) GetZ() float32 { + return s.appearance.Pos.Z +} + +// SetZ updates the spawn's Z coordinate and marks position as changed +func (s *Spawn) SetZ(z float32) { + s.updateMutex.Lock() + defer s.updateMutex.Unlock() + + s.appearance.Pos.Z = z + s.positionChanged.Store(true) + s.infoChanged.Store(true) + s.visChanged.Store(true) + s.changed.Store(true) + s.addChangedZoneSpawn() +} + +// GetHeading returns the spawn's heading in degrees +func (s *Spawn) GetHeading() float32 { + if s.appearance.Pos.Dir1 == 0 { + return 0 + } + + heading := float32(s.appearance.Pos.Dir1) / 64.0 + if heading >= 180 { + heading -= 180 + } else { + heading += 180 + } + return heading +} + +// SetHeading updates the spawn's heading using raw direction values +func (s *Spawn) SetHeading(dir1, dir2 int16) { + s.updateMutex.Lock() + defer s.updateMutex.Unlock() + + s.appearance.Pos.Dir1 = dir1 + s.appearance.Pos.Dir2 = dir2 + s.positionChanged.Store(true) + s.infoChanged.Store(true) + s.visChanged.Store(true) + s.changed.Store(true) + s.addChangedZoneSpawn() +} + +// SetHeadingFromFloat updates the spawn's heading using degrees +func (s *Spawn) SetHeadingFromFloat(heading float32) { + s.lastHeadingAngle = heading + if heading != 180 { + heading = (heading - 180) * 64 + } + s.SetHeading(int16(heading), int16(heading)) +} + +// GetDistance calculates distance to specific coordinates +func (s *Spawn) GetDistance(x, y, z float32, ignoreY bool) float32 { + dx := s.GetX() - x + dy := s.GetY() - y + dz := s.GetZ() - z + + if ignoreY { + return float32(math.Sqrt(float64(dx*dx + dz*dz))) + } + return float32(math.Sqrt(float64(dx*dx + dy*dy + dz*dz))) +} + +// GetDistanceToSpawn calculates distance to another spawn +func (s *Spawn) GetDistanceToSpawn(target *Spawn, ignoreY, includeRadius bool) float32 { + if target == nil { + return 0 + } + + distance := s.GetDistance(target.GetX(), target.GetY(), target.GetZ(), ignoreY) + + if includeRadius { + distance -= s.CalculateRadius(target) + } + + return distance +} + +// CalculateRadius calculates the combined collision radius for two spawns +func (s *Spawn) CalculateRadius(target *Spawn) float32 { + if target == nil { + return 0 + } + + myRadius := float32(s.appearance.Pos.CollisionRadius) + targetRadius := float32(target.appearance.Pos.CollisionRadius) + + return myRadius + targetRadius +} + +// IsAlive returns whether the spawn is currently alive +func (s *Spawn) IsAlive() bool { + return s.isAlive.Load() +} + +// SetAlive updates the spawn's alive state +func (s *Spawn) SetAlive(alive bool) { + s.isAlive.Store(alive) +} + +// GetHP returns the spawn's current hit points +func (s *Spawn) GetHP() int32 { + return s.basicInfo.CurHP +} + +// SetHP updates the spawn's current hit points +func (s *Spawn) SetHP(hp int32) { + s.updateMutex.Lock() + defer s.updateMutex.Unlock() + + s.basicInfo.CurHP = hp + s.infoChanged.Store(true) + s.changed.Store(true) + s.addChangedZoneSpawn() +} + +// GetTotalHP returns the spawn's maximum hit points +func (s *Spawn) GetTotalHP() int32 { + return s.basicInfo.MaxHP +} + +// SetTotalHP updates the spawn's maximum hit points +func (s *Spawn) SetTotalHP(maxHP int32) { + s.basicInfo.MaxHP = maxHP +} + +// GetPower returns the spawn's current power points +func (s *Spawn) GetPower() int32 { + return s.basicInfo.CurPower +} + +// SetPower updates the spawn's current power points +func (s *Spawn) SetPower(power int32) { + s.updateMutex.Lock() + defer s.updateMutex.Unlock() + + s.basicInfo.CurPower = power + s.infoChanged.Store(true) + s.changed.Store(true) + s.addChangedZoneSpawn() +} + +// GetTotalPower returns the spawn's maximum power points +func (s *Spawn) GetTotalPower() int32 { + return s.basicInfo.MaxPower +} + +// SetTotalPower updates the spawn's maximum power points +func (s *Spawn) SetTotalPower(maxPower int32) { + s.basicInfo.MaxPower = maxPower +} + +// GetLockedNoLoot returns the spawn's loot lock state +func (s *Spawn) GetLockedNoLoot() int8 { + return s.appearance.LockedNoLoot +} + +// SetLockedNoLoot updates the spawn's loot lock state +func (s *Spawn) SetLockedNoLoot(state int8) { + s.updateMutex.Lock() + defer s.updateMutex.Unlock() + + s.appearance.LockedNoLoot = state + s.visChanged.Store(true) + s.changed.Store(true) + s.addChangedZoneSpawn() +} + +// AddPrimaryEntityCommand adds a new primary command to the spawn +func (s *Spawn) AddPrimaryEntityCommand(name string, distance float32, command, errorText string, castTime int16, spellVisual int32, defaultAllowList bool) { + s.commandMutex.Lock() + defer s.commandMutex.Unlock() + + entityCommand := &EntityCommand{ + Name: name, + Distance: distance, + Command: command, + ErrorText: errorText, + CastTime: castTime, + SpellVisual: spellVisual, + AllowOrDeny: make(map[int32]bool), + DefaultAllowList: defaultAllowList, + } + + s.primaryCommandList = append(s.primaryCommandList, entityCommand) +} + +// RemovePrimaryCommands clears all primary commands +func (s *Spawn) RemovePrimaryCommands() { + s.commandMutex.Lock() + defer s.commandMutex.Unlock() + + s.primaryCommandList = make([]*EntityCommand, 0) +} + +// GetPrimaryCommands returns the list of primary commands +func (s *Spawn) GetPrimaryCommands() []*EntityCommand { + s.commandMutex.Lock() + defer s.commandMutex.Unlock() + + // Return a copy to prevent race conditions + commands := make([]*EntityCommand, len(s.primaryCommandList)) + copy(commands, s.primaryCommandList) + return commands +} + +// GetSecondaryCommands returns the list of secondary commands +func (s *Spawn) GetSecondaryCommands() []*EntityCommand { + s.commandMutex.Lock() + defer s.commandMutex.Unlock() + + // Return a copy to prevent race conditions + commands := make([]*EntityCommand, len(s.secondaryCommandList)) + copy(commands, s.secondaryCommandList) + return commands +} + +// GetFactionID returns the spawn's faction ID +func (s *Spawn) GetFactionID() int32 { + return s.factionID +} + +// SetFactionID updates the spawn's faction ID +func (s *Spawn) SetFactionID(factionID int32) { + s.updateMutex.Lock() + defer s.updateMutex.Unlock() + + s.factionID = factionID + s.infoChanged.Store(true) + s.changed.Store(true) + s.addChangedZoneSpawn() +} + +// GetTarget returns the spawn's current target ID +func (s *Spawn) GetTarget() int32 { + return s.target +} + +// SetTarget updates the spawn's current target +func (s *Spawn) SetTarget(targetID int32) { + s.updateMutex.Lock() + defer s.updateMutex.Unlock() + + s.target = targetID + s.infoChanged.Store(true) + s.changed.Store(true) + s.addChangedZoneSpawn() +} + +// GetSize returns the spawn's size +func (s *Spawn) GetSize() int16 { + return s.size +} + +// SetSize updates the spawn's size +func (s *Spawn) SetSize(size int16) { + s.updateMutex.Lock() + defer s.updateMutex.Unlock() + + s.size = size + s.sizeChanged.Store(true) + s.infoChanged.Store(true) + s.changed.Store(true) + s.addChangedZoneSpawn() +} + +// GetSpawnType returns the spawn's type +func (s *Spawn) GetSpawnType() int8 { + return s.spawnType +} + +// SetSpawnType updates the spawn's type +func (s *Spawn) SetSpawnType(spawnType int8) { + s.updateMutex.Lock() + defer s.updateMutex.Unlock() + + s.spawnType = spawnType + s.infoChanged.Store(true) + s.changed.Store(true) + s.addChangedZoneSpawn() +} + +// SetSpawnScript updates the spawn's Lua script +func (s *Spawn) SetSpawnScript(script string, dbSet bool) { + s.spawnScript = script + s.spawnScriptSetDB = dbSet +} + +// GetSpawnScript returns the spawn's Lua script +func (s *Spawn) GetSpawnScript() string { + return s.spawnScript +} + +// AddTempVariable stores a temporary string variable for Lua scripting +func (s *Spawn) AddTempVariable(name, value string) { + s.updateMutex.Lock() + defer s.updateMutex.Unlock() + + s.tempVariables[name] = value + s.tempVariableTypes[name] = 0 // String type +} + +// GetTempVariable retrieves a temporary string variable +func (s *Spawn) GetTempVariable(name string) string { + s.updateMutex.RLock() + defer s.updateMutex.RUnlock() + + if value, exists := s.tempVariables[name]; exists { + return value + } + return "" +} + +// DeleteTempVariable removes a temporary variable +func (s *Spawn) DeleteTempVariable(name string) { + s.updateMutex.Lock() + defer s.updateMutex.Unlock() + + delete(s.tempVariables, name) + delete(s.tempVariableTypes, name) + delete(s.tempVariableSpawn, name) +} + +// IsNPC returns whether this spawn is an NPC (to be overridden by subclasses) +func (s *Spawn) IsNPC() bool { + return false +} + +// IsPlayer returns whether this spawn is a player (to be overridden by subclasses) +func (s *Spawn) IsPlayer() bool { + return false +} + +// IsObject returns whether this spawn is an object (to be overridden by subclasses) +func (s *Spawn) IsObject() bool { + return false +} + +// IsWidget returns whether this spawn is a widget (to be overridden by subclasses) +func (s *Spawn) IsWidget() bool { + return false +} + +// IsSign returns whether this spawn is a sign (to be overridden by subclasses) +func (s *Spawn) IsSign() bool { + return false +} + +// IsGroundSpawn returns whether this spawn is a ground spawn (to be overridden by subclasses) +func (s *Spawn) IsGroundSpawn() bool { + return false +} + +// IsEntity returns whether this spawn is an entity (to be overridden by subclasses) +func (s *Spawn) IsEntity() bool { + return false +} + +// IsBot returns whether this spawn is a bot (to be overridden by subclasses) +func (s *Spawn) IsBot() bool { + return false +} + +// GetAppearanceData returns the spawn's appearance data +func (s *Spawn) GetAppearanceData() *common.AppearanceData { + s.updateMutex.RLock() + defer s.updateMutex.RUnlock() + return &s.appearance +} + +// GetBasicInfo returns the spawn's basic info structure +func (s *Spawn) GetBasicInfo() *BasicInfoStruct { + s.updateMutex.RLock() + defer s.updateMutex.RUnlock() + return &s.basicInfo +} + +// SetBasicInfo updates the spawn's basic info structure +func (s *Spawn) SetBasicInfo(info *BasicInfoStruct) { + s.updateMutex.Lock() + defer s.updateMutex.Unlock() + + if info != nil { + s.basicInfo = *info + s.infoChanged.Store(true) + s.changed.Store(true) + s.addChangedZoneSpawn() + } +} + +// GetClient returns the client associated with this spawn (overridden by Player) +func (s *Spawn) GetClient() interface{} { + return nil // Base spawns have no client +} + +// HasChanged returns whether the spawn has any pending changes +func (s *Spawn) HasChanged() bool { + return s.changed.Load() +} + +// HasInfoChanged returns whether the spawn's info has changed +func (s *Spawn) HasInfoChanged() bool { + return s.infoChanged.Load() +} + +// HasPositionChanged returns whether the spawn's position has changed +func (s *Spawn) HasPositionChanged() bool { + return s.positionChanged.Load() +} + +// HasVisualChanged returns whether the spawn's visual appearance has changed +func (s *Spawn) HasVisualChanged() bool { + return s.visChanged.Load() +} + +// ClearChanged resets all change flags +func (s *Spawn) ClearChanged() { + s.changed.Store(false) + s.infoChanged.Store(false) + s.positionChanged.Store(false) + s.visChanged.Store(false) + s.sizeChanged.Store(false) +} + +// GetLastAttacker returns the ID of the spawn's last attacker +func (s *Spawn) GetLastAttacker() int32 { + return s.lastAttacker +} + +// SetLastAttacker updates the spawn's last attacker +func (s *Spawn) SetLastAttacker(attackerID int32) { + s.updateMutex.Lock() + defer s.updateMutex.Unlock() + + s.lastAttacker = attackerID +} + +// addChangedZoneSpawn notifies the zone that this spawn has changed +// TODO: Implement when zone system is available +func (s *Spawn) addChangedZoneSpawn() { + // This would notify the zone server of spawn changes + // Implementation depends on zone system architecture +} + +// TODO: Additional methods to implement: +// - Serialization methods (serialize, spawn_serialize, etc.) +// - Movement system methods +// - Combat and damage methods +// - Loot system methods +// - Group and faction methods +// - Equipment methods +// - Proximity and region methods +// - Knockback physics methods +// - Many more getter/setter methods + +// Item placeholder - to be moved to appropriate package when implemented +// Basic structure to prevent compilation errors in Entity system +type Item struct { + ID int32 // Item ID + UniqueID int32 // Unique instance ID + Name string // Item name + Description string // Item description + Icon int32 // Icon ID + Count int16 // Stack count + Price int32 // Item price + Slot int8 // Equipment slot + Type int8 // Item type + SubType int8 // Item subtype + // TODO: Add complete item structure when item system is implemented + // This is a minimal placeholder to allow Entity system compilation +} diff --git a/internal/spawn/spawn_lists.go b/internal/spawn/spawn_lists.go new file mode 100644 index 0000000..1641f60 --- /dev/null +++ b/internal/spawn/spawn_lists.go @@ -0,0 +1,446 @@ +package spawn + +import ( + "sync" +) + +// Spawn entry types +const ( + SpawnEntryTypeNPC = 0 + SpawnEntryTypeObject = 1 + SpawnEntryTypeWidget = 2 + SpawnEntryTypeSign = 3 + SpawnEntryTypeGroundSpawn = 4 +) + +// SpawnEntry represents a possible spawn at a location with its configuration +type SpawnEntry struct { + SpawnEntryID int32 // Unique identifier for this spawn entry + SpawnLocationID int32 // ID of the location this entry belongs to + SpawnType int8 // Type of spawn (NPC, Object, Widget, etc.) + SpawnID int32 // ID of the actual spawn template + SpawnPercentage float32 // Chance this spawn will appear + Respawn int32 // Base respawn time in seconds + RespawnOffsetLow int32 // Minimum random offset for respawn + RespawnOffsetHigh int32 // Maximum random offset for respawn + DuplicatedSpawn bool // Whether this spawn can appear multiple times + ExpireTime int32 // Time before spawn expires (0 = permanent) + ExpireOffset int32 // Random offset for expire time + + // Spawn location overrides - these override the base spawn's stats + LevelOverride int32 // Override spawn level + HPOverride int32 // Override spawn HP + MPOverride int32 // Override spawn MP/Power + StrengthOverride int32 // Override strength stat + StaminaOverride int32 // Override stamina stat + WisdomOverride int32 // Override wisdom stat + IntelligenceOverride int32 // Override intelligence stat + AgilityOverride int32 // Override agility stat + HeatOverride int32 // Override heat resistance + ColdOverride int32 // Override cold resistance + MagicOverride int32 // Override magic resistance + MentalOverride int32 // Override mental resistance + DivineOverride int32 // Override divine resistance + DiseaseOverride int32 // Override disease resistance + PoisonOverride int32 // Override poison resistance + DifficultyOverride int32 // Override encounter level/difficulty +} + +// NewSpawnEntry creates a new spawn entry with default values +func NewSpawnEntry() *SpawnEntry { + return &SpawnEntry{ + SpawnEntryID: 0, + SpawnLocationID: 0, + SpawnType: SpawnEntryTypeNPC, + SpawnID: 0, + SpawnPercentage: 100.0, + Respawn: 600, // 10 minutes default + RespawnOffsetLow: 0, + RespawnOffsetHigh: 0, + DuplicatedSpawn: true, + ExpireTime: 0, + ExpireOffset: 0, + LevelOverride: 0, + HPOverride: 0, + MPOverride: 0, + StrengthOverride: 0, + StaminaOverride: 0, + WisdomOverride: 0, + IntelligenceOverride: 0, + AgilityOverride: 0, + HeatOverride: 0, + ColdOverride: 0, + MagicOverride: 0, + MentalOverride: 0, + DivineOverride: 0, + DiseaseOverride: 0, + PoisonOverride: 0, + DifficultyOverride: 0, + } +} + +// GetSpawnTypeName returns a human-readable name for the spawn type +func (se *SpawnEntry) GetSpawnTypeName() string { + switch se.SpawnType { + case SpawnEntryTypeNPC: + return "NPC" + case SpawnEntryTypeObject: + return "Object" + case SpawnEntryTypeWidget: + return "Widget" + case SpawnEntryTypeSign: + return "Sign" + case SpawnEntryTypeGroundSpawn: + return "GroundSpawn" + default: + return "Unknown" + } +} + +// HasStatOverrides returns true if this entry has any stat overrides configured +func (se *SpawnEntry) HasStatOverrides() bool { + return se.LevelOverride != 0 || se.HPOverride != 0 || se.MPOverride != 0 || + se.StrengthOverride != 0 || se.StaminaOverride != 0 || se.WisdomOverride != 0 || + se.IntelligenceOverride != 0 || se.AgilityOverride != 0 || se.HeatOverride != 0 || + se.ColdOverride != 0 || se.MagicOverride != 0 || se.MentalOverride != 0 || + se.DivineOverride != 0 || se.DiseaseOverride != 0 || se.PoisonOverride != 0 || + se.DifficultyOverride != 0 +} + +// GetActualRespawnTime calculates the actual respawn time including random offset +func (se *SpawnEntry) GetActualRespawnTime() int32 { + if se.RespawnOffsetLow == 0 && se.RespawnOffsetHigh == 0 { + return se.Respawn + } + + // TODO: Implement random number generation + // For now, return base respawn time + return se.Respawn +} + +// SpawnLocation represents a location in the world where spawns can appear +type SpawnLocation struct { + // Position data + X float32 // X coordinate in world space + Y float32 // Y coordinate in world space + Z float32 // Z coordinate in world space + Heading float32 // Direction the spawn faces + Pitch float32 // Pitch angle + Roll float32 // Roll angle + + // Offset ranges for randomizing spawn positions + XOffset float32 // Random X offset range (+/-) + YOffset float32 // Random Y offset range (+/-) + ZOffset float32 // Random Z offset range (+/-) + + // Location metadata + PlacementID int32 // Unique placement identifier + GridID int32 // Grid cell this location belongs to + Script string // Lua script to run for this location + Conditional int8 // Conditional flag for spawn logic + + // Spawn management + Entities []*SpawnEntry // List of possible spawns at this location + TotalPercentage float32 // Sum of all spawn percentages + + // Thread safety + mutex sync.RWMutex +} + +// NewSpawnLocation creates a new spawn location with default values +func NewSpawnLocation() *SpawnLocation { + return &SpawnLocation{ + X: 0.0, + Y: 0.0, + Z: 0.0, + Heading: 0.0, + Pitch: 0.0, + Roll: 0.0, + XOffset: 0.0, + YOffset: 0.0, + ZOffset: 0.0, + PlacementID: 0, + GridID: 0, + Script: "", + Conditional: 0, + Entities: make([]*SpawnEntry, 0), + TotalPercentage: 0.0, + } +} + +// AddSpawnEntry adds a spawn entry to this location +func (sl *SpawnLocation) AddSpawnEntry(entry *SpawnEntry) { + if entry == nil { + return + } + + sl.mutex.Lock() + defer sl.mutex.Unlock() + + sl.Entities = append(sl.Entities, entry) + sl.TotalPercentage += entry.SpawnPercentage +} + +// RemoveSpawnEntry removes a spawn entry by its ID +func (sl *SpawnLocation) RemoveSpawnEntry(entryID int32) bool { + sl.mutex.Lock() + defer sl.mutex.Unlock() + + for i, entry := range sl.Entities { + if entry.SpawnEntryID == entryID { + // Remove from slice + sl.Entities = append(sl.Entities[:i], sl.Entities[i+1:]...) + sl.TotalPercentage -= entry.SpawnPercentage + return true + } + } + return false +} + +// GetSpawnEntries returns a copy of all spawn entries +func (sl *SpawnLocation) GetSpawnEntries() []*SpawnEntry { + sl.mutex.RLock() + defer sl.mutex.RUnlock() + + entries := make([]*SpawnEntry, len(sl.Entities)) + copy(entries, sl.Entities) + return entries +} + +// GetSpawnEntryCount returns the number of spawn entries at this location +func (sl *SpawnLocation) GetSpawnEntryCount() int { + sl.mutex.RLock() + defer sl.mutex.RUnlock() + + return len(sl.Entities) +} + +// GetSpawnEntryByID finds a spawn entry by its ID +func (sl *SpawnLocation) GetSpawnEntryByID(entryID int32) *SpawnEntry { + sl.mutex.RLock() + defer sl.mutex.RUnlock() + + for _, entry := range sl.Entities { + if entry.SpawnEntryID == entryID { + return entry + } + } + return nil +} + +// CalculateRandomPosition returns a randomized position within the offset ranges +// TODO: Implement proper random number generation +func (sl *SpawnLocation) CalculateRandomPosition() (float32, float32, float32) { + sl.mutex.RLock() + defer sl.mutex.RUnlock() + + // For now, return base position + // In full implementation, would add random offsets within ranges + return sl.X, sl.Y, sl.Z +} + +// SelectRandomSpawn selects a spawn entry based on spawn percentages +// Returns nil if no spawn is selected (based on random chance) +func (sl *SpawnLocation) SelectRandomSpawn() *SpawnEntry { + sl.mutex.RLock() + defer sl.mutex.RUnlock() + + if len(sl.Entities) == 0 || sl.TotalPercentage <= 0 { + return nil + } + + // TODO: Implement proper random selection based on percentages + // For now, return first entry if any exist + if len(sl.Entities) > 0 { + return sl.Entities[0] + } + + return nil +} + +// HasScript returns true if this location has a Lua script +func (sl *SpawnLocation) HasScript() bool { + return sl.Script != "" +} + +// IsConditional returns true if this location has conditional spawning +func (sl *SpawnLocation) IsConditional() bool { + return sl.Conditional != 0 +} + +// GetDistance calculates distance from this location to specified coordinates +func (sl *SpawnLocation) GetDistance(x, y, z float32, ignoreY bool) float32 { + dx := sl.X - x + dy := sl.Y - y + dz := sl.Z - z + + if ignoreY { + return float32(dx*dx + dz*dz) + } + return float32(dx*dx + dy*dy + dz*dz) +} + +// SetPosition updates the location's position coordinates +func (sl *SpawnLocation) SetPosition(x, y, z float32) { + sl.mutex.Lock() + defer sl.mutex.Unlock() + + sl.X = x + sl.Y = y + sl.Z = z +} + +// SetRotation updates the location's rotation angles +func (sl *SpawnLocation) SetRotation(heading, pitch, roll float32) { + sl.mutex.Lock() + defer sl.mutex.Unlock() + + sl.Heading = heading + sl.Pitch = pitch + sl.Roll = roll +} + +// SetOffsets updates the location's position offset ranges +func (sl *SpawnLocation) SetOffsets(xOffset, yOffset, zOffset float32) { + sl.mutex.Lock() + defer sl.mutex.Unlock() + + sl.XOffset = xOffset + sl.YOffset = yOffset + sl.ZOffset = zOffset +} + +// RecalculateTotalPercentage recalculates the total spawn percentage +// Should be called if spawn entry percentages are modified directly +func (sl *SpawnLocation) RecalculateTotalPercentage() { + sl.mutex.Lock() + defer sl.mutex.Unlock() + + sl.TotalPercentage = 0.0 + for _, entry := range sl.Entities { + sl.TotalPercentage += entry.SpawnPercentage + } +} + +// Cleanup releases resources used by this spawn location +func (sl *SpawnLocation) Cleanup() { + sl.mutex.Lock() + defer sl.mutex.Unlock() + + // Clear spawn entries + sl.Entities = nil + sl.TotalPercentage = 0.0 +} + +// SpawnLocationManager manages collections of spawn locations +type SpawnLocationManager struct { + locations map[int32]*SpawnLocation // Key is placement ID + mutex sync.RWMutex +} + +// NewSpawnLocationManager creates a new spawn location manager +func NewSpawnLocationManager() *SpawnLocationManager { + return &SpawnLocationManager{ + locations: make(map[int32]*SpawnLocation), + } +} + +// AddLocation adds a spawn location to the manager +func (slm *SpawnLocationManager) AddLocation(placementID int32, location *SpawnLocation) { + if location == nil { + return + } + + slm.mutex.Lock() + defer slm.mutex.Unlock() + + location.PlacementID = placementID + slm.locations[placementID] = location +} + +// RemoveLocation removes a spawn location by placement ID +func (slm *SpawnLocationManager) RemoveLocation(placementID int32) bool { + slm.mutex.Lock() + defer slm.mutex.Unlock() + + if location, exists := slm.locations[placementID]; exists { + location.Cleanup() + delete(slm.locations, placementID) + return true + } + return false +} + +// GetLocation retrieves a spawn location by placement ID +func (slm *SpawnLocationManager) GetLocation(placementID int32) *SpawnLocation { + slm.mutex.RLock() + defer slm.mutex.RUnlock() + + return slm.locations[placementID] +} + +// GetAllLocations returns a copy of all spawn locations +func (slm *SpawnLocationManager) GetAllLocations() map[int32]*SpawnLocation { + slm.mutex.RLock() + defer slm.mutex.RUnlock() + + locations := make(map[int32]*SpawnLocation) + for id, location := range slm.locations { + locations[id] = location + } + return locations +} + +// GetLocationCount returns the number of spawn locations +func (slm *SpawnLocationManager) GetLocationCount() int { + slm.mutex.RLock() + defer slm.mutex.RUnlock() + + return len(slm.locations) +} + +// GetLocationsInRange returns all locations within the specified distance of coordinates +func (slm *SpawnLocationManager) GetLocationsInRange(x, y, z, maxDistance float32, ignoreY bool) []*SpawnLocation { + slm.mutex.RLock() + defer slm.mutex.RUnlock() + + var locations []*SpawnLocation + maxDistanceSquared := maxDistance * maxDistance + + for _, location := range slm.locations { + distance := location.GetDistance(x, y, z, ignoreY) + if distance <= maxDistanceSquared { + locations = append(locations, location) + } + } + + return locations +} + +// GetLocationsByGridID returns all locations in a specific grid +func (slm *SpawnLocationManager) GetLocationsByGridID(gridID int32) []*SpawnLocation { + slm.mutex.RLock() + defer slm.mutex.RUnlock() + + var locations []*SpawnLocation + for _, location := range slm.locations { + if location.GridID == gridID { + locations = append(locations, location) + } + } + + return locations +} + +// Clear removes all spawn locations +func (slm *SpawnLocationManager) Clear() { + slm.mutex.Lock() + defer slm.mutex.Unlock() + + // Cleanup all locations + for _, location := range slm.locations { + location.Cleanup() + } + + slm.locations = make(map[int32]*SpawnLocation) +} \ No newline at end of file diff --git a/internal/spells/README.md b/internal/spells/README.md new file mode 100644 index 0000000..8eb7415 --- /dev/null +++ b/internal/spells/README.md @@ -0,0 +1,68 @@ +# Spells System + +Complete spell system for EverQuest II server emulation with spell definitions, casting mechanics, effects, and processing. + +## Components + +**Core System:** +- **SpellData/Spell** - Spell definitions with properties, levels, effects, LUA data +- **SpellEffectManager** - Active effects management (buffs, debuffs, bonuses) +- **MasterSpellList/SpellBook** - Global registry and per-character spell collections + +**Spell Processing:** +- **SpellProcess** - Real-time casting engine (50ms intervals) with timers, queues, interrupts +- **SpellTargeting** - All target types: self, single, group, AOE, PBAE with validation +- **SpellResourceChecker** - Power, health, concentration, savagery, dissonance management +- **SpellManager** - High-level coordinator integrating all systems + +## Key Features + +- **Real-time Processing**: Cast/recast timers, active spell tracking, interrupt handling +- **Comprehensive Targeting**: Range, LOS, spell criteria validation for all target types +- **Resource Management**: All EQ2 resource types with validation and consumption +- **Effect System**: 30 maintained effects, 45 spell effects, detrimental effects +- **Heroic Opportunities**: Solo/group coordination with timing +- **Thread Safety**: Concurrent access with proper mutexes +- **80+ Effect Types**: All spell modifications from original C++ system + +## Usage + +```go +// Create and use spell manager +spellManager := spells.NewSpellManager() + +// Cast spell with full validation +err := spellManager.CastSpell(casterID, targetID, spellID) +if err != nil { + log.Printf("Spell failed: %v", err) +} + +// Check spell readiness +canCast, reason := spellManager.CanCastSpell(casterID, targetID, spellID) + +// Process spells in main loop +spellManager.ProcessSpells() + +// Manage spell books +spellBook := spellManager.GetSpellBook(characterID) +spellBook.AddSpell(spell) +spellBook.SetSpellBarSlot(0, 1, spell) + +// Effect management +sem := spells.NewSpellEffectManager() +sem.AddMaintainedEffect(maintainedEffect) +sem.AddSpellEffect(tempEffect) +``` + +## Files + +**Core**: `constants.go`, `spell_data.go`, `spell.go`, `spell_effects.go`, `spell_manager.go` +**Processing**: `process_constants.go`, `spell_process.go`, `spell_targeting.go`, `spell_resources.go` +**Docs**: `README.md`, `SPELL_PROCESS.md` + +## Integration + +**Database**: Spell data loading, character spell book persistence +**Packet System**: Spell info serialization, effect updates +**Entity System**: SpellEffectManager embedded, stat integration +**LUA Scripting**: Custom spell behaviors, effect calculations \ No newline at end of file diff --git a/internal/spells/SPELL_PROCESS.md b/internal/spells/SPELL_PROCESS.md new file mode 100644 index 0000000..0e1c185 --- /dev/null +++ b/internal/spells/SPELL_PROCESS.md @@ -0,0 +1,112 @@ +# Spell Processing System + +Comprehensive spell casting engine managing all aspects of spell processing including timers, targeting, resource management, and heroic opportunities. + +## Components + +**SpellProcess** - Core engine managing active spells, cast/recast timers, interrupt queues, spell queues, and heroic opportunities +**SpellTargeting** - Target selection for all spell types (self, single, group, AOE, PBAE) with validation +**SpellResourceChecker** - Resource validation/consumption for power, health, concentration, savagery, dissonance +**SpellManager** - High-level coordinator integrating all systems with comprehensive casting API + +## Key Data Structures + +```go +// Active spell instance +type LuaSpell struct { + Spell *Spell // Spell definition + CasterID int32 // Casting entity + Targets []int32 // Target list + Timer SpellTimer // Duration/tick timing + NumCalls int32 // Tick count + Interrupted bool // Interrupt state +} + +// Cast timing +type CastTimer struct { + CasterID int32 // Casting entity + SpellID int32 // Spell being cast + StartTime time.Time // Cast start + Duration time.Duration // Cast time +} + +// Cooldown timing +type RecastTimer struct { + CasterID int32 // Entity with cooldown + SpellID int32 // Spell on cooldown + Duration time.Duration // Cooldown time + LinkedTimer int32 // Shared cooldown group +} +``` + +## Usage + +```go +// Main spell processing loop (50ms intervals) +spellManager.ProcessSpells() + +// Cast spell with full validation +err := spellManager.CastSpell(casterID, targetID, spellID) + +// Interrupt casting +spellManager.InterruptSpell(entityID, spellID, errorCode, canceled, fromMovement) + +// Queue management +spellManager.AddSpellToQueue(spellID, casterID, targetID, priority) +spellManager.RemoveSpellFromQueue(spellID, casterID) + +// Status checks +ready := spellManager.IsSpellReady(spellID, casterID) +recastTime := spellManager.GetSpellRecastTime(spellID, casterID) +canCast, reason := spellManager.CanCastSpell(casterID, targetID, spellID) + +// Resource checking +resourceChecker := spellManager.GetResourceChecker() +powerResult := resourceChecker.CheckPower(luaSpell, customPowerReq) +allResults := resourceChecker.CheckAllResources(luaSpell, 0, 0) +success := resourceChecker.ConsumeAllResources(luaSpell, 0, 0) + +// Targeting +targeting := spellManager.GetTargeting() +targetResult := targeting.GetSpellTargets(luaSpell, options) +``` + +## Effect Types (80+) + +**Stat Modifications**: Health, power, stats (STR/AGI/STA/INT/WIS), resistances, attack, mitigation +**Spell Modifications**: Cast time, power req, range, duration, resistibility, crit chance +**Actions**: Damage, healing, DOT/HOT, resurrect, summon, mount, invisibility +**Control**: Stun, root, mez, fear, charm, blind, kill +**Special**: Change race/size/title, faction, exp, tradeskill bonuses + +## Target Types + +**TargetTypeSelf** (0) - Self-only spells +**TargetTypeSingle** (1) - Single target with validation +**TargetTypeGroup** (2) - Group members +**TargetTypeGroupAE** (3) - Group area effect +**TargetTypeAE** (4) - True area effect +**TargetTypePBAE** (5) - Point blank area effect + +## Interrupt System + +**Causes**: Movement, damage, stun, mesmerize, fear, manual cancellation, out of range +**Processing**: Queued interrupts processed every cycle with proper cleanup +**Error Codes**: Match client expectations for proper UI feedback + +## Performance + +- **50ms Processing**: Matches client update expectations +- **Efficient Indexing**: Fast lookups by caster, spell, target +- **Thread Safety**: Concurrent access with proper locking +- **Memory Management**: Cleanup of expired timers and effects +- **Batch Operations**: Multiple resource checks/targeting in single calls + +## Integration Points + +**Entity System**: Caster/target info, position data, combat state +**Zone System**: Position validation, line-of-sight, spawn management +**Group System**: Group member targeting and coordination +**Database**: Persistent spell data, character spell books +**Packet System**: Client communication for spell states +**LUA System**: Custom spell scripting (future) \ No newline at end of file diff --git a/internal/spells/constants.go b/internal/spells/constants.go new file mode 100644 index 0000000..c2844ab --- /dev/null +++ b/internal/spells/constants.go @@ -0,0 +1,140 @@ +package spells + +// Spell target types +const ( + SpellTargetSelf = 0 + SpellTargetEnemy = 1 + SpellTargetGroupAE = 2 + SpellTargetCasterPet = 3 + SpellTargetEnemyPet = 4 + SpellTargetEnemyCorpse = 5 + SpellTargetGroupCorpse = 6 + SpellTargetNone = 7 + SpellTargetRaidAE = 8 + SpellTargetOtherGroupAE = 9 +) + +// Spell book types +const ( + SpellBookTypeSpell = 0 + SpellBookTypeCombatArt = 1 + SpellBookTypeAbility = 2 + SpellBookTypeTradeskill = 3 + SpellBookTypeNotShown = 4 +) + +// Spell cast types +const ( + SpellCastTypeNormal = 0 + SpellCastTypeToggle = 1 +) + +// Spell error codes +const ( + SpellErrorNotEnoughKnowledge = 1 + SpellErrorInterrupted = 2 + SpellErrorTakeEffectMorePowerful = 3 + SpellErrorTakeEffectSameSpell = 4 + SpellErrorCannotCastDead = 5 + SpellErrorNotAlive = 6 + SpellErrorNotDead = 7 + SpellErrorCannotCastSitting = 8 + SpellErrorCannotCastUncon = 9 + SpellErrorAlreadyCasting = 10 + SpellErrorRecovering = 11 + SpellErrorNonCombatOnly = 12 + SpellErrorCannotCastStunned = 13 + SpellErrorCannotCastStiffled = 14 + SpellErrorCannotCastCharmed = 15 + SpellErrorNotWhileMounted = 16 + SpellErrorNotWhileFlying = 17 + SpellErrorNotWhileClimbing = 18 + SpellErrorNotReady = 19 + SpellErrorCantSeeTarget = 20 + SpellErrorIncorrectStance = 21 + SpellErrorCannotCastFeignDeath = 22 + SpellErrorInventoryFull = 23 + SpellErrorNotEnoughCoin = 24 + SpellErrorNotAllowedHere = 25 + SpellErrorNotWhileCrafting = 26 + SpellErrorOnlyWhenCrafting = 27 + SpellErrorItemNotAttuned = 28 + SpellErrorItemWornOut = 29 + SpellErrorMustEquipWeapon = 30 + SpellErrorWeaponBroken = 31 + SpellErrorCannotCastFeared = 32 + SpellErrorTargetImmuneHostile = 33 + SpellErrorTargetImmuneBeneficial = 34 + SpellErrorNoTauntSpells = 35 + SpellErrorCannotUseInBattlegrounds = 36 + SpellErrorCannotPrepare = 37 + SpellErrorNoEligibleTarget = 38 + SpellErrorNoTargetsInRange = 39 + SpellErrorTooClose = 40 + SpellErrorTooFarAway = 41 + SpellErrorTargetTooWeak = 42 + SpellErrorTargetTooPowerful = 43 + SpellErrorTargetNotPlayer = 44 + SpellErrorTargetNotNPC = 45 + SpellErrorTargetNotOwner = 46 + SpellErrorTargetNotGrouped = 47 + SpellErrorCannotCastInCombat = 48 + SpellErrorCannotCastOutOfCombat = 49 + SpellErrorTargetNotCorrectType = 50 + SpellErrorTargetNotCorrectClass = 51 + SpellErrorTargetNotCorrectRace = 52 + SpellErrorNoCaster = 53 + SpellErrorCannotCastOnCorpse = 54 + SpellErrorCannotCastOnGuild = 55 + SpellErrorCannotCastOnRaid = 56 + SpellErrorCannotCastOnGroup = 57 + SpellErrorCannotCastOnSelf = 58 + SpellErrorTargetNotInGroup = 59 + SpellErrorTargetNotInRaid = 60 + SpellErrorCannotCastThatOnYourself = 61 + SpellErrorAbilityUnavailable = 62 + SpellErrorCannotPrepareWhileCasting = 63 + SpellErrorNoPowerRegaining = 64 +) + +// Control effect types (moved from entity package) +const ( + ControlEffectStun = iota + ControlEffectRoot + ControlEffectMez + ControlEffectDaze + ControlEffectFear + ControlEffectSlow + ControlEffectSnare + ControlEffectCharm +) + +const ( + ControlMaxEffects = 8 // Maximum number of control effect types +) + +// GivenByType represents how a spell was acquired +type GivenByType int8 + +const ( + GivenByUnset GivenByType = 0 + GivenByTradeskillClass GivenByType = 1 + GivenBySpellScroll GivenByType = 2 + GivenByAltAdvancement GivenByType = 3 + GivenByRace GivenByType = 4 + GivenByRacialInnate GivenByType = 5 + GivenByRacialTradition GivenByType = 6 + GivenByClass GivenByType = 7 + GivenByCharacterTrait GivenByType = 8 + GivenByFocusAbility GivenByType = 9 + GivenByClassTraining GivenByType = 10 + GivenByWarderSpell GivenByType = 11 +) + +// Spell component types for LUA data +const ( + SpellLUADataTypeInt = 0 + SpellLUADataTypeFloat = 1 + SpellLUADataTypeBool = 2 + SpellLUADataTypeString = 3 +) \ No newline at end of file diff --git a/internal/spells/process_constants.go b/internal/spells/process_constants.go new file mode 100644 index 0000000..a6bc198 --- /dev/null +++ b/internal/spells/process_constants.go @@ -0,0 +1,304 @@ +package spells + +// Spell effect modification types - from SpellProcess.h +const ( + ModifyHealth = 1 + ModifyFocus = 2 + ModifyDefense = 3 + ModifyPower = 4 + ModifySpeed = 5 + ModifyInt = 6 + ModifyWis = 7 + ModifyStr = 8 + ModifyAgi = 9 + ModifySta = 10 + ModifyColdResist = 11 + ModifyHeatResist = 12 + ModifyDiseaseResist = 13 + ModifyPoisonResist = 14 + ModifyMagicResist = 15 + ModifyMentalResist = 16 + ModifyDivineResist = 17 + ModifyAttack = 18 + ModifyMitigation = 19 + ModifyAvoidance = 20 + ModifyConcentration = 21 + ModifyExp = 22 + ModifyFaction = 23 + ChangeSize = 24 + ChangeRace = 25 + ChangeLocation = 26 + ChangeZone = 27 + ChangePrefixTitle = 28 + ChangeDeity = 29 + ChangeLastName = 30 + ModifyHaste = 31 + ModifySkill = 32 + ChangeTarget = 33 + ChangeLevel = 34 + ModifySpellCastTime = 35 + ModifySpellPowerReq = 36 + ModifySpellHealthReq = 37 + ModifySpellRecovery = 38 + ModifySpellRecastTime = 39 + ModifySpellRadius = 40 + ModifySpellAOETargets = 41 + ModifySpellRange = 42 + ModifySpellDuration = 43 + ModifySpellResistibility = 44 + ModifyDamage = 45 + ModifyDelay = 46 + ModifyTradeskillExp = 47 + AddMount = 48 + RemoveMount = 49 + ModifySpellCritChance = 50 + ModifyCritChance = 51 + SummonItem = 52 + ModifyJump = 53 + ModifyFallSpeed = 54 + InflictDamage = 55 + AddDot = 56 + RemoveDot = 57 + HealTarget = 58 + HealAOE = 59 + InflictAOEDamage = 60 + HealGroupAOE = 61 + AddAOEDot = 62 + RemoveAOEDot = 63 + AddHot = 64 + RemoveHot = 65 + ModifyAggroRange = 66 + BlindTarget = 67 + UnblindTarget = 68 + KillTarget = 69 + ResurrectTarget = 70 + ChangeSuffixTitle = 71 + SummonPet = 72 + ModifyHate = 73 + AddReactiveHeal = 74 + ModifyPowerRegen = 75 + ModifyHPRegen = 76 + FeignDeath = 77 + ModifyVision = 78 + Invisibility = 79 + CharmTarget = 80 + ModifyTradeskillDurability = 81 + ModifyTradeskillProgress = 82 +) + +// Active spell states +const ( + ActiveSpellNormal = 0 + ActiveSpellAdd = 1 + ActiveSpellRemove = 2 +) + +// Spell process constants +const ( + GetValueBadValue = 0xFFFFFFFF + ProcessCheckInterval = 50 // milliseconds between process checks +) + +// Cast timer states +const ( + CastTimerActive = 0 + CastTimerComplete = 1 + CastTimerExpired = 2 + CastTimerCanceled = 3 +) + +// Interrupt error codes +const ( + InterruptErrorNone = 0 + InterruptErrorMovement = 1 + InterruptErrorDamage = 2 + InterruptErrorStun = 3 + InterruptErrorMesmerize = 4 + InterruptErrorFear = 5 + InterruptErrorRoot = 6 + InterruptErrorCanceled = 7 + InterruptErrorInvalidTarget = 8 + InterruptErrorOutOfRange = 9 + InterruptErrorInsufficientPower = 10 + InterruptErrorInsufficientHealth = 11 + InterruptErrorInsufficientConcentration = 12 +) + +// Spell queue priorities +const ( + QueuePriorityLow = 0 + QueuePriorityNormal = 1 + QueuePriorityHigh = 2 + QueuePriorityUrgent = 3 +) + +// Heroic Opportunity states +const ( + HeroicOpInactive = 0 + HeroicOpActive = 1 + HeroicOpComplete = 2 + HeroicOpFailed = 3 + HeroicOpCanceled = 4 +) + +// Resource check types +const ( + ResourceCheckPower = 1 + ResourceCheckHealth = 2 + ResourceCheckConcentration = 3 + ResourceCheckSavagery = 4 + ResourceCheckDissonance = 5 +) + +// Spell targeting types +const ( + TargetTypeSelf = 0 + TargetTypeSingle = 1 + TargetTypeGroup = 2 + TargetTypeGroupAE = 3 + TargetTypeAE = 4 + TargetTypePBAE = 5 // Point Blank Area Effect + TargetTypeCorpse = 6 + TargetTypeItem = 7 + TargetTypeLocation = 8 + TargetTypeNone = 9 +) + +// Spell resist types +const ( + ResistTypeNone = 0 + ResistTypeMagic = 1 + ResistTypeDivine = 2 + ResistTypeMental = 3 + ResistTypeCold = 4 + ResistTypeHeat = 5 + ResistTypeDisease = 6 + ResistTypePoison = 7 + ResistTypeArcane = 8 + ResistTypeNoxious = 9 + ResistTypeElemental = 10 +) + +// Spell damage types +const ( + DamageTypeSlashing = 0 + DamageTypeCrushing = 1 + DamageTypePiercing = 2 + DamageTypeBurning = 3 + DamageTypeFreezing = 4 + DamageTypeAcid = 5 + DamageTypePoison = 6 + DamageTypeDisease = 7 + DamageTypeMental = 8 + DamageTypeDivine = 9 + DamageTypeMagic = 10 +) + +// Spell effect duration types +const ( + DurationTypeInstant = 0 + DurationTypeTemporary = 1 + DurationTypePermanent = 2 + DurationTypeUntilCanceled = 3 + DurationTypeConditional = 4 +) + +// Maximum values for spell system limits +const ( + MaxCastTimers = 1000 // Maximum number of active cast timers + MaxRecastTimers = 5000 // Maximum number of active recast timers + MaxActiveSpells = 10000 // Maximum number of active spells + MaxQueuedSpells = 50 // Maximum spells per player queue + MaxSpellTargets = 100 // Maximum targets per spell + MaxInterrupts = 500 // Maximum queued interrupts + MaxHeroicOps = 100 // Maximum active heroic opportunities +) + +// Spell book constants +const ( + SpellBookTabGeneral = 0 + SpellBookTabCombatArts = 1 + SpellBookTabSpells = 2 + SpellBookTabTradeskills = 3 + SpellBookTabReligious = 4 + SpellBookTabTempSpells = 5 + SpellBookTabCharacteristics = 6 + SpellBookTabKnowledgeSpells = 7 + SpellBookTabHeroicOps = 8 +) + +// Spell effect categories for organization +const ( + EffectCategoryBuff = "Buff" + EffectCategoryDebuff = "Debuff" + EffectCategoryDamage = "Damage" + EffectCategoryHealing = "Healing" + EffectCategorySummon = "Summon" + EffectCategoryTransport = "Transport" + EffectCategoryUtility = "Utility" + EffectCategoryControl = "Control" +) + +// Spell component types +const ( + ComponentTypeNone = 0 + ComponentTypeVerbal = 1 + ComponentTypeSomatic = 2 + ComponentTypeMaterial = 3 + ComponentTypeFocus = 4 + ComponentTypeDivine = 5 +) + +// Spell school types +const ( + SchoolTypeGeneral = 0 + SchoolTypeElemental = 1 + SchoolTypeSpiritual = 2 + SchoolTypeArcane = 3 + SchoolTypeNature = 4 + SchoolTypeTemporal = 5 +) + +// Casting requirement flags +const ( + RequireLineOfSight = 1 << 0 + RequireNotMoving = 1 << 1 + RequireNotInCombat = 1 << 2 + RequireTargetAlive = 1 << 3 + RequireTargetDead = 1 << 4 + RequireGrouped = 1 << 5 + RequireNotGrouped = 1 << 6 + RequireGuild = 1 << 7 + RequirePeaceful = 1 << 8 +) + +// Spell failure reasons +const ( + FailureReasonNone = 0 + FailureReasonInsufficientPower = 1 + FailureReasonInsufficientHealth = 2 + FailureReasonInsufficientConc = 3 + FailureReasonInterrupted = 4 + FailureReasonOutOfRange = 5 + FailureReasonInvalidTarget = 6 + FailureReasonResisted = 7 + FailureReasonImmune = 8 + FailureReasonBlocked = 9 + FailureReasonReflected = 10 + FailureReasonAbsorbed = 11 + FailureReasonFizzled = 12 + FailureReasonMissed = 13 + FailureReasonRequirementNotMet = 14 +) + +// Special spell flags for unique behaviors +const ( + SpellFlagCannotBeResisted = 1 << 0 + SpellFlagCannotBeReflected = 1 << 1 + SpellFlagCannotBeAbsorbed = 1 << 2 + SpellFlagIgnoreImmunity = 1 << 3 + SpellFlagBypassProtections = 1 << 4 + SpellFlagAlwaysHits = 1 << 5 + SpellFlagCanCritical = 1 << 6 + SpellFlagNoInterrupt = 1 << 7 +) \ No newline at end of file diff --git a/internal/spells/spell.go b/internal/spells/spell.go new file mode 100644 index 0000000..be594b3 --- /dev/null +++ b/internal/spells/spell.go @@ -0,0 +1,655 @@ +package spells + +import ( + "fmt" + "sync" +) + +// Spell represents a complete spell with data, levels, effects, and LUA data +// This is the main spell class converted from C++ Spell class +type Spell struct { + // Core spell data + data *SpellData + + // Spell progression and requirements + levels []*LevelArray // Level requirements by class + effects []*SpellDisplayEffect // Display effects for tooltips + luaData []*LUAData // LUA script data + + // Computed properties (cached for performance) + healSpell bool // Cached: is this a healing spell + buffSpell bool // Cached: is this a buff spell + damageSpell bool // Cached: is this a damage spell + controlSpell bool // Cached: is this a control spell + offenseSpell bool // Cached: is this an offensive spell + copiedSpell bool // Whether this is a copied/derived spell + + // Runtime state + stayLocked bool // Whether spell should stay locked + + // Thread safety + mutex sync.RWMutex +} + +// NewSpell creates a new spell with default spell data +func NewSpell() *Spell { + return &Spell{ + data: NewSpellData(), + levels: make([]*LevelArray, 0), + effects: make([]*SpellDisplayEffect, 0), + luaData: make([]*LUAData, 0), + healSpell: false, + buffSpell: false, + damageSpell: false, + controlSpell: false, + offenseSpell: false, + copiedSpell: false, + stayLocked: false, + } +} + +// NewSpellFromData creates a new spell with existing spell data +func NewSpellFromData(spellData *SpellData) *Spell { + s := NewSpell() + s.data = spellData + s.computeSpellProperties() + return s +} + +// NewSpellCopy creates a copy of an existing spell (for spell upgrades/variants) +func NewSpellCopy(hostSpell *Spell, uniqueSpell bool) *Spell { + if hostSpell == nil { + return NewSpell() + } + + hostSpell.mutex.RLock() + defer hostSpell.mutex.RUnlock() + + s := &Spell{ + data: hostSpell.data.Clone(), + levels: make([]*LevelArray, 0), + effects: make([]*SpellDisplayEffect, 0), + luaData: make([]*LUAData, 0), + healSpell: hostSpell.healSpell, + buffSpell: hostSpell.buffSpell, + damageSpell: hostSpell.damageSpell, + controlSpell: hostSpell.controlSpell, + offenseSpell: hostSpell.offenseSpell, + copiedSpell: true, + stayLocked: hostSpell.stayLocked, + } + + // Copy levels + for _, level := range hostSpell.levels { + newLevel := &LevelArray{ + AdventureClass: level.AdventureClass, + TradeskillClass: level.TradeskillClass, + SpellLevel: level.SpellLevel, + ClassicSpellLevel: level.ClassicSpellLevel, + } + s.levels = append(s.levels, newLevel) + } + + // Copy effects + for _, effect := range hostSpell.effects { + newEffect := &SpellDisplayEffect{ + Percentage: effect.Percentage, + Subbullet: effect.Subbullet, + Description: effect.Description, + NeedsDBSave: effect.NeedsDBSave, + } + s.effects = append(s.effects, newEffect) + } + + // Copy LUA data + for _, lua := range hostSpell.luaData { + newLua := &LUAData{ + Type: lua.Type, + IntValue: lua.IntValue, + BoolValue: lua.BoolValue, + FloatValue: lua.FloatValue, + StringValue: lua.StringValue, + StringValue2: lua.StringValue2, + IntValue2: lua.IntValue2, + FloatValue2: lua.FloatValue2, + StringHelper: lua.StringHelper, + NeedsDBSave: lua.NeedsDBSave, + } + s.luaData = append(s.luaData, newLua) + } + + // If unique spell, generate new ID + if uniqueSpell { + // TODO: Generate unique spell ID when spell ID management is implemented + // s.data.SetID(GenerateUniqueSpellID()) + } + + return s +} + +// GetSpellData returns the core spell data (thread-safe) +func (s *Spell) GetSpellData() *SpellData { + s.mutex.RLock() + defer s.mutex.RUnlock() + return s.data +} + +// GetSpellID returns the spell's unique ID +func (s *Spell) GetSpellID() int32 { + return s.data.GetID() +} + +// GetName returns the spell's name +func (s *Spell) GetName() string { + return s.data.GetName() +} + +// GetDescription returns the spell's description +func (s *Spell) GetDescription() string { + s.mutex.RLock() + defer s.mutex.RUnlock() + return s.data.Description +} + +// GetSpellTier returns the spell's tier +func (s *Spell) GetSpellTier() int8 { + return s.data.GetTier() +} + +// GetSpellDuration returns the spell's duration +func (s *Spell) GetSpellDuration() int32 { + return s.data.GetDuration() +} + +// GetSpellIcon returns the spell's icon ID +func (s *Spell) GetSpellIcon() int16 { + s.mutex.RLock() + defer s.mutex.RUnlock() + return s.data.Icon +} + +// GetSpellIconBackdrop returns the spell's icon backdrop +func (s *Spell) GetSpellIconBackdrop() int16 { + s.mutex.RLock() + defer s.mutex.RUnlock() + return s.data.IconBackdrop +} + +// GetSpellIconHeroicOp returns the spell's heroic opportunity icon +func (s *Spell) GetSpellIconHeroicOp() int16 { + s.mutex.RLock() + defer s.mutex.RUnlock() + return s.data.IconHeroicOp +} + +// AddSpellLevel adds a level requirement for a specific class +func (s *Spell) AddSpellLevel(adventureClass, tradeskillClass int8, level int16, classicLevel float32) { + s.mutex.Lock() + defer s.mutex.Unlock() + + levelArray := &LevelArray{ + AdventureClass: adventureClass, + TradeskillClass: tradeskillClass, + SpellLevel: level, + ClassicSpellLevel: classicLevel, + } + + s.levels = append(s.levels, levelArray) +} + +// AddSpellEffect adds a display effect to the spell +func (s *Spell) AddSpellEffect(percentage, subbullet int8, description string) { + s.mutex.Lock() + defer s.mutex.Unlock() + + effect := &SpellDisplayEffect{ + Percentage: percentage, + Subbullet: subbullet, + Description: description, + NeedsDBSave: true, + } + + s.effects = append(s.effects, effect) +} + +// AddSpellLuaData adds LUA data to the spell +func (s *Spell) AddSpellLuaData(dataType int8, intValue, intValue2 int32, floatValue, floatValue2 float32, boolValue bool, stringValue, stringValue2, helper string) { + s.mutex.Lock() + defer s.mutex.Unlock() + + luaData := &LUAData{ + Type: dataType, + IntValue: intValue, + BoolValue: boolValue, + FloatValue: floatValue, + StringValue: stringValue, + StringValue2: stringValue2, + IntValue2: intValue2, + FloatValue2: floatValue2, + StringHelper: helper, + NeedsDBSave: true, + } + + s.luaData = append(s.luaData, luaData) +} + +// Convenience methods for adding specific LUA data types + +// AddSpellLuaDataInt adds integer LUA data +func (s *Spell) AddSpellLuaDataInt(value, value2 int32, helper string) { + s.AddSpellLuaData(SpellLUADataTypeInt, value, value2, 0.0, 0.0, false, "", "", helper) +} + +// AddSpellLuaDataFloat adds float LUA data +func (s *Spell) AddSpellLuaDataFloat(value, value2 float32, helper string) { + s.AddSpellLuaData(SpellLUADataTypeFloat, 0, 0, value, value2, false, "", "", helper) +} + +// AddSpellLuaDataBool adds boolean LUA data +func (s *Spell) AddSpellLuaDataBool(value bool, helper string) { + s.AddSpellLuaData(SpellLUADataTypeBool, 0, 0, 0.0, 0.0, value, "", "", helper) +} + +// AddSpellLuaDataString adds string LUA data +func (s *Spell) AddSpellLuaDataString(value, value2, helper string) { + s.AddSpellLuaData(SpellLUADataTypeString, 0, 0, 0.0, 0.0, false, value, value2, helper) +} + +// GetSpellLevels returns the spell level requirements +func (s *Spell) GetSpellLevels() []*LevelArray { + s.mutex.RLock() + defer s.mutex.RUnlock() + + // Return a copy to prevent external modification + levels := make([]*LevelArray, len(s.levels)) + copy(levels, s.levels) + return levels +} + +// GetSpellEffects returns the spell display effects +func (s *Spell) GetSpellEffects() []*SpellDisplayEffect { + s.mutex.RLock() + defer s.mutex.RUnlock() + + // Return a copy to prevent external modification + effects := make([]*SpellDisplayEffect, len(s.effects)) + copy(effects, s.effects) + return effects +} + +// GetSpellEffectSafe returns a spell effect safely by index +func (s *Spell) GetSpellEffectSafe(index int) *SpellDisplayEffect { + s.mutex.RLock() + defer s.mutex.RUnlock() + + if index < 0 || index >= len(s.effects) { + return nil + } + return s.effects[index] +} + +// GetLUAData returns the spell's LUA data +func (s *Spell) GetLUAData() []*LUAData { + s.mutex.RLock() + defer s.mutex.RUnlock() + + // Return a copy to prevent external modification + luaData := make([]*LUAData, len(s.luaData)) + copy(luaData, s.luaData) + return luaData +} + +// Spell classification methods (cached for performance) + +// IsHealSpell returns whether this is a healing spell +func (s *Spell) IsHealSpell() bool { + s.mutex.RLock() + defer s.mutex.RUnlock() + return s.healSpell +} + +// IsBuffSpell returns whether this is a buff spell +func (s *Spell) IsBuffSpell() bool { + s.mutex.RLock() + defer s.mutex.RUnlock() + return s.buffSpell +} + +// IsDamageSpell returns whether this is a damage spell +func (s *Spell) IsDamageSpell() bool { + s.mutex.RLock() + defer s.mutex.RUnlock() + return s.damageSpell +} + +// IsControlSpell returns whether this is a control spell +func (s *Spell) IsControlSpell() bool { + s.mutex.RLock() + defer s.mutex.RUnlock() + return s.controlSpell +} + +// IsOffenseSpell returns whether this is an offensive spell +func (s *Spell) IsOffenseSpell() bool { + s.mutex.RLock() + defer s.mutex.RUnlock() + return s.offenseSpell +} + +// IsCopiedSpell returns whether this is a copied spell +func (s *Spell) IsCopiedSpell() bool { + s.mutex.RLock() + defer s.mutex.RUnlock() + return s.copiedSpell +} + +// GetStayLocked returns whether the spell should stay locked +func (s *Spell) GetStayLocked() bool { + s.mutex.RLock() + defer s.mutex.RUnlock() + return s.stayLocked +} + +// StayLocked sets whether the spell should stay locked +func (s *Spell) StayLocked(val bool) { + s.mutex.Lock() + defer s.mutex.Unlock() + s.stayLocked = val +} + +// Cast type checking methods + +// CastWhileStunned returns whether spell can be cast while stunned +func (s *Spell) CastWhileStunned() bool { + return s.data.CanCastWhileStunned() +} + +// CastWhileMezzed returns whether spell can be cast while mezzed +func (s *Spell) CastWhileMezzed() bool { + return s.data.CanCastWhileMezzed() +} + +// CastWhileStifled returns whether spell can be cast while stifled +func (s *Spell) CastWhileStifled() bool { + return s.data.CanCastWhileStifled() +} + +// CastWhileFeared returns whether spell can be cast while feared +func (s *Spell) CastWhileFeared() bool { + return s.data.CanCastWhileFeared() +} + +// Requirement checking methods + +// GetLevelRequired returns the required level for a specific player +func (s *Spell) GetLevelRequired(playerClass int8, tradeskillClass int8) int16 { + s.mutex.RLock() + defer s.mutex.RUnlock() + + for _, level := range s.levels { + if level.AdventureClass == playerClass || level.TradeskillClass == tradeskillClass { + return level.SpellLevel + } + } + + return 0 // No specific requirement found +} + +// GetHPRequired returns HP required to cast (could be modified by entity stats) +func (s *Spell) GetHPRequired() int16 { + s.mutex.RLock() + defer s.mutex.RUnlock() + return s.data.HPReq +} + +// GetPowerRequired returns power required to cast +func (s *Spell) GetPowerRequired() float32 { + s.mutex.RLock() + defer s.mutex.RUnlock() + return s.data.PowerReq +} + +// GetSavageryRequired returns savagery required to cast +func (s *Spell) GetSavageryRequired() int16 { + s.mutex.RLock() + defer s.mutex.RUnlock() + return s.data.SavageryReq +} + +// GetDissonanceRequired returns dissonance required to cast +func (s *Spell) GetDissonanceRequired() int16 { + s.mutex.RLock() + defer s.mutex.RUnlock() + return s.data.DissonanceReq +} + +// computeSpellProperties analyzes the spell to determine its classification +func (s *Spell) computeSpellProperties() { + // This would analyze spell effects, target types, etc. to determine spell classification + // For now, use basic logic based on spell data + + s.buffSpell = s.data.IsBuffSpell() + s.controlSpell = s.data.IsControlSpell() + s.offenseSpell = s.data.IsOffenseSpell() + s.healSpell = s.data.IsHealSpell() + s.damageSpell = s.data.IsDamageSpell() +} + +// String returns a string representation of the spell +func (s *Spell) String() string { + return fmt.Sprintf("Spell[ID=%d, Name=%s, Tier=%d]", + s.GetSpellID(), s.GetName(), s.GetSpellTier()) +} + +// LuaSpell represents an active spell instance with runtime state +// This is converted from the C++ LuaSpell class +type LuaSpell struct { + // Core identification + Spell *Spell // Reference to the spell definition + CasterID int32 // ID of the entity casting this spell + + // Targeting + InitialTarget int32 // Original target ID + Targets []int32 // Current target IDs + + // Timing and state + Timer SpellTimer // Timer for duration/tick tracking + NumCalls int32 // Number of times spell has ticked + Restored bool // Whether this spell was restored from DB + SlotPos int16 // Spell book slot position + + // Runtime flags + Interrupted bool // Whether spell was interrupted + Deleted bool // Whether spell is marked for deletion + HasProc bool // Whether spell has proc effects + CasterCharID int32 // Character ID of caster (for cross-zone) + DamageRemaining int32 // Remaining damage for DOT spells + EffectBitmask int32 // Bitmask of active effects + + // Spell-specific data + CustomFunction string // Custom LUA function name + ResurrectHP float32 // HP to restore on resurrect + ResurrectPower float32 // Power to restore on resurrect + + // Thread safety + mutex sync.RWMutex +} + +// SpellTimer represents timing information for an active spell +type SpellTimer struct { + StartTime int64 // When the spell started (milliseconds) + Duration int32 // Total duration in milliseconds + SetAtTrigger int64 // Time when timer was set/triggered +} + +// NewLuaSpell creates a new LuaSpell instance +func NewLuaSpell(spell *Spell, casterID int32) *LuaSpell { + return &LuaSpell{ + Spell: spell, + CasterID: casterID, + InitialTarget: 0, + Targets: make([]int32, 0), + Timer: SpellTimer{}, + NumCalls: 0, + Restored: false, + SlotPos: 0, + Interrupted: false, + Deleted: false, + HasProc: false, + CasterCharID: 0, + DamageRemaining: 0, + EffectBitmask: 0, + CustomFunction: "", + ResurrectHP: 0.0, + ResurrectPower: 0.0, + } +} + +// GetTargets returns a copy of the target list +func (ls *LuaSpell) GetTargets() []int32 { + ls.mutex.RLock() + defer ls.mutex.RUnlock() + + targets := make([]int32, len(ls.Targets)) + copy(targets, ls.Targets) + return targets +} + +// AddTarget adds a target to the spell +func (ls *LuaSpell) AddTarget(targetID int32) { + ls.mutex.Lock() + defer ls.mutex.Unlock() + + // Check if target already exists + for _, id := range ls.Targets { + if id == targetID { + return + } + } + + ls.Targets = append(ls.Targets, targetID) +} + +// RemoveTarget removes a target from the spell +func (ls *LuaSpell) RemoveTarget(targetID int32) bool { + ls.mutex.Lock() + defer ls.mutex.Unlock() + + for i, id := range ls.Targets { + if id == targetID { + ls.Targets = append(ls.Targets[:i], ls.Targets[i+1:]...) + return true + } + } + + return false +} + +// HasTarget checks if the spell targets a specific entity +func (ls *LuaSpell) HasTarget(targetID int32) bool { + ls.mutex.RLock() + defer ls.mutex.RUnlock() + + for _, id := range ls.Targets { + if id == targetID { + return true + } + } + + return false +} + +// GetTargetCount returns the number of targets +func (ls *LuaSpell) GetTargetCount() int { + ls.mutex.RLock() + defer ls.mutex.RUnlock() + + return len(ls.Targets) +} + +// ClearTargets removes all targets from the spell +func (ls *LuaSpell) ClearTargets() { + ls.mutex.Lock() + defer ls.mutex.Unlock() + + ls.Targets = ls.Targets[:0] +} + +// SetCustomFunction sets a custom LUA function for this spell instance +func (ls *LuaSpell) SetCustomFunction(functionName string) { + ls.mutex.Lock() + defer ls.mutex.Unlock() + + ls.CustomFunction = functionName +} + +// GetCustomFunction returns the custom LUA function name +func (ls *LuaSpell) GetCustomFunction() string { + ls.mutex.RLock() + defer ls.mutex.RUnlock() + + return ls.CustomFunction +} + +// MarkForDeletion marks the spell for removal +func (ls *LuaSpell) MarkForDeletion() { + ls.mutex.Lock() + defer ls.mutex.Unlock() + + ls.Deleted = true +} + +// IsDeleted returns whether the spell is marked for deletion +func (ls *LuaSpell) IsDeleted() bool { + ls.mutex.RLock() + defer ls.mutex.RUnlock() + + return ls.Deleted +} + +// SetInterrupted marks the spell as interrupted +func (ls *LuaSpell) SetInterrupted(interrupted bool) { + ls.mutex.Lock() + defer ls.mutex.Unlock() + + ls.Interrupted = interrupted +} + +// IsInterrupted returns whether the spell was interrupted +func (ls *LuaSpell) IsInterrupted() bool { + ls.mutex.RLock() + defer ls.mutex.RUnlock() + + return ls.Interrupted +} + +// SetResurrectValues sets HP and power restoration values for resurrect spells +func (ls *LuaSpell) SetResurrectValues(hp, power float32) { + ls.mutex.Lock() + defer ls.mutex.Unlock() + + ls.ResurrectHP = hp + ls.ResurrectPower = power +} + +// GetResurrectValues returns HP and power restoration values +func (ls *LuaSpell) GetResurrectValues() (float32, float32) { + ls.mutex.RLock() + defer ls.mutex.RUnlock() + + return ls.ResurrectHP, ls.ResurrectPower +} + +// String returns a string representation of the LuaSpell +func (ls *LuaSpell) String() string { + ls.mutex.RLock() + defer ls.mutex.RUnlock() + + spellName := "Unknown" + if ls.Spell != nil { + spellName = ls.Spell.GetName() + } + + return fmt.Sprintf("LuaSpell[%s, Caster=%d, Targets=%d]", + spellName, ls.CasterID, len(ls.Targets)) +} \ No newline at end of file diff --git a/internal/spells/spell_data.go b/internal/spells/spell_data.go new file mode 100644 index 0000000..0f63e23 --- /dev/null +++ b/internal/spells/spell_data.go @@ -0,0 +1,434 @@ +package spells + +import ( + "sync" +) + +// LevelArray represents spell level requirements for different classes +type LevelArray struct { + AdventureClass int8 // Adventure class ID + TradeskillClass int8 // Tradeskill class ID + SpellLevel int16 // Required level + ClassicSpellLevel float32 // Classic spell level calculation +} + +// SpellDisplayEffect represents a displayed effect in spell descriptions +type SpellDisplayEffect struct { + Percentage int8 // Effect percentage + Subbullet int8 // Subbullet indicator + Description string // Effect description text + NeedsDBSave bool // Whether this needs database saving +} + +// LUAData represents Lua script data for spells +type LUAData struct { + Type int8 // Data type (int, float, bool, string) + IntValue int32 // Integer value + BoolValue bool // Boolean value + FloatValue float32 // Float value + StringValue string // String value + StringValue2 string // Second string value + IntValue2 int32 // Second integer value + FloatValue2 float32 // Second float value + StringHelper string // Helper string for identification + NeedsDBSave bool // Whether this needs database saving +} + +// SpellData contains all core spell information +// This is the main spell data structure converted from C++ SpellData +type SpellData struct { + // Basic identification + SpellBookType int32 // Type of spell book this belongs to + ID int32 // Unique spell ID + InheritedSpellID int32 // ID of spell this inherits from + Name string // Spell name + Description string // Spell description + + // Visual information + Icon int16 // Spell icon ID + IconHeroicOp int16 // Heroic opportunity icon + IconBackdrop int16 // Icon backdrop + SpellVisual int32 // Visual effect ID + + // Classification + Type int16 // Spell type + SpellType int8 // Additional spell type classification + ClassSkill int32 // Required class skill + MinClassSkillReq int16 // Minimum class skill requirement + MasterySkill int32 // Mastery skill required + TSLocIndex int8 // Tradeskill location index + Tier int8 // Spell tier + NumLevels int8 // Number of levels this spell has + + // Resource requirements + HPReq int16 // Health points required + HPUpkeep int16 // Health points upkeep + PowerReq float32 // Power required to cast + PowerByLevel bool // Power requirement scales by level + PowerUpkeep int16 // Power upkeep cost + SavageryReq int16 // Savagery required + SavageryUpkeep int16 // Savagery upkeep + DissonanceReq int16 // Dissonance required + DissonanceUpkeep int16 // Dissonance upkeep + ReqConcentration int16 // Concentration required + + // Percentage-based requirements + PowerReqPercent int8 // Power requirement as percentage of max + HPReqPercent int8 // HP requirement as percentage of max + SavageryReqPercent int8 // Savagery requirement as percentage + DissonanceReqPercent int8 // Dissonance requirement as percentage + + // Targeting and range + TargetType int8 // Type of target (self, enemy, group, etc.) + Range float32 // Casting range + MinRange float32 // Minimum casting range + Radius float32 // Area of effect radius + MaxAOETargets int16 // Maximum AoE targets + FriendlySpell int8 // Whether this is a friendly spell + + // Timing + CastTime int16 // Cast time in deciseconds + OrigCastTime int16 // Original cast time (before modifications) + Recovery float32 // Recovery time + Recast float32 // Recast delay + LinkedTimer int32 // Linked timer ID + CallFrequency int32 // How often spell effect is called + + // Duration and resistibility + Duration1 int32 // Primary duration + Duration2 int32 // Secondary duration + Resistibility float32 // How resistible the spell is + DurationUntilCancel bool // Duration lasts until cancelled + + // Combat and effect properties + HitBonus float32 // Hit bonus provided + CanEffectRaid int8 // Can affect raid members + AffectOnlyGroupMembers int8 // Only affects group members + GroupSpell int8 // Is a group spell + DetType int8 // Detrimental type + Incurable bool // Cannot be cured + ControlEffectType int8 // Type of control effect + + // Behavioral flags + CastType int8 // Cast type (normal, toggle) + CastingFlags int32 // Various casting flags + CastWhileMoving bool // Can cast while moving + PersistThroughDeath bool // Persists through death + NotMaintained bool // Not a maintained spell + IsAA bool // Is an Alternate Advancement ability + CanFizzle bool // Can fizzle on cast + Interruptable bool // Can be interrupted + IsActive bool // Spell is active/enabled + + // Savage bar (for certain spell types) + SavageBar int8 // Savage bar requirement + SavageBarSlot int8 // Savage bar slot + + // Messages + SuccessMessage string // Message on successful cast + FadeMessage string // Message when spell fades + FadeMessageOthers string // Fade message for others + EffectMessage string // Effect message + + // Scripting + LuaScript string // Lua script filename + + // Versioning and classification + DisplaySpellTier int8 // Displayed tier + SOESpellCRC int32 // SOE spell CRC + SpellNameCRC int32 // Spell name CRC + TypeGroupSpellID int32 // Type group spell ID + GivenBy string // Description of how spell was obtained + GivenByType GivenByType // Type of how spell was obtained + + // Thread safety + mutex sync.RWMutex +} + +// NewSpellData creates a new SpellData with default values +func NewSpellData() *SpellData { + return &SpellData{ + SpellBookType: SpellBookTypeSpell, + ID: 0, + InheritedSpellID: 0, + Name: "", + Description: "", + Icon: 0, + IconHeroicOp: 0, + IconBackdrop: 0, + SpellVisual: 0, + Type: 0, + SpellType: 0, + ClassSkill: 0, + MinClassSkillReq: 0, + MasterySkill: 0, + TSLocIndex: 0, + Tier: 1, + NumLevels: 1, + HPReq: 0, + HPUpkeep: 0, + PowerReq: 0.0, + PowerByLevel: false, + PowerUpkeep: 0, + SavageryReq: 0, + SavageryUpkeep: 0, + DissonanceReq: 0, + DissonanceUpkeep: 0, + ReqConcentration: 0, + PowerReqPercent: 0, + HPReqPercent: 0, + SavageryReqPercent: 0, + DissonanceReqPercent: 0, + TargetType: SpellTargetSelf, + Range: 0.0, + MinRange: 0.0, + Radius: 0.0, + MaxAOETargets: 0, + FriendlySpell: 1, + CastTime: 0, + OrigCastTime: 0, + Recovery: 0.0, + Recast: 0.0, + LinkedTimer: 0, + CallFrequency: 0, + Duration1: 0, + Duration2: 0, + Resistibility: 0.0, + DurationUntilCancel: false, + HitBonus: 0.0, + CanEffectRaid: 0, + AffectOnlyGroupMembers: 0, + GroupSpell: 0, + DetType: 0, + Incurable: false, + ControlEffectType: 0, + CastType: SpellCastTypeNormal, + CastingFlags: 0, + CastWhileMoving: false, + PersistThroughDeath: false, + NotMaintained: false, + IsAA: false, + CanFizzle: true, + Interruptable: true, + IsActive: true, + SavageBar: 0, + SavageBarSlot: 0, + SuccessMessage: "", + FadeMessage: "", + FadeMessageOthers: "", + EffectMessage: "", + LuaScript: "", + DisplaySpellTier: 1, + SOESpellCRC: 0, + SpellNameCRC: 0, + TypeGroupSpellID: 0, + GivenBy: "", + GivenByType: GivenByUnset, + } +} + +// GetID returns the spell ID (thread-safe) +func (sd *SpellData) GetID() int32 { + sd.mutex.RLock() + defer sd.mutex.RUnlock() + return sd.ID +} + +// SetID updates the spell ID (thread-safe) +func (sd *SpellData) SetID(id int32) { + sd.mutex.Lock() + defer sd.mutex.Unlock() + sd.ID = id +} + +// GetName returns the spell name (thread-safe) +func (sd *SpellData) GetName() string { + sd.mutex.RLock() + defer sd.mutex.RUnlock() + return sd.Name +} + +// SetName updates the spell name (thread-safe) +func (sd *SpellData) SetName(name string) { + sd.mutex.Lock() + defer sd.mutex.Unlock() + sd.Name = name +} + +// GetTier returns the spell tier +func (sd *SpellData) GetTier() int8 { + sd.mutex.RLock() + defer sd.mutex.RUnlock() + return sd.Tier +} + +// GetDuration returns the primary spell duration +func (sd *SpellData) GetDuration() int32 { + sd.mutex.RLock() + defer sd.mutex.RUnlock() + return sd.Duration1 +} + +// GetCastTime returns the cast time +func (sd *SpellData) GetCastTime() int16 { + sd.mutex.RLock() + defer sd.mutex.RUnlock() + return sd.CastTime +} + +// GetTargetType returns the target type +func (sd *SpellData) GetTargetType() int8 { + sd.mutex.RLock() + defer sd.mutex.RUnlock() + return sd.TargetType +} + +// GetRange returns the casting range +func (sd *SpellData) GetRange() float32 { + sd.mutex.RLock() + defer sd.mutex.RUnlock() + return sd.Range +} + +// IsHealSpell determines if this is a healing spell +func (sd *SpellData) IsHealSpell() bool { + // TODO: Implement based on spell effects or type classification + return false +} + +// IsBuffSpell determines if this is a buff spell +func (sd *SpellData) IsBuffSpell() bool { + // TODO: Implement based on spell effects or duration + return sd.GetDuration() > 0 && sd.FriendlySpell == 1 +} + +// IsDamageSpell determines if this is a damage spell +func (sd *SpellData) IsDamageSpell() bool { + // TODO: Implement based on spell effects + return false +} + +// IsControlSpell determines if this is a control spell +func (sd *SpellData) IsControlSpell() bool { + sd.mutex.RLock() + defer sd.mutex.RUnlock() + return sd.ControlEffectType > 0 +} + +// IsOffenseSpell determines if this is an offensive spell +func (sd *SpellData) IsOffenseSpell() bool { + sd.mutex.RLock() + defer sd.mutex.RUnlock() + return sd.FriendlySpell == 0 +} + +// CanCastWhileStunned returns whether spell can be cast while stunned +func (sd *SpellData) CanCastWhileStunned() bool { + // Check casting flags for stun immunity + return (sd.CastingFlags & 0x01) != 0 +} + +// CanCastWhileMezzed returns whether spell can be cast while mezzed +func (sd *SpellData) CanCastWhileMezzed() bool { + // Check casting flags for mez immunity + return (sd.CastingFlags & 0x02) != 0 +} + +// CanCastWhileStifled returns whether spell can be cast while stifled +func (sd *SpellData) CanCastWhileStifled() bool { + // Check casting flags for stifle immunity + return (sd.CastingFlags & 0x04) != 0 +} + +// CanCastWhileFeared returns whether spell can be cast while feared +func (sd *SpellData) CanCastWhileFeared() bool { + // Check casting flags for fear immunity + return (sd.CastingFlags & 0x08) != 0 +} + +// Clone creates a deep copy of the SpellData +func (sd *SpellData) Clone() *SpellData { + sd.mutex.RLock() + defer sd.mutex.RUnlock() + + clone := &SpellData{ + SpellBookType: sd.SpellBookType, + ID: sd.ID, + InheritedSpellID: sd.InheritedSpellID, + Name: sd.Name, + Description: sd.Description, + Icon: sd.Icon, + IconHeroicOp: sd.IconHeroicOp, + IconBackdrop: sd.IconBackdrop, + SpellVisual: sd.SpellVisual, + Type: sd.Type, + SpellType: sd.SpellType, + ClassSkill: sd.ClassSkill, + MinClassSkillReq: sd.MinClassSkillReq, + MasterySkill: sd.MasterySkill, + TSLocIndex: sd.TSLocIndex, + Tier: sd.Tier, + NumLevels: sd.NumLevels, + HPReq: sd.HPReq, + HPUpkeep: sd.HPUpkeep, + PowerReq: sd.PowerReq, + PowerByLevel: sd.PowerByLevel, + PowerUpkeep: sd.PowerUpkeep, + SavageryReq: sd.SavageryReq, + SavageryUpkeep: sd.SavageryUpkeep, + DissonanceReq: sd.DissonanceReq, + DissonanceUpkeep: sd.DissonanceUpkeep, + ReqConcentration: sd.ReqConcentration, + PowerReqPercent: sd.PowerReqPercent, + HPReqPercent: sd.HPReqPercent, + SavageryReqPercent: sd.SavageryReqPercent, + DissonanceReqPercent: sd.DissonanceReqPercent, + TargetType: sd.TargetType, + Range: sd.Range, + MinRange: sd.MinRange, + Radius: sd.Radius, + MaxAOETargets: sd.MaxAOETargets, + FriendlySpell: sd.FriendlySpell, + CastTime: sd.CastTime, + OrigCastTime: sd.OrigCastTime, + Recovery: sd.Recovery, + Recast: sd.Recast, + LinkedTimer: sd.LinkedTimer, + CallFrequency: sd.CallFrequency, + Duration1: sd.Duration1, + Duration2: sd.Duration2, + Resistibility: sd.Resistibility, + DurationUntilCancel: sd.DurationUntilCancel, + HitBonus: sd.HitBonus, + CanEffectRaid: sd.CanEffectRaid, + AffectOnlyGroupMembers: sd.AffectOnlyGroupMembers, + GroupSpell: sd.GroupSpell, + DetType: sd.DetType, + Incurable: sd.Incurable, + ControlEffectType: sd.ControlEffectType, + CastType: sd.CastType, + CastingFlags: sd.CastingFlags, + CastWhileMoving: sd.CastWhileMoving, + PersistThroughDeath: sd.PersistThroughDeath, + NotMaintained: sd.NotMaintained, + IsAA: sd.IsAA, + CanFizzle: sd.CanFizzle, + Interruptable: sd.Interruptable, + IsActive: sd.IsActive, + SavageBar: sd.SavageBar, + SavageBarSlot: sd.SavageBarSlot, + SuccessMessage: sd.SuccessMessage, + FadeMessage: sd.FadeMessage, + FadeMessageOthers: sd.FadeMessageOthers, + EffectMessage: sd.EffectMessage, + LuaScript: sd.LuaScript, + DisplaySpellTier: sd.DisplaySpellTier, + SOESpellCRC: sd.SOESpellCRC, + SpellNameCRC: sd.SpellNameCRC, + TypeGroupSpellID: sd.TypeGroupSpellID, + GivenBy: sd.GivenBy, + GivenByType: sd.GivenByType, + } + + return clone +} diff --git a/internal/spells/spell_effects.go b/internal/spells/spell_effects.go new file mode 100644 index 0000000..e1a036b --- /dev/null +++ b/internal/spells/spell_effects.go @@ -0,0 +1,621 @@ +package spells + +import ( + "sync" + "time" +) + +// BonusValues represents a stat bonus from equipment, spells, or other sources +// Moved from entity package to centralize spell-related structures +type BonusValues struct { + SpellID int32 // ID of spell providing this bonus + Tier int8 // Tier of the bonus + Type int16 // Type of bonus (stat type) + Value float32 // Bonus value + ClassReq int64 // Required class bitmask + RaceReq []int16 // Required race IDs + FactionReq []int16 // Required faction IDs + // TODO: Add LuaSpell reference when spell system is implemented + // LuaSpell *LuaSpell // Associated Lua spell +} + +// NewBonusValues creates a new bonus value entry +func NewBonusValues(spellID int32, bonusType int16, value float32) *BonusValues { + return &BonusValues{ + SpellID: spellID, + Tier: 1, + Type: bonusType, + Value: value, + ClassReq: 0, + RaceReq: make([]int16, 0), + FactionReq: make([]int16, 0), + } +} + +// MeetsRequirements checks if an entity meets the requirements for this bonus +func (bv *BonusValues) MeetsRequirements(entityClass int64, race int16, factionID int32) bool { + // Check class requirement + if bv.ClassReq != 0 && (entityClass&bv.ClassReq) == 0 { + return false + } + + // Check race requirement + if len(bv.RaceReq) > 0 { + raceMatch := false + for _, reqRace := range bv.RaceReq { + if reqRace == race { + raceMatch = true + break + } + } + if !raceMatch { + return false + } + } + + // Check faction requirement + if len(bv.FactionReq) > 0 { + factionMatch := false + for _, reqFaction := range bv.FactionReq { + if reqFaction == int16(factionID) { + factionMatch = true + break + } + } + if !factionMatch { + return false + } + } + + return true +} + +// MaintainedEffects represents a buff that is actively maintained on an entity +// Moved from entity package to centralize spell-related structures +type MaintainedEffects struct { + Name [60]byte // Name of the spell + Target int32 // Target entity ID + TargetType int8 // Type of target + SpellID int32 // Spell ID + InheritedSpellID int32 // Inherited spell ID (for spell upgrades) + SlotPos int32 // Slot position in maintained effects + Icon int16 // Icon to display + IconBackdrop int16 // Icon backdrop + ConcUsed int8 // Concentration used + Tier int8 // Spell tier + TotalTime float32 // Total duration of effect + ExpireTimestamp int32 // When the effect expires + // TODO: Add LuaSpell reference when spell system is implemented + // Spell *LuaSpell // Associated Lua spell +} + +// NewMaintainedEffects creates a new maintained effect +func NewMaintainedEffects(name string, spellID int32, duration float32) *MaintainedEffects { + effect := &MaintainedEffects{ + Target: 0, + TargetType: 0, + SpellID: spellID, + InheritedSpellID: 0, + SlotPos: 0, + Icon: 0, + IconBackdrop: 0, + ConcUsed: 0, + Tier: 1, + TotalTime: duration, + ExpireTimestamp: int32(time.Now().Unix()) + int32(duration), + } + + // Copy name to fixed-size array + nameBytes := []byte(name) + copy(effect.Name[:], nameBytes) + + return effect +} + +// GetName returns the spell name as a string +func (me *MaintainedEffects) GetName() string { + // Find the null terminator + nameBytes := me.Name[:] + for i, b := range nameBytes { + if b == 0 { + return string(nameBytes[:i]) + } + } + return string(nameBytes) +} + +// IsExpired checks if the maintained effect has expired +func (me *MaintainedEffects) IsExpired() bool { + if me.TotalTime <= 0 { + return false // Permanent effect + } + return int32(time.Now().Unix()) >= me.ExpireTimestamp +} + +// GetTimeRemaining returns the time remaining for this effect +func (me *MaintainedEffects) GetTimeRemaining() float32 { + if me.TotalTime <= 0 { + return -1 // Permanent effect + } + + remaining := float32(me.ExpireTimestamp - int32(time.Now().Unix())) + if remaining < 0 { + return 0 + } + return remaining +} + +// SpellEffects represents a temporary spell effect on an entity +// Moved from entity package to centralize spell-related structures +type SpellEffects struct { + SpellID int32 // Spell ID + InheritedSpellID int32 // Inherited spell ID + // TODO: Add Entity reference when implemented + // Caster *Entity // Entity that cast the spell + CasterID int32 // ID of caster entity (temporary) + TotalTime float32 // Total duration + ExpireTimestamp int32 // When effect expires + Icon int16 // Icon to display + IconBackdrop int16 // Icon backdrop + Tier int8 // Spell tier + // TODO: Add LuaSpell reference when spell system is implemented + // Spell *LuaSpell // Associated Lua spell +} + +// NewSpellEffects creates a new spell effect +func NewSpellEffects(spellID int32, casterID int32, duration float32) *SpellEffects { + return &SpellEffects{ + SpellID: spellID, + InheritedSpellID: 0, + CasterID: casterID, + TotalTime: duration, + ExpireTimestamp: int32(time.Now().Unix()) + int32(duration), + Icon: 0, + IconBackdrop: 0, + Tier: 1, + } +} + +// IsExpired checks if the spell effect has expired +func (se *SpellEffects) IsExpired() bool { + if se.TotalTime <= 0 { + return false // Permanent effect + } + return int32(time.Now().Unix()) >= se.ExpireTimestamp +} + +// GetTimeRemaining returns the time remaining for this effect +func (se *SpellEffects) GetTimeRemaining() float32 { + if se.TotalTime <= 0 { + return -1 // Permanent effect + } + + remaining := float32(se.ExpireTimestamp - int32(time.Now().Unix())) + if remaining < 0 { + return 0 + } + return remaining +} + +// DetrimentalEffects represents a debuff or harmful effect on an entity +// Moved from entity package to centralize spell-related structures +type DetrimentalEffects struct { + SpellID int32 // Spell ID + InheritedSpellID int32 // Inherited spell ID + // TODO: Add Entity reference when implemented + // Caster *Entity // Entity that cast the spell + CasterID int32 // ID of caster entity (temporary) + ExpireTimestamp int32 // When effect expires + Icon int16 // Icon to display + IconBackdrop int16 // Icon backdrop + Tier int8 // Spell tier + DetType int8 // Detrimental type + Incurable bool // Cannot be cured + ControlEffect int8 // Control effect type + TotalTime float32 // Total duration + // TODO: Add LuaSpell reference when spell system is implemented + // Spell *LuaSpell // Associated Lua spell +} + +// NewDetrimentalEffects creates a new detrimental effect +func NewDetrimentalEffects(spellID int32, casterID int32, duration float32) *DetrimentalEffects { + return &DetrimentalEffects{ + SpellID: spellID, + InheritedSpellID: 0, + CasterID: casterID, + ExpireTimestamp: int32(time.Now().Unix()) + int32(duration), + Icon: 0, + IconBackdrop: 0, + Tier: 1, + DetType: 0, + Incurable: false, + ControlEffect: 0, + TotalTime: duration, + } +} + +// IsExpired checks if the detrimental effect has expired +func (de *DetrimentalEffects) IsExpired() bool { + if de.TotalTime <= 0 { + return false // Permanent effect + } + return int32(time.Now().Unix()) >= de.ExpireTimestamp +} + +// GetTimeRemaining returns the time remaining for this effect +func (de *DetrimentalEffects) GetTimeRemaining() float32 { + if de.TotalTime <= 0 { + return -1 // Permanent effect + } + + remaining := float32(de.ExpireTimestamp - int32(time.Now().Unix())) + if remaining < 0 { + return 0 + } + return remaining +} + +// IsControlEffect checks if this detrimental is a control effect +func (de *DetrimentalEffects) IsControlEffect() bool { + return de.ControlEffect > 0 +} + +// SpellEffectManager manages all spell effects for an entity +// Moved from entity package to centralize spell-related structures +type SpellEffectManager struct { + // Maintained effects (buffs that use concentration) + maintainedEffects [30]*MaintainedEffects + maintainedMutex sync.RWMutex + + // Temporary spell effects (buffs/debuffs with durations) + spellEffects [45]*SpellEffects + effectsMutex sync.RWMutex + + // Detrimental effects (debuffs) + detrimentalEffects []DetrimentalEffects + detrimentalMutex sync.RWMutex + + // Control effects organized by type + controlEffects map[int8][]*DetrimentalEffects + controlMutex sync.RWMutex + + // Detrimental count by type for stacking limits + detCountList map[int8]int8 + countMutex sync.RWMutex + + // Bonus list for stat modifications + bonusList []*BonusValues + bonusMutex sync.RWMutex + + // Immunity list organized by effect type + immunities map[int8][]*DetrimentalEffects + immunityMutex sync.RWMutex +} + +// NewSpellEffectManager creates a new spell effect manager +func NewSpellEffectManager() *SpellEffectManager { + return &SpellEffectManager{ + maintainedEffects: [30]*MaintainedEffects{}, + spellEffects: [45]*SpellEffects{}, + detrimentalEffects: make([]DetrimentalEffects, 0), + controlEffects: make(map[int8][]*DetrimentalEffects), + detCountList: make(map[int8]int8), + bonusList: make([]*BonusValues, 0), + immunities: make(map[int8][]*DetrimentalEffects), + } +} + +// AddMaintainedEffect adds a maintained effect to an available slot +func (sem *SpellEffectManager) AddMaintainedEffect(effect *MaintainedEffects) bool { + sem.maintainedMutex.Lock() + defer sem.maintainedMutex.Unlock() + + // Find an empty slot + for i := 0; i < len(sem.maintainedEffects); i++ { + if sem.maintainedEffects[i] == nil { + effect.SlotPos = int32(i) + sem.maintainedEffects[i] = effect + return true + } + } + + return false // No available slots +} + +// RemoveMaintainedEffect removes a maintained effect by spell ID +func (sem *SpellEffectManager) RemoveMaintainedEffect(spellID int32) bool { + sem.maintainedMutex.Lock() + defer sem.maintainedMutex.Unlock() + + for i := 0; i < len(sem.maintainedEffects); i++ { + if sem.maintainedEffects[i] != nil && sem.maintainedEffects[i].SpellID == spellID { + sem.maintainedEffects[i] = nil + return true + } + } + + return false +} + +// GetMaintainedEffect retrieves a maintained effect by spell ID +func (sem *SpellEffectManager) GetMaintainedEffect(spellID int32) *MaintainedEffects { + sem.maintainedMutex.RLock() + defer sem.maintainedMutex.RUnlock() + + for _, effect := range sem.maintainedEffects { + if effect != nil && effect.SpellID == spellID { + return effect + } + } + + return nil +} + +// GetAllMaintainedEffects returns a copy of all active maintained effects +func (sem *SpellEffectManager) GetAllMaintainedEffects() []*MaintainedEffects { + sem.maintainedMutex.RLock() + defer sem.maintainedMutex.RUnlock() + + effects := make([]*MaintainedEffects, 0) + for _, effect := range sem.maintainedEffects { + if effect != nil { + effects = append(effects, effect) + } + } + + return effects +} + +// AddSpellEffect adds a temporary spell effect to an available slot +func (sem *SpellEffectManager) AddSpellEffect(effect *SpellEffects) bool { + sem.effectsMutex.Lock() + defer sem.effectsMutex.Unlock() + + // Find an empty slot + for i := 0; i < len(sem.spellEffects); i++ { + if sem.spellEffects[i] == nil { + sem.spellEffects[i] = effect + return true + } + } + + return false // No available slots +} + +// RemoveSpellEffect removes a spell effect by spell ID +func (sem *SpellEffectManager) RemoveSpellEffect(spellID int32) bool { + sem.effectsMutex.Lock() + defer sem.effectsMutex.Unlock() + + for i := 0; i < len(sem.spellEffects); i++ { + if sem.spellEffects[i] != nil && sem.spellEffects[i].SpellID == spellID { + sem.spellEffects[i] = nil + return true + } + } + + return false +} + +// GetSpellEffect retrieves a spell effect by spell ID +func (sem *SpellEffectManager) GetSpellEffect(spellID int32) *SpellEffects { + sem.effectsMutex.RLock() + defer sem.effectsMutex.RUnlock() + + for _, effect := range sem.spellEffects { + if effect != nil && effect.SpellID == spellID { + return effect + } + } + + return nil +} + +// AddDetrimentalEffect adds a detrimental effect +func (sem *SpellEffectManager) AddDetrimentalEffect(effect DetrimentalEffects) { + sem.detrimentalMutex.Lock() + defer sem.detrimentalMutex.Unlock() + + sem.detrimentalEffects = append(sem.detrimentalEffects, effect) + + // Update detrimental count + sem.countMutex.Lock() + sem.detCountList[effect.DetType]++ + sem.countMutex.Unlock() + + // Add to control effects if applicable + if effect.IsControlEffect() { + sem.controlMutex.Lock() + if sem.controlEffects[effect.ControlEffect] == nil { + sem.controlEffects[effect.ControlEffect] = make([]*DetrimentalEffects, 0) + } + sem.controlEffects[effect.ControlEffect] = append(sem.controlEffects[effect.ControlEffect], &effect) + sem.controlMutex.Unlock() + } +} + +// RemoveDetrimentalEffect removes a detrimental effect by spell ID and caster +func (sem *SpellEffectManager) RemoveDetrimentalEffect(spellID int32, casterID int32) bool { + sem.detrimentalMutex.Lock() + defer sem.detrimentalMutex.Unlock() + + for i, effect := range sem.detrimentalEffects { + if effect.SpellID == spellID && effect.CasterID == casterID { + // Remove from slice + sem.detrimentalEffects = append(sem.detrimentalEffects[:i], sem.detrimentalEffects[i+1:]...) + + // Update detrimental count + sem.countMutex.Lock() + sem.detCountList[effect.DetType]-- + if sem.detCountList[effect.DetType] <= 0 { + delete(sem.detCountList, effect.DetType) + } + sem.countMutex.Unlock() + + // Remove from control effects if applicable + if effect.IsControlEffect() { + sem.removeFromControlEffects(effect.ControlEffect, spellID, casterID) + } + + return true + } + } + + return false +} + +// removeFromControlEffects removes a control effect from the control effects map +func (sem *SpellEffectManager) removeFromControlEffects(controlType int8, spellID int32, casterID int32) { + sem.controlMutex.Lock() + defer sem.controlMutex.Unlock() + + if effects, exists := sem.controlEffects[controlType]; exists { + for i, effect := range effects { + if effect.SpellID == spellID && effect.CasterID == casterID { + sem.controlEffects[controlType] = append(effects[:i], effects[i+1:]...) + if len(sem.controlEffects[controlType]) == 0 { + delete(sem.controlEffects, controlType) + } + break + } + } + } +} + +// GetDetrimentalEffect retrieves a detrimental effect by spell ID and caster +func (sem *SpellEffectManager) GetDetrimentalEffect(spellID int32, casterID int32) *DetrimentalEffects { + sem.detrimentalMutex.RLock() + defer sem.detrimentalMutex.RUnlock() + + for i, effect := range sem.detrimentalEffects { + if effect.SpellID == spellID && effect.CasterID == casterID { + return &sem.detrimentalEffects[i] + } + } + + return nil +} + +// HasControlEffect checks if the entity has a specific control effect active +func (sem *SpellEffectManager) HasControlEffect(controlType int8) bool { + sem.controlMutex.RLock() + defer sem.controlMutex.RUnlock() + + effects, exists := sem.controlEffects[controlType] + return exists && len(effects) > 0 +} + +// AddBonus adds a stat bonus +func (sem *SpellEffectManager) AddBonus(bonus *BonusValues) { + sem.bonusMutex.Lock() + defer sem.bonusMutex.Unlock() + + sem.bonusList = append(sem.bonusList, bonus) +} + +// RemoveBonus removes a stat bonus by spell ID +func (sem *SpellEffectManager) RemoveBonus(spellID int32) bool { + sem.bonusMutex.Lock() + defer sem.bonusMutex.Unlock() + + for i, bonus := range sem.bonusList { + if bonus.SpellID == spellID { + sem.bonusList = append(sem.bonusList[:i], sem.bonusList[i+1:]...) + return true + } + } + + return false +} + +// GetBonusValue calculates the total bonus value for a specific stat type +func (sem *SpellEffectManager) GetBonusValue(bonusType int16, entityClass int64, race int16, factionID int32) float32 { + sem.bonusMutex.RLock() + defer sem.bonusMutex.RUnlock() + + var total float32 = 0 + + for _, bonus := range sem.bonusList { + if bonus.Type == bonusType && bonus.MeetsRequirements(entityClass, race, factionID) { + total += bonus.Value + } + } + + return total +} + +// CleanupExpiredEffects removes all expired effects +func (sem *SpellEffectManager) CleanupExpiredEffects() { + // Clean maintained effects + sem.maintainedMutex.Lock() + for i, effect := range sem.maintainedEffects { + if effect != nil && effect.IsExpired() { + sem.maintainedEffects[i] = nil + } + } + sem.maintainedMutex.Unlock() + + // Clean spell effects + sem.effectsMutex.Lock() + for i, effect := range sem.spellEffects { + if effect != nil && effect.IsExpired() { + sem.spellEffects[i] = nil + } + } + sem.effectsMutex.Unlock() + + // Clean detrimental effects + sem.detrimentalMutex.Lock() + newDetrimentals := make([]DetrimentalEffects, 0) + for _, effect := range sem.detrimentalEffects { + if !effect.IsExpired() { + newDetrimentals = append(newDetrimentals, effect) + } else { + // Update count + sem.countMutex.Lock() + sem.detCountList[effect.DetType]-- + if sem.detCountList[effect.DetType] <= 0 { + delete(sem.detCountList, effect.DetType) + } + sem.countMutex.Unlock() + + // Remove from control effects + if effect.IsControlEffect() { + sem.removeFromControlEffects(effect.ControlEffect, effect.SpellID, effect.CasterID) + } + } + } + sem.detrimentalEffects = newDetrimentals + sem.detrimentalMutex.Unlock() +} + +// ClearAllEffects removes all spell effects +func (sem *SpellEffectManager) ClearAllEffects() { + sem.maintainedMutex.Lock() + for i := range sem.maintainedEffects { + sem.maintainedEffects[i] = nil + } + sem.maintainedMutex.Unlock() + + sem.effectsMutex.Lock() + for i := range sem.spellEffects { + sem.spellEffects[i] = nil + } + sem.effectsMutex.Unlock() + + sem.detrimentalMutex.Lock() + sem.detrimentalEffects = make([]DetrimentalEffects, 0) + sem.detrimentalMutex.Unlock() + + sem.controlMutex.Lock() + sem.controlEffects = make(map[int8][]*DetrimentalEffects) + sem.controlMutex.Unlock() + + sem.countMutex.Lock() + sem.detCountList = make(map[int8]int8) + sem.countMutex.Unlock() + + sem.bonusMutex.Lock() + sem.bonusList = make([]*BonusValues, 0) + sem.bonusMutex.Unlock() +} \ No newline at end of file diff --git a/internal/spells/spell_manager.go b/internal/spells/spell_manager.go new file mode 100644 index 0000000..5bfb1c5 --- /dev/null +++ b/internal/spells/spell_manager.go @@ -0,0 +1,597 @@ +package spells + +import ( + "fmt" + "sync" + "sync/atomic" +) + +// SpellScriptTimer represents a timer for spell script execution +type SpellScriptTimer struct { + // TODO: Add LuaSpell reference when implemented + // Spell *LuaSpell // The spell being timed + SpellID int32 // Spell ID for identification + CustomFunction string // Custom function to call + Time int32 // Timer duration + Caster int32 // Caster entity ID + Target int32 // Target entity ID + DeleteWhenDone bool // Whether to delete timer when finished +} + +// MasterSpellList manages all spells in the game +// This replaces the C++ MasterSpellList functionality +type MasterSpellList struct { + // Spell storage + spells map[int32]*Spell // Spells by ID + spellsByName map[string]*Spell // Spells by name for lookup + spellsByTier map[int32]map[int8]*Spell // Spells by ID and tier + + // ID management + maxSpellID int32 // Highest assigned spell ID + + // Thread safety + mutex sync.RWMutex +} + +// NewMasterSpellList creates a new master spell list +func NewMasterSpellList() *MasterSpellList { + return &MasterSpellList{ + spells: make(map[int32]*Spell), + spellsByName: make(map[string]*Spell), + spellsByTier: make(map[int32]map[int8]*Spell), + maxSpellID: 0, + } +} + +// AddSpell adds a spell to the master list +func (msl *MasterSpellList) AddSpell(spell *Spell) bool { + if spell == nil { + return false + } + + msl.mutex.Lock() + defer msl.mutex.Unlock() + + spellID := spell.GetSpellID() + + // Update max spell ID + if spellID > msl.maxSpellID { + msl.maxSpellID = spellID + } + + // Add to main spell map + msl.spells[spellID] = spell + + // Add to name lookup + name := spell.GetName() + if name != "" { + msl.spellsByName[name] = spell + } + + // Add to tier lookup + tier := spell.GetSpellTier() + if msl.spellsByTier[spellID] == nil { + msl.spellsByTier[spellID] = make(map[int8]*Spell) + } + msl.spellsByTier[spellID][tier] = spell + + return true +} + +// GetSpell retrieves a spell by ID +func (msl *MasterSpellList) GetSpell(spellID int32) *Spell { + msl.mutex.RLock() + defer msl.mutex.RUnlock() + + return msl.spells[spellID] +} + +// GetSpellByName retrieves a spell by name +func (msl *MasterSpellList) GetSpellByName(name string) *Spell { + msl.mutex.RLock() + defer msl.mutex.RUnlock() + + return msl.spellsByName[name] +} + +// GetSpellByIDAndTier retrieves a specific tier of a spell +func (msl *MasterSpellList) GetSpellByIDAndTier(spellID int32, tier int8) *Spell { + msl.mutex.RLock() + defer msl.mutex.RUnlock() + + if tierMap, exists := msl.spellsByTier[spellID]; exists { + return tierMap[tier] + } + + return nil +} + +// RemoveSpell removes a spell from the master list +func (msl *MasterSpellList) RemoveSpell(spellID int32) bool { + msl.mutex.Lock() + defer msl.mutex.Unlock() + + spell, exists := msl.spells[spellID] + if !exists { + return false + } + + // Remove from main map + delete(msl.spells, spellID) + + // Remove from name lookup + name := spell.GetName() + if name != "" { + delete(msl.spellsByName, name) + } + + // Remove from tier lookup + delete(msl.spellsByTier, spellID) + + return true +} + +// GetNewMaxSpellID returns the next available spell ID +func (msl *MasterSpellList) GetNewMaxSpellID() int32 { + return atomic.AddInt32(&msl.maxSpellID, 1) +} + +// GetSpellCount returns the total number of spells +func (msl *MasterSpellList) GetSpellCount() int { + msl.mutex.RLock() + defer msl.mutex.RUnlock() + + return len(msl.spells) +} + +// GetAllSpells returns a copy of all spells (expensive operation) +func (msl *MasterSpellList) GetAllSpells() []*Spell { + msl.mutex.RLock() + defer msl.mutex.RUnlock() + + spells := make([]*Spell, 0, len(msl.spells)) + for _, spell := range msl.spells { + spells = append(spells, spell) + } + + return spells +} + +// GetSpellsByType returns all spells of a specific type +func (msl *MasterSpellList) GetSpellsByType(spellType int16) []*Spell { + msl.mutex.RLock() + defer msl.mutex.RUnlock() + + spells := make([]*Spell, 0) + for _, spell := range msl.spells { + if spell.GetSpellData().Type == spellType { + spells = append(spells, spell) + } + } + + return spells +} + +// GetSpellsByBookType returns all spells of a specific book type +func (msl *MasterSpellList) GetSpellsByBookType(bookType int32) []*Spell { + msl.mutex.RLock() + defer msl.mutex.RUnlock() + + spells := make([]*Spell, 0) + for _, spell := range msl.spells { + if spell.GetSpellData().SpellBookType == bookType { + spells = append(spells, spell) + } + } + + return spells +} + +// Global spell list instance +var masterSpellList *MasterSpellList +var initSpellListOnce sync.Once + +// GetMasterSpellList returns the global master spell list (singleton) +func GetMasterSpellList() *MasterSpellList { + initSpellListOnce.Do(func() { + masterSpellList = NewMasterSpellList() + }) + return masterSpellList +} + +// SpellCasting represents an active spell casting attempt +type SpellCasting struct { + Spell *Spell // The spell being cast + Caster int32 // Caster entity ID + Target int32 // Target entity ID + CastTime int32 // Total cast time + TimeRemaining int32 // Time remaining in cast + Interrupted bool // Whether casting was interrupted + // TODO: Add Entity references when implemented + // CasterEntity *Entity + // TargetEntity *Entity +} + +// SpellBook represents a character's spell book +type SpellBook struct { + // Spell storage organized by type + spells map[int32]*Spell // All known spells by ID + spellsByType map[int32][]*Spell // Spells organized by book type + + // Spell bar/hotbar assignments + spellBars map[int8]map[int8]*Spell // [bar][slot] = spell + + // Thread safety + mutex sync.RWMutex +} + +// NewSpellBook creates a new spell book +func NewSpellBook() *SpellBook { + return &SpellBook{ + spells: make(map[int32]*Spell), + spellsByType: make(map[int32][]*Spell), + spellBars: make(map[int8]map[int8]*Spell), + } +} + +// AddSpell adds a spell to the spell book +func (sb *SpellBook) AddSpell(spell *Spell) bool { + if spell == nil { + return false + } + + sb.mutex.Lock() + defer sb.mutex.Unlock() + + spellID := spell.GetSpellID() + + // Check if spell already exists + if _, exists := sb.spells[spellID]; exists { + return false + } + + // Add to main collection + sb.spells[spellID] = spell + + // Add to type collection + bookType := spell.GetSpellData().SpellBookType + if sb.spellsByType[bookType] == nil { + sb.spellsByType[bookType] = make([]*Spell, 0) + } + sb.spellsByType[bookType] = append(sb.spellsByType[bookType], spell) + + return true +} + +// RemoveSpell removes a spell from the spell book +func (sb *SpellBook) RemoveSpell(spellID int32) bool { + sb.mutex.Lock() + defer sb.mutex.Unlock() + + spell, exists := sb.spells[spellID] + if !exists { + return false + } + + // Remove from main collection + delete(sb.spells, spellID) + + // Remove from type collection + bookType := spell.GetSpellData().SpellBookType + if spells, exists := sb.spellsByType[bookType]; exists { + for i, s := range spells { + if s.GetSpellID() == spellID { + sb.spellsByType[bookType] = append(spells[:i], spells[i+1:]...) + break + } + } + } + + // Remove from spell bars + for barID, bar := range sb.spellBars { + for slot, s := range bar { + if s != nil && s.GetSpellID() == spellID { + delete(sb.spellBars[barID], slot) + } + } + } + + return true +} + +// GetSpell retrieves a spell from the spell book +func (sb *SpellBook) GetSpell(spellID int32) *Spell { + sb.mutex.RLock() + defer sb.mutex.RUnlock() + + return sb.spells[spellID] +} + +// HasSpell checks if the spell book contains a specific spell +func (sb *SpellBook) HasSpell(spellID int32) bool { + sb.mutex.RLock() + defer sb.mutex.RUnlock() + + _, exists := sb.spells[spellID] + return exists +} + +// GetSpellsByType returns all spells of a specific book type +func (sb *SpellBook) GetSpellsByType(bookType int32) []*Spell { + sb.mutex.RLock() + defer sb.mutex.RUnlock() + + if spells, exists := sb.spellsByType[bookType]; exists { + // Return a copy to prevent external modification + result := make([]*Spell, len(spells)) + copy(result, spells) + return result + } + + return make([]*Spell, 0) +} + +// SetSpellBarSlot assigns a spell to a specific bar and slot +func (sb *SpellBook) SetSpellBarSlot(barID, slot int8, spell *Spell) bool { + sb.mutex.Lock() + defer sb.mutex.Unlock() + + // Ensure the spell is in the spell book + if spell != nil { + if _, exists := sb.spells[spell.GetSpellID()]; !exists { + return false + } + } + + // Initialize bar if needed + if sb.spellBars[barID] == nil { + sb.spellBars[barID] = make(map[int8]*Spell) + } + + // Set the slot + if spell == nil { + delete(sb.spellBars[barID], slot) + } else { + sb.spellBars[barID][slot] = spell + } + + return true +} + +// GetSpellBarSlot retrieves the spell at a specific bar and slot +func (sb *SpellBook) GetSpellBarSlot(barID, slot int8) *Spell { + sb.mutex.RLock() + defer sb.mutex.RUnlock() + + if bar, exists := sb.spellBars[barID]; exists { + return bar[slot] + } + + return nil +} + +// GetSpellCount returns the total number of spells in the book +func (sb *SpellBook) GetSpellCount() int { + sb.mutex.RLock() + defer sb.mutex.RUnlock() + + return len(sb.spells) +} + +// String returns a string representation of the spell book +func (sb *SpellBook) String() string { + return fmt.Sprintf("SpellBook[Spells=%d, Types=%d]", + sb.GetSpellCount(), len(sb.spellsByType)) +} + +// SpellManager manages the master spell list, player spell books, and spell processing +type SpellManager struct { + masterList *MasterSpellList // Global spell definitions + spellBooks map[int32]*SpellBook // Player spell books by character ID + spellProcess *SpellProcess // Spell processing system + targeting *SpellTargeting // Spell targeting system + resourceChecker *SpellResourceChecker // Resource checking system + mutex sync.RWMutex // Thread safety +} + +// NewSpellManager creates a new spell manager +func NewSpellManager() *SpellManager { + return &SpellManager{ + masterList: NewMasterSpellList(), + spellBooks: make(map[int32]*SpellBook), + spellProcess: NewSpellProcess(), + targeting: NewSpellTargeting(), + resourceChecker: NewSpellResourceChecker(), + } +} + +// GetMasterList returns the master spell list +func (sm *SpellManager) GetMasterList() *MasterSpellList { + return sm.masterList +} + +// GetSpellProcess returns the spell processing system +func (sm *SpellManager) GetSpellProcess() *SpellProcess { + return sm.spellProcess +} + +// GetTargeting returns the spell targeting system +func (sm *SpellManager) GetTargeting() *SpellTargeting { + return sm.targeting +} + +// GetResourceChecker returns the resource checking system +func (sm *SpellManager) GetResourceChecker() *SpellResourceChecker { + return sm.resourceChecker +} + +// GetSpellBook retrieves or creates a spell book for a character +func (sm *SpellManager) GetSpellBook(characterID int32) *SpellBook { + sm.mutex.Lock() + defer sm.mutex.Unlock() + + if book, exists := sm.spellBooks[characterID]; exists { + return book + } + + // Create new spell book + book := NewSpellBook() + sm.spellBooks[characterID] = book + return book +} + +// RemoveSpellBook removes a character's spell book (when they log out) +func (sm *SpellManager) RemoveSpellBook(characterID int32) { + sm.mutex.Lock() + defer sm.mutex.Unlock() + + delete(sm.spellBooks, characterID) +} + +// ProcessSpells processes all active spells (should be called regularly) +func (sm *SpellManager) ProcessSpells() { + sm.spellProcess.Process() +} + +// CastSpell attempts to cast a spell for a character +func (sm *SpellManager) CastSpell(casterID, targetID, spellID int32) error { + // Get the spell from master list + spell := sm.masterList.GetSpell(spellID) + if spell == nil { + return fmt.Errorf("spell %d not found", spellID) + } + + // Create LuaSpell instance + luaSpell := NewLuaSpell(spell, casterID) + luaSpell.InitialTarget = targetID + + // Check resources + results := sm.resourceChecker.CheckAllResources(luaSpell, 0, 0) + for _, result := range results { + if !result.HasSufficient { + return fmt.Errorf("insufficient resources: %s", result.ErrorMessage) + } + } + + // Get targets + targetResult := sm.targeting.GetSpellTargets(luaSpell, nil) + if targetResult.ErrorCode != 0 { + return fmt.Errorf("targeting failed: %s", targetResult.ErrorMessage) + } + + if len(targetResult.ValidTargets) == 0 { + return fmt.Errorf("no valid targets found") + } + + // TODO: Add spell to cast queue or process immediately + // This would integrate with the entity system to actually cast the spell + + return nil +} + +// InterruptSpell interrupts a spell being cast by an entity +func (sm *SpellManager) InterruptSpell(entityID, spellID int32, errorCode int16, cancel, fromMovement bool) { + sm.spellProcess.Interrupt(entityID, spellID, errorCode, cancel, fromMovement) +} + +// AddSpellToQueue adds a spell to a player's casting queue +func (sm *SpellManager) AddSpellToQueue(spellID, casterID, targetID int32, priority int32) { + sm.spellProcess.AddSpellToQueue(spellID, casterID, targetID, priority) +} + +// RemoveSpellFromQueue removes a spell from a player's casting queue +func (sm *SpellManager) RemoveSpellFromQueue(spellID, casterID int32) bool { + return sm.spellProcess.RemoveSpellFromQueue(spellID, casterID) +} + +// IsSpellReady checks if a spell is ready to cast (not on cooldown) +func (sm *SpellManager) IsSpellReady(spellID, casterID int32) bool { + return sm.spellProcess.IsReady(spellID, casterID) +} + +// GetSpellRecastTime returns remaining recast time for a spell +func (sm *SpellManager) GetSpellRecastTime(spellID, casterID int32) int32 { + remaining := sm.spellProcess.GetRecastTimeRemaining(spellID, casterID) + return int32(remaining.Milliseconds()) +} + +// CanCastSpell performs comprehensive checks to determine if a spell can be cast +func (sm *SpellManager) CanCastSpell(casterID, targetID, spellID int32) (bool, string) { + // Check if spell exists + spell := sm.masterList.GetSpell(spellID) + if spell == nil { + return false, "Spell not found" + } + + // Check if spell is ready (not on cooldown) + if !sm.spellProcess.IsReady(spellID, casterID) { + remaining := sm.spellProcess.GetRecastTimeRemaining(spellID, casterID) + return false, fmt.Sprintf("Spell on cooldown for %d seconds", int(remaining.Seconds())) + } + + // Create temporary LuaSpell for resource checks + luaSpell := NewLuaSpell(spell, casterID) + luaSpell.InitialTarget = targetID + + // Check resources + results := sm.resourceChecker.CheckAllResources(luaSpell, 0, 0) + for _, result := range results { + if !result.HasSufficient { + return false, result.ErrorMessage + } + } + + // Check targeting + targetResult := sm.targeting.GetSpellTargets(luaSpell, nil) + if targetResult.ErrorCode != 0 { + return false, targetResult.ErrorMessage + } + + if len(targetResult.ValidTargets) == 0 { + return false, "No valid targets" + } + + return true, "" +} + +// GetSpellInfo returns comprehensive information about a spell +func (sm *SpellManager) GetSpellInfo(spellID int32) map[string]interface{} { + spell := sm.masterList.GetSpell(spellID) + if spell == nil { + return nil + } + + info := make(map[string]interface{}) + + // Basic spell info + info["id"] = spell.GetSpellID() + info["name"] = spell.GetName() + info["description"] = spell.GetDescription() + info["tier"] = spell.GetSpellTier() + info["type"] = spell.GetSpellData().SpellBookType + + // Resource requirements + info["resources"] = sm.resourceChecker.GetResourceSummary(spell) + + // Targeting info + info["targeting"] = sm.targeting.GetTargetingInfo(spell) + + // Classification + info["is_buff"] = spell.IsBuffSpell() + info["is_debuff"] = !spell.IsBuffSpell() && spell.IsControlSpell() + info["is_heal"] = spell.IsHealSpell() + info["is_damage"] = spell.IsDamageSpell() + info["is_offensive"] = spell.IsOffenseSpell() + + return info +} + +// GetActiveSpellCount returns the number of active spells being processed +func (sm *SpellManager) GetActiveSpellCount() int { + return sm.spellProcess.GetActiveSpellCount() +} + +// Shutdown gracefully shuts down the spell manager +func (sm *SpellManager) Shutdown() { + sm.spellProcess.RemoveAllSpells(false) +} \ No newline at end of file diff --git a/internal/spells/spell_process.go b/internal/spells/spell_process.go new file mode 100644 index 0000000..40faf20 --- /dev/null +++ b/internal/spells/spell_process.go @@ -0,0 +1,591 @@ +package spells + +import ( + "fmt" + "sync" + "time" +) + +// InterruptStruct represents a spell interruption event +type InterruptStruct struct { + InterruptedEntityID int32 // ID of the entity being interrupted + SpellID int32 // ID of the spell being interrupted + ErrorCode int16 // Error code for the interruption + FromMovement bool // Whether interruption was caused by movement + Canceled bool // Whether the spell was canceled vs interrupted + Timestamp time.Time // When the interrupt occurred +} + +// CastTimer represents a spell casting timer +type CastTimer struct { + CasterID int32 // ID of the entity casting + TargetID int32 // ID of the target + SpellID int32 // ID of the spell being cast + ZoneID int32 // ID of the zone where casting occurs + StartTime time.Time // When casting started + Duration time.Duration // How long the cast takes + InHeroicOpp bool // Whether this is part of a heroic opportunity + DeleteTimer bool // Flag to mark timer for deletion + IsEntityCommand bool // Whether this is an entity command vs spell + + mutex sync.RWMutex // Thread safety +} + +// RecastTimer represents a spell recast cooldown timer +type RecastTimer struct { + CasterID int32 // ID of the entity with the recast + ClientID int32 // ID of the client (if player) + SpellID int32 // ID of the spell on cooldown + LinkedTimerID int32 // ID of linked timer group + TypeGroupSpellID int32 // Type group for timer sharing + StartTime time.Time // When the recast started + Duration time.Duration // How long the recast lasts + StayLocked bool // Whether spell stays locked after recast + + mutex sync.RWMutex // Thread safety +} + +// CastSpell represents a spell casting request +type CastSpell struct { + CasterID int32 // ID of the entity casting + TargetID int32 // ID of the target + SpellID int32 // ID of the spell to cast + ZoneID int32 // ID of the zone +} + +// SpellQueue represents a player's spell queue entry +type SpellQueueEntry struct { + SpellID int32 // ID of the queued spell + Priority int32 // Queue priority + QueuedTime time.Time // When the spell was queued + TargetID int32 // Target for the spell + HostileOnly bool // Whether this is a hostile-only queue +} + +// HeroicOpportunity represents a heroic opportunity instance +type HeroicOpportunity struct { + ID int32 // Unique identifier + InitiatorID int32 // ID of the player/group that started it + TargetID int32 // ID of the target + StartTime time.Time // When the HO started + Duration time.Duration // Total time allowed + CurrentStep int32 // Current step in the sequence + TotalSteps int32 // Total steps in the sequence + IsGroup bool // Whether this is a group HO + Complete bool // Whether the HO completed successfully + WheelID int32 // ID of the wheel type + + mutex sync.RWMutex // Thread safety +} + +// SpellProcess manages all spell casting for a zone +type SpellProcess struct { + // Core collections + activeSpells map[int32]*LuaSpell // Active spells by spell instance ID + castTimers []*CastTimer // Active cast timers + recastTimers []*RecastTimer // Active recast timers + interruptQueue []*InterruptStruct // Queued interruptions + spellQueues map[int32][]*SpellQueueEntry // Player spell queues by player ID + + // Heroic Opportunities + soloHeroicOps map[int32]*HeroicOpportunity // Solo HOs by client ID + groupHeroicOps map[int32]*HeroicOpportunity // Group HOs by group ID + + // Targeting and removal + removeTargetList map[int32][]int32 // Targets to remove by spell ID + spellCancelList []int32 // Spells marked for cancellation + + // State management + lastProcessTime time.Time // Last time Process() was called + nextSpellID int32 // Next available spell instance ID + + // Thread safety + mutex sync.RWMutex // Main process mutex + + // TODO: Add when other systems are available + // zoneServer *ZoneServer // Reference to zone server + // luaInterface *LuaInterface // Reference to Lua interface +} + +// NewSpellProcess creates a new spell process instance +func NewSpellProcess() *SpellProcess { + return &SpellProcess{ + activeSpells: make(map[int32]*LuaSpell), + castTimers: make([]*CastTimer, 0), + recastTimers: make([]*RecastTimer, 0), + interruptQueue: make([]*InterruptStruct, 0), + spellQueues: make(map[int32][]*SpellQueueEntry), + soloHeroicOps: make(map[int32]*HeroicOpportunity), + groupHeroicOps: make(map[int32]*HeroicOpportunity), + removeTargetList: make(map[int32][]int32), + spellCancelList: make([]int32, 0), + lastProcessTime: time.Now(), + nextSpellID: 1, + } +} + +// Process handles the main spell processing loop +func (sp *SpellProcess) Process() { + sp.mutex.Lock() + defer sp.mutex.Unlock() + + now := time.Now() + // Only process every 50ms to match C++ implementation + if now.Sub(sp.lastProcessTime) < time.Duration(ProcessCheckInterval)*time.Millisecond { + return + } + sp.lastProcessTime = now + + // Process active spells (duration checks, ticks) + sp.processActiveSpells(now) + + // Process spell cancellations + sp.processSpellCancellations() + + // Process interrupts + sp.processInterrupts() + + // Process cast timers + sp.processCastTimers(now) + + // Process recast timers + sp.processRecastTimers(now) + + // Process spell queues + sp.processSpellQueues() + + // Process heroic opportunities + sp.processHeroicOpportunities(now) +} + +// processActiveSpells handles duration checks and spell ticks +func (sp *SpellProcess) processActiveSpells(now time.Time) { + expiredSpells := make([]int32, 0) + + for spellID, luaSpell := range sp.activeSpells { + if luaSpell == nil { + expiredSpells = append(expiredSpells, spellID) + continue + } + + // Check if spell duration has expired + // TODO: Implement proper duration checking based on spell data + // This would check luaSpell.spell.GetSpellData().duration1 etc. + + // Check if spell needs to tick + // TODO: Implement spell tick processing + // This would call ProcessSpell(luaSpell, false) for tick effects + } + + // Remove expired spells + for _, spellID := range expiredSpells { + sp.deleteCasterSpell(spellID, "expired") + } +} + +// processSpellCancellations handles spells marked for cancellation +func (sp *SpellProcess) processSpellCancellations() { + if len(sp.spellCancelList) == 0 { + return + } + + canceledSpells := make([]int32, len(sp.spellCancelList)) + copy(canceledSpells, sp.spellCancelList) + sp.spellCancelList = sp.spellCancelList[:0] // Clear the list + + for _, spellID := range canceledSpells { + sp.deleteCasterSpell(spellID, "canceled") + } +} + +// processInterrupts handles queued spell interruptions +func (sp *SpellProcess) processInterrupts() { + if len(sp.interruptQueue) == 0 { + return + } + + interrupts := make([]*InterruptStruct, len(sp.interruptQueue)) + copy(interrupts, sp.interruptQueue) + sp.interruptQueue = sp.interruptQueue[:0] // Clear the queue + + for _, interrupt := range interrupts { + sp.checkInterrupt(interrupt) + } +} + +// processCastTimers handles spell casting completion +func (sp *SpellProcess) processCastTimers(now time.Time) { + completedTimers := make([]*CastTimer, 0) + remainingTimers := make([]*CastTimer, 0) + + for _, timer := range sp.castTimers { + if timer.DeleteTimer { + // Timer marked for deletion + continue + } + + if now.Sub(timer.StartTime) >= timer.Duration { + // Cast time completed + timer.DeleteTimer = true + completedTimers = append(completedTimers, timer) + + // TODO: Send finish cast packet to client + // TODO: Call CastProcessedSpell or CastProcessedEntityCommand + } else { + remainingTimers = append(remainingTimers, timer) + } + } + + sp.castTimers = remainingTimers +} + +// processRecastTimers handles spell cooldown expiration +func (sp *SpellProcess) processRecastTimers(now time.Time) { + expiredTimers := make([]*RecastTimer, 0) + remainingTimers := make([]*RecastTimer, 0) + + for _, timer := range sp.recastTimers { + if now.Sub(timer.StartTime) >= timer.Duration { + // Recast timer expired + expiredTimers = append(expiredTimers, timer) + + // TODO: Unlock spell for the caster if not a maintained effect + // TODO: Send spell book update to client + } else { + remainingTimers = append(remainingTimers, timer) + } + } + + sp.recastTimers = remainingTimers +} + +// processSpellQueues handles queued spells for players +func (sp *SpellProcess) processSpellQueues() { + for playerID, queue := range sp.spellQueues { + if len(queue) == 0 { + continue + } + + // TODO: Check if player is casting and can cast next spell + // TODO: Process highest priority spell from queue + // This would call ProcessSpell for the queued spell + + _ = playerID // Placeholder to avoid unused variable error + } +} + +// processHeroicOpportunities handles heroic opportunity timers +func (sp *SpellProcess) processHeroicOpportunities(now time.Time) { + // Process solo heroic opportunities + expiredSolo := make([]int32, 0) + for clientID, ho := range sp.soloHeroicOps { + if now.Sub(ho.StartTime) >= ho.Duration { + ho.Complete = true + expiredSolo = append(expiredSolo, clientID) + // TODO: Send heroic opportunity update packet + } + } + for _, clientID := range expiredSolo { + delete(sp.soloHeroicOps, clientID) + } + + // Process group heroic opportunities + expiredGroup := make([]int32, 0) + for groupID, ho := range sp.groupHeroicOps { + if now.Sub(ho.StartTime) >= ho.Duration { + ho.Complete = true + expiredGroup = append(expiredGroup, groupID) + // TODO: Send heroic opportunity update to all group members + } + } + for _, groupID := range expiredGroup { + delete(sp.groupHeroicOps, groupID) + } +} + +// RemoveCaster removes references to a caster when they are destroyed +func (sp *SpellProcess) RemoveCaster(casterID int32) { + sp.mutex.Lock() + defer sp.mutex.Unlock() + + // Remove from active spells + expiredSpells := make([]int32, 0) + for spellID, luaSpell := range sp.activeSpells { + if luaSpell != nil && luaSpell.CasterID == casterID { + luaSpell.CasterID = 0 // Clear caster reference + expiredSpells = append(expiredSpells, spellID) + } + } + + // Clean up spells with invalid casters + for _, spellID := range expiredSpells { + sp.deleteCasterSpell(spellID, "caster removed") + } + + // Remove cast timers for this caster + remainingCastTimers := make([]*CastTimer, 0) + for _, timer := range sp.castTimers { + if timer.CasterID != casterID { + remainingCastTimers = append(remainingCastTimers, timer) + } + } + sp.castTimers = remainingCastTimers + + // Remove recast timers for this caster + remainingRecastTimers := make([]*RecastTimer, 0) + for _, timer := range sp.recastTimers { + if timer.CasterID != casterID { + remainingRecastTimers = append(remainingRecastTimers, timer) + } + } + sp.recastTimers = remainingRecastTimers + + // Remove spell queue for this caster + delete(sp.spellQueues, casterID) +} + +// Interrupt creates an interrupt request for a casting entity +func (sp *SpellProcess) Interrupt(entityID int32, spellID int32, errorCode int16, cancel, fromMovement bool) { + sp.mutex.Lock() + defer sp.mutex.Unlock() + + interrupt := &InterruptStruct{ + InterruptedEntityID: entityID, + SpellID: spellID, + ErrorCode: errorCode, + FromMovement: fromMovement, + Canceled: cancel, + Timestamp: time.Now(), + } + + sp.interruptQueue = append(sp.interruptQueue, interrupt) +} + +// checkInterrupt processes a single interrupt +func (sp *SpellProcess) checkInterrupt(interrupt *InterruptStruct) { + if interrupt == nil { + return + } + + // TODO: Implement interrupt processing + // This would: + // 1. Find the casting entity + // 2. Send finish cast packet if needed + // 3. Remove spell timers from spawn + // 4. Set entity casting state to false + // 5. Send interrupt packet to zone + // 6. Send spell failed packet if error code > 0 + // 7. Unlock spell for player + // 8. Send spell book update + + fmt.Printf("Processing interrupt for entity %d, spell %d, error %d\n", + interrupt.InterruptedEntityID, interrupt.SpellID, interrupt.ErrorCode) +} + +// IsReady checks if an entity can cast a spell (not on recast) +func (sp *SpellProcess) IsReady(spellID, casterID int32) bool { + sp.mutex.RLock() + defer sp.mutex.RUnlock() + + // TODO: Check if caster is currently casting + // if caster.IsCasting() { return false } + + // Check recast timers + for _, timer := range sp.recastTimers { + if timer.SpellID == spellID && timer.CasterID == casterID { + return false // Still on cooldown + } + } + + return true +} + +// AddSpellToQueue adds a spell to a player's casting queue +func (sp *SpellProcess) AddSpellToQueue(spellID, casterID, targetID int32, priority int32) { + sp.mutex.Lock() + defer sp.mutex.Unlock() + + entry := &SpellQueueEntry{ + SpellID: spellID, + Priority: priority, + QueuedTime: time.Now(), + TargetID: targetID, + } + + if sp.spellQueues[casterID] == nil { + sp.spellQueues[casterID] = make([]*SpellQueueEntry, 0) + } + + // Add to queue (TODO: sort by priority) + sp.spellQueues[casterID] = append(sp.spellQueues[casterID], entry) + + // Limit queue size + if len(sp.spellQueues[casterID]) > MaxQueuedSpells { + sp.spellQueues[casterID] = sp.spellQueues[casterID][1:] // Remove oldest + } +} + +// RemoveSpellFromQueue removes a specific spell from a player's queue +func (sp *SpellProcess) RemoveSpellFromQueue(spellID, casterID int32) bool { + sp.mutex.Lock() + defer sp.mutex.Unlock() + + queue, exists := sp.spellQueues[casterID] + if !exists { + return false + } + + for i, entry := range queue { + if entry.SpellID == spellID { + // Remove entry from queue + sp.spellQueues[casterID] = append(queue[:i], queue[i+1:]...) + return true + } + } + + return false +} + +// ClearSpellQueue clears a player's spell queue +func (sp *SpellProcess) ClearSpellQueue(casterID int32, hostileOnly bool) { + sp.mutex.Lock() + defer sp.mutex.Unlock() + + if !hostileOnly { + delete(sp.spellQueues, casterID) + return + } + + // TODO: Remove only hostile spells + // This would require checking spell data to determine if spell is hostile +} + +// AddSpellCancel marks a spell for cancellation +func (sp *SpellProcess) AddSpellCancel(spellID int32) { + sp.mutex.Lock() + defer sp.mutex.Unlock() + + sp.spellCancelList = append(sp.spellCancelList, spellID) +} + +// deleteCasterSpell removes a spell from active processing +func (sp *SpellProcess) deleteCasterSpell(spellID int32, reason string) bool { + luaSpell, exists := sp.activeSpells[spellID] + if !exists { + return false + } + + // TODO: Implement proper spell removal + // This would: + // 1. Handle concentration return for toggle spells + // 2. Check recast for non-duration-until-cancel spells + // 3. Unlock spell for player + // 4. Remove procs from caster + // 5. Remove maintained spell from caster + // 6. Remove targets from spell + // 7. Process spell removal effects + + fmt.Printf("Removing spell %d, reason: %s\n", spellID, reason) + + delete(sp.activeSpells, spellID) + + // Clean up removal targets list + delete(sp.removeTargetList, spellID) + + _ = luaSpell // Placeholder to avoid unused variable error + + return true +} + +// GetActiveSpellCount returns the number of active spells +func (sp *SpellProcess) GetActiveSpellCount() int { + sp.mutex.RLock() + defer sp.mutex.RUnlock() + + return len(sp.activeSpells) +} + +// GetQueuedSpellCount returns the number of queued spells for a player +func (sp *SpellProcess) GetQueuedSpellCount(casterID int32) int { + sp.mutex.RLock() + defer sp.mutex.RUnlock() + + if queue, exists := sp.spellQueues[casterID]; exists { + return len(queue) + } + + return 0 +} + +// GetRecastTimeRemaining returns remaining recast time for a spell +func (sp *SpellProcess) GetRecastTimeRemaining(spellID, casterID int32) time.Duration { + sp.mutex.RLock() + defer sp.mutex.RUnlock() + + for _, timer := range sp.recastTimers { + if timer.SpellID == spellID && timer.CasterID == casterID { + elapsed := time.Since(timer.StartTime) + if elapsed >= timer.Duration { + return 0 + } + return timer.Duration - elapsed + } + } + + return 0 +} + +// RemoveAllSpells removes all spells from processing (used during shutdown) +func (sp *SpellProcess) RemoveAllSpells(reloadSpells bool) { + sp.mutex.Lock() + defer sp.mutex.Unlock() + + // Clear all spell collections + if reloadSpells { + // Keep some data for reload + sp.activeSpells = make(map[int32]*LuaSpell) + } else { + // Complete cleanup + for spellID := range sp.activeSpells { + sp.deleteCasterSpell(spellID, "shutdown") + } + sp.activeSpells = make(map[int32]*LuaSpell) + } + + sp.castTimers = make([]*CastTimer, 0) + sp.recastTimers = make([]*RecastTimer, 0) + sp.interruptQueue = make([]*InterruptStruct, 0) + sp.spellQueues = make(map[int32][]*SpellQueueEntry) + sp.soloHeroicOps = make(map[int32]*HeroicOpportunity) + sp.groupHeroicOps = make(map[int32]*HeroicOpportunity) + sp.removeTargetList = make(map[int32][]int32) + sp.spellCancelList = make([]int32, 0) +} + +// NewCastTimer creates a new cast timer +func (ct *CastTimer) IsExpired() bool { + ct.mutex.RLock() + defer ct.mutex.RUnlock() + + return time.Since(ct.StartTime) >= ct.Duration +} + +// NewRecastTimer creates a new recast timer +func (rt *RecastTimer) IsExpired() bool { + rt.mutex.RLock() + defer rt.mutex.RUnlock() + + return time.Since(rt.StartTime) >= rt.Duration +} + +// GetRemainingTime returns remaining time on the recast +func (rt *RecastTimer) GetRemainingTime() time.Duration { + rt.mutex.RLock() + defer rt.mutex.RUnlock() + + elapsed := time.Since(rt.StartTime) + if elapsed >= rt.Duration { + return 0 + } + return rt.Duration - elapsed +} \ No newline at end of file diff --git a/internal/spells/spell_resources.go b/internal/spells/spell_resources.go new file mode 100644 index 0000000..4d1b20f --- /dev/null +++ b/internal/spells/spell_resources.go @@ -0,0 +1,470 @@ +package spells + +import ( + "fmt" + "sync" +) + +// SpellResourceChecker handles checking and consuming spell casting resources +type SpellResourceChecker struct { + // TODO: Add references to entity system when available + // entityManager *EntityManager + + mutex sync.RWMutex +} + +// ResourceCheckResult represents the result of a resource check +type ResourceCheckResult struct { + HasSufficient bool // Whether entity has sufficient resources + CurrentValue float32 // Current value of the resource + RequiredValue float32 // Required value for the spell + ResourceType int32 // Type of resource checked + ErrorMessage string // Error message if check failed +} + +// NewSpellResourceChecker creates a new resource checker +func NewSpellResourceChecker() *SpellResourceChecker { + return &SpellResourceChecker{} +} + +// CheckPower verifies if the caster has enough power to cast the spell +// Converted from C++ SpellProcess::CheckPower +func (src *SpellResourceChecker) CheckPower(luaSpell *LuaSpell, customPowerReq float32) *ResourceCheckResult { + if luaSpell == nil || luaSpell.Spell == nil { + return &ResourceCheckResult{ + HasSufficient: false, + CurrentValue: 0, + RequiredValue: 0, + ResourceType: ResourceCheckPower, + ErrorMessage: "Invalid spell", + } + } + + powerRequired := customPowerReq + if powerRequired == 0 { + powerRequired = luaSpell.Spell.GetPowerRequired() + } + + // TODO: Get actual power from entity when entity system is available + // For now, assume entity has sufficient power + currentPower := float32(1000.0) // Placeholder + + result := &ResourceCheckResult{ + HasSufficient: currentPower >= powerRequired, + CurrentValue: currentPower, + RequiredValue: powerRequired, + ResourceType: ResourceCheckPower, + ErrorMessage: "", + } + + if !result.HasSufficient { + result.ErrorMessage = fmt.Sprintf("Insufficient power: need %.1f, have %.1f", powerRequired, currentPower) + } + + return result +} + +// TakePower consumes power for spell casting +// Converted from C++ SpellProcess::TakePower +func (src *SpellResourceChecker) TakePower(luaSpell *LuaSpell, customPowerReq float32) bool { + result := src.CheckPower(luaSpell, customPowerReq) + if !result.HasSufficient { + return false + } + + // TODO: Actually deduct power from entity when entity system is available + // This would call something like: + // entity.GetInfoStruct().SetPower(currentPower - powerRequired) + // entity.GetZone().TriggerCharSheetTimer() // Update client display + + return true +} + +// CheckHP verifies if the caster has enough health to cast the spell +// Converted from C++ SpellProcess::CheckHP +func (src *SpellResourceChecker) CheckHP(luaSpell *LuaSpell, customHPReq float32) *ResourceCheckResult { + if luaSpell == nil || luaSpell.Spell == nil { + return &ResourceCheckResult{ + HasSufficient: false, + CurrentValue: 0, + RequiredValue: 0, + ResourceType: ResourceCheckHealth, + ErrorMessage: "Invalid spell", + } + } + + hpRequired := customHPReq + if hpRequired == 0 { + hpRequired = float32(luaSpell.Spell.GetHPRequired()) + } + + // TODO: Get actual HP from entity when entity system is available + // For now, assume entity has sufficient HP + currentHP := float32(1000.0) // Placeholder + + result := &ResourceCheckResult{ + HasSufficient: currentHP >= hpRequired, + CurrentValue: currentHP, + RequiredValue: hpRequired, + ResourceType: ResourceCheckHealth, + ErrorMessage: "", + } + + if !result.HasSufficient { + result.ErrorMessage = fmt.Sprintf("Insufficient health: need %.1f, have %.1f", hpRequired, currentHP) + } + + return result +} + +// TakeHP consumes health for spell casting +// Converted from C++ SpellProcess::TakeHP +func (src *SpellResourceChecker) TakeHP(luaSpell *LuaSpell, customHPReq float32) bool { + result := src.CheckHP(luaSpell, customHPReq) + if !result.HasSufficient { + return false + } + + // TODO: Actually deduct HP from entity when entity system is available + // This would call something like: + // entity.GetInfoStruct().SetHP(currentHP - hpRequired) + // entity.GetZone().TriggerCharSheetTimer() // Update client display + + return true +} + +// CheckConcentration verifies if the caster has enough concentration to cast the spell +// Converted from C++ SpellProcess::CheckConcentration +func (src *SpellResourceChecker) CheckConcentration(luaSpell *LuaSpell) *ResourceCheckResult { + if luaSpell == nil || luaSpell.Spell == nil { + return &ResourceCheckResult{ + HasSufficient: false, + CurrentValue: 0, + RequiredValue: 0, + ResourceType: ResourceCheckConcentration, + ErrorMessage: "Invalid spell", + } + } + + spellData := luaSpell.Spell.GetSpellData() + if spellData == nil { + return &ResourceCheckResult{ + HasSufficient: false, + CurrentValue: 0, + RequiredValue: 0, + ResourceType: ResourceCheckConcentration, + ErrorMessage: "Invalid spell data", + } + } + + concentrationRequired := float32(spellData.ReqConcentration) + + // TODO: Get actual concentration from entity when entity system is available + // For now, assume entity has sufficient concentration + currentConcentration := float32(100.0) // Placeholder + + result := &ResourceCheckResult{ + HasSufficient: currentConcentration >= concentrationRequired, + CurrentValue: currentConcentration, + RequiredValue: concentrationRequired, + ResourceType: ResourceCheckConcentration, + ErrorMessage: "", + } + + if !result.HasSufficient { + result.ErrorMessage = fmt.Sprintf("Insufficient concentration: need %.1f, have %.1f", concentrationRequired, currentConcentration) + } + + return result +} + +// AddConcentration adds concentration for maintained spells +// Converted from C++ SpellProcess::AddConcentration +func (src *SpellResourceChecker) AddConcentration(luaSpell *LuaSpell) bool { + result := src.CheckConcentration(luaSpell) + if !result.HasSufficient { + return false + } + + // TODO: Actually deduct concentration from entity when entity system is available + // This would call something like: + // currentConc := entity.GetInfoStruct().GetCurConcentration() + // entity.GetInfoStruct().SetCurConcentration(currentConc + concentrationRequired) + // entity.GetZone().TriggerCharSheetTimer() // Update client display + + return true +} + +// CheckSavagery verifies if the caster has enough savagery to cast the spell +// Converted from C++ SpellProcess::CheckSavagery +func (src *SpellResourceChecker) CheckSavagery(luaSpell *LuaSpell) *ResourceCheckResult { + if luaSpell == nil || luaSpell.Spell == nil { + return &ResourceCheckResult{ + HasSufficient: false, + CurrentValue: 0, + RequiredValue: 0, + ResourceType: ResourceCheckSavagery, + ErrorMessage: "Invalid spell", + } + } + + savageryRequired := float32(luaSpell.Spell.GetSavageryRequired()) + + // TODO: Get actual savagery from entity when entity system is available + // For now, assume entity has sufficient savagery + currentSavagery := float32(100.0) // Placeholder + + result := &ResourceCheckResult{ + HasSufficient: currentSavagery >= savageryRequired, + CurrentValue: currentSavagery, + RequiredValue: savageryRequired, + ResourceType: ResourceCheckSavagery, + ErrorMessage: "", + } + + if !result.HasSufficient { + result.ErrorMessage = fmt.Sprintf("Insufficient savagery: need %.1f, have %.1f", savageryRequired, currentSavagery) + } + + return result +} + +// TakeSavagery consumes savagery for spell casting +// Converted from C++ SpellProcess::TakeSavagery +func (src *SpellResourceChecker) TakeSavagery(luaSpell *LuaSpell) bool { + result := src.CheckSavagery(luaSpell) + if !result.HasSufficient { + return false + } + + // TODO: Actually deduct savagery from entity when entity system is available + // This would call something like: + // entity.GetInfoStruct().SetSavagery(currentSavagery - savageryRequired) + // entity.GetZone().TriggerCharSheetTimer() // Update client display + + return true +} + +// CheckDissonance verifies if the caster has enough dissonance to cast the spell +// Converted from C++ SpellProcess::CheckDissonance +func (src *SpellResourceChecker) CheckDissonance(luaSpell *LuaSpell) *ResourceCheckResult { + if luaSpell == nil || luaSpell.Spell == nil { + return &ResourceCheckResult{ + HasSufficient: false, + CurrentValue: 0, + RequiredValue: 0, + ResourceType: ResourceCheckDissonance, + ErrorMessage: "Invalid spell", + } + } + + dissonanceRequired := float32(luaSpell.Spell.GetDissonanceRequired()) + + // TODO: Get actual dissonance from entity when entity system is available + // For now, assume entity has sufficient dissonance + currentDissonance := float32(100.0) // Placeholder + + result := &ResourceCheckResult{ + HasSufficient: currentDissonance >= dissonanceRequired, + CurrentValue: currentDissonance, + RequiredValue: dissonanceRequired, + ResourceType: ResourceCheckDissonance, + ErrorMessage: "", + } + + if !result.HasSufficient { + result.ErrorMessage = fmt.Sprintf("Insufficient dissonance: need %.1f, have %.1f", dissonanceRequired, currentDissonance) + } + + return result +} + +// AddDissonance adds dissonance for spell casting +// Converted from C++ SpellProcess::AddDissonance +func (src *SpellResourceChecker) AddDissonance(luaSpell *LuaSpell) bool { + result := src.CheckDissonance(luaSpell) + if !result.HasSufficient { + return false + } + + // TODO: Actually add dissonance to entity when entity system is available + // This would call something like: + // entity.GetInfoStruct().SetDissonance(currentDissonance + dissonanceRequired) + // entity.GetZone().TriggerCharSheetTimer() // Update client display + + return true +} + +// CheckAllResources performs a comprehensive resource check for a spell +func (src *SpellResourceChecker) CheckAllResources(luaSpell *LuaSpell, customPowerReq, customHPReq float32) []ResourceCheckResult { + results := make([]ResourceCheckResult, 0) + + // Check power + powerResult := src.CheckPower(luaSpell, customPowerReq) + if powerResult.RequiredValue > 0 { + results = append(results, *powerResult) + } + + // Check health + hpResult := src.CheckHP(luaSpell, customHPReq) + if hpResult.RequiredValue > 0 { + results = append(results, *hpResult) + } + + // Check concentration + concResult := src.CheckConcentration(luaSpell) + if concResult.RequiredValue > 0 { + results = append(results, *concResult) + } + + // Check savagery + savageryResult := src.CheckSavagery(luaSpell) + if savageryResult.RequiredValue > 0 { + results = append(results, *savageryResult) + } + + // Check dissonance + dissonanceResult := src.CheckDissonance(luaSpell) + if dissonanceResult.RequiredValue > 0 { + results = append(results, *dissonanceResult) + } + + return results +} + +// ConsumeAllResources attempts to consume all required resources for a spell +func (src *SpellResourceChecker) ConsumeAllResources(luaSpell *LuaSpell, customPowerReq, customHPReq float32) bool { + // First check all resources + results := src.CheckAllResources(luaSpell, customPowerReq, customHPReq) + + // Verify all resources are sufficient + for _, result := range results { + if !result.HasSufficient { + return false + } + } + + // Consume resources + success := true + + // Take power if required + powerResult := src.CheckPower(luaSpell, customPowerReq) + if powerResult.RequiredValue > 0 { + success = success && src.TakePower(luaSpell, customPowerReq) + } + + // Take health if required + hpResult := src.CheckHP(luaSpell, customHPReq) + if hpResult.RequiredValue > 0 { + success = success && src.TakeHP(luaSpell, customHPReq) + } + + // Add concentration if required (for maintained spells) + spellData := luaSpell.Spell.GetSpellData() + if spellData != nil && spellData.CastType == SpellCastTypeToggle { + concResult := src.CheckConcentration(luaSpell) + if concResult.RequiredValue > 0 { + success = success && src.AddConcentration(luaSpell) + } + } + + // Take savagery if required + savageryResult := src.CheckSavagery(luaSpell) + if savageryResult.RequiredValue > 0 { + success = success && src.TakeSavagery(luaSpell) + } + + // Add dissonance if required + dissonanceResult := src.CheckDissonance(luaSpell) + if dissonanceResult.RequiredValue > 0 { + success = success && src.AddDissonance(luaSpell) + } + + return success +} + +// GetResourceSummary returns a summary of all resource requirements for a spell +func (src *SpellResourceChecker) GetResourceSummary(spell *Spell) map[string]float32 { + summary := make(map[string]float32) + + if spell == nil { + return summary + } + + summary["power"] = spell.GetPowerRequired() + summary["health"] = float32(spell.GetHPRequired()) + summary["savagery"] = float32(spell.GetSavageryRequired()) + summary["dissonance"] = float32(spell.GetDissonanceRequired()) + + spellData := spell.GetSpellData() + if spellData != nil { + summary["concentration"] = float32(spellData.ReqConcentration) + } + + return summary +} + +// ValidateResourceRequirements checks if resource requirements are reasonable +func (src *SpellResourceChecker) ValidateResourceRequirements(spell *Spell) []string { + errors := make([]string, 0) + + if spell == nil { + errors = append(errors, "Spell is nil") + return errors + } + + // Check for negative requirements + if spell.GetPowerRequired() < 0 { + errors = append(errors, "Power requirement cannot be negative") + } + + if spell.GetHPRequired() < 0 { + errors = append(errors, "Health requirement cannot be negative") + } + + if spell.GetSavageryRequired() < 0 { + errors = append(errors, "Savagery requirement cannot be negative") + } + + if spell.GetDissonanceRequired() < 0 { + errors = append(errors, "Dissonance requirement cannot be negative") + } + + spellData := spell.GetSpellData() + if spellData != nil { + if spellData.ReqConcentration < 0 { + errors = append(errors, "Concentration requirement cannot be negative") + } + + // Check for excessive requirements + if spell.GetPowerRequired() > 10000 { + errors = append(errors, "Power requirement seems excessive (>10000)") + } + + if spell.GetHPRequired() > 5000 { + errors = append(errors, "Health requirement seems excessive (>5000)") + } + } + + return errors +} + +// GetFailureReason returns an appropriate failure reason code based on resource check results +func (src *SpellResourceChecker) GetFailureReason(results []ResourceCheckResult) int32 { + for _, result := range results { + if !result.HasSufficient { + switch result.ResourceType { + case ResourceCheckPower: + return FailureReasonInsufficientPower + case ResourceCheckHealth: + return FailureReasonInsufficientHealth + case ResourceCheckConcentration: + return FailureReasonInsufficientConc + default: + return FailureReasonRequirementNotMet + } + } + } + + return FailureReasonNone +} \ No newline at end of file diff --git a/internal/spells/spell_targeting.go b/internal/spells/spell_targeting.go new file mode 100644 index 0000000..9666bdd --- /dev/null +++ b/internal/spells/spell_targeting.go @@ -0,0 +1,442 @@ +package spells + +import ( + "fmt" + "sync" +) + +// SpellTargeting handles spell target selection and validation +type SpellTargeting struct { + // TODO: Add references to zone and entity systems when available + // zoneServer *ZoneServer + // entityManager *EntityManager + + mutex sync.RWMutex +} + +// TargetingResult represents the result of spell targeting +type TargetingResult struct { + ValidTargets []int32 // List of valid target IDs + InvalidTargets []int32 // List of invalid target IDs + ErrorCode int32 // Error code if targeting failed + ErrorMessage string // Human readable error message +} + +// TargetingOptions contains options for target selection +type TargetingOptions struct { + BypassSpellChecks bool // Skip spell-specific target validation + BypassRangeChecks bool // Skip range validation + MaxRange float32 // Override maximum range + IgnoreLineOfSight bool // Ignore line of sight requirements +} + +// NewSpellTargeting creates a new spell targeting system +func NewSpellTargeting() *SpellTargeting { + return &SpellTargeting{} +} + +// GetSpellTargets finds all valid targets for a spell and adds them to the LuaSpell +// This is the main targeting function converted from C++ SpellProcess::GetSpellTargets +func (st *SpellTargeting) GetSpellTargets(luaSpell *LuaSpell, options *TargetingOptions) *TargetingResult { + if luaSpell == nil || luaSpell.Spell == nil { + return &TargetingResult{ + ValidTargets: make([]int32, 0), + InvalidTargets: make([]int32, 0), + ErrorCode: FailureReasonInvalidTarget, + ErrorMessage: "Invalid spell or LuaSpell", + } + } + + spell := luaSpell.Spell + spellData := spell.GetSpellData() + if spellData == nil { + return &TargetingResult{ + ValidTargets: make([]int32, 0), + InvalidTargets: make([]int32, 0), + ErrorCode: FailureReasonInvalidTarget, + ErrorMessage: "Invalid spell data", + } + } + + // Default options if none provided + if options == nil { + options = &TargetingOptions{} + } + + result := &TargetingResult{ + ValidTargets: make([]int32, 0), + InvalidTargets: make([]int32, 0), + ErrorCode: 0, + ErrorMessage: "", + } + + // Determine targeting method based on spell target type + switch spellData.TargetType { + case TargetTypeSelf: + st.getSelfTargets(luaSpell, result, options) + case TargetTypeSingle: + st.getSingleTarget(luaSpell, result, options) + case TargetTypeGroup: + st.getGroupTargets(luaSpell, result, options) + case TargetTypeGroupAE: + st.getGroupAETargets(luaSpell, result, options) + case TargetTypeAE: + st.getAETargets(luaSpell, result, options) + case TargetTypePBAE: + st.getPBAETargets(luaSpell, result, options) + default: + result.ErrorCode = FailureReasonInvalidTarget + result.ErrorMessage = fmt.Sprintf("Unsupported target type: %d", spellData.TargetType) + } + + return result +} + +// getSelfTargets handles self-targeting spells +func (st *SpellTargeting) getSelfTargets(luaSpell *LuaSpell, result *TargetingResult, options *TargetingOptions) { + if luaSpell.CasterID != 0 { + result.ValidTargets = append(result.ValidTargets, luaSpell.CasterID) + luaSpell.AddTarget(luaSpell.CasterID) + } else { + result.ErrorCode = FailureReasonInvalidTarget + result.ErrorMessage = "No valid caster for self-targeting spell" + } +} + +// getSingleTarget handles single-target spells +func (st *SpellTargeting) getSingleTarget(luaSpell *LuaSpell, result *TargetingResult, options *TargetingOptions) { + targetID := luaSpell.InitialTarget + if targetID == 0 { + targetID = luaSpell.CasterID // Default to self if no target + } + + if st.isValidTarget(luaSpell, targetID, options) { + result.ValidTargets = append(result.ValidTargets, targetID) + luaSpell.AddTarget(targetID) + } else { + result.InvalidTargets = append(result.InvalidTargets, targetID) + result.ErrorCode = FailureReasonInvalidTarget + result.ErrorMessage = "Target is not valid for this spell" + } +} + +// getGroupTargets handles group-targeting spells +func (st *SpellTargeting) getGroupTargets(luaSpell *LuaSpell, result *TargetingResult, options *TargetingOptions) { + // TODO: Implement group targeting when group system is available + // This would: + // 1. Get the caster's group + // 2. Iterate through group members + // 3. Validate each member as a target + // 4. Add valid members to the target list + + // For now, just target self as placeholder + if luaSpell.CasterID != 0 { + result.ValidTargets = append(result.ValidTargets, luaSpell.CasterID) + luaSpell.AddTarget(luaSpell.CasterID) + } +} + +// getGroupAETargets handles group area effect spells +func (st *SpellTargeting) getGroupAETargets(luaSpell *LuaSpell, result *TargetingResult, options *TargetingOptions) { + // TODO: Implement group AE targeting + // This is similar to group targeting but may include pets and other considerations + st.getGroupTargets(luaSpell, result, options) +} + +// getAETargets handles area effect spells (true AOE) +func (st *SpellTargeting) getAETargets(luaSpell *LuaSpell, result *TargetingResult, options *TargetingOptions) { + // TODO: Implement AOE targeting when zone system is available + // This would: + // 1. Get the spell's radius from spell data + // 2. Find all entities within radius of the target location + // 3. Filter based on spell criteria (friendly/hostile, etc.) + // 4. Apply maximum target limits + // 5. Add valid targets to the list + + // For now, implement basic logic + spellData := luaSpell.Spell.GetSpellData() + maxTargets := int32(10) // TODO: Use spellData.AOENodeNumber when field exists + if maxTargets <= 0 { + maxTargets = 10 // Default limit + } + + // Placeholder: just target the initial target for now + if luaSpell.InitialTarget != 0 && st.isValidTarget(luaSpell, luaSpell.InitialTarget, options) { + result.ValidTargets = append(result.ValidTargets, luaSpell.InitialTarget) + luaSpell.AddTarget(luaSpell.InitialTarget) + } + + // TODO: Use actual AOE node number when SpellData is updated + _ = maxTargets // Suppress unused variable warning +} + +// getPBAETargets handles point-blank area effect spells (centered on caster) +func (st *SpellTargeting) getPBAETargets(luaSpell *LuaSpell, result *TargetingResult, options *TargetingOptions) { + // TODO: Implement PBAE targeting when zone system is available + // This is similar to AE but centered on the caster instead of a target location + + // For now, just target self + if luaSpell.CasterID != 0 { + result.ValidTargets = append(result.ValidTargets, luaSpell.CasterID) + luaSpell.AddTarget(luaSpell.CasterID) + } +} + +// isValidTarget validates whether a target is valid for a spell +func (st *SpellTargeting) isValidTarget(luaSpell *LuaSpell, targetID int32, options *TargetingOptions) bool { + if targetID == 0 { + return false + } + + if options.BypassSpellChecks { + return true + } + + // TODO: Implement comprehensive target validation when entity system is available + // This would check: + // 1. Target exists and is alive/dead as required + // 2. Target is within range + // 3. Line of sight if required + // 4. Spell can affect target type (player/NPC/etc.) + // 5. Faction/relationship requirements + // 6. Target is not immune to spell effects + // 7. Spell-specific requirements + + return true // Placeholder - accept all targets for now +} + +// ValidateRange checks if a target is within spell range +func (st *SpellTargeting) ValidateRange(casterID, targetID int32, spell *Spell, options *TargetingOptions) bool { + if options != nil && options.BypassRangeChecks { + return true + } + + // TODO: Implement range checking when position system is available + // This would: + // 1. Get caster position + // 2. Get target position + // 3. Calculate distance + // 4. Compare to spell range + + return true // Placeholder - all targets in range for now +} + +// ValidateLineOfSight checks if caster has line of sight to target +func (st *SpellTargeting) ValidateLineOfSight(casterID, targetID int32, options *TargetingOptions) bool { + if options != nil && options.IgnoreLineOfSight { + return true + } + + // TODO: Implement line of sight checking when zone geometry is available + // This would use ray tracing to check for obstacles between caster and target + + return true // Placeholder - always have LOS for now +} + +// GetPlayerGroupTargets gets valid group member targets for a spell +// This is converted from C++ SpellProcess::GetPlayerGroupTargets +func (st *SpellTargeting) GetPlayerGroupTargets(targetPlayerID, casterID int32, luaSpell *LuaSpell, options *TargetingOptions) bool { + // TODO: Implement when group system is available + // This would: + // 1. Get the target player's group + // 2. Validate caster can target this group + // 3. Add all valid group members as targets + // 4. Handle pets if included in group targeting + + // For now, just add the target player + if st.isValidTarget(luaSpell, targetPlayerID, options) { + luaSpell.AddTarget(targetPlayerID) + return true + } + + return false +} + +// AddSelfAndPet adds caster and their pet to spell targets +func (st *SpellTargeting) AddSelfAndPet(luaSpell *LuaSpell, casterID int32, onlyPet bool) { + if !onlyPet && casterID != 0 { + luaSpell.AddTarget(casterID) + } + + // TODO: Add pet targeting when pet system is available + // This would: + // 1. Get caster's active pet + // 2. Validate pet as target + // 3. Add pet to target list +} + +// AddNPCGroupOrSelfTarget adds NPC group members or self to targets +func (st *SpellTargeting) AddNPCGroupOrSelfTarget(luaSpell *LuaSpell, targetID int32) { + // TODO: Implement NPC group targeting when NPC AI system is available + // NPCs may have different grouping mechanics than players + + // For now, just add the target + if targetID != 0 { + luaSpell.AddTarget(targetID) + } +} + +// CalculateDistance calculates distance between two entities +func (st *SpellTargeting) CalculateDistance(entity1ID, entity2ID int32) float32 { + // TODO: Implement when position system is available + // This would get positions and calculate 3D distance + + return 0.0 // Placeholder +} + +// IsInRange checks if target is within spell range +func (st *SpellTargeting) IsInRange(spell *Spell, distance float32) bool { + if spell == nil { + return false + } + + spellData := spell.GetSpellData() + if spellData == nil { + return false + } + + maxRange := spellData.Range + if maxRange <= 0 { + return true // No range limit + } + + return distance <= maxRange +} + +// GetSpellRange returns the maximum range for a spell +func (st *SpellTargeting) GetSpellRange(spell *Spell) float32 { + if spell == nil { + return 0.0 + } + + spellData := spell.GetSpellData() + if spellData == nil { + return 0.0 + } + + return spellData.Range +} + +// GetSpellRadius returns the area effect radius for a spell +func (st *SpellTargeting) GetSpellRadius(spell *Spell) float32 { + if spell == nil { + return 0.0 + } + + spellData := spell.GetSpellData() + if spellData == nil { + return 0.0 + } + + return spellData.Radius +} + +// GetMaxTargets returns the maximum number of targets for an AOE spell +func (st *SpellTargeting) GetMaxTargets(spell *Spell) int32 { + if spell == nil { + return 1 + } + + spellData := spell.GetSpellData() + if spellData == nil { + return 1 + } + + // TODO: Use spellData.AOENodeNumber when field exists + maxTargets := int32(1) // Default to single target + + return maxTargets +} + +// IsHostileSpell determines if a spell is hostile/aggressive +func (st *SpellTargeting) IsHostileSpell(spell *Spell) bool { + if spell == nil { + return false + } + + // Check if spell is classified as offensive + return spell.IsOffenseSpell() +} + +// IsFriendlySpell determines if a spell is beneficial/friendly +func (st *SpellTargeting) IsFriendlySpell(spell *Spell) bool { + if spell == nil { + return false + } + + // Check if spell is classified as beneficial + return spell.IsBuffSpell() || spell.IsHealSpell() +} + +// CanTargetSelf checks if spell can target the caster +func (st *SpellTargeting) CanTargetSelf(spell *Spell) bool { + if spell == nil { + return false + } + + spellData := spell.GetSpellData() + if spellData == nil { + return false + } + + // Check target type and spell flags + return spellData.TargetType == TargetTypeSelf || + spellData.TargetType == TargetTypeGroup || + spellData.TargetType == TargetTypePBAE +} + +// CanTargetOthers checks if spell can target entities other than caster +func (st *SpellTargeting) CanTargetOthers(spell *Spell) bool { + if spell == nil { + return false + } + + spellData := spell.GetSpellData() + if spellData == nil { + return false + } + + // Most target types can target others, self-only spells cannot + return spellData.TargetType != TargetTypeSelf +} + +// RequiresTarget checks if spell requires an explicit target selection +func (st *SpellTargeting) RequiresTarget(spell *Spell) bool { + if spell == nil { + return false + } + + spellData := spell.GetSpellData() + if spellData == nil { + return false + } + + // Single target spells typically require explicit targeting + return spellData.TargetType == TargetTypeSingle +} + +// GetTargetingInfo returns information about spell targeting requirements +func (st *SpellTargeting) GetTargetingInfo(spell *Spell) map[string]interface{} { + info := make(map[string]interface{}) + + if spell == nil { + return info + } + + spellData := spell.GetSpellData() + if spellData == nil { + return info + } + + info["target_type"] = spellData.TargetType + info["range"] = spellData.Range + info["radius"] = spellData.Radius + info["max_targets"] = int32(1) // TODO: Use spellData.AOENodeNumber when field exists + info["can_target_self"] = st.CanTargetSelf(spell) + info["can_target_others"] = st.CanTargetOthers(spell) + info["requires_target"] = st.RequiresTarget(spell) + info["is_hostile"] = st.IsHostileSpell(spell) + info["is_friendly"] = st.IsFriendlySpell(spell) + + return info +} diff --git a/internal/titles/README.md b/internal/titles/README.md new file mode 100644 index 0000000..31c22a2 --- /dev/null +++ b/internal/titles/README.md @@ -0,0 +1,226 @@ +# Title System + +The title system manages character titles in the EverQuest II server emulator, allowing players to earn, display, and manage various titles that represent their achievements and progression. + +## Overview + +The title system consists of several key components: + +- **Master Titles List**: Global registry of all available titles +- **Player Title Collections**: Individual player title ownership and preferences +- **Title Manager**: Central coordination and management +- **Integration Systems**: Hooks for earning titles through various game activities + +## Core Components + +### Title Structure + +Each title has the following properties: + +- **ID**: Unique identifier +- **Name**: Display name of the title +- **Description**: Detailed description shown in UI +- **Position**: Whether it appears as prefix or suffix +- **Category**: Organizational category (Combat, Tradeskill, etc.) +- **Source**: How the title is obtained (Achievement, Quest, etc.) +- **Rarity**: Common, Uncommon, Rare, Epic, Legendary, Unique +- **Requirements**: Conditions that must be met to earn the title +- **Flags**: Various behavioral modifiers (Hidden, Temporary, Unique, etc.) + +### Title Positioning + +Titles can be displayed in two positions relative to the character name: + +- **Prefix**: Appears before the character name (e.g., "Master John") +- **Suffix**: Appears after the character name (e.g., "John the Brave") + +Players can have one active prefix and one active suffix title simultaneously. + +### Title Sources + +Titles can be obtained through various means: + +- **Achievements**: Completing specific achievements +- **Quests**: Finishing particular quest lines +- **Tradeskills**: Reaching mastery in crafting +- **Combat**: Battle-related accomplishments +- **Exploration**: Discovering new areas +- **PvP**: Player vs Player activities +- **Guild**: Guild progression and achievements +- **Events**: Special server events +- **Rare**: Uncommon encounters or collections + +## Usage Examples + +### Basic Title Management + +```go +// Create a title manager +titleManager := titles.NewTitleManager() + +// Create a new title +title, err := titleManager.CreateTitle( + "Dragon Slayer", // name + "Defeated an ancient dragon", // description + titles.CategoryCombat, // category + titles.TitlePositionSuffix, // position + titles.TitleSourceAchievement, // source + titles.TitleRarityEpic, // rarity +) + +// Grant a title to a player +err = titleManager.GrantTitle(playerID, titleID, achievementID, 0) + +// Set active titles +err = titleManager.SetPlayerActivePrefix(playerID, prefixTitleID) +err = titleManager.SetPlayerActiveSuffix(playerID, suffixTitleID) + +// Get formatted player name with titles +formattedName := titleManager.GetPlayerFormattedName(playerID, "John") +// Result: "Master John the Dragon Slayer" +``` + +### Achievement Integration + +```go +// Set up achievement integration +integrationManager := titles.NewIntegrationManager(titleManager) + +// Create achievement-linked title +title, err := titleManager.CreateAchievementTitle( + "Dungeon Master", + "Completed 100 dungeons", + achievementID, + titles.TitlePositionPrefix, + titles.TitleRarityRare, +) + +// When achievement is completed +err = integrationManager.GetAchievementIntegration().OnAchievementCompleted(playerID, achievementID) +``` + +### Event Titles + +```go +// Start a seasonal event with title reward +eventIntegration := integrationManager.GetEventIntegration() +err = eventIntegration.StartEvent( + "Halloween 2024", + "Spooky seasonal event", + 7*24*time.Hour, // 1 week duration + halloweenTitleID, +) + +// Grant participation title +err = eventIntegration.OnEventParticipation(playerID, "Halloween 2024") +``` + +## File Structure + +- `constants.go`: Title system constants and enums +- `title.go`: Core title and player title data structures +- `master_list.go`: Global title registry and management +- `player_titles.go`: Individual player title collections +- `title_manager.go`: Central title system coordinator +- `integration.go`: Integration systems for earning titles +- `README.md`: This documentation file + +## Database Integration + +The title system is designed to integrate with the database layer: + +- **Master titles**: Stored in `titles` table +- **Player titles**: Stored in `character_titles` table +- **Title requirements**: Stored in `title_requirements` table + +Database methods are marked as TODO and will be implemented when the database package is available. + +## Network Packets + +The system supports the following network packets: + +- **TitleUpdate**: Sends player's available titles to client +- **UpdateTitle**: Updates displayed title information + +Packet structures are defined based on the XML definitions in the packets directory. + +## Title Categories + +The system organizes titles into logical categories: + +- **Combat**: Battle and PvP related titles +- **Tradeskill**: Crafting and gathering achievements +- **Exploration**: Zone discovery and travel +- **Social**: Community and roleplay titles +- **Achievement**: General accomplishment titles +- **Quest**: Story and mission completion +- **Rare**: Uncommon encounters and collections +- **Seasonal**: Time-limited event titles +- **Guild**: Organization-based titles +- **Raid**: Group content achievements +- **Class**: Profession-specific titles +- **Race**: Heritage and background titles + +## Title Rarity System + +Titles have six rarity levels with associated colors: + +1. **Common** (White): Easily obtainable titles +2. **Uncommon** (Green): Moderate effort required +3. **Rare** (Blue): Significant achievement needed +4. **Epic** (Purple): Exceptional accomplishment +5. **Legendary** (Orange): Extremely difficult to obtain +6. **Unique** (Red): One-of-a-kind titles + +## Special Title Types + +### Temporary Titles + +Some titles expire after a certain period: + +- Event titles that expire when the event ends +- Temporary status titles (e.g., "Newcomer") +- Achievement-based titles with time limits + +### Unique Titles + +Certain titles can only be held by one player at a time: + +- "First to reach max level" +- "Server champion" +- Special recognition titles + +### Account-Wide Titles + +Some titles are shared across all characters on an account: + +- Beta tester recognition +- Special event participation +- Founder rewards + +## Thread Safety + +All title system components are thread-safe using appropriate synchronization: + +- `sync.RWMutex` for read-heavy operations +- Atomic operations where appropriate +- Proper locking hierarchy to prevent deadlocks + +## Performance Considerations + +- Title lookups are optimized with indexed maps +- Player title data is cached in memory +- Background cleanup processes handle expired titles +- Database operations are batched when possible + +## Future Enhancements + +Planned improvements include: + +- Advanced title search and filtering +- Title display customization options +- Title trading/gifting system +- Dynamic title generation +- Integration with guild systems +- Advanced achievement requirements +- Title collection statistics and tracking \ No newline at end of file diff --git a/internal/titles/constants.go b/internal/titles/constants.go new file mode 100644 index 0000000..4390089 --- /dev/null +++ b/internal/titles/constants.go @@ -0,0 +1,106 @@ +package titles + +// Title types and constants +const ( + // Title positioning + TitlePositionPrefix = 1 // Title appears before character name + TitlePositionSuffix = 0 // Title appears after character name + + // Title sources - how titles are obtained + TitleSourceAchievement = 1 // From completing achievements + TitleSourceQuest = 2 // From completing quests + TitleSourceTradeskill = 3 // From tradeskill mastery + TitleSourceCombat = 4 // From combat achievements + TitleSourceExploration = 5 // From exploring zones + TitleSourceRare = 6 // From rare collections/encounters + TitleSourceGuildRank = 7 // From guild progression + TitleSourcePvP = 8 // From PvP activities + TitleSourceRaid = 9 // From raid completions + TitleSourceHoliday = 10 // From holiday events + TitleSourceBetaTester = 11 // Beta testing rewards + TitleSourceDeveloper = 12 // Developer/GM titles + TitleSourceRoleplay = 13 // Roleplay-related titles + TitleSourceMiscellaneous = 14 // Other/uncategorized + + // Title display formats + DisplayFormatSimple = 0 // Just the title text + DisplayFormatWithBrackets = 1 // [Title] + DisplayFormatWithQuotes = 2 // "Title" + DisplayFormatWithCommas = 3 // ,Title, + + // Title rarity levels + TitleRarityCommon = 0 // Common titles easily obtained + TitleRarityUncommon = 1 // Moderately difficult to obtain + TitleRarityRare = 2 // Difficult to obtain + TitleRarityEpic = 3 // Very difficult to obtain + TitleRarityLegendary = 4 // Extremely rare titles + TitleRarityUnique = 5 // One-of-a-kind titles + + // Title categories for organization + CategoryCombat = "Combat" + CategoryTradeskill = "Tradeskill" + CategoryExploration = "Exploration" + CategorySocial = "Social" + CategoryAchievement = "Achievement" + CategoryQuest = "Quest" + CategoryRare = "Rare" + CategorySeasonal = "Seasonal" + CategoryGuild = "Guild" + CategoryPvP = "PvP" + CategoryRaid = "Raid" + CategoryClass = "Class" + CategoryRace = "Race" + CategoryMiscellaneous = "Miscellaneous" + + // Title unlock requirements + RequirementTypeLevel = 1 // Character level requirement + RequirementTypeQuest = 2 // Specific quest completion + RequirementTypeAchievement = 3 // Achievement completion + RequirementTypeSkill = 4 // Skill level requirement + RequirementTypeTradeskill = 5 // Tradeskill level + RequirementTypeCollection = 6 // Collection completion + RequirementTypeKill = 7 // Kill count requirement + RequirementTypeExploration = 8 // Zone discovery + RequirementTypeTime = 9 // Time-based requirement + RequirementTypeItem = 10 // Item possession + RequirementTypeGuild = 11 // Guild membership/rank + RequirementTypeFaction = 12 // Faction standing + RequirementTypeClass = 13 // Specific class requirement + RequirementTypeRace = 14 // Specific race requirement + RequirementTypeAlignment = 15 // Good/Evil alignment + RequirementTypeZone = 16 // Specific zone requirement + RequirementTypeExpansion = 17 // Expansion ownership + + // Title flags + FlagHidden = 1 << 0 // Hidden from normal display + FlagAccountWide = 1 << 1 // Available to all characters on account + FlagUnique = 1 << 2 // Only one player can have this title + FlagTemporary = 1 << 3 // Title expires after time + FlagEventRestricted = 1 << 4 // Only available during events + FlagNoLongerAvailable = 1 << 5 // Legacy title no longer obtainable + FlagStarter = 1 << 6 // Available to new characters + FlagGMOnly = 1 << 7 // Game Master only + FlagBetaRestricted = 1 << 8 // Beta tester only + FlagRoleplayFriendly = 1 << 9 // Designed for roleplay + + // Maximum limits + MaxTitleNameLength = 255 // Maximum characters in title name + MaxTitleDescriptionLength = 512 // Maximum characters in description + MaxPlayerTitles = 500 // Maximum titles per player + MaxTitleRequirements = 10 // Maximum requirements per title + + // Title IDs - Special/System titles (using negative IDs to avoid conflicts) + TitleIDNone = 0 // No title selected + TitleIDCitizen = -1 // Default citizen title + TitleIDVisitor = -2 // Default visitor title + TitleIDNewcomer = -3 // New player title + TitleIDReturning = -4 // Returning player title + + // Color codes for title rarity display + ColorCommon = 0xFFFFFF // White + ColorUncommon = 0x00FF00 // Green + ColorRare = 0x0080FF // Blue + ColorEpic = 0x8000FF // Purple + ColorLegendary = 0xFF8000 // Orange + ColorUnique = 0xFF0000 // Red +) \ No newline at end of file diff --git a/internal/titles/integration.go b/internal/titles/integration.go new file mode 100644 index 0000000..4265d2a --- /dev/null +++ b/internal/titles/integration.go @@ -0,0 +1,403 @@ +package titles + +import ( + "fmt" + "time" +) + +// AchievementIntegration handles title granting from achievements +type AchievementIntegration struct { + titleManager *TitleManager +} + +// NewAchievementIntegration creates a new achievement integration handler +func NewAchievementIntegration(titleManager *TitleManager) *AchievementIntegration { + return &AchievementIntegration{ + titleManager: titleManager, + } +} + +// OnAchievementCompleted is called when a player completes an achievement +func (ai *AchievementIntegration) OnAchievementCompleted(playerID int32, achievementID uint32) error { + return ai.titleManager.ProcessAchievementCompletion(playerID, achievementID) +} + +// QuestIntegration handles title granting from quest completion +type QuestIntegration struct { + titleManager *TitleManager + questTitles map[uint32]int32 // Maps quest ID to title ID +} + +// NewQuestIntegration creates a new quest integration handler +func NewQuestIntegration(titleManager *TitleManager) *QuestIntegration { + qi := &QuestIntegration{ + titleManager: titleManager, + questTitles: make(map[uint32]int32), + } + + // Initialize quest-to-title mappings + qi.initializeQuestTitles() + + return qi +} + +// initializeQuestTitles sets up quest-to-title relationships +func (qi *QuestIntegration) initializeQuestTitles() { + // TODO: Load quest-title mappings from database or configuration + // These would be examples of quest rewards that grant titles + + // Example mappings (these would come from quest definitions): + // qi.questTitles[1001] = heroTitleID // "Hero of Qeynos" from major storyline + // qi.questTitles[2001] = explorerTitleID // "Explorer" from exploration quest + // qi.questTitles[3001] = merchantTitleID // "Master Merchant" from trading quest +} + +// OnQuestCompleted is called when a player completes a quest +func (qi *QuestIntegration) OnQuestCompleted(playerID int32, questID uint32) error { + titleID, exists := qi.questTitles[questID] + if !exists { + return nil // Quest doesn't grant a title + } + + return qi.titleManager.GrantTitle(playerID, titleID, 0, questID) +} + +// AddQuestTitleMapping adds a quest-to-title relationship +func (qi *QuestIntegration) AddQuestTitleMapping(questID uint32, titleID int32) { + qi.questTitles[questID] = titleID +} + +// RemoveQuestTitleMapping removes a quest-to-title relationship +func (qi *QuestIntegration) RemoveQuestTitleMapping(questID uint32) { + delete(qi.questTitles, questID) +} + +// LevelIntegration handles title granting based on character level +type LevelIntegration struct { + titleManager *TitleManager + levelTitles map[int32]int32 // Maps level to title ID +} + +// NewLevelIntegration creates a new level integration handler +func NewLevelIntegration(titleManager *TitleManager) *LevelIntegration { + li := &LevelIntegration{ + titleManager: titleManager, + levelTitles: make(map[int32]int32), + } + + // Initialize level-based titles + li.initializeLevelTitles() + + return li +} + +// initializeLevelTitles sets up level milestone titles +func (li *LevelIntegration) initializeLevelTitles() { + // TODO: Create and register level milestone titles + // These would be created in the title manager and their IDs stored here + + // Example level titles: + // li.levelTitles[10] = noviceTitleID // "Novice" at level 10 + // li.levelTitles[25] = adeptTitleID // "Adept" at level 25 + // li.levelTitles[50] = veteranTitleID // "Veteran" at level 50 + // li.levelTitles[80] = masterTitleID // "Master" at level 80 + // li.levelTitles[90] = championTitleID // "Champion" at level 90 +} + +// OnLevelUp is called when a player levels up +func (li *LevelIntegration) OnLevelUp(playerID, newLevel int32) error { + titleID, exists := li.levelTitles[newLevel] + if !exists { + return nil // No title for this level + } + + return li.titleManager.GrantTitle(playerID, titleID, 0, 0) +} + +// GuildIntegration handles title granting from guild activities +type GuildIntegration struct { + titleManager *TitleManager +} + +// NewGuildIntegration creates a new guild integration handler +func NewGuildIntegration(titleManager *TitleManager) *GuildIntegration { + return &GuildIntegration{ + titleManager: titleManager, + } +} + +// OnGuildRankChanged is called when a player's guild rank changes +func (gi *GuildIntegration) OnGuildRankChanged(playerID int32, guildID, newRank int32) error { + // TODO: Implement guild rank titles + // Different guild ranks could grant different titles + + // Example: Guild leaders get "Guild Leader" title + // if newRank == GUILD_RANK_LEADER { + // return gi.titleManager.GrantTitle(playerID, guildLeaderTitleID, 0, 0) + // } + + return nil +} + +// OnGuildAchievement is called when a guild completes an achievement +func (gi *GuildIntegration) OnGuildAchievement(guildID int32, achievementID uint32, memberIDs []int32) error { + // TODO: Implement guild achievement titles + // Guild achievements could grant titles to all participating members + + // Example: Grant title to all guild members who participated + // for _, memberID := range memberIDs { + // err := gi.titleManager.GrantTitle(memberID, guildAchievementTitleID, achievementID, 0) + // if err != nil { + // // Log error but continue processing other members + // } + // } + + return nil +} + +// PvPIntegration handles title granting from PvP activities +type PvPIntegration struct { + titleManager *TitleManager + pvpStats map[int32]*PvPStats // Track PvP statistics per player +} + +// PvPStats tracks player PvP statistics for title eligibility +type PvPStats struct { + PlayerKills int32 + PlayerDeaths int32 + HonorPoints int32 + LastKillTime time.Time + KillStreak int32 + MaxKillStreak int32 +} + +// NewPvPIntegration creates a new PvP integration handler +func NewPvPIntegration(titleManager *TitleManager) *PvPIntegration { + return &PvPIntegration{ + titleManager: titleManager, + pvpStats: make(map[int32]*PvPStats), + } +} + +// OnPlayerKill is called when a player kills another player +func (pi *PvPIntegration) OnPlayerKill(killerID, victimID int32, honorGained int32) error { + // Update killer stats + killerStats := pi.getOrCreateStats(killerID) + killerStats.PlayerKills++ + killerStats.HonorPoints += honorGained + killerStats.LastKillTime = time.Now() + killerStats.KillStreak++ + + if killerStats.KillStreak > killerStats.MaxKillStreak { + killerStats.MaxKillStreak = killerStats.KillStreak + } + + // Update victim stats + victimStats := pi.getOrCreateStats(victimID) + victimStats.PlayerDeaths++ + victimStats.KillStreak = 0 // Reset kill streak on death + + // Check for PvP milestone titles + return pi.checkPvPTitles(killerID, killerStats) +} + +// getOrCreateStats gets or creates PvP stats for a player +func (pi *PvPIntegration) getOrCreateStats(playerID int32) *PvPStats { + stats, exists := pi.pvpStats[playerID] + if !exists { + stats = &PvPStats{} + pi.pvpStats[playerID] = stats + } + return stats +} + +// checkPvPTitles checks if player qualifies for any PvP titles +func (pi *PvPIntegration) checkPvPTitles(playerID int32, stats *PvPStats) error { + // TODO: Implement PvP title thresholds and grant appropriate titles + + // Example PvP title thresholds: + // if stats.PlayerKills >= 100 && stats.PlayerKills < 500 { + // pi.titleManager.GrantTitle(playerID, slayerTitleID, 0, 0) + // } else if stats.PlayerKills >= 500 && stats.PlayerKills < 1000 { + // pi.titleManager.GrantTitle(playerID, killerTitleID, 0, 0) + // } else if stats.PlayerKills >= 1000 { + // pi.titleManager.GrantTitle(playerID, warlordTitleID, 0, 0) + // } + + // if stats.MaxKillStreak >= 10 { + // pi.titleManager.GrantTitle(playerID, unstoppableTitleID, 0, 0) + // } + + return nil +} + +// EventIntegration handles title granting from special events +type EventIntegration struct { + titleManager *TitleManager + activeEvents map[string]*Event // Active events by name + eventTitles map[string]int32 // Maps event name to title ID +} + +// Event represents a time-limited server event +type Event struct { + Name string + StartTime time.Time + EndTime time.Time + IsActive bool + Description string + TitleID int32 // Title granted for participation +} + +// NewEventIntegration creates a new event integration handler +func NewEventIntegration(titleManager *TitleManager) *EventIntegration { + return &EventIntegration{ + titleManager: titleManager, + activeEvents: make(map[string]*Event), + eventTitles: make(map[string]int32), + } +} + +// StartEvent activates a special event +func (ei *EventIntegration) StartEvent(name, description string, duration time.Duration, titleID int32) error { + event := &Event{ + Name: name, + StartTime: time.Now(), + EndTime: time.Now().Add(duration), + IsActive: true, + Description: description, + TitleID: titleID, + } + + ei.activeEvents[name] = event + ei.eventTitles[name] = titleID + + return nil +} + +// EndEvent deactivates a special event +func (ei *EventIntegration) EndEvent(name string) error { + event, exists := ei.activeEvents[name] + if !exists { + return fmt.Errorf("event %s does not exist", name) + } + + event.IsActive = false + delete(ei.activeEvents, name) + + return nil +} + +// OnEventParticipation is called when a player participates in an event +func (ei *EventIntegration) OnEventParticipation(playerID int32, eventName string) error { + event, exists := ei.activeEvents[eventName] + if !exists || !event.IsActive { + return fmt.Errorf("event %s is not active", eventName) + } + + // Check if event is still within time bounds + now := time.Now() + if now.Before(event.StartTime) || now.After(event.EndTime) { + event.IsActive = false + delete(ei.activeEvents, eventName) + return fmt.Errorf("event %s has expired", eventName) + } + + // Grant event participation title + if event.TitleID > 0 { + return ei.titleManager.GrantTitle(playerID, event.TitleID, 0, 0) + } + + return nil +} + +// GetActiveEvents returns all currently active events +func (ei *EventIntegration) GetActiveEvents() []*Event { + result := make([]*Event, 0, len(ei.activeEvents)) + + for _, event := range ei.activeEvents { + // Check if event is still valid + now := time.Now() + if now.After(event.EndTime) { + event.IsActive = false + continue + } + + if event.IsActive { + result = append(result, event) + } + } + + return result +} + +// TitleEarnedCallback represents a callback function called when a title is earned +type TitleEarnedCallback func(playerID, titleID int32, source string) + +// IntegrationManager coordinates all title integration systems +type IntegrationManager struct { + titleManager *TitleManager + achievementIntegration *AchievementIntegration + questIntegration *QuestIntegration + levelIntegration *LevelIntegration + guildIntegration *GuildIntegration + pvpIntegration *PvPIntegration + eventIntegration *EventIntegration + + callbacks []TitleEarnedCallback +} + +// NewIntegrationManager creates a comprehensive integration manager +func NewIntegrationManager(titleManager *TitleManager) *IntegrationManager { + return &IntegrationManager{ + titleManager: titleManager, + achievementIntegration: NewAchievementIntegration(titleManager), + questIntegration: NewQuestIntegration(titleManager), + levelIntegration: NewLevelIntegration(titleManager), + guildIntegration: NewGuildIntegration(titleManager), + pvpIntegration: NewPvPIntegration(titleManager), + eventIntegration: NewEventIntegration(titleManager), + callbacks: make([]TitleEarnedCallback, 0), + } +} + +// AddTitleEarnedCallback adds a callback to be notified when titles are earned +func (im *IntegrationManager) AddTitleEarnedCallback(callback TitleEarnedCallback) { + im.callbacks = append(im.callbacks, callback) +} + +// NotifyTitleEarned calls all registered callbacks when a title is earned +func (im *IntegrationManager) NotifyTitleEarned(playerID, titleID int32, source string) { + for _, callback := range im.callbacks { + callback(playerID, titleID, source) + } +} + +// GetAchievementIntegration returns the achievement integration handler +func (im *IntegrationManager) GetAchievementIntegration() *AchievementIntegration { + return im.achievementIntegration +} + +// GetQuestIntegration returns the quest integration handler +func (im *IntegrationManager) GetQuestIntegration() *QuestIntegration { + return im.questIntegration +} + +// GetLevelIntegration returns the level integration handler +func (im *IntegrationManager) GetLevelIntegration() *LevelIntegration { + return im.levelIntegration +} + +// GetGuildIntegration returns the guild integration handler +func (im *IntegrationManager) GetGuildIntegration() *GuildIntegration { + return im.guildIntegration +} + +// GetPvPIntegration returns the PvP integration handler +func (im *IntegrationManager) GetPvPIntegration() *PvPIntegration { + return im.pvpIntegration +} + +// GetEventIntegration returns the event integration handler +func (im *IntegrationManager) GetEventIntegration() *EventIntegration { + return im.eventIntegration +} \ No newline at end of file diff --git a/internal/titles/master_list.go b/internal/titles/master_list.go new file mode 100644 index 0000000..da0c310 --- /dev/null +++ b/internal/titles/master_list.go @@ -0,0 +1,420 @@ +package titles + +import ( + "fmt" + "sync" +) + +// MasterTitlesList manages all available titles in the game +type MasterTitlesList struct { + titles map[int32]*Title // All titles indexed by ID + categorized map[string][]*Title // Titles grouped by category + bySource map[int32][]*Title // Titles grouped by source + byRarity map[int32][]*Title // Titles grouped by rarity + byAchievement map[uint32]*Title // Titles indexed by achievement ID + nextID int32 // Next available title ID + mutex sync.RWMutex // Thread safety +} + +// NewMasterTitlesList creates a new master titles list +func NewMasterTitlesList() *MasterTitlesList { + mtl := &MasterTitlesList{ + titles: make(map[int32]*Title), + categorized: make(map[string][]*Title), + bySource: make(map[int32][]*Title), + byRarity: make(map[int32][]*Title), + byAchievement: make(map[uint32]*Title), + nextID: 1, + } + + // Initialize default titles + mtl.initializeDefaultTitles() + + return mtl +} + +// initializeDefaultTitles creates the basic system titles +func (mtl *MasterTitlesList) initializeDefaultTitles() { + // System titles with negative IDs + citizen := NewTitle(TitleIDCitizen, "Citizen") + citizen.SetDescription("Default citizen title") + citizen.SetFlag(FlagStarter) + citizen.Position = TitlePositionSuffix + mtl.addTitleInternal(citizen) + + visitor := NewTitle(TitleIDVisitor, "Visitor") + visitor.SetDescription("Temporary visitor status") + visitor.SetFlag(FlagTemporary) + visitor.Position = TitlePositionSuffix + mtl.addTitleInternal(visitor) + + newcomer := NewTitle(TitleIDNewcomer, "Newcomer") + newcomer.SetDescription("New player welcome title") + newcomer.SetFlag(FlagStarter) + newcomer.ExpirationHours = 168 // 1 week + newcomer.Position = TitlePositionPrefix + mtl.addTitleInternal(newcomer) + + returning := NewTitle(TitleIDReturning, "Returning") + returning.SetDescription("Welcome back title for returning players") + returning.SetFlag(FlagTemporary) + returning.ExpirationHours = 72 // 3 days + returning.Position = TitlePositionPrefix + mtl.addTitleInternal(returning) +} + +// AddTitle adds a new title to the master list +func (mtl *MasterTitlesList) AddTitle(title *Title) error { + mtl.mutex.Lock() + defer mtl.mutex.Unlock() + + if title == nil { + return fmt.Errorf("cannot add nil title") + } + + // Assign ID if not set + if title.ID == 0 { + title.ID = mtl.nextID + mtl.nextID++ + } + + // Check for duplicate ID + if _, exists := mtl.titles[title.ID]; exists { + return fmt.Errorf("title with ID %d already exists", title.ID) + } + + // Validate title name length + if len(title.Name) > MaxTitleNameLength { + return fmt.Errorf("title name exceeds maximum length of %d characters", MaxTitleNameLength) + } + + // Validate description length + if len(title.Description) > MaxTitleDescriptionLength { + return fmt.Errorf("title description exceeds maximum length of %d characters", MaxTitleDescriptionLength) + } + + // Check for unique titles + if title.IsUnique() { + // TODO: Check if any player already has this unique title + } + + return mtl.addTitleInternal(title) +} + +// addTitleInternal adds a title without validation (used internally) +func (mtl *MasterTitlesList) addTitleInternal(title *Title) error { + // Add to main map + mtl.titles[title.ID] = title + + // Add to category index + if mtl.categorized[title.Category] == nil { + mtl.categorized[title.Category] = make([]*Title, 0) + } + mtl.categorized[title.Category] = append(mtl.categorized[title.Category], title) + + // Add to source index + if mtl.bySource[title.Source] == nil { + mtl.bySource[title.Source] = make([]*Title, 0) + } + mtl.bySource[title.Source] = append(mtl.bySource[title.Source], title) + + // Add to rarity index + if mtl.byRarity[title.Rarity] == nil { + mtl.byRarity[title.Rarity] = make([]*Title, 0) + } + mtl.byRarity[title.Rarity] = append(mtl.byRarity[title.Rarity], title) + + // Add to achievement index if applicable + if title.AchievementID > 0 { + mtl.byAchievement[title.AchievementID] = title + } + + // Update next ID if necessary + if title.ID >= mtl.nextID { + mtl.nextID = title.ID + 1 + } + + return nil +} + +// GetTitle retrieves a title by ID +func (mtl *MasterTitlesList) GetTitle(id int32) (*Title, bool) { + mtl.mutex.RLock() + defer mtl.mutex.RUnlock() + + title, exists := mtl.titles[id] + if !exists { + return nil, false + } + + return title.Clone(), true +} + +// GetTitleByName retrieves a title by name (case-sensitive) +func (mtl *MasterTitlesList) GetTitleByName(name string) (*Title, bool) { + mtl.mutex.RLock() + defer mtl.mutex.RUnlock() + + for _, title := range mtl.titles { + if title.Name == name { + return title.Clone(), true + } + } + + return nil, false +} + +// GetTitleByAchievement retrieves a title associated with an achievement +func (mtl *MasterTitlesList) GetTitleByAchievement(achievementID uint32) (*Title, bool) { + mtl.mutex.RLock() + defer mtl.mutex.RUnlock() + + title, exists := mtl.byAchievement[achievementID] + if !exists { + return nil, false + } + + return title.Clone(), true +} + +// GetTitlesByCategory retrieves all titles in a specific category +func (mtl *MasterTitlesList) GetTitlesByCategory(category string) []*Title { + mtl.mutex.RLock() + defer mtl.mutex.RUnlock() + + titles := mtl.categorized[category] + if titles == nil { + return make([]*Title, 0) + } + + // Return clones to prevent external modification + result := make([]*Title, len(titles)) + for i, title := range titles { + result[i] = title.Clone() + } + + return result +} + +// GetTitlesBySource retrieves all titles from a specific source +func (mtl *MasterTitlesList) GetTitlesBySource(source int32) []*Title { + mtl.mutex.RLock() + defer mtl.mutex.RUnlock() + + titles := mtl.bySource[source] + if titles == nil { + return make([]*Title, 0) + } + + // Return clones to prevent external modification + result := make([]*Title, len(titles)) + for i, title := range titles { + result[i] = title.Clone() + } + + return result +} + +// GetTitlesByRarity retrieves all titles of a specific rarity +func (mtl *MasterTitlesList) GetTitlesByRarity(rarity int32) []*Title { + mtl.mutex.RLock() + defer mtl.mutex.RUnlock() + + titles := mtl.byRarity[rarity] + if titles == nil { + return make([]*Title, 0) + } + + // Return clones to prevent external modification + result := make([]*Title, len(titles)) + for i, title := range titles { + result[i] = title.Clone() + } + + return result +} + +// GetAllTitles retrieves all titles (excluding hidden ones by default) +func (mtl *MasterTitlesList) GetAllTitles(includeHidden bool) []*Title { + mtl.mutex.RLock() + defer mtl.mutex.RUnlock() + + result := make([]*Title, 0, len(mtl.titles)) + + for _, title := range mtl.titles { + if !includeHidden && title.IsHidden() { + continue + } + result = append(result, title.Clone()) + } + + return result +} + +// GetAvailableCategories returns all categories that have titles +func (mtl *MasterTitlesList) GetAvailableCategories() []string { + mtl.mutex.RLock() + defer mtl.mutex.RUnlock() + + categories := make([]string, 0, len(mtl.categorized)) + for category := range mtl.categorized { + categories = append(categories, category) + } + + return categories +} + +// RemoveTitle removes a title from the master list +func (mtl *MasterTitlesList) RemoveTitle(id int32) error { + mtl.mutex.Lock() + defer mtl.mutex.Unlock() + + title, exists := mtl.titles[id] + if !exists { + return fmt.Errorf("title with ID %d does not exist", id) + } + + // Remove from main map + delete(mtl.titles, id) + + // Remove from category index + mtl.removeFromSlice(&mtl.categorized[title.Category], title) + if len(mtl.categorized[title.Category]) == 0 { + delete(mtl.categorized, title.Category) + } + + // Remove from source index + mtl.removeFromSlice(&mtl.bySource[title.Source], title) + if len(mtl.bySource[title.Source]) == 0 { + delete(mtl.bySource, title.Source) + } + + // Remove from rarity index + mtl.removeFromSlice(&mtl.byRarity[title.Rarity], title) + if len(mtl.byRarity[title.Rarity]) == 0 { + delete(mtl.byRarity, title.Rarity) + } + + // Remove from achievement index if applicable + if title.AchievementID > 0 { + delete(mtl.byAchievement, title.AchievementID) + } + + return nil +} + +// removeFromSlice removes a title from a slice +func (mtl *MasterTitlesList) removeFromSlice(slice *[]*Title, title *Title) { + for i, t := range *slice { + if t.ID == title.ID { + *slice = append((*slice)[:i], (*slice)[i+1:]...) + break + } + } +} + +// UpdateTitle updates an existing title +func (mtl *MasterTitlesList) UpdateTitle(title *Title) error { + mtl.mutex.Lock() + defer mtl.mutex.Unlock() + + if title == nil { + return fmt.Errorf("cannot update with nil title") + } + + existing, exists := mtl.titles[title.ID] + if !exists { + return fmt.Errorf("title with ID %d does not exist", title.ID) + } + + // Remove old title from indices + mtl.removeFromSlice(&mtl.categorized[existing.Category], existing) + mtl.removeFromSlice(&mtl.bySource[existing.Source], existing) + mtl.removeFromSlice(&mtl.byRarity[existing.Rarity], existing) + + if existing.AchievementID > 0 { + delete(mtl.byAchievement, existing.AchievementID) + } + + // Update the title + mtl.titles[title.ID] = title + + // Re-add to indices with new values + if mtl.categorized[title.Category] == nil { + mtl.categorized[title.Category] = make([]*Title, 0) + } + mtl.categorized[title.Category] = append(mtl.categorized[title.Category], title) + + if mtl.bySource[title.Source] == nil { + mtl.bySource[title.Source] = make([]*Title, 0) + } + mtl.bySource[title.Source] = append(mtl.bySource[title.Source], title) + + if mtl.byRarity[title.Rarity] == nil { + mtl.byRarity[title.Rarity] = make([]*Title, 0) + } + mtl.byRarity[title.Rarity] = append(mtl.byRarity[title.Rarity], title) + + if title.AchievementID > 0 { + mtl.byAchievement[title.AchievementID] = title + } + + return nil +} + +// GetTitleCount returns the total number of titles +func (mtl *MasterTitlesList) GetTitleCount() int { + mtl.mutex.RLock() + defer mtl.mutex.RUnlock() + + return len(mtl.titles) +} + +// ValidateTitle checks if a title meets all requirements +func (mtl *MasterTitlesList) ValidateTitle(title *Title) error { + if title == nil { + return fmt.Errorf("title cannot be nil") + } + + if len(title.Name) == 0 { + return fmt.Errorf("title name cannot be empty") + } + + if len(title.Name) > MaxTitleNameLength { + return fmt.Errorf("title name exceeds maximum length of %d characters", MaxTitleNameLength) + } + + if len(title.Description) > MaxTitleDescriptionLength { + return fmt.Errorf("title description exceeds maximum length of %d characters", MaxTitleDescriptionLength) + } + + if len(title.Requirements) > MaxTitleRequirements { + return fmt.Errorf("title has too many requirements (max %d)", MaxTitleRequirements) + } + + // Validate position + if title.Position != TitlePositionPrefix && title.Position != TitlePositionSuffix { + return fmt.Errorf("invalid title position: %d", title.Position) + } + + // Validate rarity + if title.Rarity < TitleRarityCommon || title.Rarity > TitleRarityUnique { + return fmt.Errorf("invalid title rarity: %d", title.Rarity) + } + + return nil +} + +// LoadFromDatabase would load titles from the database +// TODO: Implement database integration with zone/database package +func (mtl *MasterTitlesList) LoadFromDatabase() error { + // TODO: Implement database loading + return fmt.Errorf("LoadFromDatabase not yet implemented - requires database integration") +} + +// SaveToDatabase would save titles to the database +// TODO: Implement database integration with zone/database package +func (mtl *MasterTitlesList) SaveToDatabase() error { + // TODO: Implement database saving + return fmt.Errorf("SaveToDatabase not yet implemented - requires database integration") +} \ No newline at end of file diff --git a/internal/titles/player_titles.go b/internal/titles/player_titles.go new file mode 100644 index 0000000..f2a52dd --- /dev/null +++ b/internal/titles/player_titles.go @@ -0,0 +1,462 @@ +package titles + +import ( + "fmt" + "sync" + "time" +) + +// PlayerTitlesList manages titles owned by a specific player +type PlayerTitlesList struct { + playerID int32 // Character ID + titles map[int32]*PlayerTitle // Owned titles indexed by title ID + activePrefixID int32 // Currently active prefix title ID (0 = none) + activeSuffixID int32 // Currently active suffix title ID (0 = none) + masterList *MasterTitlesList // Reference to master titles list + mutex sync.RWMutex // Thread safety +} + +// TitlePacketData represents title data for network packets +type TitlePacketData struct { + PlayerID uint32 `json:"player_id"` + PlayerName string `json:"player_name"` + PrefixTitle string `json:"prefix_title"` + SuffixTitle string `json:"suffix_title"` + SubTitle string `json:"sub_title"` + LastName string `json:"last_name"` + Titles []TitleEntry `json:"titles"` + NumTitles uint16 `json:"num_titles"` + CurrentPrefix int16 `json:"current_prefix"` + CurrentSuffix int16 `json:"current_suffix"` +} + +// TitleEntry represents a single title for packet transmission +type TitleEntry struct { + Name string `json:"name"` + IsPrefix bool `json:"is_prefix"` +} + +// NewPlayerTitlesList creates a new player titles list +func NewPlayerTitlesList(playerID int32, masterList *MasterTitlesList) *PlayerTitlesList { + ptl := &PlayerTitlesList{ + playerID: playerID, + titles: make(map[int32]*PlayerTitle), + activePrefixID: TitleIDNone, + activeSuffixID: TitleIDCitizen, // Default suffix title + masterList: masterList, + } + + // Grant default citizen title + ptl.grantDefaultTitle() + + return ptl +} + +// grantDefaultTitle grants the basic citizen title to new players +func (ptl *PlayerTitlesList) grantDefaultTitle() { + citizenTitle := NewPlayerTitle(TitleIDCitizen, ptl.playerID) + citizenTitle.IsActive = true + citizenTitle.IsPrefix = false // Suffix title + ptl.titles[TitleIDCitizen] = citizenTitle +} + +// AddTitle grants a title to the player +func (ptl *PlayerTitlesList) AddTitle(titleID int32, sourceAchievementID, sourceQuestID uint32) error { + ptl.mutex.Lock() + defer ptl.mutex.Unlock() + + // Check if player already has this title + if _, exists := ptl.titles[titleID]; exists { + return fmt.Errorf("player %d already has title %d", ptl.playerID, titleID) + } + + // Verify title exists in master list + masterTitle, exists := ptl.masterList.GetTitle(titleID) + if !exists { + return fmt.Errorf("title %d does not exist in master list", titleID) + } + + // Check if we've hit the maximum title limit + if len(ptl.titles) >= MaxPlayerTitles { + return fmt.Errorf("player %d has reached maximum title limit of %d", ptl.playerID, MaxPlayerTitles) + } + + // Check for unique title restrictions + if masterTitle.IsUnique() { + // TODO: Check database to ensure no other player has this unique title + } + + // Create player title entry + playerTitle := NewPlayerTitle(titleID, ptl.playerID) + playerTitle.AchievementID = sourceAchievementID + playerTitle.QuestID = sourceQuestID + + // Set expiration if it's a temporary title + if masterTitle.ExpirationHours > 0 { + playerTitle.SetExpiration(masterTitle.ExpirationHours) + } + + ptl.titles[titleID] = playerTitle + + return nil +} + +// RemoveTitle removes a title from the player +func (ptl *PlayerTitlesList) RemoveTitle(titleID int32) error { + ptl.mutex.Lock() + defer ptl.mutex.Unlock() + + playerTitle, exists := ptl.titles[titleID] + if !exists { + return fmt.Errorf("player %d does not have title %d", ptl.playerID, titleID) + } + + // If this title is currently active, deactivate it + if playerTitle.IsActive { + if playerTitle.IsPrefix && ptl.activePrefixID == titleID { + ptl.activePrefixID = TitleIDNone + } else if !playerTitle.IsPrefix && ptl.activeSuffixID == titleID { + ptl.activeSuffixID = TitleIDCitizen // Revert to default + } + } + + delete(ptl.titles, titleID) + + return nil +} + +// HasTitle checks if the player owns a specific title +func (ptl *PlayerTitlesList) HasTitle(titleID int32) bool { + ptl.mutex.RLock() + defer ptl.mutex.RUnlock() + + _, exists := ptl.titles[titleID] + return exists +} + +// GetTitle retrieves a player's title information +func (ptl *PlayerTitlesList) GetTitle(titleID int32) (*PlayerTitle, bool) { + ptl.mutex.RLock() + defer ptl.mutex.RUnlock() + + title, exists := ptl.titles[titleID] + if !exists { + return nil, false + } + + return title.Clone(), true +} + +// SetActivePrefix sets the active prefix title +func (ptl *PlayerTitlesList) SetActivePrefix(titleID int32) error { + ptl.mutex.Lock() + defer ptl.mutex.Unlock() + + // Allow clearing prefix title + if titleID == TitleIDNone { + // Deactivate current prefix if any + if ptl.activePrefixID != TitleIDNone { + if currentTitle, exists := ptl.titles[ptl.activePrefixID]; exists { + currentTitle.IsActive = false + } + } + ptl.activePrefixID = TitleIDNone + return nil + } + + // Check if player owns the title + playerTitle, exists := ptl.titles[titleID] + if !exists { + return fmt.Errorf("player %d does not own title %d", ptl.playerID, titleID) + } + + // Verify title can be used as prefix + masterTitle, exists := ptl.masterList.GetTitle(titleID) + if !exists { + return fmt.Errorf("title %d not found in master list", titleID) + } + + if masterTitle.Position != TitlePositionPrefix { + return fmt.Errorf("title %d cannot be used as prefix", titleID) + } + + // Check if title has expired + if playerTitle.IsExpired() { + return fmt.Errorf("title %d has expired", titleID) + } + + // Deactivate current prefix + if ptl.activePrefixID != TitleIDNone { + if currentTitle, exists := ptl.titles[ptl.activePrefixID]; exists { + currentTitle.IsActive = false + } + } + + // Activate new prefix + playerTitle.Activate(true) + ptl.activePrefixID = titleID + + return nil +} + +// SetActiveSuffix sets the active suffix title +func (ptl *PlayerTitlesList) SetActiveSuffix(titleID int32) error { + ptl.mutex.Lock() + defer ptl.mutex.Unlock() + + // Check if player owns the title + playerTitle, exists := ptl.titles[titleID] + if !exists { + return fmt.Errorf("player %d does not own title %d", ptl.playerID, titleID) + } + + // Verify title can be used as suffix + masterTitle, exists := ptl.masterList.GetTitle(titleID) + if !exists { + return fmt.Errorf("title %d not found in master list", titleID) + } + + if masterTitle.Position != TitlePositionSuffix { + return fmt.Errorf("title %d cannot be used as suffix", titleID) + } + + // Check if title has expired + if playerTitle.IsExpired() { + return fmt.Errorf("title %d has expired", titleID) + } + + // Deactivate current suffix + if ptl.activeSuffixID != TitleIDNone { + if currentTitle, exists := ptl.titles[ptl.activeSuffixID]; exists { + currentTitle.IsActive = false + } + } + + // Activate new suffix + playerTitle.Activate(false) + ptl.activeSuffixID = titleID + + return nil +} + +// GetActivePrefixTitle returns the currently active prefix title +func (ptl *PlayerTitlesList) GetActivePrefixTitle() (*Title, bool) { + ptl.mutex.RLock() + defer ptl.mutex.RUnlock() + + if ptl.activePrefixID == TitleIDNone { + return nil, false + } + + return ptl.masterList.GetTitle(ptl.activePrefixID) +} + +// GetActiveSuffixTitle returns the currently active suffix title +func (ptl *PlayerTitlesList) GetActiveSuffixTitle() (*Title, bool) { + ptl.mutex.RLock() + defer ptl.mutex.RUnlock() + + if ptl.activeSuffixID == TitleIDNone { + return nil, false + } + + return ptl.masterList.GetTitle(ptl.activeSuffixID) +} + +// GetAllTitles returns all titles owned by the player +func (ptl *PlayerTitlesList) GetAllTitles() []*PlayerTitle { + ptl.mutex.RLock() + defer ptl.mutex.RUnlock() + + result := make([]*PlayerTitle, 0, len(ptl.titles)) + for _, title := range ptl.titles { + result = append(result, title.Clone()) + } + + return result +} + +// GetAvailablePrefixTitles returns all titles that can be used as prefix +func (ptl *PlayerTitlesList) GetAvailablePrefixTitles() []*Title { + ptl.mutex.RLock() + defer ptl.mutex.RUnlock() + + result := make([]*Title, 0) + + for titleID := range ptl.titles { + if masterTitle, exists := ptl.masterList.GetTitle(titleID); exists { + if masterTitle.Position == TitlePositionPrefix { + // Check if not expired + if playerTitle := ptl.titles[titleID]; !playerTitle.IsExpired() { + result = append(result, masterTitle) + } + } + } + } + + return result +} + +// GetAvailableSuffixTitles returns all titles that can be used as suffix +func (ptl *PlayerTitlesList) GetAvailableSuffixTitles() []*Title { + ptl.mutex.RLock() + defer ptl.mutex.RUnlock() + + result := make([]*Title, 0) + + for titleID := range ptl.titles { + if masterTitle, exists := ptl.masterList.GetTitle(titleID); exists { + if masterTitle.Position == TitlePositionSuffix { + // Check if not expired + if playerTitle := ptl.titles[titleID]; !playerTitle.IsExpired() { + result = append(result, masterTitle) + } + } + } + } + + return result +} + +// CleanupExpiredTitles removes expired temporary titles +func (ptl *PlayerTitlesList) CleanupExpiredTitles() int { + ptl.mutex.Lock() + defer ptl.mutex.Unlock() + + expiredCount := 0 + expiredTitles := make([]int32, 0) + + // Find expired titles + for titleID, playerTitle := range ptl.titles { + if playerTitle.IsExpired() { + expiredTitles = append(expiredTitles, titleID) + expiredCount++ + } + } + + // Remove expired titles + for _, titleID := range expiredTitles { + playerTitle := ptl.titles[titleID] + + // If this expired title is currently active, deactivate it + if playerTitle.IsActive { + if playerTitle.IsPrefix && ptl.activePrefixID == titleID { + ptl.activePrefixID = TitleIDNone + } else if !playerTitle.IsPrefix && ptl.activeSuffixID == titleID { + ptl.activeSuffixID = TitleIDCitizen // Revert to default + } + } + + delete(ptl.titles, titleID) + } + + return expiredCount +} + +// GetTitleCount returns the number of titles owned by the player +func (ptl *PlayerTitlesList) GetTitleCount() int { + ptl.mutex.RLock() + defer ptl.mutex.RUnlock() + + return len(ptl.titles) +} + +// BuildPacketData creates title data for network transmission +func (ptl *PlayerTitlesList) BuildPacketData(playerName string) *TitlePacketData { + ptl.mutex.RLock() + defer ptl.mutex.RUnlock() + + data := &TitlePacketData{ + PlayerID: uint32(ptl.playerID), + PlayerName: playerName, + PrefixTitle: "", + SuffixTitle: "", + SubTitle: "", // TODO: Implement subtitle system + LastName: "", // TODO: Implement last name system + Titles: make([]TitleEntry, 0, len(ptl.titles)), + CurrentPrefix: int16(ptl.activePrefixID), + CurrentSuffix: int16(ptl.activeSuffixID), + } + + // Get active prefix title name + if prefixTitle, exists := ptl.GetActivePrefixTitle(); exists { + data.PrefixTitle = prefixTitle.GetDisplayName() + } + + // Get active suffix title name + if suffixTitle, exists := ptl.GetActiveSuffixTitle(); exists { + data.SuffixTitle = suffixTitle.GetDisplayName() + } + + // Build title array for UI + for titleID := range ptl.titles { + if masterTitle, exists := ptl.masterList.GetTitle(titleID); exists { + // Skip hidden titles unless it's a GM viewing + // TODO: Add GM check parameter + if masterTitle.IsHidden() { + continue + } + + // Skip expired titles + if ptl.titles[titleID].IsExpired() { + continue + } + + entry := TitleEntry{ + Name: masterTitle.GetDisplayName(), + IsPrefix: masterTitle.Position == TitlePositionPrefix, + } + data.Titles = append(data.Titles, entry) + } + } + + data.NumTitles = uint16(len(data.Titles)) + + return data +} + +// GrantTitleFromAchievement grants a title when an achievement is completed +func (ptl *PlayerTitlesList) GrantTitleFromAchievement(achievementID uint32) error { + // Find title associated with this achievement + title, exists := ptl.masterList.GetTitleByAchievement(achievementID) + if !exists { + return nil // No title associated with this achievement + } + + // Grant the title + return ptl.AddTitle(title.ID, achievementID, 0) +} + +// LoadFromDatabase would load player titles from the database +// TODO: Implement database integration with zone/database package +func (ptl *PlayerTitlesList) LoadFromDatabase() error { + // TODO: Implement database loading + return fmt.Errorf("LoadFromDatabase not yet implemented - requires database integration") +} + +// SaveToDatabase would save player titles to the database +// TODO: Implement database integration with zone/database package +func (ptl *PlayerTitlesList) SaveToDatabase() error { + // TODO: Implement database saving + return fmt.Errorf("SaveToDatabase not yet implemented - requires database integration") +} + +// GetFormattedName returns the player name with active titles applied +func (ptl *PlayerTitlesList) GetFormattedName(playerName string) string { + ptl.mutex.RLock() + defer ptl.mutex.RUnlock() + + result := playerName + + // Add prefix if active + if prefixTitle, exists := ptl.GetActivePrefixTitle(); exists { + result = prefixTitle.GetDisplayName() + " " + result + } + + // Add suffix if active + if suffixTitle, exists := ptl.GetActiveSuffixTitle(); exists { + result = result + " " + suffixTitle.GetDisplayName() + } + + return result +} \ No newline at end of file diff --git a/internal/titles/title.go b/internal/titles/title.go new file mode 100644 index 0000000..84f5a86 --- /dev/null +++ b/internal/titles/title.go @@ -0,0 +1,325 @@ +package titles + +import ( + "sync" + "time" +) + +// Title represents a single character title with all its properties +type Title struct { + ID int32 `json:"id"` // Unique title identifier + Name string `json:"name"` // Display name of the title + Description string `json:"description"` // Description shown in UI + + // Positioning and display + Position int32 `json:"position"` // TitlePositionPrefix or TitlePositionSuffix + DisplayFormat int32 `json:"display_format"` // How the title is formatted + Color uint32 `json:"color"` // Color code for display + + // Classification + Source int32 `json:"source"` // How the title is obtained (TitleSource*) + Category string `json:"category"` // Category for organization + Rarity int32 `json:"rarity"` // Title rarity level + + // Requirements and restrictions + Requirements []TitleRequirement `json:"requirements"` // What's needed to unlock + Flags uint32 `json:"flags"` // Various title flags + + // Metadata + CreatedDate time.Time `json:"created_date"` // When title was added + LastModified time.Time `json:"last_modified"` // When title was last updated + MinLevel int32 `json:"min_level"` // Minimum character level + MaxLevel int32 `json:"max_level"` // Maximum character level (0 = no limit) + ExpansionID int32 `json:"expansion_id"` // Required expansion + AchievementID uint32 `json:"achievement_id"` // Associated achievement if any + + // Expiration (for temporary titles) + ExpirationHours int32 `json:"expiration_hours"` // Hours until expiration (0 = permanent) + + mutex sync.RWMutex // Thread safety +} + +// TitleRequirement represents a single requirement for unlocking a title +type TitleRequirement struct { + Type int32 `json:"type"` // RequirementType* constant + Value int32 `json:"value"` // Required value/amount + StringValue string `json:"string_value"` // For string-based requirements + Description string `json:"description"` // Human-readable requirement description +} + +// PlayerTitle represents a title owned by a player +type PlayerTitle struct { + TitleID int32 `json:"title_id"` // Reference to Title.ID + PlayerID int32 `json:"player_id"` // Character ID who owns it + EarnedDate time.Time `json:"earned_date"` // When the title was earned + ExpiresAt time.Time `json:"expires_at"` // When temporary title expires (zero for permanent) + IsActive bool `json:"is_active"` // Whether title is currently displayed + IsPrefix bool `json:"is_prefix"` // True if used as prefix, false for suffix + + // Achievement context + AchievementID uint32 `json:"achievement_id"` // Achievement that granted this title + QuestID uint32 `json:"quest_id"` // Quest that granted this title + + mutex sync.RWMutex // Thread safety +} + +// NewTitle creates a new title with default values +func NewTitle(id int32, name string) *Title { + return &Title{ + ID: id, + Name: name, + Position: TitlePositionSuffix, + DisplayFormat: DisplayFormatSimple, + Color: ColorCommon, + Source: TitleSourceMiscellaneous, + Category: CategoryMiscellaneous, + Rarity: TitleRarityCommon, + Requirements: make([]TitleRequirement, 0), + Flags: 0, + CreatedDate: time.Now(), + LastModified: time.Now(), + MinLevel: 1, + MaxLevel: 0, // No limit + ExpansionID: 0, // Base game + AchievementID: 0, + ExpirationHours: 0, // Permanent + } +} + +// NewPlayerTitle creates a new player title entry +func NewPlayerTitle(titleID, playerID int32) *PlayerTitle { + return &PlayerTitle{ + TitleID: titleID, + PlayerID: playerID, + EarnedDate: time.Now(), + ExpiresAt: time.Time{}, // Zero value = permanent + IsActive: false, + IsPrefix: false, + AchievementID: 0, + QuestID: 0, + } +} + +// IsExpired checks if a temporary title has expired +func (pt *PlayerTitle) IsExpired() bool { + pt.mutex.RLock() + defer pt.mutex.RUnlock() + + if pt.ExpiresAt.IsZero() { + return false // Permanent title + } + return time.Now().After(pt.ExpiresAt) +} + +// SetExpiration sets when a temporary title expires +func (pt *PlayerTitle) SetExpiration(hours int32) { + pt.mutex.Lock() + defer pt.mutex.Unlock() + + if hours <= 0 { + pt.ExpiresAt = time.Time{} // Make permanent + } else { + pt.ExpiresAt = time.Now().Add(time.Duration(hours) * time.Hour) + } +} + +// Activate makes this title the active one for the player +func (pt *PlayerTitle) Activate(isPrefix bool) { + pt.mutex.Lock() + defer pt.mutex.Unlock() + + pt.IsActive = true + pt.IsPrefix = isPrefix +} + +// Deactivate removes this title from active display +func (pt *PlayerTitle) Deactivate() { + pt.mutex.Lock() + defer pt.mutex.Unlock() + + pt.IsActive = false +} + +// Clone creates a deep copy of the title +func (t *Title) Clone() *Title { + t.mutex.RLock() + defer t.mutex.RUnlock() + + clone := &Title{ + ID: t.ID, + Name: t.Name, + Description: t.Description, + Position: t.Position, + DisplayFormat: t.DisplayFormat, + Color: t.Color, + Source: t.Source, + Category: t.Category, + Rarity: t.Rarity, + Requirements: make([]TitleRequirement, len(t.Requirements)), + Flags: t.Flags, + CreatedDate: t.CreatedDate, + LastModified: t.LastModified, + MinLevel: t.MinLevel, + MaxLevel: t.MaxLevel, + ExpansionID: t.ExpansionID, + AchievementID: t.AchievementID, + ExpirationHours: t.ExpirationHours, + } + + copy(clone.Requirements, t.Requirements) + return clone +} + +// Clone creates a deep copy of the player title +func (pt *PlayerTitle) Clone() *PlayerTitle { + pt.mutex.RLock() + defer pt.mutex.RUnlock() + + return &PlayerTitle{ + TitleID: pt.TitleID, + PlayerID: pt.PlayerID, + EarnedDate: pt.EarnedDate, + ExpiresAt: pt.ExpiresAt, + IsActive: pt.IsActive, + IsPrefix: pt.IsPrefix, + AchievementID: pt.AchievementID, + QuestID: pt.QuestID, + } +} + +// HasFlag checks if the title has a specific flag set +func (t *Title) HasFlag(flag uint32) bool { + t.mutex.RLock() + defer t.mutex.RUnlock() + + return (t.Flags & flag) != 0 +} + +// SetFlag sets a specific flag on the title +func (t *Title) SetFlag(flag uint32) { + t.mutex.Lock() + defer t.mutex.Unlock() + + t.Flags |= flag + t.LastModified = time.Now() +} + +// ClearFlag removes a specific flag from the title +func (t *Title) ClearFlag(flag uint32) { + t.mutex.Lock() + defer t.mutex.Unlock() + + t.Flags &^= flag + t.LastModified = time.Now() +} + +// AddRequirement adds a new requirement to the title +func (t *Title) AddRequirement(reqType int32, value int32, stringValue, description string) { + t.mutex.Lock() + defer t.mutex.Unlock() + + req := TitleRequirement{ + Type: reqType, + Value: value, + StringValue: stringValue, + Description: description, + } + + t.Requirements = append(t.Requirements, req) + t.LastModified = time.Now() +} + +// ClearRequirements removes all requirements from the title +func (t *Title) ClearRequirements() { + t.mutex.Lock() + defer t.mutex.Unlock() + + t.Requirements = make([]TitleRequirement, 0) + t.LastModified = time.Now() +} + +// IsHidden checks if the title is hidden from normal display +func (t *Title) IsHidden() bool { + return t.HasFlag(FlagHidden) +} + +// IsAccountWide checks if the title is available to all characters on the account +func (t *Title) IsAccountWide() bool { + return t.HasFlag(FlagAccountWide) +} + +// IsUnique checks if only one player can have this title +func (t *Title) IsUnique() bool { + return t.HasFlag(FlagUnique) +} + +// IsTemporary checks if the title expires after a certain time +func (t *Title) IsTemporary() bool { + return t.HasFlag(FlagTemporary) +} + +// IsGMOnly checks if the title is restricted to Game Masters +func (t *Title) IsGMOnly() bool { + return t.HasFlag(FlagGMOnly) +} + +// GetDisplayName returns the formatted title name for display +func (t *Title) GetDisplayName() string { + t.mutex.RLock() + defer t.mutex.RUnlock() + + switch t.DisplayFormat { + case DisplayFormatWithBrackets: + return "[" + t.Name + "]" + case DisplayFormatWithQuotes: + return "\"" + t.Name + "\"" + case DisplayFormatWithCommas: + return "," + t.Name + "," + default: + return t.Name + } +} + +// SetDescription updates the title description +func (t *Title) SetDescription(description string) { + t.mutex.Lock() + defer t.mutex.Unlock() + + t.Description = description + t.LastModified = time.Now() +} + +// SetCategory updates the title category +func (t *Title) SetCategory(category string) { + t.mutex.Lock() + defer t.mutex.Unlock() + + t.Category = category + t.LastModified = time.Now() +} + +// SetRarity updates the title rarity and adjusts color accordingly +func (t *Title) SetRarity(rarity int32) { + t.mutex.Lock() + defer t.mutex.Unlock() + + t.Rarity = rarity + + // Update color based on rarity + switch rarity { + case TitleRarityCommon: + t.Color = ColorCommon + case TitleRarityUncommon: + t.Color = ColorUncommon + case TitleRarityRare: + t.Color = ColorRare + case TitleRarityEpic: + t.Color = ColorEpic + case TitleRarityLegendary: + t.Color = ColorLegendary + case TitleRarityUnique: + t.Color = ColorUnique + } + + t.LastModified = time.Now() +} \ No newline at end of file diff --git a/internal/titles/title_manager.go b/internal/titles/title_manager.go new file mode 100644 index 0000000..d2d3dfe --- /dev/null +++ b/internal/titles/title_manager.go @@ -0,0 +1,382 @@ +package titles + +import ( + "fmt" + "sync" + "time" +) + +// TitleManager manages the entire title system for the server +type TitleManager struct { + masterList *MasterTitlesList // Global title definitions + playerLists map[int32]*PlayerTitlesList // Player-specific title collections + mutex sync.RWMutex // Thread safety + + // Background cleanup + cleanupTicker *time.Ticker + stopCleanup chan bool + + // Statistics + totalTitlesGranted int64 + totalTitlesExpired int64 +} + +// NewTitleManager creates a new title manager instance +func NewTitleManager() *TitleManager { + tm := &TitleManager{ + masterList: NewMasterTitlesList(), + playerLists: make(map[int32]*PlayerTitlesList), + totalTitlesGranted: 0, + totalTitlesExpired: 0, + } + + // Start background cleanup process + tm.startCleanupProcess() + + return tm +} + +// GetMasterList returns the master titles list +func (tm *TitleManager) GetMasterList() *MasterTitlesList { + return tm.masterList +} + +// GetPlayerTitles retrieves or creates a player's title collection +func (tm *TitleManager) GetPlayerTitles(playerID int32) *PlayerTitlesList { + tm.mutex.Lock() + defer tm.mutex.Unlock() + + playerList, exists := tm.playerLists[playerID] + if !exists { + playerList = NewPlayerTitlesList(playerID, tm.masterList) + tm.playerLists[playerID] = playerList + } + + return playerList +} + +// GrantTitle grants a title to a player +func (tm *TitleManager) GrantTitle(playerID, titleID int32, sourceAchievementID, sourceQuestID uint32) error { + playerList := tm.GetPlayerTitles(playerID) + + err := playerList.AddTitle(titleID, sourceAchievementID, sourceQuestID) + if err == nil { + tm.mutex.Lock() + tm.totalTitlesGranted++ + tm.mutex.Unlock() + } + + return err +} + +// RevokeTitle removes a title from a player +func (tm *TitleManager) RevokeTitle(playerID, titleID int32) error { + tm.mutex.RLock() + playerList, exists := tm.playerLists[playerID] + tm.mutex.RUnlock() + + if !exists { + return fmt.Errorf("player %d has no titles", playerID) + } + + return playerList.RemoveTitle(titleID) +} + +// SetPlayerActivePrefix sets a player's active prefix title +func (tm *TitleManager) SetPlayerActivePrefix(playerID, titleID int32) error { + playerList := tm.GetPlayerTitles(playerID) + return playerList.SetActivePrefix(titleID) +} + +// SetPlayerActiveSuffix sets a player's active suffix title +func (tm *TitleManager) SetPlayerActiveSuffix(playerID, titleID int32) error { + playerList := tm.GetPlayerTitles(playerID) + return playerList.SetActiveSuffix(titleID) +} + +// GetPlayerFormattedName returns a player's name with active titles +func (tm *TitleManager) GetPlayerFormattedName(playerID int32, playerName string) string { + tm.mutex.RLock() + playerList, exists := tm.playerLists[playerID] + tm.mutex.RUnlock() + + if !exists { + return playerName + } + + return playerList.GetFormattedName(playerName) +} + +// ProcessAchievementCompletion handles title grants from achievement completion +func (tm *TitleManager) ProcessAchievementCompletion(playerID int32, achievementID uint32) error { + playerList := tm.GetPlayerTitles(playerID) + return playerList.GrantTitleFromAchievement(achievementID) +} + +// CreateTitle creates a new title and adds it to the master list +func (tm *TitleManager) CreateTitle(name, description, category string, position, source, rarity int32) (*Title, error) { + title := NewTitle(0, name) // ID will be assigned automatically + title.SetDescription(description) + title.SetCategory(category) + title.Position = position + title.Source = source + title.SetRarity(rarity) + + err := tm.masterList.AddTitle(title) + if err != nil { + return nil, err + } + + return title, nil +} + +// CreateAchievementTitle creates a title tied to a specific achievement +func (tm *TitleManager) CreateAchievementTitle(name, description string, achievementID uint32, position, rarity int32) (*Title, error) { + title := NewTitle(0, name) + title.SetDescription(description) + title.SetCategory(CategoryAchievement) + title.Position = position + title.Source = TitleSourceAchievement + title.SetRarity(rarity) + title.AchievementID = achievementID + + err := tm.masterList.AddTitle(title) + if err != nil { + return nil, err + } + + return title, nil +} + +// CreateTemporaryTitle creates a title that expires after a certain time +func (tm *TitleManager) CreateTemporaryTitle(name, description string, hours int32, position, source, rarity int32) (*Title, error) { + title := NewTitle(0, name) + title.SetDescription(description) + title.Position = position + title.Source = source + title.SetRarity(rarity) + title.ExpirationHours = hours + title.SetFlag(FlagTemporary) + + err := tm.masterList.AddTitle(title) + if err != nil { + return nil, err + } + + return title, nil +} + +// CreateUniqueTitle creates a title that only one player can have +func (tm *TitleManager) CreateUniqueTitle(name, description string, position, source int32) (*Title, error) { + title := NewTitle(0, name) + title.SetDescription(description) + title.Position = position + title.Source = source + title.SetRarity(TitleRarityUnique) + title.SetFlag(FlagUnique) + + err := tm.masterList.AddTitle(title) + if err != nil { + return nil, err + } + + return title, nil +} + +// GetTitlesByCategory retrieves all titles in a category +func (tm *TitleManager) GetTitlesByCategory(category string) []*Title { + return tm.masterList.GetTitlesByCategory(category) +} + +// GetTitlesBySource retrieves all titles from a specific source +func (tm *TitleManager) GetTitlesBySource(source int32) []*Title { + return tm.masterList.GetTitlesBySource(source) +} + +// GetTitlesByRarity retrieves all titles of a specific rarity +func (tm *TitleManager) GetTitlesByRarity(rarity int32) []*Title { + return tm.masterList.GetTitlesByRarity(rarity) +} + +// SearchTitles searches for titles by name (case-insensitive partial match) +func (tm *TitleManager) SearchTitles(query string) []*Title { + allTitles := tm.masterList.GetAllTitles(false) // Exclude hidden + result := make([]*Title, 0) + + // Simple case-insensitive contains search + // TODO: Implement more sophisticated search with fuzzy matching + for _, title := range allTitles { + if contains(title.Name, query) || contains(title.Description, query) { + result = append(result, title) + } + } + + return result +} + +// contains performs case-insensitive substring search +func contains(s, substr string) bool { + // Simple implementation - could be improved with proper Unicode handling + sLower := []rune(s) + substrLower := []rune(substr) + + for i := range sLower { + if sLower[i] >= 'A' && sLower[i] <= 'Z' { + sLower[i] = sLower[i] + 32 + } + } + + for i := range substrLower { + if substrLower[i] >= 'A' && substrLower[i] <= 'Z' { + substrLower[i] = substrLower[i] + 32 + } + } + + sStr := string(sLower) + subStr := string(substrLower) + + for i := 0; i <= len(sStr)-len(subStr); i++ { + if sStr[i:i+len(subStr)] == subStr { + return true + } + } + + return false +} + +// GetPlayerTitlePacketData builds packet data for a player's titles +func (tm *TitleManager) GetPlayerTitlePacketData(playerID int32, playerName string) *TitlePacketData { + tm.mutex.RLock() + playerList, exists := tm.playerLists[playerID] + tm.mutex.RUnlock() + + if !exists { + // Create basic packet data with default titles + return &TitlePacketData{ + PlayerID: uint32(playerID), + PlayerName: playerName, + PrefixTitle: "", + SuffixTitle: "Citizen", + SubTitle: "", + LastName: "", + Titles: []TitleEntry{{"Citizen", false}}, + NumTitles: 1, + CurrentPrefix: int16(TitleIDNone), + CurrentSuffix: int16(TitleIDCitizen), + } + } + + return playerList.BuildPacketData(playerName) +} + +// startCleanupProcess begins the background cleanup of expired titles +func (tm *TitleManager) startCleanupProcess() { + tm.cleanupTicker = time.NewTicker(1 * time.Hour) // Run cleanup every hour + tm.stopCleanup = make(chan bool) + + go func() { + for { + select { + case <-tm.cleanupTicker.C: + tm.cleanupExpiredTitles() + case <-tm.stopCleanup: + tm.cleanupTicker.Stop() + return + } + } + }() +} + +// StopCleanupProcess stops the background cleanup +func (tm *TitleManager) StopCleanupProcess() { + if tm.stopCleanup != nil { + tm.stopCleanup <- true + } +} + +// cleanupExpiredTitles removes expired titles from all players +func (tm *TitleManager) cleanupExpiredTitles() { + tm.mutex.RLock() + playerLists := make([]*PlayerTitlesList, 0, len(tm.playerLists)) + for _, list := range tm.playerLists { + playerLists = append(playerLists, list) + } + tm.mutex.RUnlock() + + totalExpired := 0 + for _, playerList := range playerLists { + expired := playerList.CleanupExpiredTitles() + totalExpired += expired + } + + if totalExpired > 0 { + tm.mutex.Lock() + tm.totalTitlesExpired += int64(totalExpired) + tm.mutex.Unlock() + } +} + +// GetStatistics returns title system statistics +func (tm *TitleManager) GetStatistics() map[string]interface{} { + tm.mutex.RLock() + defer tm.mutex.RUnlock() + + return map[string]interface{}{ + "total_titles": tm.masterList.GetTitleCount(), + "total_players": len(tm.playerLists), + "titles_granted": tm.totalTitlesGranted, + "titles_expired": tm.totalTitlesExpired, + "available_categories": tm.masterList.GetAvailableCategories(), + } +} + +// RemovePlayerFromMemory removes a player's title data from memory (when they log out) +func (tm *TitleManager) RemovePlayerFromMemory(playerID int32) { + tm.mutex.Lock() + defer tm.mutex.Unlock() + + delete(tm.playerLists, playerID) +} + +// LoadPlayerTitles loads a player's titles from database +// TODO: Implement database integration with zone/database package +func (tm *TitleManager) LoadPlayerTitles(playerID int32) error { + playerList := tm.GetPlayerTitles(playerID) + return playerList.LoadFromDatabase() +} + +// SavePlayerTitles saves a player's titles to database +// TODO: Implement database integration with zone/database package +func (tm *TitleManager) SavePlayerTitles(playerID int32) error { + tm.mutex.RLock() + playerList, exists := tm.playerLists[playerID] + tm.mutex.RUnlock() + + if !exists { + return fmt.Errorf("player %d has no title data to save", playerID) + } + + return playerList.SaveToDatabase() +} + +// LoadMasterTitles loads all titles from database +// TODO: Implement database integration with zone/database package +func (tm *TitleManager) LoadMasterTitles() error { + return tm.masterList.LoadFromDatabase() +} + +// SaveMasterTitles saves all titles to database +// TODO: Implement database integration with zone/database package +func (tm *TitleManager) SaveMasterTitles() error { + return tm.masterList.SaveToDatabase() +} + +// ValidateTitle validates a title before adding it +func (tm *TitleManager) ValidateTitle(title *Title) error { + return tm.masterList.ValidateTitle(title) +} + +// Shutdown gracefully shuts down the title manager +func (tm *TitleManager) Shutdown() { + tm.StopCleanupProcess() +} \ No newline at end of file diff --git a/internal/trade/constants.go b/internal/trade/constants.go new file mode 100644 index 0000000..4ba27d6 --- /dev/null +++ b/internal/trade/constants.go @@ -0,0 +1,39 @@ +package trade + +// Trade error codes converted from C++ Trade.cpp +const ( + TradeResultSuccess = 0 // Item successfully added to trade + TradeResultAlreadyInTrade = 1 // Item already in trade + TradeResultNoTrade = 2 // Item is no-trade + TradeResultHeirloom = 3 // Item is heirloom and cannot be traded + TradeResultInvalidSlot = 254 // Slot is full or invalid + TradeResultSlotOutOfRange = 255 // Slot is out of range + TradeResultInsufficientQty = 253 // Not enough quantity to trade +) + +// Trade packet types converted from C++ Trade.cpp +const ( + TradePacketTypeOpen = 1 // Open trade window + TradePacketTypeCancel = 2 // Cancel trade + TradePacketTypeAccept = 16 // Accept trade + TradePacketTypeComplete = 24 // Trade completed +) + +// Trade slot configuration +const ( + TradeMaxSlotsDefault = 12 // Default max slots for newer clients + TradeMaxSlotsLegacy = 6 // Max slots for older clients (version <= 561) + TradeSlotAutoFind = 255 // Automatically find next free slot +) + +// Coin conversion constants (from C++ CalculateCoins) +const ( + CoinsPlatinumThreshold = 1000000 // 1 platinum = 1,000,000 copper + CoinsGoldThreshold = 10000 // 1 gold = 10,000 copper + CoinsSilverThreshold = 100 // 1 silver = 100 copper +) + +// Trade validation constants +const ( + TradeSlotEmpty = -1 // Indicates empty trade slot +) \ No newline at end of file diff --git a/internal/trade/manager.go b/internal/trade/manager.go new file mode 100644 index 0000000..f30682c --- /dev/null +++ b/internal/trade/manager.go @@ -0,0 +1,378 @@ +package trade + +import ( + "fmt" + "sync" + "time" +) + +// TradeService provides high-level trade management functionality +// This integrates the trade system with the broader server architecture +type TradeService struct { + tradeManager *TradeManager + + // Trade configuration + maxTradeDuration time.Duration // Maximum time a trade can be active + + // TODO: Add references to other systems when available + // entityManager *EntityManager + // packetManager *PacketManager + // logManager *LogManager + + mutex sync.RWMutex +} + +// NewTradeService creates a new trade service +func NewTradeService() *TradeService { + return &TradeService{ + tradeManager: NewTradeManager(), + maxTradeDuration: 30 * time.Minute, // Default 30 minute timeout + } +} + +// InitiateTrade starts a trade between two entities +// This is the main entry point for starting trades +func (ts *TradeService) InitiateTrade(initiatorID, targetID int32) (*Trade, error) { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + // Check if either entity is already in a trade + if existingTrade := ts.tradeManager.GetTrade(initiatorID); existingTrade != nil { + return nil, fmt.Errorf("initiator is already in a trade") + } + + if existingTrade := ts.tradeManager.GetTrade(targetID); existingTrade != nil { + return nil, fmt.Errorf("target is already in a trade") + } + + // TODO: Get actual entities when entity system is available + // For now, create placeholder entities + initiator := &PlaceholderEntity{ID: initiatorID} + target := &PlaceholderEntity{ID: targetID} + + // Create new trade + trade := NewTrade(initiator, target) + if trade == nil { + return nil, fmt.Errorf("failed to create trade") + } + + // Add to trade manager + ts.tradeManager.AddTrade(trade) + + return trade, nil +} + +// GetTrade retrieves an active trade for an entity +func (ts *TradeService) GetTrade(entityID int32) *Trade { + return ts.tradeManager.GetTrade(entityID) +} + +// AddItemToTrade adds an item to a player's trade offer +func (ts *TradeService) AddItemToTrade(entityID int32, item Item, quantity int32, slot int8) error { + trade := ts.tradeManager.GetTrade(entityID) + if trade == nil { + return fmt.Errorf("entity is not in a trade") + } + + return trade.AddItemToTrade(entityID, item, quantity, slot) +} + +// RemoveItemFromTrade removes an item from a player's trade offer +func (ts *TradeService) RemoveItemFromTrade(entityID int32, slot int8) error { + trade := ts.tradeManager.GetTrade(entityID) + if trade == nil { + return fmt.Errorf("entity is not in a trade") + } + + return trade.RemoveItemFromTrade(entityID, slot) +} + +// AddCoinsToTrade adds coins to a player's trade offer +func (ts *TradeService) AddCoinsToTrade(entityID int32, amount int64) error { + trade := ts.tradeManager.GetTrade(entityID) + if trade == nil { + return fmt.Errorf("entity is not in a trade") + } + + return trade.AddCoinsToTrade(entityID, amount) +} + +// RemoveCoinsFromTrade removes coins from a player's trade offer +func (ts *TradeService) RemoveCoinsFromTrade(entityID int32, amount int64) error { + trade := ts.tradeManager.GetTrade(entityID) + if trade == nil { + return fmt.Errorf("entity is not in a trade") + } + + return trade.RemoveCoinsFromTrade(entityID, amount) +} + +// AcceptTrade marks a player as having accepted their trade +func (ts *TradeService) AcceptTrade(entityID int32) (bool, error) { + trade := ts.tradeManager.GetTrade(entityID) + if trade == nil { + return false, fmt.Errorf("entity is not in a trade") + } + + completed, err := trade.SetTradeAccepted(entityID) + if err != nil { + return false, err + } + + // If trade completed, remove from manager + if completed { + ts.tradeManager.RemoveTrade(trade.GetTrader1ID()) + } + + return completed, nil +} + +// CancelTrade cancels an active trade +func (ts *TradeService) CancelTrade(entityID int32) error { + trade := ts.tradeManager.GetTrade(entityID) + if trade == nil { + return fmt.Errorf("entity is not in a trade") + } + + err := trade.CancelTrade(entityID) + if err != nil { + return err + } + + // Remove from manager + ts.tradeManager.RemoveTrade(trade.GetTrader1ID()) + + return nil +} + +// GetTradeInfo returns comprehensive information about a trade +func (ts *TradeService) GetTradeInfo(entityID int32) (map[string]interface{}, error) { + trade := ts.tradeManager.GetTrade(entityID) + if trade == nil { + return nil, fmt.Errorf("entity is not in a trade") + } + + return trade.GetTradeInfo(), nil +} + +// GetActiveTradeCount returns the number of active trades +func (ts *TradeService) GetActiveTradeCount() int { + return ts.tradeManager.GetActiveTradeCount() +} + +// ProcessTrades handles periodic trade processing (timeouts, cleanup, etc.) +func (ts *TradeService) ProcessTrades() { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + // TODO: Implement trade timeout processing + // This would check for trades that have been active too long and auto-cancel them + + // Get all active trades + // Check each trade's start time against maxTradeDuration + // Cancel expired trades +} + +// GetTradeStatistics returns statistics about trade activity +func (ts *TradeService) GetTradeStatistics() map[string]interface{} { + stats := make(map[string]interface{}) + + stats["active_trades"] = ts.tradeManager.GetActiveTradeCount() + stats["max_trade_duration_minutes"] = ts.maxTradeDuration.Minutes() + + // TODO: Add more statistics when logging/metrics system is available + // - Total trades completed today + // - Average trade completion time + // - Most traded items + // - Trade success/failure rates + + return stats +} + +// ValidateTradeRequest checks if a trade request is valid +func (ts *TradeService) ValidateTradeRequest(initiatorID, targetID int32) error { + if initiatorID == targetID { + return fmt.Errorf("cannot trade with yourself") + } + + if initiatorID <= 0 || targetID <= 0 { + return fmt.Errorf("invalid entity IDs") + } + + // Check if either entity is already in a trade + if ts.tradeManager.GetTrade(initiatorID) != nil { + return fmt.Errorf("initiator is already in a trade") + } + + if ts.tradeManager.GetTrade(targetID) != nil { + return fmt.Errorf("target is already in a trade") + } + + // TODO: Add additional validation when entity system is available: + // - Verify both entities exist and are online + // - Check if entities are in the same zone + // - Verify entities are within trade range + // - Check for any trade restrictions or bans + + return nil +} + +// ForceCompleteTrade completes a trade regardless of acceptance state (admin function) +func (ts *TradeService) ForceCompleteTrade(entityID int32) error { + trade := ts.tradeManager.GetTrade(entityID) + if trade == nil { + return fmt.Errorf("entity is not in a trade") + } + + // Force both participants to accepted state + trade.trader1.HasAccepted = true + trade.trader2.HasAccepted = true + + // Complete the trade + completed, err := trade.SetTradeAccepted(entityID) + if err != nil { + return err + } + + if completed { + ts.tradeManager.RemoveTrade(trade.GetTrader1ID()) + } + + return nil +} + +// ForceCancelTrade cancels a trade regardless of state (admin function) +func (ts *TradeService) ForceCancelTrade(entityID int32, reason string) error { + trade := ts.tradeManager.GetTrade(entityID) + if trade == nil { + return fmt.Errorf("entity is not in a trade") + } + + // TODO: Log the forced cancellation with reason when logging system is available + + err := trade.CancelTrade(entityID) + if err != nil { + return err + } + + ts.tradeManager.RemoveTrade(trade.GetTrader1ID()) + + return nil +} + +// Shutdown gracefully shuts down the trade service +func (ts *TradeService) Shutdown() { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + // TODO: Cancel all active trades with appropriate notifications + // For now, just clear the trade manager + ts.tradeManager = NewTradeManager() +} + +// PlaceholderEntity is a temporary implementation until the entity system is available +type PlaceholderEntity struct { + ID int32 + Name string + IsPlayerFlag bool + IsBotFlag bool + CoinsAmount int64 + ClientVer int32 +} + +// GetID returns the entity ID +func (pe *PlaceholderEntity) GetID() int32 { + return pe.ID +} + +// GetName returns the entity name +func (pe *PlaceholderEntity) GetName() string { + if pe.Name == "" { + return fmt.Sprintf("Entity_%d", pe.ID) + } + return pe.Name +} + +// IsPlayer returns whether this is a player entity +func (pe *PlaceholderEntity) IsPlayer() bool { + return pe.IsPlayerFlag +} + +// IsBot returns whether this is a bot entity +func (pe *PlaceholderEntity) IsBot() bool { + return pe.IsBotFlag +} + +// HasCoins checks if the entity has sufficient coins +func (pe *PlaceholderEntity) HasCoins(amount int64) bool { + return pe.CoinsAmount >= amount +} + +// GetClientVersion returns the client version +func (pe *PlaceholderEntity) GetClientVersion() int32 { + if pe.ClientVer == 0 { + return 1000 // Default to newer client + } + return pe.ClientVer +} + +// PlaceholderItem is a temporary implementation until the item system is available +type PlaceholderItem struct { + ID int32 + Name string + Quantity int32 + IconID int32 + NoTradeFlag bool + HeirloomFlag bool + AttunedFlag bool + CreatedTime time.Time + GroupIDs []int32 +} + +// GetID returns the item ID +func (pi *PlaceholderItem) GetID() int32 { + return pi.ID +} + +// GetName returns the item name +func (pi *PlaceholderItem) GetName() string { + if pi.Name == "" { + return fmt.Sprintf("Item_%d", pi.ID) + } + return pi.Name +} + +// GetQuantity returns the item quantity +func (pi *PlaceholderItem) GetQuantity() int32 { + return pi.Quantity +} + +// GetIcon returns the item icon ID +func (pi *PlaceholderItem) GetIcon(version int32) int32 { + return pi.IconID +} + +// IsNoTrade returns whether the item is no-trade +func (pi *PlaceholderItem) IsNoTrade() bool { + return pi.NoTradeFlag +} + +// IsHeirloom returns whether the item is heirloom +func (pi *PlaceholderItem) IsHeirloom() bool { + return pi.HeirloomFlag +} + +// IsAttuned returns whether the item is attuned +func (pi *PlaceholderItem) IsAttuned() bool { + return pi.AttunedFlag +} + +// GetCreationTime returns when the item was created +func (pi *PlaceholderItem) GetCreationTime() time.Time { + return pi.CreatedTime +} + +// GetGroupCharacterIDs returns the group character IDs for heirloom sharing +func (pi *PlaceholderItem) GetGroupCharacterIDs() []int32 { + return pi.GroupIDs +} \ No newline at end of file diff --git a/internal/trade/trade.go b/internal/trade/trade.go new file mode 100644 index 0000000..6bb6ef4 --- /dev/null +++ b/internal/trade/trade.go @@ -0,0 +1,487 @@ +package trade + +import ( + "fmt" + "sync" + "time" +) + +// Trade represents an active trade between two entities +// Converted from C++ Trade class +type Trade struct { + // Core trade participants + trader1 *TradeParticipant // First trader (initiator) + trader2 *TradeParticipant // Second trader (recipient) + + // Trade state + state TradeState // Current state of the trade + startTime time.Time // When the trade was initiated + + // Thread safety + mutex sync.RWMutex + + // TODO: Add references to packet system and entity manager when available + // packetManager *PacketManager + // entityManager *EntityManager +} + +// NewTrade creates a new trade between two entities +// Converted from C++ Trade::Trade constructor +func NewTrade(entity1, entity2 Entity) *Trade { + if entity1 == nil || entity2 == nil { + return nil + } + + trade := &Trade{ + trader1: NewTradeParticipant(entity1.GetID(), entity1.IsBot(), entity1.GetClientVersion()), + trader2: NewTradeParticipant(entity2.GetID(), entity2.IsBot(), entity2.GetClientVersion()), + state: TradeStateActive, + startTime: time.Now(), + } + + // TODO: Open trade window when packet system is available + // trade.openTradeWindow() + + return trade +} + +// GetTrader1ID returns the ID of the first trader +func (t *Trade) GetTrader1ID() int32 { + t.mutex.RLock() + defer t.mutex.RUnlock() + return t.trader1.EntityID +} + +// GetTrader2ID returns the ID of the second trader +func (t *Trade) GetTrader2ID() int32 { + t.mutex.RLock() + defer t.mutex.RUnlock() + return t.trader2.EntityID +} + +// GetTradee returns the other participant in the trade +// Converted from C++ Trade::GetTradee +func (t *Trade) GetTradee(entityID int32) int32 { + t.mutex.RLock() + defer t.mutex.RUnlock() + + if t.trader1.EntityID == entityID { + return t.trader2.EntityID + } else if t.trader2.EntityID == entityID { + return t.trader1.EntityID + } + + return 0 // Invalid entity ID +} + +// GetParticipant returns the trade participant for an entity +func (t *Trade) GetParticipant(entityID int32) *TradeParticipant { + t.mutex.RLock() + defer t.mutex.RUnlock() + + if t.trader1.EntityID == entityID { + return t.trader1 + } else if t.trader2.EntityID == entityID { + return t.trader2 + } + + return nil +} + +// GetState returns the current trade state +func (t *Trade) GetState() TradeState { + t.mutex.RLock() + defer t.mutex.RUnlock() + return t.state +} + +// AddItemToTrade adds an item to a participant's trade offer +// Converted from C++ Trade::AddItemToTrade +func (t *Trade) AddItemToTrade(entityID int32, item Item, quantity int32, slot int8) error { + if t.state != TradeStateActive { + return &TradeValidationError{ + Code: TradeResultInvalidSlot, + Message: "Trade is not active", + } + } + + t.mutex.Lock() + defer t.mutex.Unlock() + + participant := t.getParticipantUnsafe(entityID) + if participant == nil { + return &TradeValidationError{ + Code: TradeResultInvalidSlot, + Message: "Entity is not part of this trade", + } + } + + // Auto-find slot if needed + if slot == TradeSlotAutoFind { + slot = participant.GetNextFreeSlot() + } + + // Validate slot + if slot < 0 || slot >= participant.MaxSlots { + return &TradeValidationError{ + Code: TradeResultSlotOutOfRange, + Message: fmt.Sprintf("Invalid trade slot: %d", slot), + } + } + + // Check if slot is already occupied + if _, exists := participant.Items[slot]; exists { + return &TradeValidationError{ + Code: TradeResultInvalidSlot, + Message: "Trade slot is already occupied", + } + } + + // Validate quantity + if quantity <= 0 { + quantity = 1 + } + + if quantity > item.GetQuantity() { + return &TradeValidationError{ + Code: TradeResultInsufficientQty, + Message: "Not enough quantity available", + } + } + + // Check if item is already in trade + if participant.HasItem(item.GetID()) { + return &TradeValidationError{ + Code: TradeResultAlreadyInTrade, + Message: "Item is already in trade", + } + } + + // Validate item tradability + otherID := t.getTradeeUnsafe(entityID) + if err := t.validateItemTradability(item, entityID, otherID); err != nil { + return err + } + + // Add item to trade + participant.Items[slot] = TradeItemInfo{ + Item: item, + Quantity: quantity, + } + + // Reset acceptance flags + t.trader1.HasAccepted = false + t.trader2.HasAccepted = false + + // TODO: Send trade packet when packet system is available + // t.sendTradePacket() + + return nil +} + +// RemoveItemFromTrade removes an item from a participant's trade offer +// Converted from C++ Trade::RemoveItemFromTrade +func (t *Trade) RemoveItemFromTrade(entityID int32, slot int8) error { + if t.state != TradeStateActive { + return &TradeValidationError{ + Code: TradeResultInvalidSlot, + Message: "Trade is not active", + } + } + + t.mutex.Lock() + defer t.mutex.Unlock() + + participant := t.getParticipantUnsafe(entityID) + if participant == nil { + return &TradeValidationError{ + Code: TradeResultInvalidSlot, + Message: "Entity is not part of this trade", + } + } + + // Check if slot has an item + if _, exists := participant.Items[slot]; !exists { + return &TradeValidationError{ + Code: TradeResultInvalidSlot, + Message: "Trade slot is empty", + } + } + + // Remove item + delete(participant.Items, slot) + + // Reset acceptance flags + t.trader1.HasAccepted = false + t.trader2.HasAccepted = false + + // TODO: Send trade packet when packet system is available + // t.sendTradePacket() + + return nil +} + +// AddCoinsToTrade adds coins to a participant's trade offer +// Converted from C++ Trade::AddCoinToTrade +func (t *Trade) AddCoinsToTrade(entityID int32, amount int64) error { + if t.state != TradeStateActive { + return &TradeValidationError{ + Code: TradeResultInvalidSlot, + Message: "Trade is not active", + } + } + + if amount <= 0 { + return &TradeValidationError{ + Code: TradeResultInvalidSlot, + Message: "Invalid coin amount", + } + } + + t.mutex.Lock() + defer t.mutex.Unlock() + + participant := t.getParticipantUnsafe(entityID) + if participant == nil { + return &TradeValidationError{ + Code: TradeResultInvalidSlot, + Message: "Entity is not part of this trade", + } + } + + newTotal := participant.Coins + amount + + // TODO: Validate entity has sufficient coins when entity system is available + // For now, assume validation is done elsewhere + + participant.Coins = newTotal + + // Reset acceptance flags + t.trader1.HasAccepted = false + t.trader2.HasAccepted = false + + // TODO: Send trade packet when packet system is available + // t.sendTradePacket() + + return nil +} + +// RemoveCoinsFromTrade removes coins from a participant's trade offer +// Converted from C++ Trade::RemoveCoinFromTrade +func (t *Trade) RemoveCoinsFromTrade(entityID int32, amount int64) error { + if t.state != TradeStateActive { + return &TradeValidationError{ + Code: TradeResultInvalidSlot, + Message: "Trade is not active", + } + } + + t.mutex.Lock() + defer t.mutex.Unlock() + + participant := t.getParticipantUnsafe(entityID) + if participant == nil { + return &TradeValidationError{ + Code: TradeResultInvalidSlot, + Message: "Entity is not part of this trade", + } + } + + if amount >= participant.Coins { + participant.Coins = 0 + } else { + participant.Coins -= amount + } + + // Reset acceptance flags + t.trader1.HasAccepted = false + t.trader2.HasAccepted = false + + // TODO: Send trade packet when packet system is available + // t.sendTradePacket() + + return nil +} + +// SetTradeAccepted marks a participant as having accepted the trade +// Converted from C++ Trade::SetTradeAccepted +func (t *Trade) SetTradeAccepted(entityID int32) (bool, error) { + if t.state != TradeStateActive { + return false, &TradeValidationError{ + Code: TradeResultInvalidSlot, + Message: "Trade is not active", + } + } + + t.mutex.Lock() + defer t.mutex.Unlock() + + participant := t.getParticipantUnsafe(entityID) + if participant == nil { + return false, &TradeValidationError{ + Code: TradeResultInvalidSlot, + Message: "Entity is not part of this trade", + } + } + + participant.HasAccepted = true + + // Check if both parties have accepted + if t.trader1.HasAccepted && t.trader2.HasAccepted { + // Complete the trade + t.completeTrade() + return true, nil + } + + // TODO: Send acceptance packet to other trader when packet system is available + + return false, nil +} + +// HasAcceptedTrade checks if a participant has accepted the trade +// Converted from C++ Trade::HasAcceptedTrade +func (t *Trade) HasAcceptedTrade(entityID int32) bool { + t.mutex.RLock() + defer t.mutex.RUnlock() + + participant := t.getParticipantUnsafe(entityID) + if participant == nil { + return false + } + + return participant.HasAccepted +} + +// CancelTrade cancels the trade +// Converted from C++ Trade::CancelTrade +func (t *Trade) CancelTrade(entityID int32) error { + t.mutex.Lock() + defer t.mutex.Unlock() + + if t.state != TradeStateActive { + return &TradeValidationError{ + Code: TradeResultInvalidSlot, + Message: "Trade is not active", + } + } + + t.state = TradeStateCanceled + + // TODO: Send cancel packets to both traders when packet system is available + // TODO: Clear trade references on entities when entity system is available + + return nil +} + +// GetTraderSlot returns the item in a specific slot for a trader +// Converted from C++ Trade::GetTraderSlot +func (t *Trade) GetTraderSlot(entityID int32, slot int8) Item { + t.mutex.RLock() + defer t.mutex.RUnlock() + + participant := t.getParticipantUnsafe(entityID) + if participant == nil { + return nil + } + + if itemInfo, exists := participant.Items[slot]; exists { + return itemInfo.Item + } + + return nil +} + +// GetTradeInfo returns comprehensive information about the trade +func (t *Trade) GetTradeInfo() map[string]interface{} { + t.mutex.RLock() + defer t.mutex.RUnlock() + + info := make(map[string]interface{}) + info["state"] = t.state + info["start_time"] = t.startTime + info["trader1_id"] = t.trader1.EntityID + info["trader2_id"] = t.trader2.EntityID + info["trader1_accepted"] = t.trader1.HasAccepted + info["trader2_accepted"] = t.trader2.HasAccepted + info["trader1_items"] = len(t.trader1.Items) + info["trader2_items"] = len(t.trader2.Items) + info["trader1_coins"] = t.trader1.Coins + info["trader2_coins"] = t.trader2.Coins + + return info +} + +// Private helper methods + +// getParticipantUnsafe returns participant without locking (must be called with lock held) +func (t *Trade) getParticipantUnsafe(entityID int32) *TradeParticipant { + if t.trader1.EntityID == entityID { + return t.trader1 + } else if t.trader2.EntityID == entityID { + return t.trader2 + } + return nil +} + +// getTradeeUnsafe returns the other trader's ID without locking +func (t *Trade) getTradeeUnsafe(entityID int32) int32 { + if t.trader1.EntityID == entityID { + return t.trader2.EntityID + } else if t.trader2.EntityID == entityID { + return t.trader1.EntityID + } + return 0 +} + +// validateItemTradability checks if an item can be traded between entities +// Converted from C++ Trade::CheckItem +func (t *Trade) validateItemTradability(item Item, traderID, otherID int32) error { + // Check no-trade flag + if item.IsNoTrade() { + // TODO: Only allow no-trade items with bots when bot system is available + return &TradeValidationError{ + Code: TradeResultNoTrade, + Message: "Item cannot be traded", + } + } + + // Check heirloom restrictions + if item.IsHeirloom() { + // TODO: Implement heirloom group checking when group system is available + // For now, allow heirloom trades with basic time/attunement checks + + if item.IsAttuned() { + return &TradeValidationError{ + Code: TradeResultHeirloom, + Message: "Attuned heirloom items cannot be traded", + } + } + + // Check time-based restrictions (48 hours default) + creationTime := item.GetCreationTime() + if time.Since(creationTime) > 48*time.Hour { + return &TradeValidationError{ + Code: TradeResultHeirloom, + Message: "Heirloom item sharing period has expired", + } + } + } + + return nil +} + +// completeTrade finalizes the trade and transfers items/coins +// Converted from C++ Trade::CompleteTrade +func (t *Trade) completeTrade() { + t.state = TradeStateCompleted + + // TODO: Implement actual item/coin transfer when entity and inventory systems are available + // This would involve: + // 1. Remove items/coins from each trader's inventory + // 2. Add the other trader's items/coins to their inventory + // 3. Send completion packets + // 4. Log the trade + // 5. Clear trade references on entities + + // For now, just mark as completed +} \ No newline at end of file diff --git a/internal/trade/types.go b/internal/trade/types.go new file mode 100644 index 0000000..859c9ba --- /dev/null +++ b/internal/trade/types.go @@ -0,0 +1,188 @@ +package trade + +import ( + "sync" + "time" +) + +// TradeItemInfo represents an item in a trade slot +// Converted from C++ TradeItemInfo struct +type TradeItemInfo struct { + Item *Item // TODO: Replace with actual Item type when available + Quantity int32 // Quantity of the item being traded +} + +// CoinAmounts represents the breakdown of coins in EQ2 currency +type CoinAmounts struct { + Platinum int32 // Platinum coins + Gold int32 // Gold coins + Silver int32 // Silver coins + Copper int32 // Copper coins (base unit) +} + +// TradeState represents the current state of a trade +type TradeState int32 + +const ( + TradeStateActive TradeState = 0 // Trade is active and ongoing + TradeStateAccepted TradeState = 1 // Trade has been accepted by both parties + TradeStateCanceled TradeState = 2 // Trade was canceled + TradeStateCompleted TradeState = 3 // Trade was completed successfully +) + +// TradeValidationError represents validation errors during trade operations +type TradeValidationError struct { + Code int32 // Error code from trade constants + Message string // Human readable error message +} + +// Error implements the error interface +func (e *TradeValidationError) Error() string { + return e.Message +} + +// TradeParticipant represents one side of a trade +type TradeParticipant struct { + EntityID int32 // Entity ID of the participant + Items map[int8]TradeItemInfo // Items being traded by slot + Coins int64 // Total coins being offered (in copper) + HasAccepted bool // Whether participant has accepted the trade + MaxSlots int8 // Maximum trade slots for this participant + IsBot bool // Whether this participant is a bot + ClientVersion int32 // Client version (affects slot count) +} + +// NewTradeParticipant creates a new trade participant +func NewTradeParticipant(entityID int32, isBot bool, clientVersion int32) *TradeParticipant { + maxSlots := TradeMaxSlotsDefault + if clientVersion <= 561 { + maxSlots = TradeMaxSlotsLegacy + } + + return &TradeParticipant{ + EntityID: entityID, + Items: make(map[int8]TradeItemInfo), + Coins: 0, + HasAccepted: false, + MaxSlots: int8(maxSlots), + IsBot: isBot, + ClientVersion: clientVersion, + } +} + +// GetNextFreeSlot finds the next available slot for items +func (tp *TradeParticipant) GetNextFreeSlot() int8 { + for slot := int8(0); slot < tp.MaxSlots; slot++ { + if _, exists := tp.Items[slot]; !exists { + return slot + } + } + return TradeSlotAutoFind // No free slots available +} + +// HasItem checks if participant has a specific item in trade +func (tp *TradeParticipant) HasItem(itemID int32) bool { + for _, itemInfo := range tp.Items { + if itemInfo.Item != nil && itemInfo.Item.GetID() == itemID { + return true + } + } + return false +} + +// GetItemCount returns the total number of items in trade +func (tp *TradeParticipant) GetItemCount() int { + return len(tp.Items) +} + +// ClearItems removes all items from the trade +func (tp *TradeParticipant) ClearItems() { + tp.Items = make(map[int8]TradeItemInfo) +} + +// GetCoinAmounts converts total copper to coin denominations +func (tp *TradeParticipant) GetCoinAmounts() CoinAmounts { + return CalculateCoins(tp.Coins) +} + +// Item represents a placeholder item interface +// TODO: Replace with actual Item implementation when available +type Item interface { + GetID() int32 + GetName() string + GetQuantity() int32 + GetIcon(version int32) int32 + IsNoTrade() bool + IsHeirloom() bool + IsAttuned() bool + GetCreationTime() time.Time + GetGroupCharacterIDs() []int32 +} + +// Entity represents a placeholder entity interface +// TODO: Replace with actual Entity implementation when available +type Entity interface { + GetID() int32 + GetName() string + IsPlayer() bool + IsBot() bool + HasCoins(amount int64) bool + GetClientVersion() int32 +} + +// TradeManager handles multiple active trades +type TradeManager struct { + trades map[int32]*Trade // Active trades by trader1 ID + mutex sync.RWMutex // Thread safety +} + +// NewTradeManager creates a new trade manager +func NewTradeManager() *TradeManager { + return &TradeManager{ + trades: make(map[int32]*Trade), + } +} + +// GetTrade retrieves an active trade for an entity +func (tm *TradeManager) GetTrade(entityID int32) *Trade { + tm.mutex.RLock() + defer tm.mutex.RUnlock() + + // Check if entity is trader1 + if trade, exists := tm.trades[entityID]; exists { + return trade + } + + // Check if entity is trader2 + for _, trade := range tm.trades { + if trade.GetTrader2ID() == entityID { + return trade + } + } + + return nil +} + +// AddTrade adds a new trade to management +func (tm *TradeManager) AddTrade(trade *Trade) { + tm.mutex.Lock() + defer tm.mutex.Unlock() + + tm.trades[trade.GetTrader1ID()] = trade +} + +// RemoveTrade removes a trade from management +func (tm *TradeManager) RemoveTrade(trader1ID int32) { + tm.mutex.Lock() + defer tm.mutex.Unlock() + + delete(tm.trades, trader1ID) +} + +// GetActiveTradeCount returns the number of active trades +func (tm *TradeManager) GetActiveTradeCount() int { + tm.mutex.RLock() + defer tm.mutex.RUnlock() + + return len(tm.trades) +} \ No newline at end of file diff --git a/internal/trade/utils.go b/internal/trade/utils.go new file mode 100644 index 0000000..b2a7291 --- /dev/null +++ b/internal/trade/utils.go @@ -0,0 +1,207 @@ +package trade + +import ( + "fmt" + "strings" +) + +// CalculateCoins converts a total copper amount to coin denominations +// Converted from C++ Trade::CalculateCoins +func CalculateCoins(totalCopper int64) CoinAmounts { + coins := CoinAmounts{} + remaining := totalCopper + + // Calculate platinum (1,000,000 copper = 1 platinum) + if remaining >= CoinsPlatinumThreshold { + coins.Platinum = int32(remaining / CoinsPlatinumThreshold) + remaining -= int64(coins.Platinum) * CoinsPlatinumThreshold + } + + // Calculate gold (10,000 copper = 1 gold) + if remaining >= CoinsGoldThreshold { + coins.Gold = int32(remaining / CoinsGoldThreshold) + remaining -= int64(coins.Gold) * CoinsGoldThreshold + } + + // Calculate silver (100 copper = 1 silver) + if remaining >= CoinsSilverThreshold { + coins.Silver = int32(remaining / CoinsSilverThreshold) + remaining -= int64(coins.Silver) * CoinsSilverThreshold + } + + // Remaining is copper + if remaining > 0 { + coins.Copper = int32(remaining) + } + + return coins +} + +// CoinsToCopper converts coin denominations to total copper +func CoinsToCopper(coins CoinAmounts) int64 { + total := int64(coins.Copper) + total += int64(coins.Silver) * CoinsSilverThreshold + total += int64(coins.Gold) * CoinsGoldThreshold + total += int64(coins.Platinum) * CoinsPlatinumThreshold + return total +} + +// FormatCoins returns a human-readable string representation of coins +func FormatCoins(totalCopper int64) string { + if totalCopper == 0 { + return "0 copper" + } + + coins := CalculateCoins(totalCopper) + parts := make([]string, 0, 4) + + if coins.Platinum > 0 { + parts = append(parts, fmt.Sprintf("%d platinum", coins.Platinum)) + } + if coins.Gold > 0 { + parts = append(parts, fmt.Sprintf("%d gold", coins.Gold)) + } + if coins.Silver > 0 { + parts = append(parts, fmt.Sprintf("%d silver", coins.Silver)) + } + if coins.Copper > 0 { + parts = append(parts, fmt.Sprintf("%d copper", coins.Copper)) + } + + return strings.Join(parts, ", ") +} + +// ValidateTradeSlot checks if a slot number is valid for a participant +func ValidateTradeSlot(slot int8, maxSlots int8) bool { + return slot >= 0 && slot < maxSlots +} + +// ValidateTradeQuantity checks if a quantity is valid for trading +func ValidateTradeQuantity(quantity, available int32) bool { + return quantity > 0 && quantity <= available +} + +// FormatTradeError returns a formatted error message for trade operations +func FormatTradeError(code int32) string { + switch code { + case TradeResultSuccess: + return "Success" + case TradeResultAlreadyInTrade: + return "Item is already in the trade" + case TradeResultNoTrade: + return "Item cannot be traded" + case TradeResultHeirloom: + return "Heirloom item cannot be traded to this player" + case TradeResultInvalidSlot: + return "Invalid or occupied trade slot" + case TradeResultSlotOutOfRange: + return "Trade slot is out of range" + case TradeResultInsufficientQty: + return "Insufficient quantity to trade" + default: + return fmt.Sprintf("Unknown trade error: %d", code) + } +} + +// GetClientMaxSlots returns the maximum trade slots for a client version +// Converted from C++ Trade constructor logic +func GetClientMaxSlots(clientVersion int32) int8 { + if clientVersion <= 561 { + return TradeMaxSlotsLegacy + } + return TradeMaxSlotsDefault +} + +// IsValidTradeState checks if a trade operation is valid for the current state +func IsValidTradeState(state TradeState, operation string) bool { + switch operation { + case "add_item", "remove_item", "add_coins", "remove_coins", "accept": + return state == TradeStateActive + case "cancel": + return state == TradeStateActive + case "complete": + return state == TradeStateAccepted + default: + return false + } +} + +// GenerateTradeLogEntry creates a log entry for trade operations +func GenerateTradeLogEntry(tradeID string, operation string, entityID int32, details interface{}) string { + return fmt.Sprintf("[Trade:%s] %s by entity %d: %v", tradeID, operation, entityID, details) +} + +// CompareTradeItems checks if two trade item infos are equivalent +func CompareTradeItems(item1, item2 TradeItemInfo) bool { + if item1.Item == nil && item2.Item == nil { + return item1.Quantity == item2.Quantity + } + + if item1.Item == nil || item2.Item == nil { + return false + } + + return item1.Item.GetID() == item2.Item.GetID() && + item1.Quantity == item2.Quantity +} + +// CalculateTradeValue estimates the total value of items and coins in a trade +// This is a helper function for trade balancing and analysis +func CalculateTradeValue(participant *TradeParticipant) map[string]interface{} { + value := make(map[string]interface{}) + + // Add coin value + value["coins"] = participant.Coins + value["coins_formatted"] = FormatCoins(participant.Coins) + + // Add item information + itemCount := len(participant.Items) + value["item_count"] = itemCount + + if itemCount > 0 { + items := make([]map[string]interface{}, 0, itemCount) + for slot, itemInfo := range participant.Items { + itemData := make(map[string]interface{}) + itemData["slot"] = slot + itemData["quantity"] = itemInfo.Quantity + if itemInfo.Item != nil { + itemData["item_id"] = itemInfo.Item.GetID() + itemData["item_name"] = itemInfo.Item.GetName() + } + items = append(items, itemData) + } + value["items"] = items + } + + return value +} + +// ValidateTradeCompletion checks if a trade is ready to be completed +func ValidateTradeCompletion(trade *Trade) []string { + errors := make([]string, 0) + + if trade.GetState() != TradeStateActive { + errors = append(errors, "Trade is not in active state") + return errors + } + + // Check if both parties have accepted + trader1Accepted := trade.HasAcceptedTrade(trade.GetTrader1ID()) + trader2Accepted := trade.HasAcceptedTrade(trade.GetTrader2ID()) + + if !trader1Accepted { + errors = append(errors, "Trader 1 has not accepted the trade") + } + + if !trader2Accepted { + errors = append(errors, "Trader 2 has not accepted the trade") + } + + // TODO: Add additional validation when entity system is available: + // - Verify entities still exist and are online + // - Check inventory space for received items + // - Validate coin amounts against actual entity wealth + // - Check for item/trade restrictions that may have changed + + return errors +} \ No newline at end of file