From 1082b47942cbeaf8891abfe67fa66473a212f181 Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Wed, 30 Jul 2025 15:29:01 -0500 Subject: [PATCH] converted more internals --- CLAUDE.md | 90 ++++ internal/GroundSpawn.cpp | 582 +++++++++++++++++++++ internal/GroundSpawn.h | 86 ++++ internal/appearances/appearances.go | 288 +++++++++++ internal/appearances/constants.go | 13 + internal/appearances/interfaces.go | 308 +++++++++++ internal/appearances/manager.go | 392 ++++++++++++++ internal/appearances/types.go | 65 +++ internal/classes.cpp | 199 ------- internal/classes.h | 119 ----- internal/classes/classes.go | 366 +++++++++++++ internal/classes/constants.go | 226 ++++++++ internal/classes/integration.go | 352 +++++++++++++ internal/classes/manager.go | 455 ++++++++++++++++ internal/classes/utils.go | 451 ++++++++++++++++ internal/common/variables.go | 261 ++++++++++ internal/common/visual_states.go | 378 ++++++++++++++ internal/entity/entity.go | 17 + internal/factions/constants.go | 46 ++ internal/factions/interfaces.go | 371 ++++++++++++++ internal/factions/manager.go | 488 ++++++++++++++++++ internal/factions/master_faction_list.go | 387 ++++++++++++++ internal/factions/player_faction.go | 349 +++++++++++++ internal/factions/types.go | 108 ++++ internal/ground_spawn/constants.go | 76 +++ internal/ground_spawn/ground_spawn.go | 627 +++++++++++++++++++++++ internal/ground_spawn/interfaces.go | 260 ++++++++++ internal/ground_spawn/manager.go | 592 +++++++++++++++++++++ internal/ground_spawn/types.go | 137 +++++ internal/sign/constants.go | 31 ++ internal/sign/interfaces.go | 226 ++++++++ internal/sign/manager.go | 471 +++++++++++++++++ internal/sign/sign.go | 297 +++++++++++ internal/sign/types.go | 235 +++++++++ internal/skills/constants.go | 74 +++ internal/skills/integration.go | 253 +++++++++ internal/skills/manager.go | 283 ++++++++++ internal/skills/master_skill_list.go | 202 ++++++++ internal/skills/player_skill_list.go | 420 +++++++++++++++ internal/skills/skill_bonuses.go | 175 +++++++ internal/skills/types.go | 156 ++++++ internal/transmute/constants.go | 54 ++ internal/transmute/database.go | 249 +++++++++ internal/transmute/manager.go | 350 +++++++++++++ internal/transmute/packet_builder.go | 168 ++++++ internal/transmute/transmute.go | 415 +++++++++++++++ internal/transmute/types.go | 111 ++++ internal/widget/actions.go | 307 +++++++++++ internal/widget/constants.go | 38 ++ internal/widget/interfaces.go | 109 ++++ internal/widget/manager.go | 313 +++++++++++ internal/widget/widget.go | 498 ++++++++++++++++++ 52 files changed, 13206 insertions(+), 318 deletions(-) create mode 100644 internal/GroundSpawn.cpp create mode 100644 internal/GroundSpawn.h create mode 100644 internal/appearances/appearances.go create mode 100644 internal/appearances/constants.go create mode 100644 internal/appearances/interfaces.go create mode 100644 internal/appearances/manager.go create mode 100644 internal/appearances/types.go delete mode 100644 internal/classes.cpp delete mode 100644 internal/classes.h create mode 100644 internal/classes/classes.go create mode 100644 internal/classes/constants.go create mode 100644 internal/classes/integration.go create mode 100644 internal/classes/manager.go create mode 100644 internal/classes/utils.go create mode 100644 internal/common/variables.go create mode 100644 internal/common/visual_states.go create mode 100644 internal/factions/constants.go create mode 100644 internal/factions/interfaces.go create mode 100644 internal/factions/manager.go create mode 100644 internal/factions/master_faction_list.go create mode 100644 internal/factions/player_faction.go create mode 100644 internal/factions/types.go create mode 100644 internal/ground_spawn/constants.go create mode 100644 internal/ground_spawn/ground_spawn.go create mode 100644 internal/ground_spawn/interfaces.go create mode 100644 internal/ground_spawn/manager.go create mode 100644 internal/ground_spawn/types.go create mode 100644 internal/sign/constants.go create mode 100644 internal/sign/interfaces.go create mode 100644 internal/sign/manager.go create mode 100644 internal/sign/sign.go create mode 100644 internal/sign/types.go create mode 100644 internal/skills/constants.go create mode 100644 internal/skills/integration.go create mode 100644 internal/skills/manager.go create mode 100644 internal/skills/master_skill_list.go create mode 100644 internal/skills/player_skill_list.go create mode 100644 internal/skills/skill_bonuses.go create mode 100644 internal/skills/types.go create mode 100644 internal/transmute/constants.go create mode 100644 internal/transmute/database.go create mode 100644 internal/transmute/manager.go create mode 100644 internal/transmute/packet_builder.go create mode 100644 internal/transmute/transmute.go create mode 100644 internal/transmute/types.go create mode 100644 internal/widget/actions.go create mode 100644 internal/widget/constants.go create mode 100644 internal/widget/interfaces.go create mode 100644 internal/widget/manager.go create mode 100644 internal/widget/widget.go diff --git a/CLAUDE.md b/CLAUDE.md index deac5a4..bd8bf0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,6 +83,14 @@ go run golang.org/x/vuln/cmd/govulncheck@latest ./... - `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 +- `classes/` - Class system with all EQ2 classes, progression paths, stat bonuses, and entity integration +- `widget/` - Interactive widget system for doors, lifts, and other usable objects with spawn integration +- `transmute/` - Item transmutation system for converting items into crafting materials with skill requirements +- `skills/` - Character skill system with master skill list, player skills, bonuses, and progression mechanics +- `sign/` - Interactive sign system extending spawn functionality with zone transport, entity commands, and text display +- `appearances/` - Appearance management system with client version compatibility and efficient ID-based lookups +- `factions/` - Faction reputation system with player standings, consideration levels, and inter-faction relationships +- `ground_spawn/` - Harvestable resource node system with skill-based harvesting, rare item generation, and multi-attempt mechanics ### Network Protocol EverQuest II UDP protocol with reliability layer, RC4 encryption, CRC validation, connection management, packet combining. @@ -101,6 +109,8 @@ XML-driven packet definitions with version-specific formats, conditional fields, **Core Data Structures:** - `internal/common/types.go`: EQ2-specific types (EQ2Color, EQ2Equipment, AppearanceData, etc.) +- `internal/common/visual_states.go`: Visual states, emotes, and spell visuals with version management +- `internal/common/variables.go`: Configuration variable management with type conversion utilities **Network Implementation:** - `internal/udp/server.go`: Multi-connection UDP server @@ -157,6 +167,66 @@ XML-driven packet definitions with version-specific formats, conditional fields, - `internal/races/integration.go`: Entity system integration with RaceAware interface - `internal/races/manager.go`: High-level race management with statistics and command processing +**Class System:** +- `internal/classes/classes.go`: Core class management with all EQ2 adventure and tradeskill classes +- `internal/classes/constants.go`: Class IDs, names, and display constants with progression hierarchy +- `internal/classes/utils.go`: Class utilities with progression paths, stat bonuses, and parsing +- `internal/classes/integration.go`: Entity system integration with ClassAware interface +- `internal/classes/manager.go`: High-level class management with statistics and command processing + +**Widget System:** +- `internal/widget/widget.go`: Interactive spawn objects like doors and lifts with movement and state management +- `internal/widget/constants.go`: Widget type constants and display name mappings +- `internal/widget/actions.go`: Widget interaction logic with open/close mechanics and client handling +- `internal/widget/interfaces.go`: Integration interfaces for client and zone systems with spawn wrapper +- `internal/widget/manager.go`: Widget management with timer handling and linked spawn resolution + +**Transmute System:** +- `internal/transmute/transmute.go`: Core transmutation logic with item validation and material generation +- `internal/transmute/types.go`: Transmute data structures and interface definitions for system integration +- `internal/transmute/constants.go`: Transmutation constants including probabilities and item flags +- `internal/transmute/manager.go`: High-level transmute management with statistics and command processing +- `internal/transmute/database.go`: Database operations for transmuting tier configuration +- `internal/transmute/packet_builder.go`: Client packet construction for transmutation UI and responses + +**Skills System:** +- `internal/skills/types.go`: Core skill data structures with Skill, SkillBonus, and SkillBonusValue types +- `internal/skills/constants.go`: Skill type constants, special skill IDs, and skill increase parameters +- `internal/skills/master_skill_list.go`: Master skill registry with all available skills and packet building +- `internal/skills/player_skill_list.go`: Individual player skill management with values, caps, and updates +- `internal/skills/skill_bonuses.go`: Skill bonus system for spell-based skill modifications and calculations +- `internal/skills/manager.go`: High-level skill management with statistics, validation, and command processing +- `internal/skills/integration.go`: Integration interfaces including SkillAware and EntitySkillAdapter + +**Sign System:** +- `internal/sign/types.go`: Core Sign struct extending spawn functionality with widget and zone transport properties +- `internal/sign/constants.go`: Sign type constants, default values, and configuration parameters +- `internal/sign/sign.go`: Sign functionality including copy, serialization, usage handling, and validation +- `internal/sign/interfaces.go`: Integration interfaces with SignAware, SignAdapter, and system dependencies +- `internal/sign/manager.go`: Sign management with zone loading, statistics, validation, and command processing + +**Appearances System:** +- `internal/appearances/types.go`: Core Appearance struct with ID, name, and client version compatibility +- `internal/appearances/constants.go`: Hash search constants and client version parameters +- `internal/appearances/appearances.go`: Appearance collection management with thread-safe operations +- `internal/appearances/manager.go`: High-level appearance management with database integration and statistics +- `internal/appearances/interfaces.go`: Integration interfaces with caching, entity adapters, and system dependencies + +**Factions System:** +- `internal/factions/types.go`: Core Faction struct with reputation properties and validation methods +- `internal/factions/constants.go`: Faction value limits, consideration levels, and calculation constants +- `internal/factions/master_faction_list.go`: Master faction registry with hostile/friendly relationships +- `internal/factions/player_faction.go`: Individual player faction standings with consideration and percentage calculations +- `internal/factions/manager.go`: High-level faction management with statistics, validation, and command processing +- `internal/factions/interfaces.go`: Integration interfaces with entity adapters, player managers, and system dependencies + +**Ground Spawn System:** +- `internal/ground_spawn/constants.go`: Harvest type constants, skill names, rarity flags, and configuration defaults +- `internal/ground_spawn/types.go`: Core GroundSpawn struct, harvest context data, result structures, and statistics tracking +- `internal/ground_spawn/ground_spawn.go`: Core ground spawn functionality with complex harvest processing and skill-based mechanics +- `internal/ground_spawn/interfaces.go`: Integration interfaces with database, players, items, skills, and event handling systems +- `internal/ground_spawn/manager.go`: High-level ground spawn management with respawn scheduling, statistics, and command processing + **Packet Definitions:** - `internal/packets/xml/`: XML packet structure definitions - `internal/packets/PARSER.md`: Packet definition language documentation @@ -217,6 +287,26 @@ Command-line flags override JSON configuration. **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. +**Class System**: Complete EverQuest II class management with all 58 classes (adventure and tradeskill), hierarchical progression paths (Commoner → Base → Secondary → Final), stat bonuses, starting stat calculations, and full entity system integration. Features class transition validation, progression tracking, usage statistics, and ClassAware interface for seamless integration with existing systems. Includes all 4 base classes (Fighter, Priest, Mage, Scout) with their complete specialization trees. + +**Visual States System**: Manages visual animations, emotes, and spell visuals with client version support. Features version-based emote/visual selection, animation ID mapping, message formatting (targeted/untargeted), and thread-safe concurrent access. Supports both named lookups and ID-based lookups for efficient client communication. + +**Variables System**: Configuration variable management for runtime settings and game parameters. Features type-safe value conversion (int, float, bool), partial name matching, variable cloning and merging, comment support for documentation, and thread-safe operations. Used for game rules, server settings, and dynamic configuration. + +**Widget System**: Interactive spawn objects like doors, lifts, and other usable world elements. Features open/close state management, position-based movement with timers, sound integration, linked widget chains (action/linked spawns), house integration for player housing, multi-floor lift support, and complete spawn system integration. Supports complex interactions like transporter integration and custom scripted behaviors. + +**Transmute System**: Item transmutation system allowing players to convert items into crafting materials. Features tier-based material generation, skill requirement validation, probabilistic loot rolls (15% both materials, 75%/25% split), automatic skill progression, request state management, and comprehensive validation. Supports level-based transmuting tiers with four material types (fragments, powder, infusions, mana) and integrates with item, spell, and skill systems. + +**Skills System**: Complete character skill system with master skill registry and individual player skill management. Features all EQ2 skill types (weaponry, spellcasting, avoidance, armor, harvesting, artisan, etc.), skill bonuses from spells, skill progression with automatic increases, disarm skill checks for chests, and comprehensive skill value calculations. Supports skill caps, type-based operations, packet building for client updates, and full integration with entity system through SkillAware interface. Includes special handling for weapon skills, crafting skills, and language skills with version-specific client compatibility. + +**Sign System**: Interactive sign system extending spawn functionality for in-world text displays and zone transport. Features two sign types (generic and zone transport), zone teleportation with coordinate validation, entity command processing, sign marking system, transporter integration, distance-based interaction, and comprehensive text display with location/heading options. Supports quest requirement checking, instance zone handling, size randomization on copy, validation system, and full spawn system integration. Includes SignAware interface and SignAdapter for seamless integration with existing entity systems. + +**Appearances System**: Comprehensive appearance management system handling visual character and entity representations with client version compatibility. Features efficient hash-based ID lookups, client version compatibility checking, name-based searching, statistics tracking, and thread-safe operations. Supports caching with SimpleAppearanceCache and CachedAppearanceManager, database integration for persistence, entity appearance adapters for seamless integration, and comprehensive validation. Includes AppearanceAware interface and EntityAppearanceAdapter for entity system integration. Designed to handle large appearance collections with sparse ID ranges efficiently using hash tables as noted in C++ comments. + +**Factions System**: Complete faction reputation system managing player standings and inter-faction relationships. Features master faction list with hostile/friendly relationships, individual player faction standings with consideration levels (-4 to 4), percentage calculations within consideration ranges, attack determination based on faction standing, and comprehensive faction value management (-50000 to 50000 range). Supports special faction handling (IDs <= 10), faction increase/decrease with configurable amounts, packet building for client updates, statistics tracking, and thread-safe operations. Includes EntityFactionAdapter and PlayerFactionManager for seamless integration with entity and player systems. Maintains exact C++ calculation formulas for consideration levels and percentage values. + +**Ground Spawn System**: Harvestable resource node system extending spawn functionality for gathering, mining, fishing, trapping, and foresting. Features skill-based table selection, probabilistic harvest outcomes (1/3/5/10 items + rare/imbue types), multi-attempt harvesting sessions, skill progression mechanics, and automatic respawn scheduling. Implements complex C++ harvest logic with table filtering by skill/level requirements, random item selection from filtered pools, grid-based location restrictions, and comprehensive item reward processing. Supports collection vs. harvesting skill differentiation, harvest message generation, spell integration for casting animations, and statistics tracking. Includes PlayerGroundSpawnAdapter and HarvestEventAdapter for seamless integration with player and event systems. Thread-safe operations with separate mutexes for harvest processing and usage handling. + 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. diff --git a/internal/GroundSpawn.cpp b/internal/GroundSpawn.cpp new file mode 100644 index 0000000..86d243a --- /dev/null +++ b/internal/GroundSpawn.cpp @@ -0,0 +1,582 @@ +/* + 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 "GroundSpawn.h" +#include "World.h" +#include "Spells.h" +#include "Rules/Rules.h" +#include "../common/MiscFunctions.h" +#include "../common/Log.h" + +extern ConfigReader configReader; +extern MasterSpellList master_spell_list; +extern World world; +extern RuleManager rule_manager; + +GroundSpawn::GroundSpawn(){ + packet_num = 0; + appearance.difficulty = 0; + spawn_type = 2; + appearance.pos.state = 129; + number_harvests = 0; + num_attempts_per_harvest = 0; + groundspawn_id = 0; + MHarvest.SetName("GroundSpawn::MHarvest"); + MHarvestUse.SetName("GroundSpawn::MHarvestUse"); + randomize_heading = true; // we by default randomize heading of groundspawns DB overrides +} + +GroundSpawn::~GroundSpawn(){ + +} + +EQ2Packet* GroundSpawn::serialize(Player* player, int16 version){ + return spawn_serialize(player, version); +} + +int8 GroundSpawn::GetNumberHarvests(){ + return number_harvests; +} + +void GroundSpawn::SetNumberHarvests(int8 val){ + number_harvests = val; +} + +int8 GroundSpawn::GetAttemptsPerHarvest(){ + return num_attempts_per_harvest; +} + +void GroundSpawn::SetAttemptsPerHarvest(int8 val){ + num_attempts_per_harvest = val; +} + +int32 GroundSpawn::GetGroundSpawnEntryID(){ + return groundspawn_id; +} + +void GroundSpawn::SetGroundSpawnEntryID(int32 val){ + groundspawn_id = val; +} + +void GroundSpawn::SetCollectionSkill(const char* val){ + if(val) + collection_skill = string(val); +} + +const char* GroundSpawn::GetCollectionSkill(){ + return collection_skill.c_str(); +} + +void GroundSpawn::ProcessHarvest(Client* client) { + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "Process harvesting for player '%s' (%u)", client->GetPlayer()->GetName(), client->GetPlayer()->GetID()); + + MHarvest.lock(); + + vector* groundspawn_entries = GetZone()->GetGroundSpawnEntries(groundspawn_id); + vector* groundspawn_items = GetZone()->GetGroundSpawnEntryItems(groundspawn_id); + + Item* master_item = 0; + Item* master_rare = 0; + Item* item = 0; + Item* item_rare = 0; + + int16 lowest_skill_level = 0; + int16 table_choice = 0; + int32 item_choice = 0; + int32 rare_choice = 0; + int8 harvest_type = 0; + int32 item_harvested = 0; + int8 reward_total = 1; + int32 rare_harvested = 0; + int8 rare_item = 0; + bool is_collection = false; + + if (!groundspawn_entries || !groundspawn_items) { + LogWrite(GROUNDSPAWN__ERROR, 3, "GSpawn", "No groundspawn entries or items assigned to groundspawn id: %u", groundspawn_id); + client->Message(CHANNEL_COLOR_RED, "Error: There are no groundspawn entries or items assigned to groundspawn id: %u", groundspawn_id); + MHarvest.unlock(); + return; + } + + if (number_harvests == 0) { + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "Total harvests depleated for groundspawn id: %u", groundspawn_id); + client->SimpleMessage(CHANNEL_COLOR_RED, "Error: This spawn has nothing more to harvest!"); + MHarvest.unlock(); + return; + } + + Skill* skill = 0; + if (collection_skill == "Collecting") { + skill = client->GetPlayer()->GetSkillByName("Gathering"); + is_collection = true; + } + else + skill = client->GetPlayer()->GetSkillByName(collection_skill.c_str()); // Fix: #576 - don't skill up yet with GetSkillByName(skill, true), we might be trying to harvest low level + + if (!skill) { + LogWrite(GROUNDSPAWN__WARNING, 3, "GSpawn", "Player '%s' lacks the skill: '%s'", client->GetPlayer()->GetName(), collection_skill.c_str()); + client->Message(CHANNEL_COLOR_RED, "Error: You do not have the '%s' skill!", collection_skill.c_str()); + MHarvest.unlock(); + return; + } + + int16 totalSkill = skill->current_val; + int32 skillID = master_item_list.GetItemStatIDByName(collection_skill); + int16 max_skill_req_groundspawn = rule_manager.GetZoneRule(client->GetCurrentZoneID(), R_Player, MinSkillMultiplierValue)->GetInt16(); + if(max_skill_req_groundspawn < 1) // can't be 0 + max_skill_req_groundspawn = 1; + + if(skillID != 0xFFFFFFFF) + { + ((Entity*)client->GetPlayer())->MStats.lock(); + totalSkill += ((Entity*)client->GetPlayer())->stats[skillID]; + ((Entity*)client->GetPlayer())->MStats.unlock(); + } + + for (int8 i = 0; i < num_attempts_per_harvest; i++) { + vector mod_groundspawn_entries; + + if (groundspawn_entries) { + vector highest_match; + vector::iterator itr; + + GroundSpawnEntry* entry = 0; // current data + GroundSpawnEntry* selected_table = 0; // selected table data + + // first, iterate through groundspawn_entries, discard tables player cannot use + for (itr = groundspawn_entries->begin(); itr != groundspawn_entries->end(); itr++) { + entry = *itr; + + if(entry->min_skill_level > max_skill_req_groundspawn) + max_skill_req_groundspawn = entry->min_skill_level; + + // if player lacks skill, skip table + if (entry->min_skill_level > totalSkill) + continue; + // if bonus, but player lacks level, skip table + if (entry->bonus_table && (client->GetPlayer()->GetLevel() < entry->min_adventure_level)) + continue; + + // build modified entries table + mod_groundspawn_entries.push_back(entry); + LogWrite(GROUNDSPAWN__DEBUG, 5, "GSpawn", "Keeping groundspawn_entry: %i", entry->min_skill_level); + } + + // if anything remains, find lowest min_skill_level in remaining set(s) + if (mod_groundspawn_entries.size() > 0) { + vector::iterator itr; + GroundSpawnEntry* entry = 0; + + for (itr = mod_groundspawn_entries.begin(); itr != mod_groundspawn_entries.end(); itr++) { + entry = *itr; + + // find the low range of available tables for random roll + if (lowest_skill_level > entry->min_skill_level || lowest_skill_level == 0) + lowest_skill_level = entry->min_skill_level; + } + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "Lowest Skill Level: %i", lowest_skill_level); + } + else { + // if no tables chosen, you must lack the skills + // TODO: move this check to LUA when harvest command is first selected + client->Message(CHANNEL_COLOR_RED, "You lack the skills to harvest this node!"); + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "All groundspawn_entry tables tossed! No Skills? Something broke?"); + MHarvest.unlock(); + return; + } + + // now roll to see which table to use + table_choice = MakeRandomInt(lowest_skill_level, totalSkill); + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "Random INT for Table by skill level: %i", table_choice); + + int16 highest_score = 0; + for (itr = mod_groundspawn_entries.begin(); itr != mod_groundspawn_entries.end(); itr++) { + entry = *itr; + + // determines the highest min_skill_level in the current set of tables (if multiple tables) + if (table_choice >= entry->min_skill_level && (highest_score == 0 || highest_score < table_choice)) { + // removes old highest for the new one + highest_match.clear(); + highest_score = entry->min_skill_level; + } + // if the score = level, push into highest_match set + if (highest_score == entry->min_skill_level) + highest_match.push_back(entry); + } + + // if there is STILL more than 1 table player qualifies for, rand() and pick one + if (highest_match.size() > 1) { + int16 rand_index = rand() % highest_match.size(); + selected_table = highest_match.at(rand_index); + } + else if (highest_match.size() > 0) + selected_table = highest_match.at(0); + + // by this point, we should have 1 table who's min skill matches the score (selected_table) + if (selected_table) { + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "Using Table: %i, %i, %i, %.2f, %.2f, %.2f, %.2f, %.2f, %.2f, %i", + selected_table->min_skill_level, + selected_table->min_adventure_level, + selected_table->bonus_table, + selected_table->harvest1, + selected_table->harvest3, + selected_table->harvest5, + selected_table->harvest_imbue, + selected_table->harvest_rare, + selected_table->harvest10, + selected_table->harvest_coin); + + + // roll 1-100 for chance-to-harvest percentage + float chance = MakeRandomFloat(0, 100); + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "Random FLOAT for harvest percentages: %.2f", chance); + + // starting with the lowest %, select a harvest type + reward qty + if (chance <= selected_table->harvest10 && is_collection == false) { + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "Harvest 10 items + Rare Item from table : %i", selected_table->min_skill_level); + harvest_type = 6; + reward_total = 10; + } + else if (chance <= selected_table->harvest_rare && is_collection == false) { + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "Harvest Rare Item from table : %i", selected_table->min_skill_level); + harvest_type = 5; + } + else if (chance <= selected_table->harvest_imbue && is_collection == false) { + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "Harvest Imbue Item from table : %i", selected_table->min_skill_level); + harvest_type = 4; + } + else if (chance <= selected_table->harvest5 && is_collection == false) { + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "Harvest 5 Items from table : %i", selected_table->min_skill_level); + harvest_type = 3; + reward_total = 5; + } + else if (chance <= selected_table->harvest3 && is_collection == false) { + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "Harvest 3 Items from table : %i", selected_table->min_skill_level); + harvest_type = 2; + reward_total = 3; + } + else if (chance <= selected_table->harvest1 || totalSkill >= skill->max_val || is_collection) { + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "Harvest 1 Item from table : %i", selected_table->min_skill_level); + harvest_type = 1; + } + else + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "Harvest nothing..."); + + float node_maxskill_multiplier = rule_manager.GetZoneRule(client->GetCurrentZoneID(), R_Player, HarvestSkillUpMultiplier)->GetFloat(); + if(node_maxskill_multiplier <= 0.0f) { + node_maxskill_multiplier = 1.0f; + } + int16 skillup_max_skill_allowed = (int16)((float)max_skill_req_groundspawn*node_maxskill_multiplier); + if (!is_collection && skill && skill->current_val < skillup_max_skill_allowed) { + skill = client->GetPlayer()->GetSkillByName(collection_skill.c_str(), true); // Fix: #576 - skill up after min skill and adv level checks + } + } + + // once you know how many and what type of item to harvest, pick an item from the list + if (harvest_type) { + vector mod_groundspawn_items; + vector mod_groundspawn_rares; + vector mod_groundspawn_imbue; + + vector::iterator itr; + GroundSpawnEntryItem* entry = 0; + + // iterate through groundspawn_items, discard items player cannot roll for + for (itr = groundspawn_items->begin(); itr != groundspawn_items->end(); itr++) { + entry = *itr; + + // if this is a Rare, or an Imbue, but is_rare flag is 0, skip item + if ((harvest_type == 5 || harvest_type == 4) && entry->is_rare == 0) + continue; + // if it is a 1, 3, or 5 and is_rare = 1, skip + else if (harvest_type < 4 && entry->is_rare == 1) + continue; + + // if the grid_id on the item matches player grid, or is 0, keep the item + if (!entry->grid_id || (entry->grid_id == client->GetPlayer()->GetLocation())) { + // build modified entries table + if ((entry->is_rare == 1 && harvest_type == 5) || (entry->is_rare == 1 && harvest_type == 6)) { + // if the matching item is rare, or harvest10 push to mod rares + mod_groundspawn_rares.push_back(entry); + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "Keeping groundspawn_rare_item: %u", entry->item_id); + } + if (entry->is_rare == 0 && harvest_type != 4 && harvest_type != 5) { + // if the matching item is normal,or harvest 10 push to mod items + mod_groundspawn_items.push_back(entry); + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "Keeping groundspawn_common_item: %u", entry->item_id); + } + if (entry->is_rare == 2 && harvest_type == 4) { + // if the matching item is imbue item, push to mod imbue + mod_groundspawn_imbue.push_back(entry); + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "Keeping groundspawn_imbue_item: %u", entry->item_id); + } + } + } + + // if any items remain in the list, random to see which one gets awarded + if (mod_groundspawn_items.size() > 0) { + // roll to see which item index to use + item_choice = rand() % mod_groundspawn_items.size(); + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "Random INT for which item to award: %i", item_choice); + + // set item_id to be awarded + item_harvested = mod_groundspawn_items[item_choice]->item_id; + + // if reward is rare, set flag + rare_item = mod_groundspawn_items[item_choice]->is_rare; + + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "Item ID to award: %u, Rare = %i", item_harvested, item_rare); + + // if 10+rare, handle additional "rare" reward + if (harvest_type == 6) { + // make sure there is a rare table to choose from! + if (mod_groundspawn_rares.size() > 0) { + // roll to see which rare index to use + rare_choice = rand() % mod_groundspawn_rares.size(); + + // set (rare) item_id to be awarded + rare_harvested = mod_groundspawn_rares[rare_choice]->item_id; + + // we're picking a rare here, so obviously this is true ;) + rare_item = 1; + + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "RARE Item ID to award: %u", rare_harvested); + } + else { + // all rare entries were eliminated above, or none are assigned. Either way, shouldn't be here! + LogWrite(GROUNDSPAWN__ERROR, 3, "GSpawn", "Groundspawn Entry for '%s' (%i) has no RARE items!", GetName(), GetID()); + } + } + } + else if (mod_groundspawn_rares.size() > 0) { + // roll to see which rare index to use + item_choice = rand() % mod_groundspawn_rares.size(); + + // set (rare) item_id to be awarded + item_harvested = mod_groundspawn_rares[item_choice]->item_id; + + // we're picking a rare here, so obviously this is true ;) + rare_item = 1; + + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "RARE Item ID to award: %u", rare_harvested); + } + else if (mod_groundspawn_imbue.size() > 0) { + // roll to see which rare index to use + item_choice = rand() % mod_groundspawn_imbue.size(); + + // set (rare) item_id to be awarded + item_harvested = mod_groundspawn_imbue[item_choice]->item_id; + + // we're picking a rare here, so obviously this is true ;) + rare_item = 0; + + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "imbue Item ID to award: %u", rare_harvested); + } + + + + + else { + // all item entries were eliminated above, or none are assigned. Either way, shouldn't be here! + LogWrite(GROUNDSPAWN__ERROR, 0, "GSpawn", "Groundspawn Entry for '%s' (%i) has no items!", GetName(), GetID()); + } + + // if an item was harvested, send updates to client, add item to inventory + if (item_harvested) { + char tmp[200] = { 0 }; + + // set Normal item harvested + master_item = master_item_list.GetItem(item_harvested); + if (master_item) { + // set details of Normal item + item = new Item(master_item); + // set how many of this item the player receives + item->details.count = reward_total; + + // chat box update for normal item (todo: verify output text) + client->Message(CHANNEL_HARVESTING, "You %s %i %s from the %s.", GetHarvestMessageName(true).c_str(), item->details.count, item->CreateItemLink(client->GetVersion(), true).c_str(), GetName()); + // add Normal item to player inventory + bool itemDeleted = false; + client->AddItem(item, &itemDeleted); + + if(!itemDeleted) { + //Check if the player has a harvesting quest for this + client->GetPlayer()->CheckQuestsHarvestUpdate(item, reward_total); + + // if this is a 10+rare, handle sepErately + if (harvest_type == 6 && rare_item == 1) { + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "Item ID %u is Normal. Qty %i", item_harvested, item->details.count); + + // send Normal harvest message to client + sprintf(tmp, "\\#64FFFFYou have %s:\12\\#C8FFFF%i %s", GetHarvestMessageName().c_str(), item->details.count, item->name.c_str()); + client->SendPopupMessage(10, tmp, "ui_harvested_normal", 2.25, 0xFF, 0xFF, 0xFF); + client->GetPlayer()->UpdatePlayerStatistic(STAT_PLAYER_ITEMS_HARVESTED, item->details.count); + + // set Rare item harvested + master_rare = master_item_list.GetItem(rare_harvested); + if (master_rare) { + // set details of Rare item + item_rare = new Item(master_rare); + // count of Rare is always 1 + item_rare->details.count = 1; + + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "Item ID %u is RARE!", rare_harvested); + + // send Rare harvest message to client + sprintf(tmp, "\\#FFFF6ERare item found!\12%s: \\#C8FFFF%i %s", GetHarvestMessageName().c_str(), item_rare->details.count, item_rare->name.c_str()); + client->Message(CHANNEL_HARVESTING, "You have found a rare item!"); + client->SendPopupMessage(11, tmp, "ui_harvested_rare", 2.25, 0xFF, 0xFF, 0xFF); + client->GetPlayer()->UpdatePlayerStatistic(STAT_PLAYER_RARES_HARVESTED, item_rare->details.count); + + // chat box update for rare item (todo: verify output text) + client->Message(CHANNEL_HARVESTING, "You %s %i %s from the %s.", GetHarvestMessageName(true).c_str(), item_rare->details.count, item->CreateItemLink(client->GetVersion(), true).c_str(), GetName()); + // add Rare item to player inventory + client->AddItem(item_rare); + //Check if the player has a harvesting quest for this + client->GetPlayer()->CheckQuestsHarvestUpdate(item_rare, 1); + } + } + else if (rare_item == 1) { + // if harvest signaled rare or imbue type + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "Item ID %u is RARE! Qty: %i", item_harvested, item->details.count); + + // send Rare harvest message to client + sprintf(tmp, "\\#FFFF6ERare item found!\12%s: \\#C8FFFF%i %s", GetHarvestMessageName().c_str(), item->details.count, item->name.c_str()); + client->Message(CHANNEL_HARVESTING, "You have found a rare item!"); + client->SendPopupMessage(11, tmp, "ui_harvested_rare", 2.25, 0xFF, 0xFF, 0xFF); + client->GetPlayer()->UpdatePlayerStatistic(STAT_PLAYER_RARES_HARVESTED, item->details.count); + } + else { + // send Normal harvest message to client + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "Item ID %u is Normal. Qty %i", item_harvested, item->details.count); + sprintf(tmp, "\\#64FFFFYou have %s:\12\\#C8FFFF%i %s", GetHarvestMessageName().c_str(), item->details.count, item->name.c_str()); + client->SendPopupMessage(10, tmp, "ui_harvested_normal", 2.25, 0xFF, 0xFF, 0xFF); + client->GetPlayer()->UpdatePlayerStatistic(STAT_PLAYER_ITEMS_HARVESTED, item->details.count); + } + + } + } + else { + // error! + LogWrite(GROUNDSPAWN__ERROR, 0, "GSpawn", "Error: Item ID Not Found - %u", item_harvested); + client->Message(CHANNEL_COLOR_RED, "Error: Unable to find item id %u", item_harvested); + } + // decrement # of pulls on this node before it despawns + number_harvests--; + } + else { + // if no item harvested + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "No item_harvested"); + client->Message(CHANNEL_HARVESTING, "You failed to %s anything from %s.", GetHarvestMessageName(true, true).c_str(), GetName()); + } + } + else { + // if no harvest type + LogWrite(GROUNDSPAWN__DEBUG, 3, "GSpawn", "No harvest_type"); + client->Message(CHANNEL_HARVESTING, "You failed to %s anything from %s.", GetHarvestMessageName(true, true).c_str(), GetName()); + } + } + } // cycle through num_attempts_per_harvest + MHarvest.unlock(); + + LogWrite(GROUNDSPAWN__DEBUG, 0, "GSpawn", "Process harvest complete for player '%s' (%u)", client->GetPlayer()->GetName(), client->GetPlayer()->GetID()); +} + +string GroundSpawn::GetHarvestMessageName(bool present_tense, bool failure){ + string ret = ""; + if((collection_skill == "Gathering" ||collection_skill == "Collecting") && !present_tense) + ret = "gathered"; + else if(collection_skill == "Gathering" || collection_skill == "Collecting") + ret = "gather"; + else if(collection_skill == "Mining" && !present_tense) + ret = "mined"; + else if(collection_skill == "Mining") + ret = "mine"; + else if (collection_skill == "Fishing" && !present_tense) + ret = "fished"; + else if(collection_skill == "Fishing") + ret = "fish"; + else if(collection_skill == "Trapping" && !present_tense && !failure) + ret = "acquired"; + else if(collection_skill == "Trapping" && failure) + ret = "trap"; + else if(collection_skill == "Trapping") + ret = "acquire"; + else if(collection_skill == "Foresting" && !present_tense) + ret = "forested"; + else if(collection_skill == "Foresting") + ret = "forest"; + else if (collection_skill == "Collecting") + ret = "collect"; + return ret; +} + +string GroundSpawn::GetHarvestSpellType(){ + string ret = ""; + if(collection_skill == "Gathering" || collection_skill == "Collecting") + ret = "gather"; + else if(collection_skill == "Mining") + ret = "mine"; + else if(collection_skill == "Trapping") + ret = "trap"; + else if(collection_skill == "Foresting") + ret = "chop"; + else if(collection_skill == "Fishing") + ret = "fish"; + return ret; +} + +string GroundSpawn::GetHarvestSpellName() { + string ret = ""; + if (collection_skill == "Collecting") + ret = "Gathering"; + else + ret = collection_skill; + return ret; +} + +void GroundSpawn::HandleUse(Client* client, string type){ + if(!client || (client->GetVersion() > 561 && type.length() == 0)) // older clients do not send the type + return; + //The following check disables the use of the groundspawn if spawn access is not granted + if (client) { + bool meets_quest_reqs = MeetsSpawnAccessRequirements(client->GetPlayer()); + if (!meets_quest_reqs && (GetQuestsRequiredOverride() & 2) == 0) + return; + else if (meets_quest_reqs && appearance.show_command_icon != 1) + return; + } + + MHarvestUse.lock(); + std::string typeLwr = ToLower(type); + if(client->GetVersion() <= 561 && (typeLwr == "" || typeLwr == "collect" || typeLwr == "gather" || typeLwr == "chop" || typeLwr == "mine")) + type = GetHarvestSpellType(); + + if (type == GetHarvestSpellType() && MeetsSpawnAccessRequirements(client->GetPlayer())) { + Spell* spell = master_spell_list.GetSpellByName(GetHarvestSpellName().c_str()); + if (spell) + client->GetCurrentZone()->ProcessSpell(spell, client->GetPlayer(), client->GetPlayer()->GetTarget(), true, true); + } + else if (appearance.show_command_icon == 1 && MeetsSpawnAccessRequirements(client->GetPlayer())) { + EntityCommand* entity_command = FindEntityCommand(type); + if (entity_command) + client->GetCurrentZone()->ProcessEntityCommand(entity_command, client->GetPlayer(), client->GetPlayer()->GetTarget()); + } + MHarvestUse.unlock(); +} diff --git a/internal/GroundSpawn.h b/internal/GroundSpawn.h new file mode 100644 index 0000000..f830686 --- /dev/null +++ b/internal/GroundSpawn.h @@ -0,0 +1,86 @@ +/* + 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 __EQ2_GroundSpawn__ +#define __EQ2_GroundSpawn__ + +#include "Spawn.h" +#include "client.h" +#include "../common/Mutex.h" + +class GroundSpawn : public Spawn { +public: + GroundSpawn(); + virtual ~GroundSpawn(); + GroundSpawn* Copy(){ + GroundSpawn* new_spawn = new GroundSpawn(); + new_spawn->size = size; + new_spawn->SetPrimaryCommands(&primary_command_list); + new_spawn->SetSecondaryCommands(&secondary_command_list); + new_spawn->database_id = database_id; + new_spawn->primary_command_list_id = primary_command_list_id; + new_spawn->secondary_command_list_id = secondary_command_list_id; + memcpy(&new_spawn->appearance, &appearance, sizeof(AppearanceData)); + new_spawn->faction_id = faction_id; + new_spawn->target = 0; + new_spawn->SetTotalHP(GetTotalHP()); + new_spawn->SetTotalPower(GetTotalPower()); + new_spawn->SetHP(GetHP()); + new_spawn->SetPower(GetPower()); + new_spawn->SetNumberHarvests(number_harvests); + new_spawn->SetAttemptsPerHarvest(num_attempts_per_harvest); + new_spawn->SetGroundSpawnEntryID(groundspawn_id); + new_spawn->SetCollectionSkill(collection_skill.c_str()); + SetQuestsRequired(new_spawn); + new_spawn->forceMapCheck = forceMapCheck; + new_spawn->SetOmittedByDBFlag(IsOmittedByDBFlag()); + new_spawn->SetLootTier(GetLootTier()); + new_spawn->SetLootDropType(GetLootDropType()); + new_spawn->SetRandomizeHeading(GetRandomizeHeading()); + return new_spawn; + } + bool IsGroundSpawn(){ return true; } + EQ2Packet* serialize(Player* player, int16 version); + int8 GetNumberHarvests(); + void SetNumberHarvests(int8 val); + int8 GetAttemptsPerHarvest(); + void SetAttemptsPerHarvest(int8 val); + int32 GetGroundSpawnEntryID(); + void SetGroundSpawnEntryID(int32 val); + void ProcessHarvest(Client* client); + void SetCollectionSkill(const char* val); + const char* GetCollectionSkill(); + string GetHarvestMessageName(bool present_tense = false, bool failure = false); + string GetHarvestSpellType(); + string GetHarvestSpellName(); + void HandleUse(Client* client, string type); + + void SetRandomizeHeading(bool val) { randomize_heading = val; } + bool GetRandomizeHeading() { return randomize_heading; } +private: + int8 number_harvests; + int8 num_attempts_per_harvest; + int32 groundspawn_id; + string collection_skill; + Mutex MHarvest; + Mutex MHarvestUse; + bool randomize_heading; +}; +#endif + diff --git a/internal/appearances/appearances.go b/internal/appearances/appearances.go new file mode 100644 index 0000000..dd9829c --- /dev/null +++ b/internal/appearances/appearances.go @@ -0,0 +1,288 @@ +package appearances + +import ( + "fmt" + "sync" +) + +// Appearances manages a collection of appearance objects with thread-safe operations +type Appearances struct { + appearanceMap map[int32]*Appearance // Map of appearance ID to appearance + mutex sync.RWMutex // Thread safety for concurrent access +} + +// NewAppearances creates a new appearances manager +func NewAppearances() *Appearances { + return &Appearances{ + appearanceMap: make(map[int32]*Appearance), + } +} + +// Reset clears all appearances from the manager +func (a *Appearances) Reset() { + a.ClearAppearances() +} + +// ClearAppearances removes all appearances from the manager +func (a *Appearances) ClearAppearances() { + a.mutex.Lock() + defer a.mutex.Unlock() + + // Clear the map - Go's garbage collector will handle cleanup + a.appearanceMap = make(map[int32]*Appearance) +} + +// InsertAppearance adds an appearance to the manager +func (a *Appearances) InsertAppearance(appearance *Appearance) error { + if appearance == nil { + return fmt.Errorf("appearance cannot be nil") + } + + a.mutex.Lock() + defer a.mutex.Unlock() + + a.appearanceMap[appearance.GetID()] = appearance + return nil +} + +// FindAppearanceByID retrieves an appearance by its ID +func (a *Appearances) FindAppearanceByID(id int32) *Appearance { + a.mutex.RLock() + defer a.mutex.RUnlock() + + if appearance, exists := a.appearanceMap[id]; exists { + return appearance + } + + return nil +} + +// HasAppearance checks if an appearance exists by ID +func (a *Appearances) HasAppearance(id int32) bool { + a.mutex.RLock() + defer a.mutex.RUnlock() + + _, exists := a.appearanceMap[id] + return exists +} + +// GetAppearanceCount returns the total number of appearances +func (a *Appearances) GetAppearanceCount() int { + a.mutex.RLock() + defer a.mutex.RUnlock() + + return len(a.appearanceMap) +} + +// GetAllAppearances returns a copy of all appearances +func (a *Appearances) GetAllAppearances() map[int32]*Appearance { + a.mutex.RLock() + defer a.mutex.RUnlock() + + // Return a copy to prevent external modification + result := make(map[int32]*Appearance) + for id, appearance := range a.appearanceMap { + result[id] = appearance + } + + return result +} + +// GetAppearanceIDs returns all appearance IDs +func (a *Appearances) GetAppearanceIDs() []int32 { + a.mutex.RLock() + defer a.mutex.RUnlock() + + ids := make([]int32, 0, len(a.appearanceMap)) + for id := range a.appearanceMap { + ids = append(ids, id) + } + + return ids +} + +// FindAppearancesByName finds all appearances with names containing the given substring +func (a *Appearances) FindAppearancesByName(nameSubstring string) []*Appearance { + a.mutex.RLock() + defer a.mutex.RUnlock() + + var results []*Appearance + + for _, appearance := range a.appearanceMap { + if contains(appearance.GetName(), nameSubstring) { + results = append(results, appearance) + } + } + + return results +} + +// FindAppearancesByMinClient finds all appearances with a specific minimum client version +func (a *Appearances) FindAppearancesByMinClient(minClient int16) []*Appearance { + a.mutex.RLock() + defer a.mutex.RUnlock() + + var results []*Appearance + + for _, appearance := range a.appearanceMap { + if appearance.GetMinClientVersion() == minClient { + results = append(results, appearance) + } + } + + return results +} + +// GetCompatibleAppearances returns all appearances compatible with the given client version +func (a *Appearances) GetCompatibleAppearances(clientVersion int16) []*Appearance { + a.mutex.RLock() + defer a.mutex.RUnlock() + + var results []*Appearance + + for _, appearance := range a.appearanceMap { + if appearance.IsCompatibleWithClient(clientVersion) { + results = append(results, appearance) + } + } + + return results +} + +// RemoveAppearance removes an appearance by ID +func (a *Appearances) RemoveAppearance(id int32) bool { + a.mutex.Lock() + defer a.mutex.Unlock() + + if _, exists := a.appearanceMap[id]; exists { + delete(a.appearanceMap, id) + return true + } + + return false +} + +// UpdateAppearance updates an existing appearance or inserts it if it doesn't exist +func (a *Appearances) UpdateAppearance(appearance *Appearance) error { + if appearance == nil { + return fmt.Errorf("appearance cannot be nil") + } + + a.mutex.Lock() + defer a.mutex.Unlock() + + a.appearanceMap[appearance.GetID()] = appearance + return nil +} + +// GetAppearancesByIDRange returns all appearances within the given ID range (inclusive) +func (a *Appearances) GetAppearancesByIDRange(minID, maxID int32) []*Appearance { + a.mutex.RLock() + defer a.mutex.RUnlock() + + var results []*Appearance + + for id, appearance := range a.appearanceMap { + if id >= minID && id <= maxID { + results = append(results, appearance) + } + } + + return results +} + +// ValidateAppearances checks all appearances for consistency +func (a *Appearances) ValidateAppearances() []string { + a.mutex.RLock() + defer a.mutex.RUnlock() + + var issues []string + + for id, appearance := range a.appearanceMap { + if appearance == nil { + issues = append(issues, fmt.Sprintf("Appearance ID %d is nil", id)) + continue + } + + if appearance.GetID() != id { + issues = append(issues, fmt.Sprintf("Appearance ID mismatch: map key %d != appearance ID %d", id, appearance.GetID())) + } + + if len(appearance.GetName()) == 0 { + issues = append(issues, fmt.Sprintf("Appearance ID %d has empty name", id)) + } + + if appearance.GetMinClientVersion() < 0 { + issues = append(issues, fmt.Sprintf("Appearance ID %d has negative min client version: %d", id, appearance.GetMinClientVersion())) + } + } + + return issues +} + +// IsValid returns true if all appearances are valid +func (a *Appearances) IsValid() bool { + issues := a.ValidateAppearances() + return len(issues) == 0 +} + +// GetStatistics returns statistics about the appearance collection +func (a *Appearances) GetStatistics() map[string]interface{} { + a.mutex.RLock() + defer a.mutex.RUnlock() + + stats := make(map[string]interface{}) + stats["total_appearances"] = len(a.appearanceMap) + + // Count by minimum client version + versionCounts := make(map[int16]int) + for _, appearance := range a.appearanceMap { + versionCounts[appearance.GetMinClientVersion()]++ + } + stats["appearances_by_min_client"] = versionCounts + + // Find ID range + if len(a.appearanceMap) > 0 { + var minID, maxID int32 + first := true + + for id := range a.appearanceMap { + if first { + minID = id + maxID = id + first = false + } else { + if id < minID { + minID = id + } + if id > maxID { + maxID = id + } + } + } + + stats["min_id"] = minID + stats["max_id"] = maxID + stats["id_range"] = maxID - minID + } + + return stats +} + +// contains checks if a string contains a substring (case-sensitive) +func contains(str, substr string) bool { + if len(substr) == 0 { + return true + } + if len(str) < len(substr) { + return false + } + + for i := 0; i <= len(str)-len(substr); i++ { + if str[i:i+len(substr)] == substr { + return true + } + } + + return false +} \ No newline at end of file diff --git a/internal/appearances/constants.go b/internal/appearances/constants.go new file mode 100644 index 0000000..fab00db --- /dev/null +++ b/internal/appearances/constants.go @@ -0,0 +1,13 @@ +package appearances + +// Hash search constants +const ( + // Maximum number of iterations to find an entry in hash table + HashSearchMax = 20 +) + +// Client version constants for appearance compatibility +const ( + MinimumClientVersion = 0 + DefaultClientVersion = 283 +) \ No newline at end of file diff --git a/internal/appearances/interfaces.go b/internal/appearances/interfaces.go new file mode 100644 index 0000000..a800777 --- /dev/null +++ b/internal/appearances/interfaces.go @@ -0,0 +1,308 @@ +package appearances + +import ( + "fmt" + "sync" +) + +// Database interface for appearance persistence +type Database interface { + LoadAllAppearances() ([]*Appearance, error) + SaveAppearance(appearance *Appearance) error + DeleteAppearance(id int32) error + LoadAppearancesByClientVersion(minClientVersion int16) ([]*Appearance, error) +} + +// Logger interface for appearance logging +type Logger interface { + LogInfo(message string, args ...interface{}) + LogError(message string, args ...interface{}) + LogDebug(message string, args ...interface{}) + LogWarning(message string, args ...interface{}) +} + +// AppearanceProvider interface for entities that provide appearances +type AppearanceProvider interface { + GetAppearanceID() int32 + SetAppearanceID(id int32) + GetAppearance() *Appearance + IsCompatibleWithClient(clientVersion int16) bool +} + +// AppearanceAware interface for entities that use appearances +type AppearanceAware interface { + GetAppearanceManager() *Manager + FindAppearanceByID(id int32) *Appearance + GetCompatibleAppearances(clientVersion int16) []*Appearance +} + +// Client interface for appearance-related client operations +type Client interface { + GetVersion() int16 + SendAppearanceUpdate(appearanceID int32) error +} + +// AppearanceCache interface for caching appearance data +type AppearanceCache interface { + Get(id int32) *Appearance + Set(id int32, appearance *Appearance) + Remove(id int32) + Clear() + GetSize() int +} + +// EntityAppearanceAdapter provides appearance functionality for entities +type EntityAppearanceAdapter struct { + entity Entity + appearanceID int32 + manager *Manager + logger Logger +} + +// Entity interface for things that can have appearances +type Entity interface { + GetID() int32 + GetName() string + GetDatabaseID() int32 +} + +// NewEntityAppearanceAdapter creates a new entity appearance adapter +func NewEntityAppearanceAdapter(entity Entity, manager *Manager, logger Logger) *EntityAppearanceAdapter { + return &EntityAppearanceAdapter{ + entity: entity, + appearanceID: 0, + manager: manager, + logger: logger, + } +} + +// GetAppearanceID returns the entity's appearance ID +func (eaa *EntityAppearanceAdapter) GetAppearanceID() int32 { + return eaa.appearanceID +} + +// SetAppearanceID sets the entity's appearance ID +func (eaa *EntityAppearanceAdapter) SetAppearanceID(id int32) { + eaa.appearanceID = id + + if eaa.logger != nil { + eaa.logger.LogDebug("Entity %d (%s): Set appearance ID to %d", + eaa.entity.GetID(), eaa.entity.GetName(), id) + } +} + +// GetAppearance returns the entity's appearance object +func (eaa *EntityAppearanceAdapter) GetAppearance() *Appearance { + if eaa.appearanceID == 0 { + return nil + } + + if eaa.manager == nil { + if eaa.logger != nil { + eaa.logger.LogError("Entity %d (%s): No appearance manager available", + eaa.entity.GetID(), eaa.entity.GetName()) + } + return nil + } + + return eaa.manager.FindAppearanceByID(eaa.appearanceID) +} + +// IsCompatibleWithClient checks if the entity's appearance is compatible with client version +func (eaa *EntityAppearanceAdapter) IsCompatibleWithClient(clientVersion int16) bool { + appearance := eaa.GetAppearance() + if appearance == nil { + return true // No appearance means compatible with all clients + } + + return appearance.IsCompatibleWithClient(clientVersion) +} + +// GetAppearanceName returns the name of the entity's appearance +func (eaa *EntityAppearanceAdapter) GetAppearanceName() string { + appearance := eaa.GetAppearance() + if appearance == nil { + return "" + } + + return appearance.GetName() +} + +// ValidateAppearance validates that the entity's appearance exists and is valid +func (eaa *EntityAppearanceAdapter) ValidateAppearance() error { + if eaa.appearanceID == 0 { + return nil // No appearance is valid + } + + appearance := eaa.GetAppearance() + if appearance == nil { + return fmt.Errorf("appearance ID %d not found", eaa.appearanceID) + } + + return nil +} + +// UpdateAppearance updates the entity's appearance from the manager +func (eaa *EntityAppearanceAdapter) UpdateAppearance(id int32) error { + if eaa.manager == nil { + return fmt.Errorf("no appearance manager available") + } + + appearance := eaa.manager.FindAppearanceByID(id) + if appearance == nil { + return fmt.Errorf("appearance ID %d not found", id) + } + + eaa.SetAppearanceID(id) + + if eaa.logger != nil { + eaa.logger.LogInfo("Entity %d (%s): Updated appearance to %d (%s)", + eaa.entity.GetID(), eaa.entity.GetName(), id, appearance.GetName()) + } + + return nil +} + +// SendAppearanceToClient sends the appearance to a client +func (eaa *EntityAppearanceAdapter) SendAppearanceToClient(client Client) error { + if client == nil { + return fmt.Errorf("client is nil") + } + + if eaa.appearanceID == 0 { + return nil // No appearance to send + } + + // Check client compatibility + if !eaa.IsCompatibleWithClient(client.GetVersion()) { + if eaa.logger != nil { + eaa.logger.LogWarning("Entity %d (%s): Appearance %d not compatible with client version %d", + eaa.entity.GetID(), eaa.entity.GetName(), eaa.appearanceID, client.GetVersion()) + } + return fmt.Errorf("appearance not compatible with client version %d", client.GetVersion()) + } + + return client.SendAppearanceUpdate(eaa.appearanceID) +} + +// SimpleAppearanceCache is a basic in-memory appearance cache +type SimpleAppearanceCache struct { + cache map[int32]*Appearance + mutex sync.RWMutex +} + +// NewSimpleAppearanceCache creates a new simple appearance cache +func NewSimpleAppearanceCache() *SimpleAppearanceCache { + return &SimpleAppearanceCache{ + cache: make(map[int32]*Appearance), + } +} + +// Get retrieves an appearance from cache +func (sac *SimpleAppearanceCache) Get(id int32) *Appearance { + sac.mutex.RLock() + defer sac.mutex.RUnlock() + + return sac.cache[id] +} + +// Set stores an appearance in cache +func (sac *SimpleAppearanceCache) Set(id int32, appearance *Appearance) { + sac.mutex.Lock() + defer sac.mutex.Unlock() + + sac.cache[id] = appearance +} + +// Remove removes an appearance from cache +func (sac *SimpleAppearanceCache) Remove(id int32) { + sac.mutex.Lock() + defer sac.mutex.Unlock() + + delete(sac.cache, id) +} + +// Clear removes all appearances from cache +func (sac *SimpleAppearanceCache) Clear() { + sac.mutex.Lock() + defer sac.mutex.Unlock() + + sac.cache = make(map[int32]*Appearance) +} + +// GetSize returns the number of cached appearances +func (sac *SimpleAppearanceCache) GetSize() int { + sac.mutex.RLock() + defer sac.mutex.RUnlock() + + return len(sac.cache) +} + +// CachedAppearanceManager wraps a Manager with caching functionality +type CachedAppearanceManager struct { + *Manager + cache AppearanceCache +} + +// NewCachedAppearanceManager creates a new cached appearance manager +func NewCachedAppearanceManager(manager *Manager, cache AppearanceCache) *CachedAppearanceManager { + return &CachedAppearanceManager{ + Manager: manager, + cache: cache, + } +} + +// FindAppearanceByID finds an appearance with caching +func (cam *CachedAppearanceManager) FindAppearanceByID(id int32) *Appearance { + // Check cache first + if appearance := cam.cache.Get(id); appearance != nil { + return appearance + } + + // Load from manager + appearance := cam.Manager.FindAppearanceByID(id) + if appearance != nil { + // Cache the result + cam.cache.Set(id, appearance) + } + + return appearance +} + +// AddAppearance adds an appearance and updates cache +func (cam *CachedAppearanceManager) AddAppearance(appearance *Appearance) error { + err := cam.Manager.AddAppearance(appearance) + if err == nil { + // Update cache + cam.cache.Set(appearance.GetID(), appearance) + } + + return err +} + +// UpdateAppearance updates an appearance and cache +func (cam *CachedAppearanceManager) UpdateAppearance(appearance *Appearance) error { + err := cam.Manager.UpdateAppearance(appearance) + if err == nil { + // Update cache + cam.cache.Set(appearance.GetID(), appearance) + } + + return err +} + +// RemoveAppearance removes an appearance and updates cache +func (cam *CachedAppearanceManager) RemoveAppearance(id int32) error { + err := cam.Manager.RemoveAppearance(id) + if err == nil { + // Remove from cache + cam.cache.Remove(id) + } + + return err +} + +// ClearCache clears the appearance cache +func (cam *CachedAppearanceManager) ClearCache() { + cam.cache.Clear() +} \ No newline at end of file diff --git a/internal/appearances/manager.go b/internal/appearances/manager.go new file mode 100644 index 0000000..6318fc6 --- /dev/null +++ b/internal/appearances/manager.go @@ -0,0 +1,392 @@ +package appearances + +import ( + "fmt" + "sync" +) + +// Manager provides high-level management of the appearance system +type Manager struct { + appearances *Appearances + database Database + logger Logger + mutex sync.RWMutex + + // Statistics + totalLookups int64 + successfulLookups int64 + failedLookups int64 + cacheHits int64 + cacheMisses int64 +} + +// NewManager creates a new appearance manager +func NewManager(database Database, logger Logger) *Manager { + return &Manager{ + appearances: NewAppearances(), + database: database, + logger: logger, + } +} + +// Initialize loads appearances from database +func (m *Manager) Initialize() error { + if m.logger != nil { + m.logger.LogInfo("Initializing appearance manager...") + } + + if m.database == nil { + if m.logger != nil { + m.logger.LogWarning("No database provided, starting with empty appearance list") + } + return nil + } + + appearances, err := m.database.LoadAllAppearances() + if err != nil { + return fmt.Errorf("failed to load appearances from database: %w", err) + } + + for _, appearance := range appearances { + if err := m.appearances.InsertAppearance(appearance); err != nil { + if m.logger != nil { + m.logger.LogError("Failed to insert appearance %d: %v", appearance.GetID(), err) + } + } + } + + if m.logger != nil { + m.logger.LogInfo("Loaded %d appearances from database", len(appearances)) + } + + return nil +} + +// GetAppearances returns the appearances collection +func (m *Manager) GetAppearances() *Appearances { + return m.appearances +} + +// FindAppearanceByID finds an appearance by ID with statistics tracking +func (m *Manager) FindAppearanceByID(id int32) *Appearance { + m.mutex.Lock() + m.totalLookups++ + m.mutex.Unlock() + + appearance := m.appearances.FindAppearanceByID(id) + + m.mutex.Lock() + if appearance != nil { + m.successfulLookups++ + m.cacheHits++ + } else { + m.failedLookups++ + m.cacheMisses++ + } + m.mutex.Unlock() + + if m.logger != nil && appearance == nil { + m.logger.LogDebug("Appearance lookup failed for ID: %d", id) + } + + return appearance +} + +// AddAppearance adds a new appearance +func (m *Manager) AddAppearance(appearance *Appearance) error { + if appearance == nil { + return fmt.Errorf("appearance cannot be nil") + } + + // Validate the appearance + if len(appearance.GetName()) == 0 { + return fmt.Errorf("appearance name cannot be empty") + } + + if appearance.GetID() <= 0 { + return fmt.Errorf("appearance ID must be positive") + } + + // Check if appearance already exists + if m.appearances.HasAppearance(appearance.GetID()) { + return fmt.Errorf("appearance with ID %d already exists", appearance.GetID()) + } + + // Add to collection + if err := m.appearances.InsertAppearance(appearance); err != nil { + return fmt.Errorf("failed to insert appearance: %w", err) + } + + // Save to database if available + if m.database != nil { + if err := m.database.SaveAppearance(appearance); err != nil { + // Remove from collection if database save failed + m.appearances.RemoveAppearance(appearance.GetID()) + return fmt.Errorf("failed to save appearance to database: %w", err) + } + } + + if m.logger != nil { + m.logger.LogInfo("Added appearance %d: %s (min client: %d)", + appearance.GetID(), appearance.GetName(), appearance.GetMinClientVersion()) + } + + return nil +} + +// UpdateAppearance updates an existing appearance +func (m *Manager) UpdateAppearance(appearance *Appearance) error { + if appearance == nil { + return fmt.Errorf("appearance cannot be nil") + } + + // Check if appearance exists + if !m.appearances.HasAppearance(appearance.GetID()) { + return fmt.Errorf("appearance with ID %d does not exist", appearance.GetID()) + } + + // Update in collection + if err := m.appearances.UpdateAppearance(appearance); err != nil { + return fmt.Errorf("failed to update appearance: %w", err) + } + + // Save to database if available + if m.database != nil { + if err := m.database.SaveAppearance(appearance); err != nil { + return fmt.Errorf("failed to save appearance to database: %w", err) + } + } + + if m.logger != nil { + m.logger.LogInfo("Updated appearance %d: %s", appearance.GetID(), appearance.GetName()) + } + + return nil +} + +// RemoveAppearance removes an appearance +func (m *Manager) RemoveAppearance(id int32) error { + // Check if appearance exists + if !m.appearances.HasAppearance(id) { + return fmt.Errorf("appearance with ID %d does not exist", id) + } + + // Remove from database first if available + if m.database != nil { + if err := m.database.DeleteAppearance(id); err != nil { + return fmt.Errorf("failed to delete appearance from database: %w", err) + } + } + + // Remove from collection + if !m.appearances.RemoveAppearance(id) { + return fmt.Errorf("failed to remove appearance from collection") + } + + if m.logger != nil { + m.logger.LogInfo("Removed appearance %d", id) + } + + return nil +} + +// GetCompatibleAppearances returns appearances compatible with client version +func (m *Manager) GetCompatibleAppearances(clientVersion int16) []*Appearance { + return m.appearances.GetCompatibleAppearances(clientVersion) +} + +// SearchAppearancesByName searches for appearances by name substring +func (m *Manager) SearchAppearancesByName(nameSubstring string) []*Appearance { + return m.appearances.FindAppearancesByName(nameSubstring) +} + +// GetStatistics returns appearance system statistics +func (m *Manager) GetStatistics() map[string]interface{} { + m.mutex.RLock() + defer m.mutex.RUnlock() + + // Get basic appearance statistics + stats := m.appearances.GetStatistics() + + // Add manager statistics + stats["total_lookups"] = m.totalLookups + stats["successful_lookups"] = m.successfulLookups + stats["failed_lookups"] = m.failedLookups + stats["cache_hits"] = m.cacheHits + stats["cache_misses"] = m.cacheMisses + + if m.totalLookups > 0 { + stats["success_rate"] = float64(m.successfulLookups) / float64(m.totalLookups) * 100 + stats["cache_hit_rate"] = float64(m.cacheHits) / float64(m.totalLookups) * 100 + } + + return stats +} + +// ResetStatistics resets all statistics +func (m *Manager) ResetStatistics() { + m.mutex.Lock() + defer m.mutex.Unlock() + + m.totalLookups = 0 + m.successfulLookups = 0 + m.failedLookups = 0 + m.cacheHits = 0 + m.cacheMisses = 0 +} + +// ValidateAllAppearances validates all appearances in the system +func (m *Manager) ValidateAllAppearances() []string { + return m.appearances.ValidateAppearances() +} + +// ReloadFromDatabase reloads all appearances from database +func (m *Manager) ReloadFromDatabase() error { + if m.database == nil { + return fmt.Errorf("no database available") + } + + // Clear current appearances + m.appearances.ClearAppearances() + + // Reload from database + return m.Initialize() +} + +// GetAppearanceCount returns the total number of appearances +func (m *Manager) GetAppearanceCount() int { + return m.appearances.GetAppearanceCount() +} + +// ProcessCommand handles appearance-related commands +func (m *Manager) ProcessCommand(command string, args []string) (string, error) { + switch command { + case "stats": + return m.handleStatsCommand(args) + case "validate": + return m.handleValidateCommand(args) + case "search": + return m.handleSearchCommand(args) + case "info": + return m.handleInfoCommand(args) + case "reload": + return m.handleReloadCommand(args) + default: + return "", fmt.Errorf("unknown appearance command: %s", command) + } +} + +// handleStatsCommand shows appearance system statistics +func (m *Manager) handleStatsCommand(args []string) (string, error) { + stats := m.GetStatistics() + + result := "Appearance System Statistics:\n" + result += fmt.Sprintf("Total Appearances: %d\n", stats["total_appearances"]) + result += fmt.Sprintf("Total Lookups: %d\n", stats["total_lookups"]) + result += fmt.Sprintf("Successful Lookups: %d\n", stats["successful_lookups"]) + result += fmt.Sprintf("Failed Lookups: %d\n", stats["failed_lookups"]) + + if successRate, exists := stats["success_rate"]; exists { + result += fmt.Sprintf("Success Rate: %.1f%%\n", successRate) + } + + if cacheHitRate, exists := stats["cache_hit_rate"]; exists { + result += fmt.Sprintf("Cache Hit Rate: %.1f%%\n", cacheHitRate) + } + + if minID, exists := stats["min_id"]; exists { + result += fmt.Sprintf("ID Range: %d - %d\n", minID, stats["max_id"]) + } + + return result, nil +} + +// handleValidateCommand validates all appearances +func (m *Manager) handleValidateCommand(args []string) (string, error) { + issues := m.ValidateAllAppearances() + + if len(issues) == 0 { + return "All appearances are valid.", nil + } + + result := fmt.Sprintf("Found %d issues with appearances:\n", len(issues)) + for i, issue := range issues { + if i >= 10 { // Limit output + result += "... (and more)\n" + break + } + result += fmt.Sprintf("%d. %s\n", i+1, issue) + } + + return result, nil +} + +// handleSearchCommand searches for appearances by name +func (m *Manager) handleSearchCommand(args []string) (string, error) { + if len(args) == 0 { + return "", fmt.Errorf("search term required") + } + + searchTerm := args[0] + results := m.SearchAppearancesByName(searchTerm) + + if len(results) == 0 { + return fmt.Sprintf("No appearances found matching '%s'.", searchTerm), nil + } + + result := fmt.Sprintf("Found %d appearances matching '%s':\n", len(results), searchTerm) + for i, appearance := range results { + if i >= 20 { // Limit output + result += "... (and more)\n" + break + } + result += fmt.Sprintf(" %d: %s (min client: %d)\n", + appearance.GetID(), appearance.GetName(), appearance.GetMinClientVersion()) + } + + return result, nil +} + +// handleInfoCommand shows information about a specific appearance +func (m *Manager) handleInfoCommand(args []string) (string, error) { + if len(args) == 0 { + return "", fmt.Errorf("appearance ID required") + } + + var appearanceID int32 + if _, err := fmt.Sscanf(args[0], "%d", &appearanceID); err != nil { + return "", fmt.Errorf("invalid appearance ID: %s", args[0]) + } + + appearance := m.FindAppearanceByID(appearanceID) + if appearance == nil { + return fmt.Sprintf("Appearance %d not found.", appearanceID), nil + } + + result := fmt.Sprintf("Appearance Information:\n") + result += fmt.Sprintf("ID: %d\n", appearance.GetID()) + result += fmt.Sprintf("Name: %s\n", appearance.GetName()) + result += fmt.Sprintf("Min Client Version: %d\n", appearance.GetMinClientVersion()) + + return result, nil +} + +// handleReloadCommand reloads appearances from database +func (m *Manager) handleReloadCommand(args []string) (string, error) { + if err := m.ReloadFromDatabase(); err != nil { + return "", fmt.Errorf("failed to reload appearances: %w", err) + } + + count := m.GetAppearanceCount() + return fmt.Sprintf("Successfully reloaded %d appearances from database.", count), nil +} + +// Shutdown gracefully shuts down the manager +func (m *Manager) Shutdown() { + if m.logger != nil { + m.logger.LogInfo("Shutting down appearance manager...") + } + + // Clear appearances + m.appearances.ClearAppearances() +} \ No newline at end of file diff --git a/internal/appearances/types.go b/internal/appearances/types.go new file mode 100644 index 0000000..3064c01 --- /dev/null +++ b/internal/appearances/types.go @@ -0,0 +1,65 @@ +package appearances + +// Appearance represents a single appearance with ID, name, and client version requirements +type Appearance struct { + id int32 // Appearance ID + name string // Appearance name + minClient int16 // Minimum client version required +} + +// NewAppearance creates a new appearance with the given parameters +func NewAppearance(id int32, name string, minClientVersion int16) *Appearance { + if len(name) == 0 { + return nil + } + + return &Appearance{ + id: id, + name: name, + minClient: minClientVersion, + } +} + +// GetID returns the appearance ID +func (a *Appearance) GetID() int32 { + return a.id +} + +// GetName returns the appearance name +func (a *Appearance) GetName() string { + return a.name +} + +// GetMinClientVersion returns the minimum client version required +func (a *Appearance) GetMinClientVersion() int16 { + return a.minClient +} + +// GetNameString returns the name as a string (alias for GetName for C++ compatibility) +func (a *Appearance) GetNameString() string { + return a.name +} + +// SetName sets the appearance name +func (a *Appearance) SetName(name string) { + a.name = name +} + +// SetMinClientVersion sets the minimum client version +func (a *Appearance) SetMinClientVersion(version int16) { + a.minClient = version +} + +// IsCompatibleWithClient returns true if the appearance is compatible with the given client version +func (a *Appearance) IsCompatibleWithClient(clientVersion int16) bool { + return clientVersion >= a.minClient +} + +// Clone creates a copy of the appearance +func (a *Appearance) Clone() *Appearance { + return &Appearance{ + id: a.id, + name: a.name, + minClient: a.minClient, + } +} \ No newline at end of file diff --git a/internal/classes.cpp b/internal/classes.cpp deleted file mode 100644 index ba9d7fd..0000000 --- a/internal/classes.cpp +++ /dev/null @@ -1,199 +0,0 @@ -/* - 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 deleted file mode 100644 index 58beefe..0000000 --- a/internal/classes.h +++ /dev/null @@ -1,119 +0,0 @@ -/* - 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/classes/classes.go b/internal/classes/classes.go new file mode 100644 index 0000000..ee6573d --- /dev/null +++ b/internal/classes/classes.go @@ -0,0 +1,366 @@ +package classes + +import ( + "strings" + "sync" +) + +// Classes manages class information and lookups +// Converted from C++ Classes class +type Classes struct { + // Class name to ID mapping (uppercase keys) + classMap map[string]int8 + + // ID to display name mapping for friendly names + displayNameMap map[int8]string + + // Thread safety + mutex sync.RWMutex +} + +// NewClasses creates a new classes manager with all EQ2 classes +// Converted from C++ Classes::Classes constructor +func NewClasses() *Classes { + classes := &Classes{ + classMap: make(map[string]int8), + displayNameMap: make(map[int8]string), + } + + classes.initializeClasses() + return classes +} + +// initializeClasses sets up all class mappings +func (c *Classes) initializeClasses() { + // Initialize class name to ID mappings (from C++ constructor) + c.classMap[ClassNameCommoner] = ClassCommoner + c.classMap[ClassNameFighter] = ClassFighter + c.classMap[ClassNameWarrior] = ClassWarrior + c.classMap[ClassNameGuardian] = ClassGuardian + c.classMap[ClassNameBerserker] = ClassBerserker + c.classMap[ClassNameBrawler] = ClassBrawler + c.classMap[ClassNameMonk] = ClassMonk + c.classMap[ClassNameBruiser] = ClassBruiser + c.classMap[ClassNameCrusader] = ClassCrusader + c.classMap[ClassNameShadowknight] = ClassShadowknight + c.classMap[ClassNamePaladin] = ClassPaladin + c.classMap[ClassNamePriest] = ClassPriest + c.classMap[ClassNameCleric] = ClassCleric + c.classMap[ClassNameTemplar] = ClassTemplar + c.classMap[ClassNameInquisitor] = ClassInquisitor + c.classMap[ClassNameDruid] = ClassDruid + c.classMap[ClassNameWarden] = ClassWarden + c.classMap[ClassNameFury] = ClassFury + c.classMap[ClassNameShaman] = ClassShaman + c.classMap[ClassNameMystic] = ClassMystic + c.classMap[ClassNameDefiler] = ClassDefiler + c.classMap[ClassNameMage] = ClassMage + c.classMap[ClassNameSorcerer] = ClassSorcerer + c.classMap[ClassNameWizard] = ClassWizard + c.classMap[ClassNameWarlock] = ClassWarlock + c.classMap[ClassNameEnchanter] = ClassEnchanter + c.classMap[ClassNameIllusionist] = ClassIllusionist + c.classMap[ClassNameCoercer] = ClassCoercer + c.classMap[ClassNameSummoner] = ClassSummoner + c.classMap[ClassNameConjuror] = ClassConjuror + c.classMap[ClassNameNecromancer] = ClassNecromancer + c.classMap[ClassNameScout] = ClassScout + c.classMap[ClassNameRogue] = ClassRogue + c.classMap[ClassNameSwashbuckler] = ClassSwashbuckler + c.classMap[ClassNameBrigand] = ClassBrigand + c.classMap[ClassNameBard] = ClassBard + c.classMap[ClassNameTroubador] = ClassTroubador + c.classMap[ClassNameDirge] = ClassDirge + c.classMap[ClassNamePredator] = ClassPredator + c.classMap[ClassNameRanger] = ClassRanger + c.classMap[ClassNameAssassin] = ClassAssassin + c.classMap[ClassNameAnimalist] = ClassAnimalist + c.classMap[ClassNameBeastlord] = ClassBeastlord + c.classMap[ClassNameShaper] = ClassShaper + c.classMap[ClassNameChanneler] = ClassChanneler + c.classMap[ClassNameArtisan] = ClassArtisan + c.classMap[ClassNameCraftsman] = ClassCraftsman + c.classMap[ClassNameProvisioner] = ClassProvisioner + c.classMap[ClassNameWoodworker] = ClassWoodworker + c.classMap[ClassNameCarpenter] = ClassCarpenter + c.classMap[ClassNameOutfitter] = ClassOutfitter + c.classMap[ClassNameArmorer] = ClassArmorer + c.classMap[ClassNameWeaponsmith] = ClassWeaponsmith + c.classMap[ClassNameTailor] = ClassTailor + c.classMap[ClassNameScholar] = ClassScholar + c.classMap[ClassNameJeweler] = ClassJeweler + c.classMap[ClassNameSage] = ClassSage + c.classMap[ClassNameAlchemist] = ClassAlchemist + + // Initialize display names + c.displayNameMap[ClassCommoner] = DisplayNameCommoner + c.displayNameMap[ClassFighter] = DisplayNameFighter + c.displayNameMap[ClassWarrior] = DisplayNameWarrior + c.displayNameMap[ClassGuardian] = DisplayNameGuardian + c.displayNameMap[ClassBerserker] = DisplayNameBerserker + c.displayNameMap[ClassBrawler] = DisplayNameBrawler + c.displayNameMap[ClassMonk] = DisplayNameMonk + c.displayNameMap[ClassBruiser] = DisplayNameBruiser + c.displayNameMap[ClassCrusader] = DisplayNameCrusader + c.displayNameMap[ClassShadowknight] = DisplayNameShadowknight + c.displayNameMap[ClassPaladin] = DisplayNamePaladin + c.displayNameMap[ClassPriest] = DisplayNamePriest + c.displayNameMap[ClassCleric] = DisplayNameCleric + c.displayNameMap[ClassTemplar] = DisplayNameTemplar + c.displayNameMap[ClassInquisitor] = DisplayNameInquisitor + c.displayNameMap[ClassDruid] = DisplayNameDruid + c.displayNameMap[ClassWarden] = DisplayNameWarden + c.displayNameMap[ClassFury] = DisplayNameFury + c.displayNameMap[ClassShaman] = DisplayNameShaman + c.displayNameMap[ClassMystic] = DisplayNameMystic + c.displayNameMap[ClassDefiler] = DisplayNameDefiler + c.displayNameMap[ClassMage] = DisplayNameMage + c.displayNameMap[ClassSorcerer] = DisplayNameSorcerer + c.displayNameMap[ClassWizard] = DisplayNameWizard + c.displayNameMap[ClassWarlock] = DisplayNameWarlock + c.displayNameMap[ClassEnchanter] = DisplayNameEnchanter + c.displayNameMap[ClassIllusionist] = DisplayNameIllusionist + c.displayNameMap[ClassCoercer] = DisplayNameCoercer + c.displayNameMap[ClassSummoner] = DisplayNameSummoner + c.displayNameMap[ClassConjuror] = DisplayNameConjuror + c.displayNameMap[ClassNecromancer] = DisplayNameNecromancer + c.displayNameMap[ClassScout] = DisplayNameScout + c.displayNameMap[ClassRogue] = DisplayNameRogue + c.displayNameMap[ClassSwashbuckler] = DisplayNameSwashbuckler + c.displayNameMap[ClassBrigand] = DisplayNameBrigand + c.displayNameMap[ClassBard] = DisplayNameBard + c.displayNameMap[ClassTroubador] = DisplayNameTroubador + c.displayNameMap[ClassDirge] = DisplayNameDirge + c.displayNameMap[ClassPredator] = DisplayNamePredator + c.displayNameMap[ClassRanger] = DisplayNameRanger + c.displayNameMap[ClassAssassin] = DisplayNameAssassin + c.displayNameMap[ClassAnimalist] = DisplayNameAnimalist + c.displayNameMap[ClassBeastlord] = DisplayNameBeastlord + c.displayNameMap[ClassShaper] = DisplayNameShaper + c.displayNameMap[ClassChanneler] = DisplayNameChanneler + c.displayNameMap[ClassArtisan] = DisplayNameArtisan + c.displayNameMap[ClassCraftsman] = DisplayNameCraftsman + c.displayNameMap[ClassProvisioner] = DisplayNameProvisioner + c.displayNameMap[ClassWoodworker] = DisplayNameWoodworker + c.displayNameMap[ClassCarpenter] = DisplayNameCarpenter + c.displayNameMap[ClassOutfitter] = DisplayNameOutfitter + c.displayNameMap[ClassArmorer] = DisplayNameArmorer + c.displayNameMap[ClassWeaponsmith] = DisplayNameWeaponsmith + c.displayNameMap[ClassTailor] = DisplayNameTailor + c.displayNameMap[ClassScholar] = DisplayNameScholar + c.displayNameMap[ClassJeweler] = DisplayNameJeweler + c.displayNameMap[ClassSage] = DisplayNameSage + c.displayNameMap[ClassAlchemist] = DisplayNameAlchemist +} + +// GetClassID returns the class ID for a given class name +// Converted from C++ Classes::GetClassID +func (c *Classes) GetClassID(name string) int8 { + c.mutex.RLock() + defer c.mutex.RUnlock() + + className := strings.ToUpper(strings.TrimSpace(name)) + if classID, exists := c.classMap[className]; exists { + return classID + } + + return -1 // Invalid class +} + +// GetClassName returns the uppercase class name for a given ID +// Converted from C++ Classes::GetClassName +func (c *Classes) GetClassName(classID int8) string { + c.mutex.RLock() + defer c.mutex.RUnlock() + + // Search through class map to find the name + for name, id := range c.classMap { + if id == classID { + return name + } + } + + return "" // Invalid class ID +} + +// GetClassNameCase returns the friendly display name for a given class ID +// Converted from C++ Classes::GetClassNameCase +func (c *Classes) GetClassNameCase(classID int8) string { + c.mutex.RLock() + defer c.mutex.RUnlock() + + if displayName, exists := c.displayNameMap[classID]; exists { + return displayName + } + + return "" // Invalid class ID +} + +// GetBaseClass returns the base class ID for a given class +// Converted from C++ Classes::GetBaseClass +func (c *Classes) GetBaseClass(classID int8) int8 { + if classID >= ClassWarrior && classID <= ClassPaladin { + return ClassFighter + } + if (classID >= ClassCleric && classID <= ClassDefiler) || (classID == ClassShaper || classID == ClassChanneler) { + return ClassPriest + } + if classID >= ClassSorcerer && classID <= ClassNecromancer { + return ClassMage + } + if classID >= ClassRogue && classID <= ClassBeastlord { + return ClassScout + } + + return ClassCommoner // Default for unknown classes +} + +// GetSecondaryBaseClass returns the secondary base class ID for specialized classes +// Converted from C++ Classes::GetSecondaryBaseClass +func (c *Classes) GetSecondaryBaseClass(classID int8) int8 { + switch classID { + case ClassGuardian, ClassBerserker: + return ClassWarrior + case ClassMonk, ClassBruiser: + return ClassBrawler + case ClassShadowknight, ClassPaladin: + return ClassCrusader + case ClassTemplar, ClassInquisitor: + return ClassCleric + case ClassWarden, ClassFury: + return ClassDruid + case ClassMystic, ClassDefiler: + return ClassShaman + case ClassWizard, ClassWarlock: + return ClassSorcerer + case ClassIllusionist, ClassCoercer: + return ClassEnchanter + case ClassConjuror, ClassNecromancer: + return ClassSummoner + case ClassSwashbuckler, ClassBrigand: + return ClassRogue + case ClassTroubador, ClassDirge: + return ClassBard + case ClassRanger, ClassAssassin: + return ClassPredator + case ClassBeastlord: + return ClassAnimalist + case ClassChanneler: + return ClassShaper + } + + return ClassCommoner // Default for unknown classes +} + +// GetTSBaseClass returns the tradeskill base class ID +// Converted from C++ Classes::GetTSBaseClass +func (c *Classes) GetTSBaseClass(classID int8) int8 { + if classID+42 >= ClassArtisan { + return ClassArtisan - 44 + } + + return classID +} + +// GetSecondaryTSBaseClass returns the secondary tradeskill base class ID +// Converted from C++ Classes::GetSecondaryTSBaseClass +func (c *Classes) GetSecondaryTSBaseClass(classID int8) int8 { + ret := classID + 42 + + if ret == ClassArtisan { + return ClassArtisan - 44 + } else if ret >= ClassCraftsman && ret < ClassOutfitter { + return ClassCraftsman - 44 + } else if ret >= ClassOutfitter && ret < ClassScholar { + return ClassOutfitter - 44 + } else if ret >= ClassScholar { + return ClassScholar - 44 + } + + return classID +} + +// IsValidClassID checks if a class ID is valid +func (c *Classes) IsValidClassID(classID int8) bool { + return classID >= MinClassID && classID <= MaxClassID +} + +// GetAllClasses returns all class IDs and their display names +func (c *Classes) GetAllClasses() map[int8]string { + c.mutex.RLock() + defer c.mutex.RUnlock() + + result := make(map[int8]string) + for classID, displayName := range c.displayNameMap { + result[classID] = displayName + } + + return result +} + +// IsAdventureClass checks if a class is an adventure class +func (c *Classes) IsAdventureClass(classID int8) bool { + return classID >= ClassCommoner && classID <= ClassChanneler +} + +// IsTradeskillClass checks if a class is a tradeskill class +func (c *Classes) IsTradeskillClass(classID int8) bool { + return classID >= ClassArtisan && classID <= ClassAlchemist +} + +// GetClassType returns the type of class (adventure, tradeskill, etc.) +func (c *Classes) GetClassType(classID int8) string { + if c.IsAdventureClass(classID) { + return ClassTypeAdventure + } + if c.IsTradeskillClass(classID) { + return ClassTypeTradeskill + } + + return ClassTypeSpecial +} + +// GetClassCount returns the total number of classes +func (c *Classes) GetClassCount() int { + c.mutex.RLock() + defer c.mutex.RUnlock() + + return len(c.displayNameMap) +} + +// GetClassInfo returns comprehensive information about a class +func (c *Classes) GetClassInfo(classID int8) map[string]interface{} { + c.mutex.RLock() + defer c.mutex.RUnlock() + + info := make(map[string]interface{}) + + if !c.IsValidClassID(classID) { + info["valid"] = false + return info + } + + info["valid"] = true + info["class_id"] = classID + info["name"] = c.GetClassName(classID) + info["display_name"] = c.GetClassNameCase(classID) + info["base_class"] = c.GetBaseClass(classID) + info["secondary_base_class"] = c.GetSecondaryBaseClass(classID) + info["type"] = c.GetClassType(classID) + info["is_adventure"] = c.IsAdventureClass(classID) + info["is_tradeskill"] = c.IsTradeskillClass(classID) + + return info +} + +// Global classes instance +var globalClasses *Classes +var initClassesOnce sync.Once + +// GetGlobalClasses returns the global classes manager (singleton) +func GetGlobalClasses() *Classes { + initClassesOnce.Do(func() { + globalClasses = NewClasses() + }) + return globalClasses +} \ No newline at end of file diff --git a/internal/classes/constants.go b/internal/classes/constants.go new file mode 100644 index 0000000..5ca71d7 --- /dev/null +++ b/internal/classes/constants.go @@ -0,0 +1,226 @@ +package classes + +// Adventure class ID constants converted from C++ classes.h +const ( + // Base classes + ClassCommoner = 0 + ClassFighter = 1 + ClassPriest = 11 + ClassMage = 21 + ClassScout = 31 + + // Fighter subclasses + ClassWarrior = 2 + ClassGuardian = 3 + ClassBerserker = 4 + ClassBrawler = 5 + ClassMonk = 6 + ClassBruiser = 7 + ClassCrusader = 8 + ClassShadowknight = 9 + ClassPaladin = 10 + + // Priest subclasses + ClassCleric = 12 + ClassTemplar = 13 + ClassInquisitor = 14 + ClassDruid = 15 + ClassWarden = 16 + ClassFury = 17 + ClassShaman = 18 + ClassMystic = 19 + ClassDefiler = 20 + + // Mage subclasses + ClassSorcerer = 22 + ClassWizard = 23 + ClassWarlock = 24 + ClassEnchanter = 25 + ClassIllusionist = 26 + ClassCoercer = 27 + ClassSummoner = 28 + ClassConjuror = 29 + ClassNecromancer = 30 + + // Scout subclasses + ClassRogue = 32 + ClassSwashbuckler = 33 + ClassBrigand = 34 + ClassBard = 35 + ClassTroubador = 36 + ClassDirge = 37 + ClassPredator = 38 + ClassRanger = 39 + ClassAssassin = 40 + ClassAnimalist = 41 + ClassBeastlord = 42 + + // Special classes + ClassShaper = 43 + ClassChanneler = 44 +) + +// Tradeskill class ID constants +const ( + // Base tradeskill classes + ClassArtisan = 45 + ClassCraftsman = 46 + ClassOutfitter = 50 + ClassScholar = 54 + + // Craftsman subclasses + ClassProvisioner = 47 + ClassWoodworker = 48 + ClassCarpenter = 49 + + // Outfitter subclasses + ClassArmorer = 51 + ClassWeaponsmith = 52 + ClassTailor = 53 + + // Scholar subclasses + ClassJeweler = 55 + ClassSage = 56 + ClassAlchemist = 57 +) + +// Class validation constants +const ( + MaxClassID = 57 + MinClassID = 0 + DefaultClassID = ClassCommoner + ClassicMaxAdventureClass = 40 // Classic adventure classes (0-40) + ClassicMaxTradeskillClass = 13 // Classic tradeskill progression (0-13) + MaxClasses = 58 // Total number of classes +) + +// Class type categories +const ( + ClassTypeAdventure = "adventure" + ClassTypeTradeskill = "tradeskill" + ClassTypeSpecial = "special" +) + +// Class name constants for lookup (uppercase keys from C++) +const ( + ClassNameCommoner = "COMMONER" + ClassNameFighter = "FIGHTER" + ClassNameWarrior = "WARRIOR" + ClassNameGuardian = "GUARDIAN" + ClassNameBerserker = "BERSERKER" + ClassNameBrawler = "BRAWLER" + ClassNameMonk = "MONK" + ClassNameBruiser = "BRUISER" + ClassNameCrusader = "CRUSADER" + ClassNameShadowknight = "SHADOWKNIGHT" + ClassNamePaladin = "PALADIN" + ClassNamePriest = "PRIEST" + ClassNameCleric = "CLERIC" + ClassNameTemplar = "TEMPLAR" + ClassNameInquisitor = "INQUISITOR" + ClassNameDruid = "DRUID" + ClassNameWarden = "WARDEN" + ClassNameFury = "FURY" + ClassNameShaman = "SHAMAN" + ClassNameMystic = "MYSTIC" + ClassNameDefiler = "DEFILER" + ClassNameMage = "MAGE" + ClassNameSorcerer = "SORCERER" + ClassNameWizard = "WIZARD" + ClassNameWarlock = "WARLOCK" + ClassNameEnchanter = "ENCHANTER" + ClassNameIllusionist = "ILLUSIONIST" + ClassNameCoercer = "COERCER" + ClassNameSummoner = "SUMMONER" + ClassNameConjuror = "CONJUROR" + ClassNameNecromancer = "NECROMANCER" + ClassNameScout = "SCOUT" + ClassNameRogue = "ROGUE" + ClassNameSwashbuckler = "SWASHBUCKLER" + ClassNameBrigand = "BRIGAND" + ClassNameBard = "BARD" + ClassNameTroubador = "TROUBADOR" + ClassNameDirge = "DIRGE" + ClassNamePredator = "PREDATOR" + ClassNameRanger = "RANGER" + ClassNameAssassin = "ASSASSIN" + ClassNameAnimalist = "ANIMALIST" + ClassNameBeastlord = "BEASTLORD" + ClassNameShaper = "SHAPER" + ClassNameChanneler = "CHANNELER" + ClassNameArtisan = "ARTISAN" + ClassNameCraftsman = "CRAFTSMAN" + ClassNameProvisioner = "PROVISIONER" + ClassNameWoodworker = "WOODWORKER" + ClassNameCarpenter = "CARPENTER" + ClassNameOutfitter = "OUTFITTER" + ClassNameArmorer = "ARMORER" + ClassNameWeaponsmith = "WEAPONSMITH" + ClassNameTailor = "TAILOR" + ClassNameScholar = "SCHOLAR" + ClassNameJeweler = "JEWELER" + ClassNameSage = "SAGE" + ClassNameAlchemist = "ALCHEMIST" +) + +// Class display names (proper case) +const ( + DisplayNameCommoner = "Commoner" + DisplayNameFighter = "Fighter" + DisplayNameWarrior = "Warrior" + DisplayNameGuardian = "Guardian" + DisplayNameBerserker = "Berserker" + DisplayNameBrawler = "Brawler" + DisplayNameMonk = "Monk" + DisplayNameBruiser = "Bruiser" + DisplayNameCrusader = "Crusader" + DisplayNameShadowknight = "Shadowknight" + DisplayNamePaladin = "Paladin" + DisplayNamePriest = "Priest" + DisplayNameCleric = "Cleric" + DisplayNameTemplar = "Templar" + DisplayNameInquisitor = "Inquisitor" + DisplayNameDruid = "Druid" + DisplayNameWarden = "Warden" + DisplayNameFury = "Fury" + DisplayNameShaman = "Shaman" + DisplayNameMystic = "Mystic" + DisplayNameDefiler = "Defiler" + DisplayNameMage = "Mage" + DisplayNameSorcerer = "Sorcerer" + DisplayNameWizard = "Wizard" + DisplayNameWarlock = "Warlock" + DisplayNameEnchanter = "Enchanter" + DisplayNameIllusionist = "Illusionist" + DisplayNameCoercer = "Coercer" + DisplayNameSummoner = "Summoner" + DisplayNameConjuror = "Conjuror" + DisplayNameNecromancer = "Necromancer" + DisplayNameScout = "Scout" + DisplayNameRogue = "Rogue" + DisplayNameSwashbuckler = "Swashbuckler" + DisplayNameBrigand = "Brigand" + DisplayNameBard = "Bard" + DisplayNameTroubador = "Troubador" + DisplayNameDirge = "Dirge" + DisplayNamePredator = "Predator" + DisplayNameRanger = "Ranger" + DisplayNameAssassin = "Assassin" + DisplayNameAnimalist = "Animalist" + DisplayNameBeastlord = "Beastlord" + DisplayNameShaper = "Shaper" + DisplayNameChanneler = "Channeler" + DisplayNameArtisan = "Artisan" + DisplayNameCraftsman = "Craftsman" + DisplayNameProvisioner = "Provisioner" + DisplayNameWoodworker = "Woodworker" + DisplayNameCarpenter = "Carpenter" + DisplayNameOutfitter = "Outfitter" + DisplayNameArmorer = "Armorer" + DisplayNameWeaponsmith = "Weaponsmith" + DisplayNameTailor = "Tailor" + DisplayNameScholar = "Scholar" + DisplayNameJeweler = "Jeweler" + DisplayNameSage = "Sage" + DisplayNameAlchemist = "Alchemist" +) \ No newline at end of file diff --git a/internal/classes/integration.go b/internal/classes/integration.go new file mode 100644 index 0000000..a2beb54 --- /dev/null +++ b/internal/classes/integration.go @@ -0,0 +1,352 @@ +package classes + +import ( + "fmt" +) + +// ClassAware interface for entities that have class information +type ClassAware interface { + GetClass() int8 + SetClass(int8) +} + +// EntityWithClass interface extends ClassAware with additional entity properties +type EntityWithClass interface { + ClassAware + GetID() int32 + GetName() string + GetLevel() int8 +} + +// ClassIntegration provides class-related functionality for other systems +type ClassIntegration struct { + classes *Classes + utils *ClassUtils +} + +// NewClassIntegration creates a new class integration helper +func NewClassIntegration() *ClassIntegration { + return &ClassIntegration{ + classes: GetGlobalClasses(), + utils: NewClassUtils(), + } +} + +// ValidateEntityClass validates an entity's class and provides detailed information +func (ci *ClassIntegration) ValidateEntityClass(entity ClassAware) (bool, string, map[string]interface{}) { + classID := entity.GetClass() + + if !ci.classes.IsValidClassID(classID) { + return false, fmt.Sprintf("Invalid class ID: %d", classID), nil + } + + classInfo := ci.classes.GetClassInfo(classID) + return true, "Valid class", classInfo +} + +// GetEntityClassInfo returns comprehensive class information for an entity +func (ci *ClassIntegration) GetEntityClassInfo(entity EntityWithClass) map[string]interface{} { + info := make(map[string]interface{}) + + // Basic entity info + info["entity_id"] = entity.GetID() + info["entity_name"] = entity.GetName() + info["entity_level"] = entity.GetLevel() + + // Class information + classID := entity.GetClass() + classInfo := ci.classes.GetClassInfo(classID) + info["class"] = classInfo + + // Additional class-specific info + info["description"] = ci.utils.GetClassDescription(classID) + info["eq_class_name"] = ci.utils.GetEQClassName(classID, entity.GetLevel()) + info["progression"] = ci.utils.GetClassProgression(classID) + info["aliases"] = ci.utils.GetClassAliases(classID) + info["is_base_class"] = ci.utils.IsBaseClass(classID) + info["is_secondary_base"] = ci.utils.IsSecondaryBaseClass(classID) + + return info +} + +// ChangeEntityClass changes an entity's class with validation +func (ci *ClassIntegration) ChangeEntityClass(entity ClassAware, newClassID int8) error { + if !ci.classes.IsValidClassID(newClassID) { + return fmt.Errorf("invalid class ID: %d", newClassID) + } + + oldClassID := entity.GetClass() + + // Validate the class transition + if valid, reason := ci.utils.ValidateClassTransition(oldClassID, newClassID); !valid { + return fmt.Errorf("class change not allowed: %s", reason) + } + + // Perform the class change + entity.SetClass(newClassID) + + return nil +} + +// GetRandomClassForEntity returns a random class appropriate for an entity +func (ci *ClassIntegration) GetRandomClassForEntity(classType string) int8 { + return ci.utils.GetRandomClassByType(classType) +} + +// CheckClassCompatibility checks if two entities' classes are compatible for grouping +func (ci *ClassIntegration) CheckClassCompatibility(entity1, entity2 ClassAware) bool { + class1 := entity1.GetClass() + class2 := entity2.GetClass() + + if !ci.classes.IsValidClassID(class1) || !ci.classes.IsValidClassID(class2) { + return false + } + + // Same class is always compatible + if class1 == class2 { + return true + } + + // Check if they share the same base class (good for grouping) + base1 := ci.classes.GetBaseClass(class1) + base2 := ci.classes.GetBaseClass(class2) + + // Different base classes can group together (provides diversity) + // Same base class provides synergy + return true // For now, all classes are compatible for grouping +} + +// FormatEntityClass returns a formatted class name for an entity +func (ci *ClassIntegration) FormatEntityClass(entity EntityWithClass, format string) string { + classID := entity.GetClass() + level := entity.GetLevel() + + switch format { + case "eq": + return ci.utils.GetEQClassName(classID, level) + default: + return ci.utils.FormatClassName(classID, format) + } +} + +// GetEntityBaseClass returns an entity's base class +func (ci *ClassIntegration) GetEntityBaseClass(entity ClassAware) int8 { + classID := entity.GetClass() + return ci.classes.GetBaseClass(classID) +} + +// GetEntitySecondaryBaseClass returns an entity's secondary base class +func (ci *ClassIntegration) GetEntitySecondaryBaseClass(entity ClassAware) int8 { + classID := entity.GetClass() + return ci.classes.GetSecondaryBaseClass(classID) +} + +// IsEntityAdventureClass checks if an entity has an adventure class +func (ci *ClassIntegration) IsEntityAdventureClass(entity ClassAware) bool { + classID := entity.GetClass() + return ci.classes.IsAdventureClass(classID) +} + +// IsEntityTradeskillClass checks if an entity has a tradeskill class +func (ci *ClassIntegration) IsEntityTradeskillClass(entity ClassAware) bool { + classID := entity.GetClass() + return ci.classes.IsTradeskillClass(classID) +} + +// GetEntitiesByClass filters entities by class +func (ci *ClassIntegration) GetEntitiesByClass(entities []ClassAware, classID int8) []ClassAware { + result := make([]ClassAware, 0) + + for _, entity := range entities { + if entity.GetClass() == classID { + result = append(result, entity) + } + } + + return result +} + +// GetEntitiesByBaseClass filters entities by base class +func (ci *ClassIntegration) GetEntitiesByBaseClass(entities []ClassAware, baseClassID int8) []ClassAware { + result := make([]ClassAware, 0) + + for _, entity := range entities { + if ci.GetEntityBaseClass(entity) == baseClassID { + result = append(result, entity) + } + } + + return result +} + +// GetEntitiesByClassType filters entities by class type (adventure/tradeskill) +func (ci *ClassIntegration) GetEntitiesByClassType(entities []ClassAware, classType string) []ClassAware { + result := make([]ClassAware, 0) + + for _, entity := range entities { + classID := entity.GetClass() + if ci.classes.GetClassType(classID) == classType { + result = append(result, entity) + } + } + + return result +} + +// ValidateClassForRace checks if a class/race combination is valid +func (ci *ClassIntegration) ValidateClassForRace(classID, raceID int8) (bool, string) { + if !ci.classes.IsValidClassID(classID) { + return false, "Invalid class" + } + + // Use the utility function (which currently allows all combinations) + if ci.utils.ValidateClassForRace(classID, raceID) { + return true, "" + } + + className := ci.classes.GetClassNameCase(classID) + return false, fmt.Sprintf("Class %s cannot be race %d", className, raceID) +} + +// GetClassStartingStats returns the starting stats for a class +func (ci *ClassIntegration) GetClassStartingStats(classID int8) map[string]int16 { + // Base stats that all classes start with + baseStats := map[string]int16{ + "strength": 50, + "stamina": 50, + "agility": 50, + "wisdom": 50, + "intelligence": 50, + } + + // Apply class modifiers based on class type and role + switch ci.classes.GetBaseClass(classID) { + case ClassFighter: + baseStats["strength"] += 5 + baseStats["stamina"] += 5 + baseStats["intelligence"] -= 3 + case ClassPriest: + baseStats["wisdom"] += 5 + baseStats["intelligence"] += 3 + baseStats["strength"] -= 2 + case ClassMage: + baseStats["intelligence"] += 5 + baseStats["wisdom"] += 3 + baseStats["strength"] -= 3 + baseStats["stamina"] -= 2 + case ClassScout: + baseStats["agility"] += 5 + baseStats["stamina"] += 3 + baseStats["wisdom"] -= 2 + } + + // Fine-tune for specific secondary base classes + switch ci.classes.GetSecondaryBaseClass(classID) { + case ClassWarrior: + baseStats["strength"] += 2 + baseStats["stamina"] += 2 + case ClassBrawler: + baseStats["agility"] += 2 + baseStats["strength"] += 1 + case ClassCrusader: + baseStats["wisdom"] += 2 + baseStats["strength"] += 1 + case ClassCleric: + baseStats["wisdom"] += 3 + case ClassDruid: + baseStats["wisdom"] += 2 + baseStats["intelligence"] += 1 + case ClassShaman: + baseStats["wisdom"] += 2 + baseStats["stamina"] += 1 + case ClassSorcerer: + baseStats["intelligence"] += 3 + case ClassEnchanter: + baseStats["intelligence"] += 2 + baseStats["agility"] += 1 + case ClassSummoner: + baseStats["intelligence"] += 2 + baseStats["wisdom"] += 1 + case ClassRogue: + baseStats["agility"] += 3 + case ClassBard: + baseStats["agility"] += 2 + baseStats["intelligence"] += 1 + case ClassPredator: + baseStats["agility"] += 2 + baseStats["stamina"] += 1 + } + + return baseStats +} + +// CreateClassSpecificEntity creates entity data with class-specific properties +func (ci *ClassIntegration) CreateClassSpecificEntity(classID int8) map[string]interface{} { + if !ci.classes.IsValidClassID(classID) { + return nil + } + + entityData := make(map[string]interface{}) + + // Basic class info + entityData["class_id"] = classID + entityData["class_name"] = ci.classes.GetClassNameCase(classID) + entityData["class_type"] = ci.classes.GetClassType(classID) + + // Starting stats + entityData["starting_stats"] = ci.GetClassStartingStats(classID) + + // Class progression + entityData["progression"] = ci.utils.GetClassProgression(classID) + + // Class description + entityData["description"] = ci.utils.GetClassDescription(classID) + + // Role information + entityData["base_class"] = ci.classes.GetBaseClass(classID) + entityData["secondary_base_class"] = ci.classes.GetSecondaryBaseClass(classID) + + return entityData +} + +// GetClassSelectionData returns data for class selection UI +func (ci *ClassIntegration) GetClassSelectionData() map[string]interface{} { + data := make(map[string]interface{}) + + // All available adventure classes (exclude tradeskill for character creation) + allClasses := ci.classes.GetAllClasses() + adventureClasses := make([]map[string]interface{}, 0) + + for classID, displayName := range allClasses { + if ci.classes.IsAdventureClass(classID) { + classData := map[string]interface{}{ + "id": classID, + "name": displayName, + "type": ci.classes.GetClassType(classID), + "description": ci.utils.GetClassDescription(classID), + "base_class": ci.classes.GetBaseClass(classID), + "secondary_base_class": ci.classes.GetSecondaryBaseClass(classID), + "starting_stats": ci.GetClassStartingStats(classID), + "progression": ci.utils.GetClassProgression(classID), + "is_base_class": ci.utils.IsBaseClass(classID), + } + adventureClasses = append(adventureClasses, classData) + } + } + + data["adventure_classes"] = adventureClasses + data["statistics"] = ci.utils.GetClassStatistics() + + return data +} + +// Global class integration instance +var globalClassIntegration *ClassIntegration + +// GetGlobalClassIntegration returns the global class integration helper +func GetGlobalClassIntegration() *ClassIntegration { + if globalClassIntegration == nil { + globalClassIntegration = NewClassIntegration() + } + return globalClassIntegration +} \ No newline at end of file diff --git a/internal/classes/manager.go b/internal/classes/manager.go new file mode 100644 index 0000000..9f38f80 --- /dev/null +++ b/internal/classes/manager.go @@ -0,0 +1,455 @@ +package classes + +import ( + "fmt" + "sync" +) + +// ClassManager provides high-level class management functionality +type ClassManager struct { + classes *Classes + utils *ClassUtils + integration *ClassIntegration + + // Statistics tracking + classUsageStats map[int8]int32 // Track how often each class is used + + // Thread safety + mutex sync.RWMutex +} + +// NewClassManager creates a new class manager +func NewClassManager() *ClassManager { + return &ClassManager{ + classes: GetGlobalClasses(), + utils: NewClassUtils(), + integration: NewClassIntegration(), + classUsageStats: make(map[int8]int32), + } +} + +// RegisterClassUsage tracks class usage for statistics +func (cm *ClassManager) RegisterClassUsage(classID int8) { + if !cm.classes.IsValidClassID(classID) { + return + } + + cm.mutex.Lock() + defer cm.mutex.Unlock() + + cm.classUsageStats[classID]++ +} + +// GetClassUsageStats returns class usage statistics +func (cm *ClassManager) GetClassUsageStats() map[int8]int32 { + cm.mutex.RLock() + defer cm.mutex.RUnlock() + + // Return a copy to prevent external modification + stats := make(map[int8]int32) + for classID, count := range cm.classUsageStats { + stats[classID] = count + } + + return stats +} + +// GetMostPopularClass returns the most frequently used class +func (cm *ClassManager) GetMostPopularClass() (int8, int32) { + cm.mutex.RLock() + defer cm.mutex.RUnlock() + + var mostPopularClass int8 = -1 + var maxUsage int32 = 0 + + for classID, usage := range cm.classUsageStats { + if usage > maxUsage { + maxUsage = usage + mostPopularClass = classID + } + } + + return mostPopularClass, maxUsage +} + +// GetLeastPopularClass returns the least frequently used class +func (cm *ClassManager) GetLeastPopularClass() (int8, int32) { + cm.mutex.RLock() + defer cm.mutex.RUnlock() + + var leastPopularClass int8 = -1 + var minUsage int32 = -1 + + for classID, usage := range cm.classUsageStats { + if minUsage == -1 || usage < minUsage { + minUsage = usage + leastPopularClass = classID + } + } + + return leastPopularClass, minUsage +} + +// ResetUsageStats clears all usage statistics +func (cm *ClassManager) ResetUsageStats() { + cm.mutex.Lock() + defer cm.mutex.Unlock() + + cm.classUsageStats = make(map[int8]int32) +} + +// ProcessClassCommand handles class-related commands +func (cm *ClassManager) ProcessClassCommand(command string, args []string) (string, error) { + switch command { + case "list": + return cm.handleListCommand(args) + case "info": + return cm.handleInfoCommand(args) + case "random": + return cm.handleRandomCommand(args) + case "stats": + return cm.handleStatsCommand(args) + case "search": + return cm.handleSearchCommand(args) + case "progression": + return cm.handleProgressionCommand(args) + default: + return "", fmt.Errorf("unknown class command: %s", command) + } +} + +// handleListCommand lists classes by criteria +func (cm *ClassManager) handleListCommand(args []string) (string, error) { + if len(args) == 0 { + // List all classes + allClasses := cm.classes.GetAllClasses() + result := "All Classes:\n" + for classID, displayName := range allClasses { + classType := cm.classes.GetClassType(classID) + baseClass := cm.classes.GetBaseClass(classID) + baseClassName := cm.classes.GetClassNameCase(baseClass) + result += fmt.Sprintf("%d: %s (%s, Base: %s)\n", classID, displayName, classType, baseClassName) + } + return result, nil + } + + // List classes by type + classType := args[0] + allClasses := cm.classes.GetAllClasses() + result := fmt.Sprintf("%s Classes:\n", classType) + count := 0 + + for classID, displayName := range allClasses { + if cm.classes.GetClassType(classID) == classType { + baseClass := cm.classes.GetBaseClass(classID) + baseClassName := cm.classes.GetClassNameCase(baseClass) + result += fmt.Sprintf("%d: %s (Base: %s)\n", classID, displayName, baseClassName) + count++ + } + } + + if count == 0 { + return fmt.Sprintf("No classes found for type: %s", classType), nil + } + + return result, nil +} + +// handleInfoCommand provides detailed information about a class +func (cm *ClassManager) handleInfoCommand(args []string) (string, error) { + if len(args) == 0 { + return "", fmt.Errorf("class name or ID required") + } + + // Try to parse as class name or ID + classID := cm.utils.ParseClassName(args[0]) + if classID == -1 { + return fmt.Sprintf("Invalid class: %s", args[0]), nil + } + + classInfo := cm.classes.GetClassInfo(classID) + if !classInfo["valid"].(bool) { + return fmt.Sprintf("Invalid class ID: %d", classID), nil + } + + result := fmt.Sprintf("Class Information:\n") + result += fmt.Sprintf("ID: %d\n", classID) + result += fmt.Sprintf("Name: %s\n", classInfo["display_name"]) + result += fmt.Sprintf("Type: %s\n", classInfo["type"]) + result += fmt.Sprintf("Base Class: %s\n", cm.classes.GetClassNameCase(classInfo["base_class"].(int8))) + + if secondaryBase := classInfo["secondary_base_class"].(int8); secondaryBase != DefaultClassID { + result += fmt.Sprintf("Secondary Base: %s\n", cm.classes.GetClassNameCase(secondaryBase)) + } + + result += fmt.Sprintf("Description: %s\n", cm.utils.GetClassDescription(classID)) + + // Add progression path + progression := cm.utils.GetClassProgression(classID) + if len(progression) > 1 { + result += "Progression Path: " + progressionNames := make([]string, len(progression)) + for i, progClassID := range progression { + progressionNames[i] = cm.classes.GetClassNameCase(progClassID) + } + result += fmt.Sprintf("%s\n", cm.utils.FormatClassList(progression, " → ")) + } + + // Add starting stats + startingStats := cm.integration.GetClassStartingStats(classID) + if len(startingStats) > 0 { + result += "Starting Stats:\n" + for stat, value := range startingStats { + result += fmt.Sprintf(" %s: %d\n", stat, value) + } + } + + // Add usage statistics if available + cm.mutex.RLock() + usage, hasUsage := cm.classUsageStats[classID] + cm.mutex.RUnlock() + + if hasUsage { + result += fmt.Sprintf("Usage Count: %d\n", usage) + } + + return result, nil +} + +// handleRandomCommand generates random classes +func (cm *ClassManager) handleRandomCommand(args []string) (string, error) { + classType := ClassTypeAdventure + if len(args) > 0 { + classType = args[0] + } + + classID := cm.utils.GetRandomClassByType(classType) + if classID == -1 { + return "Failed to generate random class", nil + } + + displayName := cm.classes.GetClassNameCase(classID) + actualType := cm.classes.GetClassType(classID) + + return fmt.Sprintf("Random %s Class: %s (ID: %d)", actualType, displayName, classID), nil +} + +// handleStatsCommand shows class system statistics +func (cm *ClassManager) handleStatsCommand(args []string) (string, error) { + systemStats := cm.utils.GetClassStatistics() + usageStats := cm.GetClassUsageStats() + + result := "Class System Statistics:\n" + result += fmt.Sprintf("Total Classes: %d\n", systemStats["total_classes"]) + result += fmt.Sprintf("Adventure Classes: %d\n", systemStats["adventure_classes"]) + result += fmt.Sprintf("Tradeskill Classes: %d\n", systemStats["tradeskill_classes"]) + result += fmt.Sprintf("Special Classes: %d\n", systemStats["special_classes"]) + + if len(usageStats) > 0 { + result += "\nUsage Statistics:\n" + mostPopular, maxUsage := cm.GetMostPopularClass() + leastPopular, minUsage := cm.GetLeastPopularClass() + + if mostPopular != -1 { + mostPopularName := cm.classes.GetClassNameCase(mostPopular) + result += fmt.Sprintf("Most Popular: %s (%d uses)\n", mostPopularName, maxUsage) + } + + if leastPopular != -1 { + leastPopularName := cm.classes.GetClassNameCase(leastPopular) + result += fmt.Sprintf("Least Popular: %s (%d uses)\n", leastPopularName, minUsage) + } + } + + // Show base class distribution + if baseDistribution, exists := systemStats["base_class_distribution"]; exists { + result += "\nBase Class Distribution:\n" + distribution := baseDistribution.(map[string][]string) + for baseClass, subClasses := range distribution { + result += fmt.Sprintf("%s: %d subclasses\n", baseClass, len(subClasses)) + } + } + + return result, nil +} + +// handleSearchCommand searches for classes by pattern +func (cm *ClassManager) handleSearchCommand(args []string) (string, error) { + if len(args) == 0 { + return "", fmt.Errorf("search pattern required") + } + + pattern := args[0] + matchingClasses := cm.utils.GetClassesByPattern(pattern) + + if len(matchingClasses) == 0 { + return fmt.Sprintf("No classes found matching pattern: %s", pattern), nil + } + + result := fmt.Sprintf("Classes matching '%s':\n", pattern) + for _, classID := range matchingClasses { + displayName := cm.classes.GetClassNameCase(classID) + classType := cm.classes.GetClassType(classID) + baseClass := cm.classes.GetBaseClass(classID) + baseClassName := cm.classes.GetClassNameCase(baseClass) + result += fmt.Sprintf("%d: %s (%s, Base: %s)\n", classID, displayName, classType, baseClassName) + } + + return result, nil +} + +// handleProgressionCommand shows class progression information +func (cm *ClassManager) handleProgressionCommand(args []string) (string, error) { + if len(args) == 0 { + return "", fmt.Errorf("class name or ID required") + } + + classID := cm.utils.ParseClassName(args[0]) + if classID == -1 { + return fmt.Sprintf("Invalid class: %s", args[0]), nil + } + + progression := cm.utils.GetClassProgression(classID) + if len(progression) <= 1 { + return fmt.Sprintf("Class %s has no progression path", cm.classes.GetClassNameCase(classID)), nil + } + + result := fmt.Sprintf("Progression Path for %s:\n", cm.classes.GetClassNameCase(classID)) + for i, stepClassID := range progression { + stepName := cm.classes.GetClassNameCase(stepClassID) + if i == 0 { + result += fmt.Sprintf("1. %s (Starting Class)\n", stepName) + } else if i == len(progression)-1 { + result += fmt.Sprintf("%d. %s (Final Class)\n", i+1, stepName) + } else { + result += fmt.Sprintf("%d. %s\n", i+1, stepName) + } + } + + return result, nil +} + +// ValidateEntityClasses validates classes for a collection of entities +func (cm *ClassManager) ValidateEntityClasses(entities []ClassAware) map[string]interface{} { + validationResults := make(map[string]interface{}) + + validCount := 0 + invalidCount := 0 + classDistribution := make(map[int8]int) + + for i, entity := range entities { + classID := entity.GetClass() + isValid := cm.classes.IsValidClassID(classID) + + if isValid { + validCount++ + classDistribution[classID]++ + } 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, + "class_id": classID, + }) + validationResults["invalid_entities"] = invalidList + } + } + + validationResults["total_entities"] = len(entities) + validationResults["valid_count"] = validCount + validationResults["invalid_count"] = invalidCount + validationResults["class_distribution"] = classDistribution + + return validationResults +} + +// GetClassRecommendations returns class recommendations for character creation +func (cm *ClassManager) GetClassRecommendations(preferences map[string]interface{}) []int8 { + recommendations := make([]int8, 0) + + // Check for class type preference + if classType, exists := preferences["class_type"]; exists { + if typeStr, ok := classType.(string); ok { + allClasses := cm.classes.GetAllClasses() + for classID := range allClasses { + if cm.classes.GetClassType(classID) == typeStr { + recommendations = append(recommendations, classID) + } + } + } + } + + // Check for base class preference + if baseClass, exists := preferences["base_class"]; exists { + if baseClassID, ok := baseClass.(int8); ok { + subClasses := cm.utils.GetClassesByBaseClass(baseClassID) + recommendations = append(recommendations, subClasses...) + } + } + + // Check for specific stat preferences + if preferredStats, exists := preferences["preferred_stats"]; exists { + if stats, ok := preferredStats.([]string); ok { + allClasses := cm.classes.GetAllClasses() + + for classID := range allClasses { + startingStats := cm.integration.GetClassStartingStats(classID) + + // Check if this class has bonuses in preferred stats + hasPreferredBonus := false + for _, preferredStat := range stats { + if statValue, exists := startingStats[preferredStat]; exists && statValue > 52 { // Above base of 50 + minor bonus + hasPreferredBonus = true + break + } + } + + if hasPreferredBonus { + recommendations = append(recommendations, classID) + } + } + } + } + + // If no specific preferences, recommend popular classes + if len(recommendations) == 0 { + // Get usage stats and recommend most popular classes + usageStats := cm.GetClassUsageStats() + if len(usageStats) > 0 { + // Sort by usage and take top classes + // For simplicity, just return all classes with usage > 0 + for classID, usage := range usageStats { + if usage > 0 { + recommendations = append(recommendations, classID) + } + } + } + + // If still no recommendations, return a default set of beginner-friendly classes + if len(recommendations) == 0 { + recommendations = []int8{ClassWarrior, ClassCleric, ClassWizard, ClassRogue} + } + } + + return recommendations +} + +// Global class manager instance +var globalClassManager *ClassManager +var initClassManagerOnce sync.Once + +// GetGlobalClassManager returns the global class manager (singleton) +func GetGlobalClassManager() *ClassManager { + initClassManagerOnce.Do(func() { + globalClassManager = NewClassManager() + }) + return globalClassManager +} \ No newline at end of file diff --git a/internal/classes/utils.go b/internal/classes/utils.go new file mode 100644 index 0000000..e3fa641 --- /dev/null +++ b/internal/classes/utils.go @@ -0,0 +1,451 @@ +package classes + +import ( + "fmt" + "math/rand" + "strings" +) + +// ClassUtils provides utility functions for class operations +type ClassUtils struct { + classes *Classes +} + +// NewClassUtils creates a new class utilities instance +func NewClassUtils() *ClassUtils { + return &ClassUtils{ + classes: GetGlobalClasses(), + } +} + +// ParseClassName attempts to parse a class name from various input formats +func (cu *ClassUtils) ParseClassName(input string) int8 { + if input == "" { + return -1 + } + + // Try direct lookup first + classID := cu.classes.GetClassID(input) + if classID != -1 { + return classID + } + + // 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 classID := cu.classes.GetClassID(variation); classID != -1 { + return classID + } + } + + // Try matching against friendly names (case insensitive) + inputLower := strings.ToLower(input) + allClasses := cu.classes.GetAllClasses() + for classID, displayName := range allClasses { + if strings.ToLower(displayName) == inputLower { + return classID + } + } + + return -1 // Not found +} + +// FormatClassName returns a properly formatted class name +func (cu *ClassUtils) FormatClassName(classID int8, format string) string { + switch strings.ToLower(format) { + case "display", "friendly", "proper": + return cu.classes.GetClassNameCase(classID) + case "upper", "uppercase": + return cu.classes.GetClassName(classID) + case "lower", "lowercase": + return strings.ToLower(cu.classes.GetClassName(classID)) + default: + return cu.classes.GetClassNameCase(classID) // Default to friendly name + } +} + +// GetRandomClassByType returns a random class of the specified type +func (cu *ClassUtils) GetRandomClassByType(classType string) int8 { + allClasses := cu.classes.GetAllClasses() + validClasses := make([]int8, 0) + + for classID := range allClasses { + if cu.classes.GetClassType(classID) == classType { + validClasses = append(validClasses, classID) + } + } + + if len(validClasses) == 0 { + return DefaultClassID + } + + return validClasses[rand.Intn(len(validClasses))] +} + +// GetRandomAdventureClass returns a random adventure class +func (cu *ClassUtils) GetRandomAdventureClass() int8 { + return cu.GetRandomClassByType(ClassTypeAdventure) +} + +// GetRandomTradeskillClass returns a random tradeskill class +func (cu *ClassUtils) GetRandomTradeskillClass() int8 { + return cu.GetRandomClassByType(ClassTypeTradeskill) +} + +// ValidateClassForRace checks if a class is valid for a specific race +// This is a placeholder for future race-class restrictions +func (cu *ClassUtils) ValidateClassForRace(classID, raceID int8) bool { + // TODO: Implement race-class restrictions when race system is available + // For now, all classes can be all races + return cu.classes.IsValidClassID(classID) +} + +// GetClassDescription returns a description of the class +func (cu *ClassUtils) GetClassDescription(classID int8) string { + // This would typically come from a database or configuration + // For now, provide basic descriptions based on class + + switch classID { + case ClassCommoner: + return "A starting class for all characters before choosing their path." + case ClassFighter: + return "Warriors who excel in melee combat and defense." + case ClassWarrior: + return "Masters of weapons and armor, the ultimate melee combatants." + case ClassGuardian: + return "Defensive warriors who protect their allies with shield and sword." + case ClassBerserker: + return "Rage-fueled fighters who sacrifice defense for devastating attacks." + case ClassBrawler: + return "Hand-to-hand combat specialists who fight with fists and focus." + case ClassMonk: + return "Disciplined fighters who use martial arts and inner peace." + case ClassBruiser: + return "Brutal brawlers who overwhelm enemies with raw power." + case ClassCrusader: + return "Holy warriors who blend combat prowess with divine magic." + case ClassShadowknight: + return "Dark knights who wield unholy magic alongside martial skill." + case ClassPaladin: + return "Champions of good who protect the innocent with sword and spell." + case ClassPriest: + return "Divine casters who channel the power of the gods." + case ClassCleric: + return "Healers and supporters who keep their allies alive and fighting." + case ClassTemplar: + return "Protective priests who shield allies from harm." + case ClassInquisitor: + return "Militant clerics who combine healing with righteous fury." + case ClassDruid: + return "Nature priests who harness the power of the natural world." + case ClassWarden: + return "Protective druids who shield allies with nature's blessing." + case ClassFury: + return "Destructive druids who unleash nature's wrath upon enemies." + case ClassShaman: + return "Spirit-workers who commune with ancestors and totems." + case ClassMystic: + return "Supportive shamans who provide wards and spiritual guidance." + case ClassDefiler: + return "Dark shamans who corrupt and weaken their enemies." + case ClassMage: + return "Wielders of arcane magic who bend reality to their will." + case ClassSorcerer: + return "Destructive mages who specialize in damaging spells." + case ClassWizard: + return "Scholarly sorcerers who master the elements." + case ClassWarlock: + return "Dark sorcerers who deal in forbidden magic." + case ClassEnchanter: + return "Mind-controlling mages who manipulate enemies and allies." + case ClassIllusionist: + return "Deceptive enchanters who confuse and misdirect." + case ClassCoercer: + return "Dominating enchanters who force enemies to obey." + case ClassSummoner: + return "Mages who call forth creatures to fight for them." + case ClassConjuror: + return "Elemental summoners who command earth and air." + case ClassNecromancer: + return "Death mages who raise undead minions and drain life." + case ClassScout: + return "Agile fighters who rely on speed and cunning." + case ClassRogue: + return "Stealthy combatants who strike from the shadows." + case ClassSwashbuckler: + return "Dashing rogues who fight with finesse and flair." + case ClassBrigand: + return "Brutal rogues who prefer dirty fighting tactics." + case ClassBard: + return "Musical combatants who inspire allies and demoralize foes." + case ClassTroubador: + return "Supportive bards who strengthen their allies." + case ClassDirge: + return "Dark bards who weaken enemies with haunting melodies." + case ClassPredator: + return "Hunters who excel at tracking and ranged combat." + case ClassRanger: + return "Nature-loving predators who protect the wilderness." + case ClassAssassin: + return "Deadly predators who eliminate targets with precision." + case ClassAnimalist: + return "Beast masters who fight alongside animal companions." + case ClassBeastlord: + return "Animalists who have formed powerful bonds with their pets." + case ClassShaper: + return "Mystic priests who manipulate spiritual energy." + case ClassChanneler: + return "Shapers who focus spiritual power through channeling." + case ClassArtisan: + return "Crafters who create useful items for adventurers." + case ClassCraftsman: + return "Specialized artisans who work with physical materials." + case ClassProvisioner: + return "Food and drink specialists who create consumables." + case ClassWoodworker: + return "Crafters who work with wood to create furniture and tools." + case ClassCarpenter: + return "Master woodworkers who create complex wooden items." + case ClassOutfitter: + return "Equipment crafters who create armor and weapons." + case ClassArmorer: + return "Specialists in creating protective armor." + case ClassWeaponsmith: + return "Masters of weapon crafting and enhancement." + case ClassTailor: + return "Cloth workers who create clothing and soft armor." + case ClassScholar: + return "Academic crafters who create magical and scholarly items." + case ClassJeweler: + return "Specialists in creating jewelry and accessories." + case ClassSage: + return "Book and scroll crafters who preserve knowledge." + case ClassAlchemist: + return "Potion makers who brew magical elixirs and potions." + default: + return "An unknown class with mysterious abilities." + } +} + +// GetClassProgression returns the class progression path +func (cu *ClassUtils) GetClassProgression(classID int8) []int8 { + progression := make([]int8, 0) + + // Always start with Commoner (except for Commoner itself) + if classID != ClassCommoner { + progression = append(progression, ClassCommoner) + } + + // Add base class if different from current + baseClass := cu.classes.GetBaseClass(classID) + if baseClass != classID && baseClass != ClassCommoner { + progression = append(progression, baseClass) + } + + // Add secondary base class if different + secondaryBase := cu.classes.GetSecondaryBaseClass(classID) + if secondaryBase != classID && secondaryBase != baseClass && secondaryBase != ClassCommoner { + progression = append(progression, secondaryBase) + } + + // Add the final class + progression = append(progression, classID) + + return progression +} + +// GetClasssByBaseClass returns all classes that belong to a base class +func (cu *ClassUtils) GetClasssByBaseClass(baseClassID int8) []int8 { + result := make([]int8, 0) + allClasses := cu.classes.GetAllClasses() + + for classID := range allClasses { + if cu.classes.GetBaseClass(classID) == baseClassID { + result = append(result, classID) + } + } + + return result +} + +// GetClassesBySecondaryBase returns all classes that belong to a secondary base class +func (cu *ClassUtils) GetClassesBySecondaryBase(secondaryBaseID int8) []int8 { + result := make([]int8, 0) + allClasses := cu.classes.GetAllClasses() + + for classID := range allClasses { + if cu.classes.GetSecondaryBaseClass(classID) == secondaryBaseID { + result = append(result, classID) + } + } + + return result +} + +// GetClassesByPattern returns classes matching a name pattern +func (cu *ClassUtils) GetClassesByPattern(pattern string) []int8 { + pattern = strings.ToLower(pattern) + result := make([]int8, 0) + + allClasses := cu.classes.GetAllClasses() + for classID, displayName := range allClasses { + if strings.Contains(strings.ToLower(displayName), pattern) { + result = append(result, classID) + } + } + + return result +} + +// ValidateClassTransition checks if a class change is allowed +func (cu *ClassUtils) ValidateClassTransition(fromClassID, toClassID int8) (bool, string) { + if !cu.classes.IsValidClassID(fromClassID) { + return false, "Invalid source class" + } + + if !cu.classes.IsValidClassID(toClassID) { + return false, "Invalid target class" + } + + if fromClassID == toClassID { + return false, "Cannot change to the same class" + } + + // Basic progression validation - can only advance, not go backward + fromProgression := cu.GetClassProgression(fromClassID) + toProgression := cu.GetClassProgression(toClassID) + + // Check if the target class is a valid advancement + if len(toProgression) <= len(fromProgression) { + return false, "Cannot regress to a lower tier class" + } + + // Check if the progressions are compatible (share the same base path) + for i := 0; i < len(fromProgression); i++ { + if i >= len(toProgression) || fromProgression[i] != toProgression[i] { + return false, "Incompatible class progression paths" + } + } + + return true, "" +} + +// GetClassAliases returns common aliases for a class +func (cu *ClassUtils) GetClassAliases(classID int8) []string { + aliases := make([]string, 0) + + switch classID { + case ClassShadowknight: + aliases = append(aliases, "SK", "Shadow Knight", "Dark Knight") + case ClassSwashbuckler: + aliases = append(aliases, "Swash", "Swashy") + case ClassTroubador: + aliases = append(aliases, "Troub", "Troubadour") + case ClassIllusionist: + aliases = append(aliases, "Illy", "Illusion") + case ClassConjuror: + aliases = append(aliases, "Conj", "Conjurer") + case ClassNecromancer: + aliases = append(aliases, "Necro", "Nec") + case ClassBeastlord: + aliases = append(aliases, "BL", "Beast Lord") + case ClassWeaponsmith: + aliases = append(aliases, "WS", "Weapon Smith") + } + + // Always include the official names + aliases = append(aliases, cu.classes.GetClassName(classID)) + aliases = append(aliases, cu.classes.GetClassNameCase(classID)) + + return aliases +} + +// GetClassStatistics returns statistics about the class system +func (cu *ClassUtils) GetClassStatistics() map[string]interface{} { + stats := make(map[string]interface{}) + + allClasses := cu.classes.GetAllClasses() + stats["total_classes"] = len(allClasses) + + adventureCount := 0 + tradeskillCount := 0 + specialCount := 0 + + for classID := range allClasses { + switch cu.classes.GetClassType(classID) { + case ClassTypeAdventure: + adventureCount++ + case ClassTypeTradeskill: + tradeskillCount++ + default: + specialCount++ + } + } + + stats["adventure_classes"] = adventureCount + stats["tradeskill_classes"] = tradeskillCount + stats["special_classes"] = specialCount + + // Base class distribution + baseClassDistribution := make(map[string][]string) + for classID, displayName := range allClasses { + if cu.classes.IsAdventureClass(classID) { + baseClassID := cu.classes.GetBaseClass(classID) + baseClassName := cu.classes.GetClassNameCase(baseClassID) + baseClassDistribution[baseClassName] = append(baseClassDistribution[baseClassName], displayName) + } + } + stats["base_class_distribution"] = baseClassDistribution + + return stats +} + +// FormatClassList returns a formatted string of class names +func (cu *ClassUtils) FormatClassList(classIDs []int8, separator string) string { + if len(classIDs) == 0 { + return "" + } + + names := make([]string, len(classIDs)) + for i, classID := range classIDs { + names[i] = cu.classes.GetClassNameCase(classID) + } + + return strings.Join(names, separator) +} + +// GetEQClassName returns the EQ-style class name for a given class and level +// This is a placeholder for the original C++ GetEQClassName functionality +func (cu *ClassUtils) GetEQClassName(classID int8, level int8) string { + // TODO: Implement level-based class names when level system is available + // For now, just return the display name + return cu.classes.GetClassNameCase(classID) +} + +// GetStartingClass returns the appropriate starting class for character creation +func (cu *ClassUtils) GetStartingClass() int8 { + return ClassCommoner +} + +// IsBaseClass checks if a class is a base class (Fighter, Priest, Mage, Scout) +func (cu *ClassUtils) IsBaseClass(classID int8) bool { + return classID == ClassFighter || classID == ClassPriest || classID == ClassMage || classID == ClassScout +} + +// IsSecondaryBaseClass checks if a class is a secondary base class +func (cu *ClassUtils) IsSecondaryBaseClass(classID int8) bool { + // Check if any class has this as their secondary base + allClasses := cu.classes.GetAllClasses() + for checkClassID := range allClasses { + if cu.classes.GetSecondaryBaseClass(checkClassID) == classID && checkClassID != classID { + return true + } + } + return false +} \ No newline at end of file diff --git a/internal/common/variables.go b/internal/common/variables.go new file mode 100644 index 0000000..6129733 --- /dev/null +++ b/internal/common/variables.go @@ -0,0 +1,261 @@ +package common + +import ( + "fmt" + "strings" + "sync" +) + +// Variable represents a configuration variable with name, value, and optional comment +type Variable struct { + name string + value string + comment string +} + +// NewVariable creates a new variable +func NewVariable(name, value, comment string) *Variable { + return &Variable{ + name: name, + value: value, + comment: comment, + } +} + +// GetName returns the variable name +func (v *Variable) GetName() string { + return v.name +} + +// GetValue returns the variable value +func (v *Variable) GetValue() string { + return v.value +} + +// GetComment returns the variable comment +func (v *Variable) GetComment() string { + return v.comment +} + +// GetNameValuePair returns the name and value as a single string +func (v *Variable) GetNameValuePair() string { + return fmt.Sprintf("%s %s", v.name, v.value) +} + +// SetValue updates the variable value +func (v *Variable) SetValue(value string) { + v.value = value +} + +// Variables manages a collection of configuration variables +type Variables struct { + variables map[string]*Variable + mutex sync.RWMutex +} + +// NewVariables creates a new variables manager +func NewVariables() *Variables { + return &Variables{ + variables: make(map[string]*Variable), + } +} + +// AddVariable adds a variable to the collection +func (v *Variables) AddVariable(variable *Variable) { + v.mutex.Lock() + defer v.mutex.Unlock() + + v.variables[variable.name] = variable +} + +// FindVariable finds a variable by exact name +func (v *Variables) FindVariable(name string) *Variable { + v.mutex.RLock() + defer v.mutex.RUnlock() + + return v.variables[name] +} + +// GetVariable is an alias for FindVariable for convenience +func (v *Variables) GetVariable(name string) *Variable { + return v.FindVariable(name) +} + +// GetVariables returns all variables that contain the partial name +func (v *Variables) GetVariables(partialName string) []*Variable { + v.mutex.RLock() + defer v.mutex.RUnlock() + + results := make([]*Variable, 0) + partialLower := strings.ToLower(partialName) + + for name, variable := range v.variables { + if strings.Contains(strings.ToLower(name), partialLower) { + results = append(results, variable) + } + } + + return results +} + +// GetAllVariables returns all variables in the collection +func (v *Variables) GetAllVariables() map[string]*Variable { + v.mutex.RLock() + defer v.mutex.RUnlock() + + // Return a copy to prevent external modification + result := make(map[string]*Variable) + for name, variable := range v.variables { + result[name] = variable + } + + return result +} + +// GetVariableNames returns all variable names +func (v *Variables) GetVariableNames() []string { + v.mutex.RLock() + defer v.mutex.RUnlock() + + names := make([]string, 0, len(v.variables)) + for name := range v.variables { + names = append(names, name) + } + + return names +} + +// SetVariable sets or updates a variable value +func (v *Variables) SetVariable(name, value string) { + v.mutex.Lock() + defer v.mutex.Unlock() + + if variable, exists := v.variables[name]; exists { + variable.SetValue(value) + } else { + // Create new variable if it doesn't exist + v.variables[name] = NewVariable(name, value, "") + } +} + +// DeleteVariable removes a variable by name +func (v *Variables) DeleteVariable(name string) bool { + v.mutex.Lock() + defer v.mutex.Unlock() + + if _, exists := v.variables[name]; exists { + delete(v.variables, name) + return true + } + + return false +} + +// ClearVariables removes all variables +func (v *Variables) ClearVariables() { + v.mutex.Lock() + defer v.mutex.Unlock() + + v.variables = make(map[string]*Variable) +} + +// Count returns the number of variables +func (v *Variables) Count() int { + v.mutex.RLock() + defer v.mutex.RUnlock() + + return len(v.variables) +} + +// HasVariable checks if a variable exists +func (v *Variables) HasVariable(name string) bool { + v.mutex.RLock() + defer v.mutex.RUnlock() + + _, exists := v.variables[name] + return exists +} + +// GetVariableValue returns just the value of a variable, or empty string if not found +func (v *Variables) GetVariableValue(name string) string { + if variable := v.FindVariable(name); variable != nil { + return variable.GetValue() + } + return "" +} + +// GetVariableValueWithDefault returns the value of a variable, or a default if not found +func (v *Variables) GetVariableValueWithDefault(name, defaultValue string) string { + if variable := v.FindVariable(name); variable != nil { + return variable.GetValue() + } + return defaultValue +} + +// GetVariableAsInt attempts to parse a variable value as an integer +func (v *Variables) GetVariableAsInt(name string, defaultValue int) int { + if variable := v.FindVariable(name); variable != nil { + var intValue int + if _, err := fmt.Sscanf(variable.GetValue(), "%d", &intValue); err == nil { + return intValue + } + } + return defaultValue +} + +// GetVariableAsFloat attempts to parse a variable value as a float +func (v *Variables) GetVariableAsFloat(name string, defaultValue float64) float64 { + if variable := v.FindVariable(name); variable != nil { + var floatValue float64 + if _, err := fmt.Sscanf(variable.GetValue(), "%f", &floatValue); err == nil { + return floatValue + } + } + return defaultValue +} + +// GetVariableAsBool attempts to parse a variable value as a boolean +func (v *Variables) GetVariableAsBool(name string, defaultValue bool) bool { + if variable := v.FindVariable(name); variable != nil { + value := strings.ToLower(variable.GetValue()) + switch value { + case "true", "1", "yes", "on", "enabled": + return true + case "false", "0", "no", "off", "disabled": + return false + } + } + return defaultValue +} + +// Clone creates a deep copy of the variables collection +func (v *Variables) Clone() *Variables { + v.mutex.RLock() + defer v.mutex.RUnlock() + + newVars := NewVariables() + for name, variable := range v.variables { + newVar := NewVariable(variable.name, variable.value, variable.comment) + newVars.variables[name] = newVar + } + + return newVars +} + +// Merge merges another Variables collection into this one +// If overwrite is true, existing variables will be overwritten +func (v *Variables) Merge(other *Variables, overwrite bool) { + if other == nil { + return + } + + v.mutex.Lock() + defer v.mutex.Unlock() + + otherVars := other.GetAllVariables() + for name, variable := range otherVars { + if _, exists := v.variables[name]; !exists || overwrite { + v.variables[name] = NewVariable(variable.name, variable.value, variable.comment) + } + } +} \ No newline at end of file diff --git a/internal/common/visual_states.go b/internal/common/visual_states.go new file mode 100644 index 0000000..0659bab --- /dev/null +++ b/internal/common/visual_states.go @@ -0,0 +1,378 @@ +package common + +import ( + "fmt" + "strings" + "sync" +) + +// VisualState represents a visual animation state +type VisualState struct { + id int + name string +} + +// NewVisualState creates a new visual state +func NewVisualState(id int, name string) *VisualState { + return &VisualState{ + id: id, + name: name, + } +} + +// GetID returns the visual state ID +func (vs *VisualState) GetID() int { + return vs.id +} + +// GetName returns the visual state name +func (vs *VisualState) GetName() string { + return vs.name +} + +// Emote represents an emote with visual state and messages +type Emote struct { + name string + visualState int32 + message string + targetedMessage string +} + +// NewEmote creates a new emote +func NewEmote(name string, visualState int32, message, targetedMessage string) *Emote { + return &Emote{ + name: name, + visualState: visualState, + message: message, + targetedMessage: targetedMessage, + } +} + +// GetName returns the emote name +func (e *Emote) GetName() string { + return e.name +} + +// GetVisualState returns the visual state ID +func (e *Emote) GetVisualState() int32 { + return e.visualState +} + +// GetMessage returns the emote message +func (e *Emote) GetMessage() string { + return e.message +} + +// GetTargetedMessage returns the targeted emote message +func (e *Emote) GetTargetedMessage() string { + return e.targetedMessage +} + +// VersionRange represents a min/max version range +type VersionRange struct { + minVersion int32 + maxVersion int32 +} + +// NewVersionRange creates a new version range +func NewVersionRange(min, max int32) *VersionRange { + return &VersionRange{ + minVersion: min, + maxVersion: max, + } +} + +// GetMinVersion returns the minimum version +func (vr *VersionRange) GetMinVersion() int32 { + return vr.minVersion +} + +// GetMaxVersion returns the maximum version +func (vr *VersionRange) GetMaxVersion() int32 { + return vr.maxVersion +} + +// InRange checks if a version is within this range +func (vr *VersionRange) InRange(version int32) bool { + return version >= vr.minVersion && (vr.maxVersion == 0 || version <= vr.maxVersion) +} + +// EmoteVersionRange manages emotes across different client versions +type EmoteVersionRange struct { + name string + versionMap map[*VersionRange]*Emote + mutex sync.RWMutex +} + +// NewEmoteVersionRange creates a new emote version range +func NewEmoteVersionRange(name string) *EmoteVersionRange { + return &EmoteVersionRange{ + name: name, + versionMap: make(map[*VersionRange]*Emote), + } +} + +// GetName returns the emote range name +func (evr *EmoteVersionRange) GetName() string { + return evr.name +} + +// AddVersionRange adds an emote for a specific version range +func (evr *EmoteVersionRange) AddVersionRange(minVersion, maxVersion int32, name string, visualState int32, message, targetedMessage string) error { + evr.mutex.Lock() + defer evr.mutex.Unlock() + + // Check for duplicate ranges + for vr := range evr.versionMap { + if evr.rangesOverlap(vr, minVersion, maxVersion) { + return fmt.Errorf("duplicate emote mapping of %s with range min %d max %d, existing found with range min %d max %d", + evr.name, minVersion, maxVersion, vr.minVersion, vr.maxVersion) + } + } + + vr := NewVersionRange(minVersion, maxVersion) + emote := NewEmote(name, visualState, message, targetedMessage) + evr.versionMap[vr] = emote + + return nil +} + +// rangesOverlap checks if two version ranges overlap +func (evr *EmoteVersionRange) rangesOverlap(existing *VersionRange, minVersion, maxVersion int32) bool { + // Check various overlap conditions + if existing.minVersion <= minVersion && maxVersion <= existing.maxVersion { + return true + } + if existing.minVersion <= minVersion && existing.maxVersion == 0 { + return true + } + if existing.minVersion == 0 && maxVersion <= existing.maxVersion { + return true + } + return false +} + +// FindEmoteByVersion finds the emote for a specific client version +func (evr *EmoteVersionRange) FindEmoteByVersion(version int32) *Emote { + evr.mutex.RLock() + defer evr.mutex.RUnlock() + + for vr, emote := range evr.versionMap { + if vr.InRange(version) { + return emote + } + } + + return nil +} + +// VisualStates manages all visual states, emotes, and spell visuals +type VisualStates struct { + visualStateMap map[string]*VisualState + emoteMap map[string]*EmoteVersionRange + emoteMapID map[int32]*EmoteVersionRange + spellMap map[string]*EmoteVersionRange + spellMapID map[int32]*EmoteVersionRange + mutex sync.RWMutex +} + +// NewVisualStates creates a new visual states manager +func NewVisualStates() *VisualStates { + return &VisualStates{ + visualStateMap: make(map[string]*VisualState), + emoteMap: make(map[string]*EmoteVersionRange), + emoteMapID: make(map[int32]*EmoteVersionRange), + spellMap: make(map[string]*EmoteVersionRange), + spellMapID: make(map[int32]*EmoteVersionRange), + } +} + +// InsertVisualState adds a visual state +func (vs *VisualStates) InsertVisualState(state *VisualState) { + vs.mutex.Lock() + defer vs.mutex.Unlock() + + vs.visualStateMap[state.name] = state +} + +// FindVisualState finds a visual state by name +func (vs *VisualStates) FindVisualState(name string) *VisualState { + vs.mutex.RLock() + defer vs.mutex.RUnlock() + + return vs.visualStateMap[name] +} + +// InsertEmoteRange adds an emote range +func (vs *VisualStates) InsertEmoteRange(emote *EmoteVersionRange, animationID int32) { + vs.mutex.Lock() + defer vs.mutex.Unlock() + + vs.emoteMap[emote.name] = emote + vs.emoteMapID[animationID] = emote +} + +// FindEmoteRange finds an emote range by name +func (vs *VisualStates) FindEmoteRange(name string) *EmoteVersionRange { + vs.mutex.RLock() + defer vs.mutex.RUnlock() + + return vs.emoteMap[name] +} + +// FindEmote finds an emote by name and version +func (vs *VisualStates) FindEmote(name string, version int32) *Emote { + vs.mutex.RLock() + defer vs.mutex.RUnlock() + + if emoteRange, exists := vs.emoteMap[name]; exists { + return emoteRange.FindEmoteByVersion(version) + } + + return nil +} + +// FindEmoteRangeByID finds an emote range by ID +func (vs *VisualStates) FindEmoteRangeByID(id int32) *EmoteVersionRange { + vs.mutex.RLock() + defer vs.mutex.RUnlock() + + return vs.emoteMapID[id] +} + +// FindEmoteByID finds an emote by visual ID and version +func (vs *VisualStates) FindEmoteByID(visualID int32, version int32) *Emote { + vs.mutex.RLock() + defer vs.mutex.RUnlock() + + if emoteRange, exists := vs.emoteMapID[visualID]; exists { + return emoteRange.FindEmoteByVersion(version) + } + + return nil +} + +// InsertSpellVisualRange adds a spell visual range +func (vs *VisualStates) InsertSpellVisualRange(emote *EmoteVersionRange, spellVisualID int32) { + vs.mutex.Lock() + defer vs.mutex.Unlock() + + vs.spellMap[emote.name] = emote + vs.spellMapID[spellVisualID] = emote +} + +// FindSpellVisualRange finds a spell visual range by name +func (vs *VisualStates) FindSpellVisualRange(name string) *EmoteVersionRange { + vs.mutex.RLock() + defer vs.mutex.RUnlock() + + return vs.spellMap[name] +} + +// FindSpellVisualRangeByID finds a spell visual range by ID +func (vs *VisualStates) FindSpellVisualRangeByID(id int32) *EmoteVersionRange { + vs.mutex.RLock() + defer vs.mutex.RUnlock() + + return vs.spellMapID[id] +} + +// FindSpellVisual finds a spell visual by name and version +func (vs *VisualStates) FindSpellVisual(name string, version int32) *Emote { + vs.mutex.RLock() + defer vs.mutex.RUnlock() + + if spellRange, exists := vs.spellMap[name]; exists { + return spellRange.FindEmoteByVersion(version) + } + + return nil +} + +// FindSpellVisualByID finds a spell visual by ID and version +func (vs *VisualStates) FindSpellVisualByID(visualID int32, version int32) *Emote { + vs.mutex.RLock() + defer vs.mutex.RUnlock() + + if spellRange, exists := vs.spellMapID[visualID]; exists { + return spellRange.FindEmoteByVersion(version) + } + + return nil +} + +// ClearVisualStates clears all visual states +func (vs *VisualStates) ClearVisualStates() { + vs.mutex.Lock() + defer vs.mutex.Unlock() + + vs.visualStateMap = make(map[string]*VisualState) +} + +// ClearEmotes clears all emotes +func (vs *VisualStates) ClearEmotes() { + vs.mutex.Lock() + defer vs.mutex.Unlock() + + vs.emoteMap = make(map[string]*EmoteVersionRange) + vs.emoteMapID = make(map[int32]*EmoteVersionRange) +} + +// ClearSpellVisuals clears all spell visuals +func (vs *VisualStates) ClearSpellVisuals() { + vs.mutex.Lock() + defer vs.mutex.Unlock() + + vs.spellMap = make(map[string]*EmoteVersionRange) + vs.spellMapID = make(map[int32]*EmoteVersionRange) +} + +// Reset clears all data +func (vs *VisualStates) Reset() { + vs.ClearVisualStates() + vs.ClearEmotes() + vs.ClearSpellVisuals() +} + +// GetEmoteList returns a list of all emote names +func (vs *VisualStates) GetEmoteList() []string { + vs.mutex.RLock() + defer vs.mutex.RUnlock() + + names := make([]string, 0, len(vs.emoteMap)) + for name := range vs.emoteMap { + names = append(names, name) + } + + return names +} + +// GetVisualStateList returns a list of all visual state names +func (vs *VisualStates) GetVisualStateList() []string { + vs.mutex.RLock() + defer vs.mutex.RUnlock() + + names := make([]string, 0, len(vs.visualStateMap)) + for name := range vs.visualStateMap { + names = append(names, name) + } + + return names +} + +// FindEmoteByPartialName finds emotes that contain the partial name +func (vs *VisualStates) FindEmoteByPartialName(partial string) []*EmoteVersionRange { + vs.mutex.RLock() + defer vs.mutex.RUnlock() + + partial = strings.ToLower(partial) + results := make([]*EmoteVersionRange, 0) + + for name, emote := range vs.emoteMap { + if strings.Contains(strings.ToLower(name), partial) { + results = append(results, emote) + } + } + + return results +} \ No newline at end of file diff --git a/internal/entity/entity.go b/internal/entity/entity.go index 990ddce..161d368 100644 --- a/internal/entity/entity.go +++ b/internal/entity/entity.go @@ -659,6 +659,23 @@ func (e *Entity) ProcessEffects() { // TODO: Handle effect-based stat changes } +// Class system integration adapters + +// GetClass returns the entity's primary class (ClassAware interface compatibility) +func (e *Entity) GetClass() int8 { + return e.infoStruct.GetClass1() +} + +// SetClass sets the entity's primary class (ClassAware interface compatibility) +func (e *Entity) SetClass(classID int8) { + e.infoStruct.SetClass1(classID) +} + +// GetLevel returns the entity's level (EntityWithClass interface compatibility) +func (e *Entity) GetLevel() int8 { + return int8(e.infoStruct.GetLevel()) +} + // TODO: Additional methods to implement: // - Combat calculation methods (damage, healing, etc.) // - Equipment bonus application methods diff --git a/internal/factions/constants.go b/internal/factions/constants.go new file mode 100644 index 0000000..a2532c5 --- /dev/null +++ b/internal/factions/constants.go @@ -0,0 +1,46 @@ +package factions + +// Faction value constants +const ( + // Maximum and minimum faction values + MaxFactionValue = 50000 + MinFactionValue = -50000 + + // Special faction ID ranges + SpecialFactionIDMax = 10 // Faction IDs <= 10 are special (not real factions) + + // Faction consideration (con) ranges + MinCon = -4 // Hostile + MaxCon = 4 // Ally + + // Con value thresholds + ConNeutralMin = -9999 + ConNeutralMax = 9999 + ConAllyMin = 40000 + ConHostileMax = -40000 + + // Con calculation multiplier + ConMultiplier = 10000 + ConRemainder = 9999 + + // Percentage calculation constants + PercentMultiplier = 100 + PercentNeutralOffset = 10000 + PercentNeutralDivisor = 20000 +) + +// Attack threshold - factions with con <= this value should attack +const AttackThreshold = -4 + +// Default faction consideration values +const ( + ConKOS = -4 // Kill on sight + ConThreat = -3 // Threatening + ConDubious = -2 // Dubiously + ConAppre = -1 // Apprehensive + ConIndiff = 0 // Indifferent + ConAmiable = 1 // Amiable + ConKindly = 2 // Kindly + ConWarmly = 3 // Warmly + ConAlly = 4 // Ally +) \ No newline at end of file diff --git a/internal/factions/interfaces.go b/internal/factions/interfaces.go new file mode 100644 index 0000000..14889c3 --- /dev/null +++ b/internal/factions/interfaces.go @@ -0,0 +1,371 @@ +package factions + +import ( + "fmt" + "sync" +) + +// Database interface for faction persistence +type Database interface { + LoadAllFactions() ([]*Faction, error) + SaveFaction(faction *Faction) error + DeleteFaction(factionID int32) error + LoadHostileFactionRelations() ([]*FactionRelation, error) + LoadFriendlyFactionRelations() ([]*FactionRelation, error) + SaveFactionRelation(relation *FactionRelation) error + DeleteFactionRelation(factionID, relatedFactionID int32, isHostile bool) error +} + +// Logger interface for faction logging +type Logger interface { + LogInfo(message string, args ...interface{}) + LogError(message string, args ...interface{}) + LogDebug(message string, args ...interface{}) + LogWarning(message string, args ...interface{}) +} + +// FactionRelation represents a relationship between two factions +type FactionRelation struct { + FactionID int32 // Primary faction ID + HostileFactionID int32 // Hostile faction ID (if this is a hostile relation) + FriendlyFactionID int32 // Friendly faction ID (if this is a friendly relation) +} + +// Client interface for faction-related client operations +type Client interface { + GetVersion() int16 + SendFactionUpdate(factionData []byte) error + GetCharacterID() int32 +} + +// Player interface for faction-related player operations +type Player interface { + GetFactionSystem() *PlayerFaction + GetCharacterID() int32 + SendMessage(message string) +} + +// FactionAware interface for entities that interact with factions +type FactionAware interface { + GetFactionID() int32 + SetFactionID(factionID int32) + GetFactionStanding(playerFaction *PlayerFaction) int8 + ShouldAttackPlayer(playerFaction *PlayerFaction) bool +} + +// FactionProvider interface for systems that provide faction information +type FactionProvider interface { + GetMasterFactionList() *MasterFactionList + GetFaction(factionID int32) *Faction + GetFactionByName(name string) *Faction + CreatePlayerFaction() *PlayerFaction +} + +// EntityFactionAdapter provides faction functionality for entities +type EntityFactionAdapter struct { + entity Entity + factionID int32 + manager *Manager + logger Logger + mutex sync.RWMutex +} + +// Entity interface for things that can have faction affiliations +type Entity interface { + GetID() int32 + GetName() string + GetDatabaseID() int32 +} + +// NewEntityFactionAdapter creates a new entity faction adapter +func NewEntityFactionAdapter(entity Entity, manager *Manager, logger Logger) *EntityFactionAdapter { + return &EntityFactionAdapter{ + entity: entity, + factionID: 0, + manager: manager, + logger: logger, + } +} + +// GetFactionID returns the entity's faction ID +func (efa *EntityFactionAdapter) GetFactionID() int32 { + efa.mutex.RLock() + defer efa.mutex.RUnlock() + + return efa.factionID +} + +// SetFactionID sets the entity's faction ID +func (efa *EntityFactionAdapter) SetFactionID(factionID int32) { + efa.mutex.Lock() + defer efa.mutex.Unlock() + + efa.factionID = factionID + + if efa.logger != nil { + efa.logger.LogDebug("Entity %d (%s): Set faction ID to %d", + efa.entity.GetID(), efa.entity.GetName(), factionID) + } +} + +// GetFaction returns the entity's faction object +func (efa *EntityFactionAdapter) GetFaction() *Faction { + factionID := efa.GetFactionID() + if factionID == 0 { + return nil + } + + if efa.manager == nil { + if efa.logger != nil { + efa.logger.LogError("Entity %d (%s): No faction manager available", + efa.entity.GetID(), efa.entity.GetName()) + } + return nil + } + + return efa.manager.GetFaction(factionID) +} + +// GetFactionStanding returns the consideration level with a player +func (efa *EntityFactionAdapter) GetFactionStanding(playerFaction *PlayerFaction) int8 { + factionID := efa.GetFactionID() + if factionID == 0 || playerFaction == nil { + return ConIndiff // Indifferent if no faction or player faction + } + + return playerFaction.GetCon(factionID) +} + +// ShouldAttackPlayer returns true if the entity should attack the player based on faction +func (efa *EntityFactionAdapter) ShouldAttackPlayer(playerFaction *PlayerFaction) bool { + factionID := efa.GetFactionID() + if factionID == 0 || playerFaction == nil { + return false // Don't attack if no faction + } + + return playerFaction.ShouldAttack(factionID) +} + +// GetFactionName returns the name of the entity's faction +func (efa *EntityFactionAdapter) GetFactionName() string { + faction := efa.GetFaction() + if faction == nil { + return "" + } + + return faction.Name +} + +// IsHostileToFaction returns true if this entity's faction is hostile to another faction +func (efa *EntityFactionAdapter) IsHostileToFaction(otherFactionID int32) bool { + factionID := efa.GetFactionID() + if factionID == 0 || efa.manager == nil { + return false + } + + hostileFactions := efa.manager.GetMasterFactionList().GetHostileFactions(factionID) + + for _, hostileID := range hostileFactions { + if hostileID == otherFactionID { + return true + } + } + + return false +} + +// IsFriendlyToFaction returns true if this entity's faction is friendly to another faction +func (efa *EntityFactionAdapter) IsFriendlyToFaction(otherFactionID int32) bool { + factionID := efa.GetFactionID() + if factionID == 0 || efa.manager == nil { + return false + } + + friendlyFactions := efa.manager.GetMasterFactionList().GetFriendlyFactions(factionID) + + for _, friendlyID := range friendlyFactions { + if friendlyID == otherFactionID { + return true + } + } + + return false +} + +// ValidateFaction validates that the entity's faction exists and is valid +func (efa *EntityFactionAdapter) ValidateFaction() error { + factionID := efa.GetFactionID() + if factionID == 0 { + return nil // No faction is valid + } + + faction := efa.GetFaction() + if faction == nil { + return fmt.Errorf("faction ID %d not found", factionID) + } + + if !faction.IsValid() { + return fmt.Errorf("faction ID %d is invalid", factionID) + } + + return nil +} + +// PlayerFactionManager handles faction interactions for a player +type PlayerFactionManager struct { + playerFaction *PlayerFaction + manager *Manager + player Player + logger Logger + mutex sync.RWMutex +} + +// NewPlayerFactionManager creates a new player faction manager +func NewPlayerFactionManager(player Player, manager *Manager, logger Logger) *PlayerFactionManager { + return &PlayerFactionManager{ + playerFaction: manager.CreatePlayerFaction(), + manager: manager, + player: player, + logger: logger, + } +} + +// GetPlayerFaction returns the player's faction system +func (pfm *PlayerFactionManager) GetPlayerFaction() *PlayerFaction { + return pfm.playerFaction +} + +// IncreaseFaction increases a faction and records statistics +func (pfm *PlayerFactionManager) IncreaseFaction(factionID int32, amount int32) bool { + result := pfm.playerFaction.IncreaseFaction(factionID, amount) + + if result { + pfm.manager.RecordFactionIncrease(factionID) + + if pfm.logger != nil { + pfm.logger.LogDebug("Player %d: Increased faction %d by %d", + pfm.player.GetCharacterID(), factionID, amount) + } + } + + return result +} + +// DecreaseFaction decreases a faction and records statistics +func (pfm *PlayerFactionManager) DecreaseFaction(factionID int32, amount int32) bool { + result := pfm.playerFaction.DecreaseFaction(factionID, amount) + + if result { + pfm.manager.RecordFactionDecrease(factionID) + + if pfm.logger != nil { + pfm.logger.LogDebug("Player %d: Decreased faction %d by %d", + pfm.player.GetCharacterID(), factionID, amount) + } + } + + return result +} + +// SetFactionValue sets a faction to a specific value +func (pfm *PlayerFactionManager) SetFactionValue(factionID int32, value int32) bool { + result := pfm.playerFaction.SetFactionValue(factionID, value) + + if pfm.logger != nil { + pfm.logger.LogDebug("Player %d: Set faction %d to %d", + pfm.player.GetCharacterID(), factionID, value) + } + + return result +} + +// SendFactionUpdates sends pending faction updates to the client +func (pfm *PlayerFactionManager) SendFactionUpdates(client Client) error { + if client == nil { + return fmt.Errorf("client is nil") + } + + if !pfm.playerFaction.HasPendingUpdates() { + return nil // No updates needed + } + + packet, err := pfm.playerFaction.FactionUpdate(client.GetVersion()) + if err != nil { + return fmt.Errorf("failed to build faction update packet: %w", err) + } + + if packet != nil { + if err := client.SendFactionUpdate(packet); err != nil { + return fmt.Errorf("failed to send faction update: %w", err) + } + + if pfm.logger != nil { + pfm.logger.LogDebug("Player %d: Sent faction updates to client", + pfm.player.GetCharacterID()) + } + } + + return nil +} + +// GetFactionStanding returns the player's standing with a faction +func (pfm *PlayerFactionManager) GetFactionStanding(factionID int32) int8 { + return pfm.playerFaction.GetCon(factionID) +} + +// GetFactionValue returns the player's value with a faction +func (pfm *PlayerFactionManager) GetFactionValue(factionID int32) int32 { + return pfm.playerFaction.GetFactionValue(factionID) +} + +// ShouldAttackFaction returns true if the player should attack entities of a faction +func (pfm *PlayerFactionManager) ShouldAttackFaction(factionID int32) bool { + return pfm.playerFaction.ShouldAttack(factionID) +} + +// LoadPlayerFactions loads faction data from database +func (pfm *PlayerFactionManager) LoadPlayerFactions(database Database) error { + if database == nil { + return fmt.Errorf("database is nil") + } + + // TODO: Implement database loading when database system is integrated + // factionData, err := database.LoadPlayerFactions(pfm.player.GetCharacterID()) + // if err != nil { + // return fmt.Errorf("failed to load player factions: %w", err) + // } + // + // for factionID, value := range factionData { + // pfm.playerFaction.SetFactionValue(factionID, value) + // } + + if pfm.logger != nil { + pfm.logger.LogInfo("Player %d: Loaded faction data from database", + pfm.player.GetCharacterID()) + } + + return nil +} + +// SavePlayerFactions saves faction data to database +func (pfm *PlayerFactionManager) SavePlayerFactions(database Database) error { + if database == nil { + return fmt.Errorf("database is nil") + } + + factionValues := pfm.playerFaction.GetFactionValues() + + // TODO: Implement database saving when database system is integrated + // for factionID, value := range factionValues { + // if err := database.SavePlayerFaction(pfm.player.GetCharacterID(), factionID, value); err != nil { + // return fmt.Errorf("failed to save faction %d: %w", factionID, err) + // } + // } + + if pfm.logger != nil { + pfm.logger.LogInfo("Player %d: Saved %d faction values to database", + pfm.player.GetCharacterID(), len(factionValues)) + } + + return nil +} \ No newline at end of file diff --git a/internal/factions/manager.go b/internal/factions/manager.go new file mode 100644 index 0000000..34d50c8 --- /dev/null +++ b/internal/factions/manager.go @@ -0,0 +1,488 @@ +package factions + +import ( + "fmt" + "sync" +) + +// Manager provides high-level management of the faction system +type Manager struct { + masterFactionList *MasterFactionList + database Database + logger Logger + mutex sync.RWMutex + + // Statistics + totalFactionChanges int64 + factionIncreases int64 + factionDecreases int64 + factionLookups int64 + playersWithFactions int64 + changesByFaction map[int32]int64 // Faction ID -> total changes +} + +// NewManager creates a new faction manager +func NewManager(database Database, logger Logger) *Manager { + return &Manager{ + masterFactionList: NewMasterFactionList(), + database: database, + logger: logger, + changesByFaction: make(map[int32]int64), + } +} + +// Initialize loads factions from database +func (m *Manager) Initialize() error { + if m.logger != nil { + m.logger.LogInfo("Initializing faction manager...") + } + + if m.database == nil { + if m.logger != nil { + m.logger.LogWarning("No database provided, starting with empty faction list") + } + return nil + } + + // Load factions + factions, err := m.database.LoadAllFactions() + if err != nil { + return fmt.Errorf("failed to load factions from database: %w", err) + } + + for _, faction := range factions { + if err := m.masterFactionList.AddFaction(faction); err != nil { + if m.logger != nil { + m.logger.LogError("Failed to add faction %d (%s): %v", faction.ID, faction.Name, err) + } + } + } + + // Load faction relationships + if err := m.loadFactionRelationships(); err != nil { + if m.logger != nil { + m.logger.LogWarning("Failed to load faction relationships: %v", err) + } + } + + if m.logger != nil { + m.logger.LogInfo("Loaded %d factions from database", len(factions)) + } + + return nil +} + +// loadFactionRelationships loads hostile and friendly faction relationships +func (m *Manager) loadFactionRelationships() error { + if m.database == nil { + return nil + } + + // Load hostile relationships + hostileRelations, err := m.database.LoadHostileFactionRelations() + if err != nil { + return fmt.Errorf("failed to load hostile faction relations: %w", err) + } + + for _, relation := range hostileRelations { + m.masterFactionList.AddHostileFaction(relation.FactionID, relation.HostileFactionID) + } + + // Load friendly relationships + friendlyRelations, err := m.database.LoadFriendlyFactionRelations() + if err != nil { + return fmt.Errorf("failed to load friendly faction relations: %w", err) + } + + for _, relation := range friendlyRelations { + m.masterFactionList.AddFriendlyFaction(relation.FactionID, relation.FriendlyFactionID) + } + + if m.logger != nil { + m.logger.LogInfo("Loaded %d hostile and %d friendly faction relationships", + len(hostileRelations), len(friendlyRelations)) + } + + return nil +} + +// GetMasterFactionList returns the master faction list +func (m *Manager) GetMasterFactionList() *MasterFactionList { + return m.masterFactionList +} + +// CreatePlayerFaction creates a new player faction system +func (m *Manager) CreatePlayerFaction() *PlayerFaction { + m.mutex.Lock() + m.playersWithFactions++ + m.mutex.Unlock() + + return NewPlayerFaction(m.masterFactionList) +} + +// GetFaction returns a faction by ID +func (m *Manager) GetFaction(factionID int32) *Faction { + m.mutex.Lock() + m.factionLookups++ + m.mutex.Unlock() + + return m.masterFactionList.GetFaction(factionID) +} + +// GetFactionByName returns a faction by name +func (m *Manager) GetFactionByName(name string) *Faction { + m.mutex.Lock() + m.factionLookups++ + m.mutex.Unlock() + + return m.masterFactionList.GetFactionByName(name) +} + +// AddFaction adds a new faction +func (m *Manager) AddFaction(faction *Faction) error { + if faction == nil { + return fmt.Errorf("faction cannot be nil") + } + + // Add to master list + if err := m.masterFactionList.AddFaction(faction); err != nil { + return fmt.Errorf("failed to add faction to master list: %w", err) + } + + // Save to database if available + if m.database != nil { + if err := m.database.SaveFaction(faction); err != nil { + // Remove from master list if database save failed + m.masterFactionList.RemoveFaction(faction.ID) + return fmt.Errorf("failed to save faction to database: %w", err) + } + } + + if m.logger != nil { + m.logger.LogInfo("Added faction %d: %s (%s)", faction.ID, faction.Name, faction.Type) + } + + return nil +} + +// UpdateFaction updates an existing faction +func (m *Manager) UpdateFaction(faction *Faction) error { + if faction == nil { + return fmt.Errorf("faction cannot be nil") + } + + // Update in master list + if err := m.masterFactionList.UpdateFaction(faction); err != nil { + return fmt.Errorf("failed to update faction in master list: %w", err) + } + + // Save to database if available + if m.database != nil { + if err := m.database.SaveFaction(faction); err != nil { + return fmt.Errorf("failed to save faction to database: %w", err) + } + } + + if m.logger != nil { + m.logger.LogInfo("Updated faction %d: %s", faction.ID, faction.Name) + } + + return nil +} + +// RemoveFaction removes a faction +func (m *Manager) RemoveFaction(factionID int32) error { + // Check if faction exists + if !m.masterFactionList.HasFaction(factionID) { + return fmt.Errorf("faction with ID %d does not exist", factionID) + } + + // Remove from database first if available + if m.database != nil { + if err := m.database.DeleteFaction(factionID); err != nil { + return fmt.Errorf("failed to delete faction from database: %w", err) + } + } + + // Remove from master list + if !m.masterFactionList.RemoveFaction(factionID) { + return fmt.Errorf("failed to remove faction from master list") + } + + if m.logger != nil { + m.logger.LogInfo("Removed faction %d", factionID) + } + + return nil +} + +// RecordFactionIncrease records a faction increase for statistics +func (m *Manager) RecordFactionIncrease(factionID int32) { + m.mutex.Lock() + defer m.mutex.Unlock() + + m.totalFactionChanges++ + m.factionIncreases++ + m.changesByFaction[factionID]++ +} + +// RecordFactionDecrease records a faction decrease for statistics +func (m *Manager) RecordFactionDecrease(factionID int32) { + m.mutex.Lock() + defer m.mutex.Unlock() + + m.totalFactionChanges++ + m.factionDecreases++ + m.changesByFaction[factionID]++ +} + +// GetStatistics returns faction system statistics +func (m *Manager) GetStatistics() map[string]interface{} { + m.mutex.RLock() + defer m.mutex.RUnlock() + + stats := make(map[string]interface{}) + stats["total_factions"] = m.masterFactionList.GetFactionCount() + stats["total_faction_changes"] = m.totalFactionChanges + stats["faction_increases"] = m.factionIncreases + stats["faction_decreases"] = m.factionDecreases + stats["faction_lookups"] = m.factionLookups + stats["players_with_factions"] = m.playersWithFactions + + // Copy changes by faction + changeStats := make(map[int32]int64) + for factionID, count := range m.changesByFaction { + changeStats[factionID] = count + } + stats["changes_by_faction"] = changeStats + + return stats +} + +// ResetStatistics resets all statistics +func (m *Manager) ResetStatistics() { + m.mutex.Lock() + defer m.mutex.Unlock() + + m.totalFactionChanges = 0 + m.factionIncreases = 0 + m.factionDecreases = 0 + m.factionLookups = 0 + m.playersWithFactions = 0 + m.changesByFaction = make(map[int32]int64) +} + +// ValidateAllFactions validates all factions in the system +func (m *Manager) ValidateAllFactions() []string { + return m.masterFactionList.ValidateFactions() +} + +// ReloadFromDatabase reloads all factions from database +func (m *Manager) ReloadFromDatabase() error { + if m.database == nil { + return fmt.Errorf("no database available") + } + + // Clear current factions + m.masterFactionList.Clear() + + // Reload from database + return m.Initialize() +} + +// GetFactionCount returns the total number of factions +func (m *Manager) GetFactionCount() int32 { + return m.masterFactionList.GetFactionCount() +} + +// ProcessCommand handles faction-related commands +func (m *Manager) ProcessCommand(command string, args []string) (string, error) { + switch command { + case "stats": + return m.handleStatsCommand(args) + case "validate": + return m.handleValidateCommand(args) + case "list": + return m.handleListCommand(args) + case "info": + return m.handleInfoCommand(args) + case "reload": + return m.handleReloadCommand(args) + case "search": + return m.handleSearchCommand(args) + default: + return "", fmt.Errorf("unknown faction command: %s", command) + } +} + +// handleStatsCommand shows faction system statistics +func (m *Manager) handleStatsCommand(args []string) (string, error) { + stats := m.GetStatistics() + + result := "Faction System Statistics:\n" + result += fmt.Sprintf("Total Factions: %d\n", stats["total_factions"]) + result += fmt.Sprintf("Total Faction Changes: %d\n", stats["total_faction_changes"]) + result += fmt.Sprintf("Faction Increases: %d\n", stats["faction_increases"]) + result += fmt.Sprintf("Faction Decreases: %d\n", stats["faction_decreases"]) + result += fmt.Sprintf("Faction Lookups: %d\n", stats["faction_lookups"]) + result += fmt.Sprintf("Players with Factions: %d\n", stats["players_with_factions"]) + + return result, nil +} + +// handleValidateCommand validates all factions +func (m *Manager) handleValidateCommand(args []string) (string, error) { + issues := m.ValidateAllFactions() + + if len(issues) == 0 { + return "All factions are valid.", nil + } + + result := fmt.Sprintf("Found %d issues with factions:\n", len(issues)) + for i, issue := range issues { + if i >= 10 { // Limit output + result += "... (and more)\n" + break + } + result += fmt.Sprintf("%d. %s\n", i+1, issue) + } + + return result, nil +} + +// handleListCommand lists factions +func (m *Manager) handleListCommand(args []string) (string, error) { + factions := m.masterFactionList.GetAllFactions() + + if len(factions) == 0 { + return "No factions loaded.", nil + } + + result := fmt.Sprintf("Factions (%d):\n", len(factions)) + count := 0 + for _, faction := range factions { + if count >= 20 { // Limit output + result += "... (and more)\n" + break + } + result += fmt.Sprintf(" %d: %s (%s)\n", faction.ID, faction.Name, faction.Type) + count++ + } + + return result, nil +} + +// handleInfoCommand shows information about a specific faction +func (m *Manager) handleInfoCommand(args []string) (string, error) { + if len(args) == 0 { + return "", fmt.Errorf("faction ID or name required") + } + + var faction *Faction + + // Try to parse as ID first + var factionID int32 + if _, err := fmt.Sscanf(args[0], "%d", &factionID); err == nil { + faction = m.GetFaction(factionID) + } else { + // Try as name + faction = m.GetFactionByName(args[0]) + } + + if faction == nil { + return fmt.Sprintf("Faction '%s' not found.", args[0]), nil + } + + result := fmt.Sprintf("Faction Information:\n") + result += fmt.Sprintf("ID: %d\n", faction.ID) + result += fmt.Sprintf("Name: %s\n", faction.Name) + result += fmt.Sprintf("Type: %s\n", faction.Type) + result += fmt.Sprintf("Description: %s\n", faction.Description) + result += fmt.Sprintf("Default Value: %d\n", faction.DefaultValue) + result += fmt.Sprintf("Positive Change: %d\n", faction.PositiveChange) + result += fmt.Sprintf("Negative Change: %d\n", faction.NegativeChange) + + // Show relationships if any + hostiles := m.masterFactionList.GetHostileFactions(faction.ID) + if len(hostiles) > 0 { + result += fmt.Sprintf("Hostile Factions: %v\n", hostiles) + } + + friendlies := m.masterFactionList.GetFriendlyFactions(faction.ID) + if len(friendlies) > 0 { + result += fmt.Sprintf("Friendly Factions: %v\n", friendlies) + } + + return result, nil +} + +// handleReloadCommand reloads factions from database +func (m *Manager) handleReloadCommand(args []string) (string, error) { + if err := m.ReloadFromDatabase(); err != nil { + return "", fmt.Errorf("failed to reload factions: %w", err) + } + + count := m.GetFactionCount() + return fmt.Sprintf("Successfully reloaded %d factions from database.", count), nil +} + +// handleSearchCommand searches for factions by name or type +func (m *Manager) handleSearchCommand(args []string) (string, error) { + if len(args) == 0 { + return "", fmt.Errorf("search term required") + } + + searchTerm := args[0] + factions := m.masterFactionList.GetAllFactions() + var results []*Faction + + // Search by name or type + for _, faction := range factions { + if contains(faction.Name, searchTerm) || contains(faction.Type, searchTerm) { + results = append(results, faction) + } + } + + if len(results) == 0 { + return fmt.Sprintf("No factions found matching '%s'.", searchTerm), nil + } + + result := fmt.Sprintf("Found %d factions matching '%s':\n", len(results), searchTerm) + for i, faction := range results { + if i >= 20 { // Limit output + result += "... (and more)\n" + break + } + result += fmt.Sprintf(" %d: %s (%s)\n", faction.ID, faction.Name, faction.Type) + } + + return result, nil +} + +// Shutdown gracefully shuts down the manager +func (m *Manager) Shutdown() { + if m.logger != nil { + m.logger.LogInfo("Shutting down faction manager...") + } + + // Clear factions + m.masterFactionList.Clear() +} + +// contains checks if a string contains a substring (case-sensitive) +func contains(str, substr string) bool { + if len(substr) == 0 { + return true + } + if len(str) < len(substr) { + return false + } + + for i := 0; i <= len(str)-len(substr); i++ { + if str[i:i+len(substr)] == substr { + return true + } + } + + return false +} \ No newline at end of file diff --git a/internal/factions/master_faction_list.go b/internal/factions/master_faction_list.go new file mode 100644 index 0000000..b026772 --- /dev/null +++ b/internal/factions/master_faction_list.go @@ -0,0 +1,387 @@ +package factions + +import ( + "fmt" + "sync" +) + +// MasterFactionList manages all factions in the game +type MasterFactionList struct { + globalFactionList map[int32]*Faction // Factions by ID + factionNameList map[string]*Faction // Factions by name + hostileFactions map[int32][]int32 // Hostile faction relationships + friendlyFactions map[int32][]int32 // Friendly faction relationships + mutex sync.RWMutex // Thread safety +} + +// NewMasterFactionList creates a new master faction list +func NewMasterFactionList() *MasterFactionList { + return &MasterFactionList{ + globalFactionList: make(map[int32]*Faction), + factionNameList: make(map[string]*Faction), + hostileFactions: make(map[int32][]int32), + friendlyFactions: make(map[int32][]int32), + } +} + +// Clear removes all factions and relationships +func (mfl *MasterFactionList) Clear() { + mfl.mutex.Lock() + defer mfl.mutex.Unlock() + + // Clear all maps - Go's garbage collector will handle cleanup + mfl.globalFactionList = make(map[int32]*Faction) + mfl.factionNameList = make(map[string]*Faction) + mfl.hostileFactions = make(map[int32][]int32) + mfl.friendlyFactions = make(map[int32][]int32) +} + +// GetDefaultFactionValue returns the default value for a faction +func (mfl *MasterFactionList) GetDefaultFactionValue(factionID int32) int32 { + mfl.mutex.RLock() + defer mfl.mutex.RUnlock() + + if faction, exists := mfl.globalFactionList[factionID]; exists && faction != nil { + return faction.DefaultValue + } + + return 0 +} + +// GetFaction returns a faction by name +func (mfl *MasterFactionList) GetFactionByName(name string) *Faction { + mfl.mutex.RLock() + defer mfl.mutex.RUnlock() + + return mfl.factionNameList[name] +} + +// GetFaction returns a faction by ID +func (mfl *MasterFactionList) GetFaction(id int32) *Faction { + mfl.mutex.RLock() + defer mfl.mutex.RUnlock() + + if faction, exists := mfl.globalFactionList[id]; exists { + return faction + } + + return nil +} + +// AddFaction adds a faction to the master list +func (mfl *MasterFactionList) AddFaction(faction *Faction) error { + if faction == nil { + return fmt.Errorf("faction cannot be nil") + } + + if !faction.IsValid() { + return fmt.Errorf("faction is not valid") + } + + mfl.mutex.Lock() + defer mfl.mutex.Unlock() + + mfl.globalFactionList[faction.ID] = faction + mfl.factionNameList[faction.Name] = faction + + return nil +} + +// GetIncreaseAmount returns the default increase amount for a faction +func (mfl *MasterFactionList) GetIncreaseAmount(factionID int32) int32 { + mfl.mutex.RLock() + defer mfl.mutex.RUnlock() + + if faction, exists := mfl.globalFactionList[factionID]; exists && faction != nil { + return int32(faction.PositiveChange) + } + + return 0 +} + +// GetDecreaseAmount returns the default decrease amount for a faction +func (mfl *MasterFactionList) GetDecreaseAmount(factionID int32) int32 { + mfl.mutex.RLock() + defer mfl.mutex.RUnlock() + + if faction, exists := mfl.globalFactionList[factionID]; exists && faction != nil { + return int32(faction.NegativeChange) + } + + return 0 +} + +// GetFactionCount returns the total number of factions +func (mfl *MasterFactionList) GetFactionCount() int32 { + mfl.mutex.RLock() + defer mfl.mutex.RUnlock() + + return int32(len(mfl.globalFactionList)) +} + +// AddHostileFaction adds a hostile relationship between factions +func (mfl *MasterFactionList) AddHostileFaction(factionID, hostileFactionID int32) { + mfl.mutex.Lock() + defer mfl.mutex.Unlock() + + mfl.hostileFactions[factionID] = append(mfl.hostileFactions[factionID], hostileFactionID) +} + +// AddFriendlyFaction adds a friendly relationship between factions +func (mfl *MasterFactionList) AddFriendlyFaction(factionID, friendlyFactionID int32) { + mfl.mutex.Lock() + defer mfl.mutex.Unlock() + + mfl.friendlyFactions[factionID] = append(mfl.friendlyFactions[factionID], friendlyFactionID) +} + +// GetFriendlyFactions returns all friendly factions for a given faction +func (mfl *MasterFactionList) GetFriendlyFactions(factionID int32) []int32 { + mfl.mutex.RLock() + defer mfl.mutex.RUnlock() + + if factions, exists := mfl.friendlyFactions[factionID]; exists { + // Return a copy to prevent external modification + result := make([]int32, len(factions)) + copy(result, factions) + return result + } + + return nil +} + +// GetHostileFactions returns all hostile factions for a given faction +func (mfl *MasterFactionList) GetHostileFactions(factionID int32) []int32 { + mfl.mutex.RLock() + defer mfl.mutex.RUnlock() + + if factions, exists := mfl.hostileFactions[factionID]; exists { + // Return a copy to prevent external modification + result := make([]int32, len(factions)) + copy(result, factions) + return result + } + + return nil +} + +// GetFactionNameByID returns the faction name for a given ID +func (mfl *MasterFactionList) GetFactionNameByID(factionID int32) string { + if factionID > 0 { + mfl.mutex.RLock() + defer mfl.mutex.RUnlock() + + if faction, exists := mfl.globalFactionList[factionID]; exists { + return faction.Name + } + } + + return "" +} + +// HasFaction checks if a faction exists by ID +func (mfl *MasterFactionList) HasFaction(factionID int32) bool { + mfl.mutex.RLock() + defer mfl.mutex.RUnlock() + + _, exists := mfl.globalFactionList[factionID] + return exists +} + +// HasFactionByName checks if a faction exists by name +func (mfl *MasterFactionList) HasFactionByName(name string) bool { + mfl.mutex.RLock() + defer mfl.mutex.RUnlock() + + _, exists := mfl.factionNameList[name] + return exists +} + +// GetAllFactions returns a copy of all factions +func (mfl *MasterFactionList) GetAllFactions() map[int32]*Faction { + mfl.mutex.RLock() + defer mfl.mutex.RUnlock() + + result := make(map[int32]*Faction) + for id, faction := range mfl.globalFactionList { + result[id] = faction + } + + return result +} + +// GetFactionIDs returns all faction IDs +func (mfl *MasterFactionList) GetFactionIDs() []int32 { + mfl.mutex.RLock() + defer mfl.mutex.RUnlock() + + ids := make([]int32, 0, len(mfl.globalFactionList)) + for id := range mfl.globalFactionList { + ids = append(ids, id) + } + + return ids +} + +// GetFactionsByType returns all factions of a specific type +func (mfl *MasterFactionList) GetFactionsByType(factionType string) []*Faction { + mfl.mutex.RLock() + defer mfl.mutex.RUnlock() + + var result []*Faction + + for _, faction := range mfl.globalFactionList { + if faction.Type == factionType { + result = append(result, faction) + } + } + + return result +} + +// RemoveFaction removes a faction by ID +func (mfl *MasterFactionList) RemoveFaction(factionID int32) bool { + mfl.mutex.Lock() + defer mfl.mutex.Unlock() + + faction, exists := mfl.globalFactionList[factionID] + if !exists { + return false + } + + // Remove from both maps + delete(mfl.globalFactionList, factionID) + delete(mfl.factionNameList, faction.Name) + + // Remove from relationship maps + delete(mfl.hostileFactions, factionID) + delete(mfl.friendlyFactions, factionID) + + // Remove references to this faction in other faction's relationships + for id, hostiles := range mfl.hostileFactions { + newHostiles := make([]int32, 0, len(hostiles)) + for _, hostileID := range hostiles { + if hostileID != factionID { + newHostiles = append(newHostiles, hostileID) + } + } + mfl.hostileFactions[id] = newHostiles + } + + for id, friendlies := range mfl.friendlyFactions { + newFriendlies := make([]int32, 0, len(friendlies)) + for _, friendlyID := range friendlies { + if friendlyID != factionID { + newFriendlies = append(newFriendlies, friendlyID) + } + } + mfl.friendlyFactions[id] = newFriendlies + } + + return true +} + +// UpdateFaction updates an existing faction +func (mfl *MasterFactionList) UpdateFaction(faction *Faction) error { + if faction == nil { + return fmt.Errorf("faction cannot be nil") + } + + if !faction.IsValid() { + return fmt.Errorf("faction is not valid") + } + + mfl.mutex.Lock() + defer mfl.mutex.Unlock() + + // Check if faction exists + oldFaction, exists := mfl.globalFactionList[faction.ID] + if !exists { + return fmt.Errorf("faction with ID %d does not exist", faction.ID) + } + + // If name changed, update name map + if oldFaction.Name != faction.Name { + delete(mfl.factionNameList, oldFaction.Name) + mfl.factionNameList[faction.Name] = faction + } + + // Update faction + mfl.globalFactionList[faction.ID] = faction + + return nil +} + +// ValidateFactions checks all factions for consistency +func (mfl *MasterFactionList) ValidateFactions() []string { + mfl.mutex.RLock() + defer mfl.mutex.RUnlock() + + var issues []string + + // Check for nil factions + for id, faction := range mfl.globalFactionList { + if faction == nil { + issues = append(issues, fmt.Sprintf("Faction ID %d is nil", id)) + continue + } + + if !faction.IsValid() { + issues = append(issues, fmt.Sprintf("Faction ID %d is invalid", id)) + } + + if faction.ID != id { + issues = append(issues, fmt.Sprintf("Faction ID mismatch: map key %d != faction ID %d", id, faction.ID)) + } + } + + // Check name map consistency + for name, faction := range mfl.factionNameList { + if faction == nil { + issues = append(issues, fmt.Sprintf("Faction name '%s' maps to nil", name)) + continue + } + + if faction.Name != name { + issues = append(issues, fmt.Sprintf("Faction name mismatch: map key '%s' != faction name '%s'", name, faction.Name)) + } + + // Check if this faction exists in the ID map + if _, exists := mfl.globalFactionList[faction.ID]; !exists { + issues = append(issues, fmt.Sprintf("Faction '%s' (ID %d) exists in name map but not in ID map", name, faction.ID)) + } + } + + // Check relationship consistency + for factionID, hostiles := range mfl.hostileFactions { + if _, exists := mfl.globalFactionList[factionID]; !exists { + issues = append(issues, fmt.Sprintf("Hostile relationship defined for non-existent faction %d", factionID)) + } + + for _, hostileID := range hostiles { + if _, exists := mfl.globalFactionList[hostileID]; !exists { + issues = append(issues, fmt.Sprintf("Faction %d has hostile relationship with non-existent faction %d", factionID, hostileID)) + } + } + } + + for factionID, friendlies := range mfl.friendlyFactions { + if _, exists := mfl.globalFactionList[factionID]; !exists { + issues = append(issues, fmt.Sprintf("Friendly relationship defined for non-existent faction %d", factionID)) + } + + for _, friendlyID := range friendlies { + if _, exists := mfl.globalFactionList[friendlyID]; !exists { + issues = append(issues, fmt.Sprintf("Faction %d has friendly relationship with non-existent faction %d", factionID, friendlyID)) + } + } + } + + return issues +} + +// IsValid returns true if all factions are valid +func (mfl *MasterFactionList) IsValid() bool { + issues := mfl.ValidateFactions() + return len(issues) == 0 +} \ No newline at end of file diff --git a/internal/factions/player_faction.go b/internal/factions/player_faction.go new file mode 100644 index 0000000..31007ad --- /dev/null +++ b/internal/factions/player_faction.go @@ -0,0 +1,349 @@ +package factions + +import ( + "sync" +) + +// PlayerFaction manages faction standing for a single player +type PlayerFaction struct { + factionValues map[int32]int32 // Faction ID -> current value + factionPercent map[int32]int8 // Faction ID -> percentage within con level + factionUpdateNeeded []int32 // Factions that need client updates + masterFactionList *MasterFactionList + updateMutex sync.Mutex // Thread safety for updates + mutex sync.RWMutex // Thread safety for faction data +} + +// NewPlayerFaction creates a new player faction system +func NewPlayerFaction(masterFactionList *MasterFactionList) *PlayerFaction { + return &PlayerFaction{ + factionValues: make(map[int32]int32), + factionPercent: make(map[int32]int8), + factionUpdateNeeded: make([]int32, 0), + masterFactionList: masterFactionList, + } +} + +// GetMaxValue returns the maximum faction value for a given consideration level +func (pf *PlayerFaction) GetMaxValue(con int8) int32 { + if con < 0 { + return int32(con) * ConMultiplier + } + return (int32(con) * ConMultiplier) + ConRemainder +} + +// GetMinValue returns the minimum faction value for a given consideration level +func (pf *PlayerFaction) GetMinValue(con int8) int32 { + if con <= 0 { + return (int32(con) * ConMultiplier) - ConRemainder + } + return int32(con) * ConMultiplier +} + +// ShouldAttack returns true if the player should attack based on faction +func (pf *PlayerFaction) ShouldAttack(factionID int32) bool { + return pf.GetCon(factionID) <= AttackThreshold +} + +// GetCon returns the consideration level (-4 to 4) for a faction +func (pf *PlayerFaction) GetCon(factionID int32) int8 { + // Special faction IDs have predefined cons + if factionID <= SpecialFactionIDMax { + if factionID == 0 { + return ConIndiff + } + return int8(factionID - 5) + } + + value := pf.GetFactionValue(factionID) + + // Neutral range + if value >= ConNeutralMin && value <= ConNeutralMax { + return ConIndiff + } + + // Maximum ally + if value >= ConAllyMin { + return ConAlly + } + + // Maximum hostile + if value <= ConHostileMax { + return ConKOS + } + + // Calculate con based on value + return int8(value / ConMultiplier) +} + +// GetPercent returns the percentage within the current consideration level +func (pf *PlayerFaction) GetPercent(factionID int32) int8 { + // Special factions have no percentage + if factionID <= SpecialFactionIDMax { + return 0 + } + + con := pf.GetCon(factionID) + value := pf.GetFactionValue(factionID) + + if con != ConIndiff { + // Make value positive for calculation + if value <= 0 { + value *= -1 + } + + // Make con positive for calculation + if con < 0 { + con *= -1 + } + + // Calculate percentage within the con level + value -= int32(con) * ConMultiplier + value *= PercentMultiplier + return int8(value / ConMultiplier) + } else { + // Neutral range calculation + value += PercentNeutralOffset + value *= PercentMultiplier + return int8(value / PercentNeutralDivisor) + } +} + +// FactionUpdate builds a faction update packet for the client +func (pf *PlayerFaction) FactionUpdate(version int16) ([]byte, error) { + pf.updateMutex.Lock() + defer pf.updateMutex.Unlock() + + if len(pf.factionUpdateNeeded) == 0 { + return nil, nil + } + + // This is a placeholder for packet building + // In the full implementation, this would use the PacketStruct system: + // packet := configReader.getStruct("WS_FactionUpdate", version) + // packet.setArrayLengthByName("num_factions", len(pf.factionUpdateNeeded)) + // for i, factionID := range pf.factionUpdateNeeded { + // faction := pf.masterFactionList.GetFaction(factionID) + // if faction != nil { + // packet.setArrayDataByName("faction_id", faction.ID, i) + // packet.setArrayDataByName("name", faction.Name, i) + // packet.setArrayDataByName("description", faction.Description, i) + // packet.setArrayDataByName("category", faction.Type, i) + // packet.setArrayDataByName("con", pf.GetCon(faction.ID), i) + // packet.setArrayDataByName("percentage", pf.GetPercent(faction.ID), i) + // packet.setArrayDataByName("value", pf.GetFactionValue(faction.ID), i) + // } + // } + // return packet.serialize() + + // Clear update list + pf.factionUpdateNeeded = pf.factionUpdateNeeded[:0] + + // Return empty packet for now + return make([]byte, 0), nil +} + +// GetFactionValue returns the current faction value for a faction +func (pf *PlayerFaction) GetFactionValue(factionID int32) int32 { + // Special factions always return 0 + if factionID <= SpecialFactionIDMax { + return 0 + } + + pf.mutex.RLock() + defer pf.mutex.RUnlock() + + // Return current value or 0 if not set + // Note: The C++ code has a comment about always returning the default value, + // but the actual implementation returns the stored value or 0 + return pf.factionValues[factionID] +} + +// ShouldIncrease returns true if the faction can be increased +func (pf *PlayerFaction) ShouldIncrease(factionID int32) bool { + if factionID <= SpecialFactionIDMax { + return false + } + + if pf.masterFactionList == nil { + return false + } + + return pf.masterFactionList.GetIncreaseAmount(factionID) != 0 +} + +// ShouldDecrease returns true if the faction can be decreased +func (pf *PlayerFaction) ShouldDecrease(factionID int32) bool { + if factionID <= SpecialFactionIDMax { + return false + } + + if pf.masterFactionList == nil { + return false + } + + return pf.masterFactionList.GetDecreaseAmount(factionID) != 0 +} + +// IncreaseFaction increases a faction value +func (pf *PlayerFaction) IncreaseFaction(factionID int32, amount int32) bool { + // Special factions cannot be changed + if factionID <= SpecialFactionIDMax { + return true + } + + pf.mutex.Lock() + defer pf.mutex.Unlock() + + // Use default amount if not specified + if amount == 0 && pf.masterFactionList != nil { + amount = pf.masterFactionList.GetIncreaseAmount(factionID) + } + + // Increase the faction value + pf.factionValues[factionID] += amount + + canContinue := true + + // Cap at maximum value + if pf.factionValues[factionID] >= MaxFactionValue { + pf.factionValues[factionID] = MaxFactionValue + canContinue = false + } + + // Mark for update + pf.addFactionUpdateNeeded(factionID) + + return canContinue +} + +// DecreaseFaction decreases a faction value +func (pf *PlayerFaction) DecreaseFaction(factionID int32, amount int32) bool { + // Special factions cannot be changed + if factionID <= SpecialFactionIDMax { + return true + } + + pf.mutex.Lock() + defer pf.mutex.Unlock() + + // Use default amount if not specified + if amount == 0 && pf.masterFactionList != nil { + amount = pf.masterFactionList.GetDecreaseAmount(factionID) + } + + // Cannot decrease if no amount specified + if amount == 0 { + return false + } + + // Decrease the faction value + pf.factionValues[factionID] -= amount + + canContinue := true + + // Cap at minimum value + if pf.factionValues[factionID] <= MinFactionValue { + pf.factionValues[factionID] = MinFactionValue + canContinue = false + } + + // Mark for update + pf.addFactionUpdateNeeded(factionID) + + return canContinue +} + +// SetFactionValue sets a faction to a specific value +func (pf *PlayerFaction) SetFactionValue(factionID int32, value int32) bool { + pf.mutex.Lock() + defer pf.mutex.Unlock() + + pf.factionValues[factionID] = value + + // Mark for update + pf.addFactionUpdateNeeded(factionID) + + return true +} + +// GetFactionValues returns a copy of all faction values +func (pf *PlayerFaction) GetFactionValues() map[int32]int32 { + pf.mutex.RLock() + defer pf.mutex.RUnlock() + + // Return a copy to prevent external modification + result := make(map[int32]int32) + for id, value := range pf.factionValues { + result[id] = value + } + + return result +} + +// HasFaction returns true if the player has a value for the given faction +func (pf *PlayerFaction) HasFaction(factionID int32) bool { + pf.mutex.RLock() + defer pf.mutex.RUnlock() + + _, exists := pf.factionValues[factionID] + return exists +} + +// GetFactionCount returns the number of factions the player has values for +func (pf *PlayerFaction) GetFactionCount() int { + pf.mutex.RLock() + defer pf.mutex.RUnlock() + + return len(pf.factionValues) +} + +// ClearFactionValues removes all faction values +func (pf *PlayerFaction) ClearFactionValues() { + pf.mutex.Lock() + defer pf.mutex.Unlock() + + pf.factionValues = make(map[int32]int32) + pf.factionPercent = make(map[int32]int8) +} + +// addFactionUpdateNeeded marks a faction as needing an update (internal use, assumes lock held) +func (pf *PlayerFaction) addFactionUpdateNeeded(factionID int32) { + // Note: This method assumes the mutex is already held by the caller + pf.updateMutex.Lock() + defer pf.updateMutex.Unlock() + + pf.factionUpdateNeeded = append(pf.factionUpdateNeeded, factionID) +} + +// GetPendingUpdates returns factions that need client updates +func (pf *PlayerFaction) GetPendingUpdates() []int32 { + pf.updateMutex.Lock() + defer pf.updateMutex.Unlock() + + if len(pf.factionUpdateNeeded) == 0 { + return nil + } + + // Return a copy + result := make([]int32, len(pf.factionUpdateNeeded)) + copy(result, pf.factionUpdateNeeded) + + return result +} + +// ClearPendingUpdates clears the pending update list +func (pf *PlayerFaction) ClearPendingUpdates() { + pf.updateMutex.Lock() + defer pf.updateMutex.Unlock() + + pf.factionUpdateNeeded = pf.factionUpdateNeeded[:0] +} + +// HasPendingUpdates returns true if there are pending faction updates +func (pf *PlayerFaction) HasPendingUpdates() bool { + pf.updateMutex.Lock() + defer pf.updateMutex.Unlock() + + return len(pf.factionUpdateNeeded) > 0 +} \ No newline at end of file diff --git a/internal/factions/types.go b/internal/factions/types.go new file mode 100644 index 0000000..c921f2c --- /dev/null +++ b/internal/factions/types.go @@ -0,0 +1,108 @@ +package factions + +// Faction represents a single faction with its properties +type Faction struct { + ID int32 // Faction ID + Name string // Faction name + Type string // Faction type/category + Description string // Faction description + NegativeChange int16 // Amount faction decreases by default + PositiveChange int16 // Amount faction increases by default + DefaultValue int32 // Default faction value for new characters +} + +// NewFaction creates a new faction with the given parameters +func NewFaction(id int32, name, factionType, description string) *Faction { + return &Faction{ + ID: id, + Name: name, + Type: factionType, + Description: description, + NegativeChange: 0, + PositiveChange: 0, + DefaultValue: 0, + } +} + +// GetID returns the faction ID +func (f *Faction) GetID() int32 { + return f.ID +} + +// GetName returns the faction name +func (f *Faction) GetName() string { + return f.Name +} + +// GetType returns the faction type +func (f *Faction) GetType() string { + return f.Type +} + +// GetDescription returns the faction description +func (f *Faction) GetDescription() string { + return f.Description +} + +// GetNegativeChange returns the default decrease amount +func (f *Faction) GetNegativeChange() int16 { + return f.NegativeChange +} + +// GetPositiveChange returns the default increase amount +func (f *Faction) GetPositiveChange() int16 { + return f.PositiveChange +} + +// GetDefaultValue returns the default faction value +func (f *Faction) GetDefaultValue() int32 { + return f.DefaultValue +} + +// SetNegativeChange sets the default decrease amount +func (f *Faction) SetNegativeChange(amount int16) { + f.NegativeChange = amount +} + +// SetPositiveChange sets the default increase amount +func (f *Faction) SetPositiveChange(amount int16) { + f.PositiveChange = amount +} + +// SetDefaultValue sets the default faction value +func (f *Faction) SetDefaultValue(value int32) { + f.DefaultValue = value +} + +// Clone creates a copy of the faction +func (f *Faction) Clone() *Faction { + return &Faction{ + ID: f.ID, + Name: f.Name, + Type: f.Type, + Description: f.Description, + NegativeChange: f.NegativeChange, + PositiveChange: f.PositiveChange, + DefaultValue: f.DefaultValue, + } +} + +// IsValid returns true if the faction has valid data +func (f *Faction) IsValid() bool { + return f.ID > 0 && len(f.Name) > 0 +} + +// IsSpecialFaction returns true if this is a special faction (ID <= 10) +func (f *Faction) IsSpecialFaction() bool { + return f.ID <= SpecialFactionIDMax +} + +// CanIncrease returns true if this faction can be increased +func (f *Faction) CanIncrease() bool { + return !f.IsSpecialFaction() && f.PositiveChange != 0 +} + +// CanDecrease returns true if this faction can be decreased +func (f *Faction) CanDecrease() bool { + return !f.IsSpecialFaction() && f.NegativeChange != 0 +} \ No newline at end of file diff --git a/internal/ground_spawn/constants.go b/internal/ground_spawn/constants.go new file mode 100644 index 0000000..de47dba --- /dev/null +++ b/internal/ground_spawn/constants.go @@ -0,0 +1,76 @@ +package ground_spawn + +// Harvest type constants +const ( + HarvestTypeNone = 0 + HarvestType1Item = 1 + HarvestType3Items = 2 + HarvestType5Items = 3 + HarvestTypeImbue = 4 + HarvestTypeRare = 5 + HarvestType10AndRare = 6 +) + +// Harvest skill constants +const ( + SkillGathering = "Gathering" + SkillCollecting = "Collecting" + SkillMining = "Mining" + SkillFishing = "Fishing" + SkillTrapping = "Trapping" + SkillForesting = "Foresting" +) + +// Harvest skill spell types +const ( + SpellTypeGather = "gather" + SpellTypeMine = "mine" + SpellTypeTrap = "trap" + SpellTypeChop = "chop" + SpellTypeFish = "fish" +) + +// Harvest result constants +const ( + HarvestResultSuccess = iota + HarvestResultFailed + HarvestResultNoSkill + HarvestResultDepleted + HarvestResultNoItems +) + +// Item rarity flags +const ( + ItemRarityNormal = 0 + ItemRarityRare = 1 + ItemRarityImbue = 2 +) + +// Ground spawn state constants +const ( + StateAvailable = iota + StateDepleted + StateRespawning +) + +// Default spawn configuration +const ( + DefaultDifficulty = 0 + DefaultSpawnType = 2 + DefaultState = 129 + DefaultAttemptsPerHarvest = 1 + DefaultNumberHarvests = 1 + DefaultRandomizeHeading = true +) + +// Harvest message channels (placeholder values) +const ( + ChannelHarvesting = 15 + ChannelColorRed = 13 +) + +// Statistical tracking +const ( + StatPlayerItemsHarvested = 1 + StatPlayerRaresHarvested = 2 +) \ No newline at end of file diff --git a/internal/ground_spawn/ground_spawn.go b/internal/ground_spawn/ground_spawn.go new file mode 100644 index 0000000..abb16cc --- /dev/null +++ b/internal/ground_spawn/ground_spawn.go @@ -0,0 +1,627 @@ +package ground_spawn + +import ( + "fmt" + "math/rand" + "strings" + "sync" + "time" + + "eq2emu/internal/spawn" +) + +// NewGroundSpawn creates a new ground spawn instance +func NewGroundSpawn(config GroundSpawnConfig) *GroundSpawn { + baseSpawn := spawn.NewSpawn() + + gs := &GroundSpawn{ + Spawn: baseSpawn, + numberHarvests: config.NumberHarvests, + numAttemptsPerHarvest: config.AttemptsPerHarvest, + groundspawnID: config.GroundSpawnID, + collectionSkill: config.CollectionSkill, + randomizeHeading: config.RandomizeHeading, + } + + // Configure base spawn properties + gs.SetName(config.Name) + gs.SetSpawnType(DefaultSpawnType) + gs.SetDifficulty(DefaultDifficulty) + gs.SetState(DefaultState) + + // Set position + gs.SetX(config.Location.X) + gs.SetY(config.Location.Y) + gs.SetZ(config.Location.Z) + + if config.RandomizeHeading { + gs.SetHeading(rand.Float32() * 360.0) + } else { + gs.SetHeading(config.Location.Heading) + } + + return gs +} + +// Copy creates a deep copy of the ground spawn +func (gs *GroundSpawn) Copy() *GroundSpawn { + gs.harvestMutex.Lock() + defer gs.harvestMutex.Unlock() + + newSpawn := &GroundSpawn{ + Spawn: gs.Spawn.Copy().(*spawn.Spawn), + numberHarvests: gs.numberHarvests, + numAttemptsPerHarvest: gs.numAttemptsPerHarvest, + groundspawnID: gs.groundspawnID, + collectionSkill: gs.collectionSkill, + randomizeHeading: gs.randomizeHeading, + } + + return newSpawn +} + +// IsGroundSpawn returns true (implements spawn interface) +func (gs *GroundSpawn) IsGroundSpawn() bool { + return true +} + +// GetNumberHarvests returns the number of harvests remaining +func (gs *GroundSpawn) GetNumberHarvests() int8 { + gs.harvestMutex.Lock() + defer gs.harvestMutex.Unlock() + + return gs.numberHarvests +} + +// SetNumberHarvests sets the number of harvests remaining +func (gs *GroundSpawn) SetNumberHarvests(val int8) { + gs.harvestMutex.Lock() + defer gs.harvestMutex.Unlock() + + gs.numberHarvests = val +} + +// GetAttemptsPerHarvest returns attempts per harvest session +func (gs *GroundSpawn) GetAttemptsPerHarvest() int8 { + gs.harvestMutex.Lock() + defer gs.harvestMutex.Unlock() + + return gs.numAttemptsPerHarvest +} + +// SetAttemptsPerHarvest sets attempts per harvest session +func (gs *GroundSpawn) SetAttemptsPerHarvest(val int8) { + gs.harvestMutex.Lock() + defer gs.harvestMutex.Unlock() + + gs.numAttemptsPerHarvest = val +} + +// GetGroundSpawnEntryID returns the database entry ID +func (gs *GroundSpawn) GetGroundSpawnEntryID() int32 { + gs.harvestMutex.Lock() + defer gs.harvestMutex.Unlock() + + return gs.groundspawnID +} + +// SetGroundSpawnEntryID sets the database entry ID +func (gs *GroundSpawn) SetGroundSpawnEntryID(val int32) { + gs.harvestMutex.Lock() + defer gs.harvestMutex.Unlock() + + gs.groundspawnID = val +} + +// GetCollectionSkill returns the required harvesting skill +func (gs *GroundSpawn) GetCollectionSkill() string { + gs.harvestMutex.Lock() + defer gs.harvestMutex.Unlock() + + return gs.collectionSkill +} + +// SetCollectionSkill sets the required harvesting skill +func (gs *GroundSpawn) SetCollectionSkill(skill string) { + gs.harvestMutex.Lock() + defer gs.harvestMutex.Unlock() + + gs.collectionSkill = skill +} + +// GetRandomizeHeading returns whether heading should be randomized +func (gs *GroundSpawn) GetRandomizeHeading() bool { + gs.harvestMutex.Lock() + defer gs.harvestMutex.Unlock() + + return gs.randomizeHeading +} + +// SetRandomizeHeading sets whether heading should be randomized +func (gs *GroundSpawn) SetRandomizeHeading(val bool) { + gs.harvestMutex.Lock() + defer gs.harvestMutex.Unlock() + + gs.randomizeHeading = val +} + +// IsDepleted returns true if the ground spawn has no harvests remaining +func (gs *GroundSpawn) IsDepleted() bool { + return gs.GetNumberHarvests() <= 0 +} + +// IsAvailable returns true if the ground spawn can be harvested +func (gs *GroundSpawn) IsAvailable() bool { + return gs.GetNumberHarvests() > 0 && gs.IsAlive() +} + +// GetHarvestMessageName returns the appropriate harvest verb based on skill +func (gs *GroundSpawn) GetHarvestMessageName(presentTense bool, failure bool) string { + skill := strings.ToLower(gs.GetCollectionSkill()) + + switch skill { + case "gathering", "collecting": + if presentTense { + return "gather" + } + return "gathered" + + case "mining": + if presentTense { + return "mine" + } + return "mined" + + case "fishing": + if presentTense { + return "fish" + } + return "fished" + + case "trapping": + if failure { + return "trap" + } + if presentTense { + return "acquire" + } + return "acquired" + + case "foresting": + if presentTense { + return "forest" + } + return "forested" + + default: + if presentTense { + return "collect" + } + return "collected" + } +} + +// GetHarvestSpellType returns the spell type for harvesting +func (gs *GroundSpawn) GetHarvestSpellType() string { + skill := strings.ToLower(gs.GetCollectionSkill()) + + switch skill { + case "gathering", "collecting": + return SpellTypeGather + case "mining": + return SpellTypeMine + case "trapping": + return SpellTypeTrap + case "foresting": + return SpellTypeChop + case "fishing": + return SpellTypeFish + default: + return SpellTypeGather + } +} + +// GetHarvestSpellName returns the spell name for harvesting +func (gs *GroundSpawn) GetHarvestSpellName() string { + skill := gs.GetCollectionSkill() + + if skill == SkillCollecting { + return SkillGathering + } + + return skill +} + +// ProcessHarvest handles the complex harvesting logic +func (gs *GroundSpawn) ProcessHarvest(context *HarvestContext) (*HarvestResult, error) { + if context == nil { + return nil, fmt.Errorf("harvest context cannot be nil") + } + + if context.Player == nil { + return nil, fmt.Errorf("player cannot be nil") + } + + gs.harvestMutex.Lock() + defer gs.harvestMutex.Unlock() + + // Check if ground spawn is depleted + if gs.numberHarvests <= 0 { + return &HarvestResult{ + Success: false, + MessageText: "This spawn has nothing more to harvest!", + }, nil + } + + // Validate harvest data + if context.GroundSpawnEntries == nil || len(context.GroundSpawnEntries) == 0 { + return &HarvestResult{ + Success: false, + MessageText: fmt.Sprintf("Error: No groundspawn entries assigned to groundspawn id: %d", gs.groundspawnID), + }, nil + } + + if context.GroundSpawnItems == nil || len(context.GroundSpawnItems) == 0 { + return &HarvestResult{ + Success: false, + MessageText: fmt.Sprintf("Error: No groundspawn items assigned to groundspawn id: %d", gs.groundspawnID), + }, nil + } + + // Validate player skill + if context.PlayerSkill == nil { + return &HarvestResult{ + Success: false, + MessageText: fmt.Sprintf("Error: You do not have the '%s' skill!", gs.collectionSkill), + }, nil + } + + result := &HarvestResult{ + Success: true, + ItemsAwarded: make([]*HarvestedItem, 0), + } + + // Process each harvest attempt + for attempt := int8(0); attempt < gs.numAttemptsPerHarvest; attempt++ { + attemptResult := gs.processHarvestAttempt(context) + if attemptResult != nil { + result.ItemsAwarded = append(result.ItemsAwarded, attemptResult.ItemsAwarded...) + if attemptResult.SkillGained { + result.SkillGained = true + } + } + } + + // Decrement harvest count + gs.numberHarvests-- + + return result, nil +} + +// processHarvestAttempt handles a single harvest attempt +func (gs *GroundSpawn) processHarvestAttempt(context *HarvestContext) *HarvestResult { + // Filter available harvest tables based on player skill and level + availableTables := gs.filterHarvestTables(context) + if len(availableTables) == 0 { + return &HarvestResult{ + Success: false, + MessageText: "You lack the skills to harvest this node!", + } + } + + // Select harvest table based on skill roll + selectedTable := gs.selectHarvestTable(availableTables, context.TotalSkill) + if selectedTable == nil { + return &HarvestResult{ + Success: false, + MessageText: "Failed to determine harvest table", + } + } + + // Determine harvest type based on table percentages + harvestType := gs.determineHarvestType(selectedTable, context.IsCollection) + if harvestType == HarvestTypeNone { + return &HarvestResult{ + Success: false, + MessageText: fmt.Sprintf("You failed to %s anything from %s.", + gs.GetHarvestMessageName(true, true), gs.GetName()), + } + } + + // Award items based on harvest type + items := gs.awardHarvestItems(harvestType, context.GroundSpawnItems, context.Player) + + // Handle skill progression + skillGained := gs.handleSkillProgression(context, selectedTable) + + return &HarvestResult{ + Success: len(items) > 0, + HarvestType: harvestType, + ItemsAwarded: items, + SkillGained: skillGained, + } +} + +// filterHarvestTables filters tables based on player capabilities +func (gs *GroundSpawn) filterHarvestTables(context *HarvestContext) []*GroundSpawnEntry { + var filtered []*GroundSpawnEntry + + for _, entry := range context.GroundSpawnEntries { + // Check skill requirement + if entry.MinSkillLevel > context.TotalSkill { + continue + } + + // Check level requirement for bonus tables + if entry.BonusTable && context.Player.GetLevel() < entry.MinAdventureLevel { + continue + } + + filtered = append(filtered, entry) + } + + return filtered +} + +// selectHarvestTable selects a harvest table based on skill level +func (gs *GroundSpawn) selectHarvestTable(tables []*GroundSpawnEntry, totalSkill int16) *GroundSpawnEntry { + if len(tables) == 0 { + return nil + } + + // Find lowest skill requirement + lowestSkill := int16(32767) + for _, table := range tables { + if table.MinSkillLevel < lowestSkill { + lowestSkill = table.MinSkillLevel + } + } + + // Roll for table selection + tableChoice := int16(rand.Intn(int(totalSkill-lowestSkill+1))) + lowestSkill + + // Find best matching table + var bestTable *GroundSpawnEntry + bestScore := int16(0) + + for _, table := range tables { + if tableChoice >= table.MinSkillLevel && table.MinSkillLevel > bestScore { + bestTable = table + bestScore = table.MinSkillLevel + } + } + + // If multiple tables match, pick randomly + var matches []*GroundSpawnEntry + for _, table := range tables { + if table.MinSkillLevel == bestScore { + matches = append(matches, table) + } + } + + if len(matches) > 1 { + return matches[rand.Intn(len(matches))] + } + + return bestTable +} + +// determineHarvestType determines what type of harvest occurs +func (gs *GroundSpawn) determineHarvestType(table *GroundSpawnEntry, isCollection bool) int8 { + chance := rand.Float32() * 100.0 + + // Collection items always get 1 item + if isCollection { + return HarvestType1Item + } + + // Check harvest types in order of rarity (most rare first) + if chance <= table.Harvest10 { + return HarvestType10AndRare + } + if chance <= table.HarvestRare { + return HarvestTypeRare + } + if chance <= table.HarvestImbue { + return HarvestTypeImbue + } + if chance <= table.Harvest5 { + return HarvestType5Items + } + if chance <= table.Harvest3 { + return HarvestType3Items + } + if chance <= table.Harvest1 { + return HarvestType1Item + } + + return HarvestTypeNone +} + +// awardHarvestItems awards items based on harvest type +func (gs *GroundSpawn) awardHarvestItems(harvestType int8, availableItems []*GroundSpawnEntryItem, player *Player) []*HarvestedItem { + var items []*HarvestedItem + + // Filter items based on harvest type and player location + normalItems := gs.filterItems(availableItems, ItemRarityNormal, player.GetLocation()) + rareItems := gs.filterItems(availableItems, ItemRarityRare, player.GetLocation()) + imbueItems := gs.filterItems(availableItems, ItemRarityImbue, player.GetLocation()) + + switch harvestType { + case HarvestType1Item: + items = gs.selectRandomItems(normalItems, 1) + case HarvestType3Items: + items = gs.selectRandomItems(normalItems, 3) + case HarvestType5Items: + items = gs.selectRandomItems(normalItems, 5) + case HarvestTypeImbue: + items = gs.selectRandomItems(imbueItems, 1) + case HarvestTypeRare: + items = gs.selectRandomItems(rareItems, 1) + case HarvestType10AndRare: + normal := gs.selectRandomItems(normalItems, 10) + rare := gs.selectRandomItems(rareItems, 1) + items = append(normal, rare...) + } + + return items +} + +// filterItems filters items by rarity and grid restriction +func (gs *GroundSpawn) filterItems(items []*GroundSpawnEntryItem, rarity int8, playerGrid int32) []*GroundSpawnEntryItem { + var filtered []*GroundSpawnEntryItem + + for _, item := range items { + if item.IsRare != rarity { + continue + } + + // Check grid restriction + if item.GridID != 0 && item.GridID != playerGrid { + continue + } + + filtered = append(filtered, item) + } + + return filtered +} + +// selectRandomItems randomly selects items from available list +func (gs *GroundSpawn) selectRandomItems(items []*GroundSpawnEntryItem, quantity int16) []*HarvestedItem { + if len(items) == 0 { + return nil + } + + var result []*HarvestedItem + + for i := int16(0); i < quantity; i++ { + selectedItem := items[rand.Intn(len(items))] + + harvestedItem := &HarvestedItem{ + ItemID: selectedItem.ItemID, + Quantity: selectedItem.Quantity, + IsRare: selectedItem.IsRare == ItemRarityRare, + Name: fmt.Sprintf("Item_%d", selectedItem.ItemID), // Placeholder + } + + result = append(result, harvestedItem) + } + + return result +} + +// handleSkillProgression manages skill increases from harvesting +func (gs *GroundSpawn) handleSkillProgression(context *HarvestContext, table *GroundSpawnEntry) bool { + if context.IsCollection { + return false // Collections don't give skill + } + + if context.PlayerSkill == nil { + return false + } + + // Check if player skill is already at max for this node + maxSkillAllowed := int16(float32(context.MaxSkillRequired) * 1.0) // TODO: Use skill multiplier rule + + if context.PlayerSkill.GetCurrentValue() >= maxSkillAllowed { + return false + } + + // Award skill increase (placeholder implementation) + // TODO: Integrate with actual skill system when available + return true +} + +// HandleUse processes player interaction with the ground spawn +func (gs *GroundSpawn) HandleUse(client Client, useType string) error { + if client == nil { + return fmt.Errorf("client cannot be nil") + } + + gs.harvestUseMutex.Lock() + defer gs.harvestUseMutex.Unlock() + + // Check spawn access requirements + if !gs.MeetsSpawnAccessRequirements(client.GetPlayer()) { + return nil // Silently ignore if requirements not met + } + + // Normalize use type + useType = strings.ToLower(strings.TrimSpace(useType)) + + // Handle older clients that don't send use type + if client.GetVersion() <= 561 && useType == "" { + useType = gs.GetHarvestSpellType() + } + + // Check if this is a harvest action + expectedSpellType := gs.GetHarvestSpellType() + if useType == expectedSpellType { + return gs.handleHarvestUse(client) + } + + // Handle other command interactions + if gs.HasCommandIcon() { + return gs.handleCommandUse(client, useType) + } + + return nil +} + +// handleHarvestUse processes harvest-specific use +func (gs *GroundSpawn) handleHarvestUse(client Client) error { + spellName := gs.GetHarvestSpellName() + + // TODO: Integrate with spell system when available + // spell := masterSpellList.GetSpellByName(spellName) + // if spell != nil { + // zone.ProcessSpell(spell, player, target, true, true) + // } + + if client.GetLogger() != nil { + client.GetLogger().LogDebug("Player %s attempting to harvest %s using spell %s", + client.GetPlayer().GetName(), gs.GetName(), spellName) + } + + return nil +} + +// handleCommandUse processes command-specific use +func (gs *GroundSpawn) handleCommandUse(client Client, command string) error { + // TODO: Integrate with entity command system when available + // entityCommand := gs.FindEntityCommand(command) + // if entityCommand != nil { + // zone.ProcessEntityCommand(entityCommand, player, target) + // } + + if client.GetLogger() != nil { + client.GetLogger().LogDebug("Player %s using command %s on %s", + client.GetPlayer().GetName(), command, gs.GetName()) + } + + return nil +} + +// Serialize creates a packet representation of the ground spawn +func (gs *GroundSpawn) Serialize(player *Player, version int16) ([]byte, error) { + // Use base spawn serialization + return gs.Spawn.Serialize(player, version) +} + +// Respawn resets the ground spawn to harvestable state +func (gs *GroundSpawn) Respawn() { + gs.harvestMutex.Lock() + defer gs.harvestMutex.Unlock() + + // Reset harvest count to default + gs.numberHarvests = DefaultNumberHarvests + + // Randomize heading if configured + if gs.randomizeHeading { + gs.SetHeading(rand.Float32() * 360.0) + } + + // Mark as alive + gs.SetAlive(true) +} \ No newline at end of file diff --git a/internal/ground_spawn/interfaces.go b/internal/ground_spawn/interfaces.go new file mode 100644 index 0000000..08cbb1b --- /dev/null +++ b/internal/ground_spawn/interfaces.go @@ -0,0 +1,260 @@ +package ground_spawn + +// Database interface for ground spawn persistence +type Database interface { + LoadGroundSpawnEntries(groundspawnID int32) ([]*GroundSpawnEntry, error) + LoadGroundSpawnItems(groundspawnID int32) ([]*GroundSpawnEntryItem, error) + SaveGroundSpawn(gs *GroundSpawn) error + LoadAllGroundSpawns() ([]*GroundSpawn, error) + DeleteGroundSpawn(id int32) error +} + +// Logger interface for ground spawn logging +type Logger interface { + LogInfo(message string, args ...interface{}) + LogError(message string, args ...interface{}) + LogDebug(message string, args ...interface{}) + LogWarning(message string, args ...interface{}) +} + +// Player interface for ground spawn interactions +type Player interface { + GetID() int32 + GetName() string + GetLevel() int16 + GetLocation() int32 + GetSkillByName(skillName string) *Skill + CheckQuestsHarvestUpdate(item *Item, quantity int16) + UpdatePlayerStatistic(statType int32, amount int32) + SendMessage(message string) +} + +// Client interface for client communication +type Client interface { + GetPlayer() *Player + GetVersion() int16 + GetLogger() Logger + GetCurrentZoneID() int32 + Message(channel int32, message string, args ...interface{}) + SimpleMessage(channel int32, message string) + SendPopupMessage(type_ int32, message string, sound string, duration float32, r, g, b int32) + AddItem(item *Item, itemDeleted *bool) error +} + +// Skill interface for skill management +type Skill interface { + GetCurrentValue() int16 + GetMaxValue() int16 + GetName() string + IncreaseSkill(amount int16) bool +} + +// Item interface for harvest rewards +type Item interface { + GetID() int32 + GetName() string + GetCount() int16 + SetCount(count int16) + CreateItemLink(version int16, color bool) string +} + +// Zone interface for zone-specific operations +type Zone interface { + GetID() int32 + GetGroundSpawnEntries(groundspawnID int32) []*GroundSpawnEntry + GetGroundSpawnEntryItems(groundspawnID int32) []*GroundSpawnEntryItem + ProcessSpell(spell *Spell, caster *Player, target *Player, harvest bool, fromItem bool) + ProcessEntityCommand(command *EntityCommand, player *Player, target *Player) +} + +// Spell interface for harvest spells +type Spell interface { + GetID() int32 + GetName() string + GetType() string +} + +// EntityCommand interface for ground spawn commands +type EntityCommand interface { + GetID() int32 + GetName() string + GetCommand() string +} + +// Rules interface for game rules and configuration +type Rules interface { + GetZoneRule(zoneID int32, category string, ruleName string) *Rule +} + +// Rule interface for individual rule values +type Rule interface { + GetInt16() int16 + GetFloat() float32 + GetBool() bool + GetString() string +} + +// GroundSpawnProvider interface for systems that provide ground spawn functionality +type GroundSpawnProvider interface { + GetGroundSpawn(id int32) *GroundSpawn + CreateGroundSpawn(config GroundSpawnConfig) *GroundSpawn + GetGroundSpawnsByZone(zoneID int32) []*GroundSpawn + ProcessHarvest(gs *GroundSpawn, player *Player) (*HarvestResult, error) +} + +// HarvestHandler interface for handling harvest events +type HarvestHandler interface { + OnHarvestStart(gs *GroundSpawn, player *Player) error + OnHarvestComplete(gs *GroundSpawn, player *Player, result *HarvestResult) error + OnHarvestFailed(gs *GroundSpawn, player *Player, reason string) error + OnGroundSpawnDepleted(gs *GroundSpawn) error +} + +// ItemProvider interface for item creation and management +type ItemProvider interface { + GetItem(itemID int32) *Item + CreateItem(itemID int32, quantity int16) *Item + GetItemName(itemID int32) string +} + +// SkillProvider interface for skill management +type SkillProvider interface { + GetPlayerSkill(player *Player, skillName string) *Skill + GetSkillIDByName(skillName string) int32 + IncreasePlayerSkill(player *Player, skillName string, amount int16) bool +} + +// SpawnProvider interface for spawn system integration +type SpawnProvider interface { + CreateSpawn() interface{} + GetSpawn(id int32) interface{} + RegisterGroundSpawn(gs *GroundSpawn) error + UnregisterGroundSpawn(id int32) error +} + +// GroundSpawnAware interface for entities that can interact with ground spawns +type GroundSpawnAware interface { + CanHarvest(gs *GroundSpawn) bool + GetHarvestSkill(skillName string) *Skill + GetHarvestModifiers() *HarvestModifiers + OnHarvestResult(result *HarvestResult) +} + +// PlayerGroundSpawnAdapter provides ground spawn functionality for players +type PlayerGroundSpawnAdapter struct { + player *Player + manager *Manager + logger Logger +} + +// NewPlayerGroundSpawnAdapter creates a new player ground spawn adapter +func NewPlayerGroundSpawnAdapter(player *Player, manager *Manager, logger Logger) *PlayerGroundSpawnAdapter { + return &PlayerGroundSpawnAdapter{ + player: player, + manager: manager, + logger: logger, + } +} + +// CanHarvest returns true if the player can harvest the ground spawn +func (pgsa *PlayerGroundSpawnAdapter) CanHarvest(gs *GroundSpawn) bool { + if gs == nil || pgsa.player == nil { + return false + } + + // Check if ground spawn is available + if !gs.IsAvailable() { + return false + } + + // Check if player has required skill + skill := pgsa.player.GetSkillByName(gs.GetCollectionSkill()) + if skill == nil { + return false + } + + // TODO: Add additional checks (quest requirements, level, etc.) + + return true +} + +// GetHarvestSkill returns the player's skill for a specific harvest type +func (pgsa *PlayerGroundSpawnAdapter) GetHarvestSkill(skillName string) *Skill { + if pgsa.player == nil { + return nil + } + + return pgsa.player.GetSkillByName(skillName) +} + +// GetHarvestModifiers returns harvest modifiers for the player +func (pgsa *PlayerGroundSpawnAdapter) GetHarvestModifiers() *HarvestModifiers { + // TODO: Calculate modifiers based on player stats, equipment, buffs, etc. + return &HarvestModifiers{ + SkillMultiplier: 1.0, + RareChanceBonus: 0.0, + QuantityMultiplier: 1.0, + LuckModifier: 0, + } +} + +// OnHarvestResult handles harvest result notifications +func (pgsa *PlayerGroundSpawnAdapter) OnHarvestResult(result *HarvestResult) { + if result == nil || pgsa.player == nil { + return + } + + if result.Success && len(result.ItemsAwarded) > 0 { + if pgsa.logger != nil { + pgsa.logger.LogDebug("Player %s successfully harvested %d items", + pgsa.player.GetName(), len(result.ItemsAwarded)) + } + } +} + +// HarvestEventAdapter adapts harvest events for different systems +type HarvestEventAdapter struct { + handler HarvestHandler + logger Logger +} + +// NewHarvestEventAdapter creates a new harvest event adapter +func NewHarvestEventAdapter(handler HarvestHandler, logger Logger) *HarvestEventAdapter { + return &HarvestEventAdapter{ + handler: handler, + logger: logger, + } +} + +// ProcessHarvestEvent processes a harvest event +func (hea *HarvestEventAdapter) ProcessHarvestEvent(eventType string, gs *GroundSpawn, player *Player, data interface{}) { + if hea.handler == nil { + return + } + + switch eventType { + case "harvest_start": + if err := hea.handler.OnHarvestStart(gs, player); err != nil && hea.logger != nil { + hea.logger.LogError("Harvest start handler failed: %v", err) + } + + case "harvest_complete": + if result, ok := data.(*HarvestResult); ok { + if err := hea.handler.OnHarvestComplete(gs, player, result); err != nil && hea.logger != nil { + hea.logger.LogError("Harvest complete handler failed: %v", err) + } + } + + case "harvest_failed": + if reason, ok := data.(string); ok { + if err := hea.handler.OnHarvestFailed(gs, player, reason); err != nil && hea.logger != nil { + hea.logger.LogError("Harvest failed handler failed: %v", err) + } + } + + case "ground_spawn_depleted": + if err := hea.handler.OnGroundSpawnDepleted(gs); err != nil && hea.logger != nil { + hea.logger.LogError("Ground spawn depleted handler failed: %v", err) + } + } +} \ No newline at end of file diff --git a/internal/ground_spawn/manager.go b/internal/ground_spawn/manager.go new file mode 100644 index 0000000..761b1f9 --- /dev/null +++ b/internal/ground_spawn/manager.go @@ -0,0 +1,592 @@ +package ground_spawn + +import ( + "fmt" + "sync" + "time" +) + +// NewManager creates a new ground spawn manager +func NewManager(database Database, logger Logger) *Manager { + return &Manager{ + groundSpawns: make(map[int32]*GroundSpawn), + spawnsByZone: make(map[int32][]*GroundSpawn), + entriesByID: make(map[int32][]*GroundSpawnEntry), + itemsByID: make(map[int32][]*GroundSpawnEntryItem), + respawnQueue: make(map[int32]time.Time), + database: database, + logger: logger, + harvestsBySkill: make(map[string]int64), + } +} + +// Initialize loads ground spawn data from database +func (m *Manager) Initialize() error { + if m.logger != nil { + m.logger.LogInfo("Initializing ground spawn manager...") + } + + if m.database == nil { + if m.logger != nil { + m.logger.LogWarning("No database provided, starting with empty ground spawn list") + } + return nil + } + + // Load ground spawns from database + groundSpawns, err := m.database.LoadAllGroundSpawns() + if err != nil { + return fmt.Errorf("failed to load ground spawns from database: %w", err) + } + + m.mutex.Lock() + defer m.mutex.Unlock() + + for _, gs := range groundSpawns { + m.groundSpawns[gs.GetID()] = gs + + // Group by zone (placeholder - zone ID would come from spawn location) + zoneID := int32(1) // TODO: Get actual zone ID from spawn + m.spawnsByZone[zoneID] = append(m.spawnsByZone[zoneID], gs) + + // Load harvest entries and items + if err := m.loadGroundSpawnData(gs); err != nil && m.logger != nil { + m.logger.LogWarning("Failed to load data for ground spawn %d: %v", gs.GetID(), err) + } + } + + if m.logger != nil { + m.logger.LogInfo("Loaded %d ground spawns from database", len(groundSpawns)) + } + + return nil +} + +// loadGroundSpawnData loads entries and items for a ground spawn +func (m *Manager) loadGroundSpawnData(gs *GroundSpawn) error { + groundspawnID := gs.GetGroundSpawnEntryID() + + // Load harvest entries + entries, err := m.database.LoadGroundSpawnEntries(groundspawnID) + if err != nil { + return fmt.Errorf("failed to load entries for groundspawn %d: %w", groundspawnID, err) + } + m.entriesByID[groundspawnID] = entries + + // Load harvest items + items, err := m.database.LoadGroundSpawnItems(groundspawnID) + if err != nil { + return fmt.Errorf("failed to load items for groundspawn %d: %w", groundspawnID, err) + } + m.itemsByID[groundspawnID] = items + + return nil +} + +// CreateGroundSpawn creates a new ground spawn +func (m *Manager) CreateGroundSpawn(config GroundSpawnConfig) *GroundSpawn { + gs := NewGroundSpawn(config) + + m.mutex.Lock() + defer m.mutex.Unlock() + + // Generate ID (placeholder implementation) + newID := int32(len(m.groundSpawns) + 1) + gs.SetID(newID) + + // Store ground spawn + m.groundSpawns[newID] = gs + + // Group by zone + zoneID := int32(1) // TODO: Get actual zone ID from config.Location + m.spawnsByZone[zoneID] = append(m.spawnsByZone[zoneID], gs) + + if m.logger != nil { + m.logger.LogInfo("Created ground spawn %d: %s", newID, gs.GetName()) + } + + return gs +} + +// GetGroundSpawn returns a ground spawn by ID +func (m *Manager) GetGroundSpawn(id int32) *GroundSpawn { + m.mutex.RLock() + defer m.mutex.RUnlock() + + return m.groundSpawns[id] +} + +// GetGroundSpawnsByZone returns all ground spawns in a zone +func (m *Manager) GetGroundSpawnsByZone(zoneID int32) []*GroundSpawn { + m.mutex.RLock() + defer m.mutex.RUnlock() + + spawns := m.spawnsByZone[zoneID] + if spawns == nil { + return []*GroundSpawn{} + } + + // Return a copy to prevent external modification + result := make([]*GroundSpawn, len(spawns)) + copy(result, spawns) + + return result +} + +// ProcessHarvest handles harvesting for a player +func (m *Manager) ProcessHarvest(gs *GroundSpawn, player *Player) (*HarvestResult, error) { + if gs == nil { + return nil, fmt.Errorf("ground spawn cannot be nil") + } + + if player == nil { + return nil, fmt.Errorf("player cannot be nil") + } + + // Record statistics + m.mutex.Lock() + m.totalHarvests++ + skill := gs.GetCollectionSkill() + m.harvestsBySkill[skill]++ + m.mutex.Unlock() + + // Build harvest context + context, err := m.buildHarvestContext(gs, player) + if err != nil { + return nil, fmt.Errorf("failed to build harvest context: %w", err) + } + + // Process the harvest + result, err := gs.ProcessHarvest(context) + if err != nil { + return nil, fmt.Errorf("harvest processing failed: %w", err) + } + + // Update statistics + if result != nil && result.Success { + m.mutex.Lock() + m.successfulHarvests++ + + // Count rare items + for _, item := range result.ItemsAwarded { + if item.IsRare { + m.rareItemsHarvested++ + } + } + + if result.SkillGained { + m.skillUpsGenerated++ + } + m.mutex.Unlock() + } + + // Handle respawn if depleted + if gs.IsDepleted() { + m.scheduleRespawn(gs) + } + + return result, nil +} + +// buildHarvestContext creates a harvest context for processing +func (m *Manager) buildHarvestContext(gs *GroundSpawn, player *Player) (*HarvestContext, error) { + groundspawnID := gs.GetGroundSpawnEntryID() + + m.mutex.RLock() + entries := m.entriesByID[groundspawnID] + items := m.itemsByID[groundspawnID] + m.mutex.RUnlock() + + if entries == nil || len(entries) == 0 { + return nil, fmt.Errorf("no harvest entries found for groundspawn %d", groundspawnID) + } + + if items == nil || len(items) == 0 { + return nil, fmt.Errorf("no harvest items found for groundspawn %d", groundspawnID) + } + + // Get player skill + skillName := gs.GetCollectionSkill() + if skillName == SkillCollecting { + skillName = SkillGathering // Collections use gathering skill + } + + playerSkill := player.GetSkillByName(skillName) + if playerSkill == nil { + return nil, fmt.Errorf("player lacks required skill: %s", skillName) + } + + // Calculate total skill (base + bonuses) + totalSkill := playerSkill.GetCurrentValue() + // TODO: Add stat bonuses when stat system is integrated + + // Find max skill required + var maxSkillRequired int16 + for _, entry := range entries { + if entry.MinSkillLevel > maxSkillRequired { + maxSkillRequired = entry.MinSkillLevel + } + } + + return &HarvestContext{ + Player: player, + GroundSpawn: gs, + PlayerSkill: playerSkill, + TotalSkill: totalSkill, + GroundSpawnEntries: entries, + GroundSpawnItems: items, + IsCollection: gs.GetCollectionSkill() == SkillCollecting, + MaxSkillRequired: maxSkillRequired, + }, nil +} + +// scheduleRespawn schedules a ground spawn for respawn +func (m *Manager) scheduleRespawn(gs *GroundSpawn) { + if gs == nil { + return + } + + // TODO: Get respawn timer from configuration or database + respawnDelay := 5 * time.Minute // Default 5 minutes + respawnTime := time.Now().Add(respawnDelay) + + m.mutex.Lock() + m.respawnQueue[gs.GetID()] = respawnTime + m.mutex.Unlock() + + if m.logger != nil { + m.logger.LogDebug("Scheduled ground spawn %d for respawn at %v", gs.GetID(), respawnTime) + } +} + +// ProcessRespawns handles ground spawn respawning +func (m *Manager) ProcessRespawns() { + now := time.Now() + var toRespawn []int32 + + m.mutex.Lock() + for spawnID, respawnTime := range m.respawnQueue { + if now.After(respawnTime) { + toRespawn = append(toRespawn, spawnID) + delete(m.respawnQueue, spawnID) + } + } + m.mutex.Unlock() + + // Respawn outside of lock + for _, spawnID := range toRespawn { + if gs := m.GetGroundSpawn(spawnID); gs != nil { + gs.Respawn() + if m.logger != nil { + m.logger.LogDebug("Ground spawn %d respawned", spawnID) + } + } + } +} + +// GetStatistics returns ground spawn system statistics +func (m *Manager) GetStatistics() *HarvestStatistics { + m.mutex.RLock() + defer m.mutex.RUnlock() + + // Count spawns by zone + spawnsByZone := make(map[int32]int) + for zoneID, spawns := range m.spawnsByZone { + spawnsByZone[zoneID] = len(spawns) + } + + // Copy harvests by skill + harvestsBySkill := make(map[string]int64) + for skill, count := range m.harvestsBySkill { + harvestsBySkill[skill] = count + } + + return &HarvestStatistics{ + TotalHarvests: m.totalHarvests, + SuccessfulHarvests: m.successfulHarvests, + RareItemsHarvested: m.rareItemsHarvested, + SkillUpsGenerated: m.skillUpsGenerated, + HarvestsBySkill: harvestsBySkill, + ActiveGroundSpawns: len(m.groundSpawns), + GroundSpawnsByZone: spawnsByZone, + } +} + +// ResetStatistics resets all statistics +func (m *Manager) ResetStatistics() { + m.mutex.Lock() + defer m.mutex.Unlock() + + m.totalHarvests = 0 + m.successfulHarvests = 0 + m.rareItemsHarvested = 0 + m.skillUpsGenerated = 0 + m.harvestsBySkill = make(map[string]int64) +} + +// AddGroundSpawn adds a ground spawn to the manager +func (m *Manager) AddGroundSpawn(gs *GroundSpawn) error { + if gs == nil { + return fmt.Errorf("ground spawn cannot be nil") + } + + m.mutex.Lock() + defer m.mutex.Unlock() + + // Check if ID is already used + if _, exists := m.groundSpawns[gs.GetID()]; exists { + return fmt.Errorf("ground spawn with ID %d already exists", gs.GetID()) + } + + m.groundSpawns[gs.GetID()] = gs + + // Group by zone (placeholder) + zoneID := int32(1) // TODO: Get actual zone ID + m.spawnsByZone[zoneID] = append(m.spawnsByZone[zoneID], gs) + + // Load harvest data if database is available + if m.database != nil { + if err := m.loadGroundSpawnData(gs); err != nil && m.logger != nil { + m.logger.LogWarning("Failed to load data for ground spawn %d: %v", gs.GetID(), err) + } + } + + return nil +} + +// RemoveGroundSpawn removes a ground spawn from the manager +func (m *Manager) RemoveGroundSpawn(id int32) bool { + m.mutex.Lock() + defer m.mutex.Unlock() + + gs, exists := m.groundSpawns[id] + if !exists { + return false + } + + delete(m.groundSpawns, id) + delete(m.respawnQueue, id) + + // Remove from zone list + // TODO: Get actual zone ID from ground spawn + zoneID := int32(1) + if spawns, exists := m.spawnsByZone[zoneID]; exists { + for i, spawn := range spawns { + if spawn.GetID() == id { + m.spawnsByZone[zoneID] = append(spawns[:i], spawns[i+1:]...) + break + } + } + } + + // Clean up harvest data + if gs != nil { + groundspawnID := gs.GetGroundSpawnEntryID() + delete(m.entriesByID, groundspawnID) + delete(m.itemsByID, groundspawnID) + } + + return true +} + +// GetGroundSpawnCount returns the total number of ground spawns +func (m *Manager) GetGroundSpawnCount() int { + m.mutex.RLock() + defer m.mutex.RUnlock() + + return len(m.groundSpawns) +} + +// GetActiveGroundSpawns returns all active (harvestable) ground spawns +func (m *Manager) GetActiveGroundSpawns() []*GroundSpawn { + m.mutex.RLock() + defer m.mutex.RUnlock() + + var active []*GroundSpawn + for _, gs := range m.groundSpawns { + if gs.IsAvailable() { + active = append(active, gs) + } + } + + return active +} + +// GetDepletedGroundSpawns returns all depleted ground spawns +func (m *Manager) GetDepletedGroundSpawns() []*GroundSpawn { + m.mutex.RLock() + defer m.mutex.RUnlock() + + var depleted []*GroundSpawn + for _, gs := range m.groundSpawns { + if gs.IsDepleted() { + depleted = append(depleted, gs) + } + } + + return depleted +} + +// ProcessCommand handles ground spawn management commands +func (m *Manager) ProcessCommand(command string, args []string) (string, error) { + switch command { + case "stats": + return m.handleStatsCommand(args) + case "list": + return m.handleListCommand(args) + case "respawn": + return m.handleRespawnCommand(args) + case "info": + return m.handleInfoCommand(args) + case "reload": + return m.handleReloadCommand(args) + default: + return "", fmt.Errorf("unknown ground spawn command: %s", command) + } +} + +// handleStatsCommand shows ground spawn system statistics +func (m *Manager) handleStatsCommand(args []string) (string, error) { + stats := m.GetStatistics() + + result := "Ground Spawn System Statistics:\n" + result += fmt.Sprintf("Total Harvests: %d\n", stats.TotalHarvests) + result += fmt.Sprintf("Successful Harvests: %d\n", stats.SuccessfulHarvests) + result += fmt.Sprintf("Rare Items Harvested: %d\n", stats.RareItemsHarvested) + result += fmt.Sprintf("Skill Ups Generated: %d\n", stats.SkillUpsGenerated) + result += fmt.Sprintf("Active Ground Spawns: %d\n", stats.ActiveGroundSpawns) + + if len(stats.HarvestsBySkill) > 0 { + result += "\nHarvests by Skill:\n" + for skill, count := range stats.HarvestsBySkill { + result += fmt.Sprintf(" %s: %d\n", skill, count) + } + } + + return result, nil +} + +// handleListCommand lists ground spawns +func (m *Manager) handleListCommand(args []string) (string, error) { + count := m.GetGroundSpawnCount() + if count == 0 { + return "No ground spawns loaded.", nil + } + + active := m.GetActiveGroundSpawns() + depleted := m.GetDepletedGroundSpawns() + + result := fmt.Sprintf("Ground Spawns (Total: %d, Active: %d, Depleted: %d):\n", + count, len(active), len(depleted)) + + // Show first 10 active spawns + shown := 0 + for _, gs := range active { + if shown >= 10 { + result += "... (and more)\n" + break + } + result += fmt.Sprintf(" %d: %s (%s) - %d harvests remaining\n", + gs.GetID(), gs.GetName(), gs.GetCollectionSkill(), gs.GetNumberHarvests()) + shown++ + } + + return result, nil +} + +// handleRespawnCommand respawns ground spawns +func (m *Manager) handleRespawnCommand(args []string) (string, error) { + if len(args) > 0 { + // Respawn specific ground spawn + var spawnID int32 + if _, err := fmt.Sscanf(args[0], "%d", &spawnID); err != nil { + return "", fmt.Errorf("invalid ground spawn ID: %s", args[0]) + } + + gs := m.GetGroundSpawn(spawnID) + if gs == nil { + return fmt.Sprintf("Ground spawn %d not found.", spawnID), nil + } + + gs.Respawn() + return fmt.Sprintf("Ground spawn %d respawned.", spawnID), nil + } + + // Respawn all depleted spawns + depleted := m.GetDepletedGroundSpawns() + for _, gs := range depleted { + gs.Respawn() + } + + return fmt.Sprintf("Respawned %d depleted ground spawns.", len(depleted)), nil +} + +// handleInfoCommand shows information about a specific ground spawn +func (m *Manager) handleInfoCommand(args []string) (string, error) { + if len(args) == 0 { + return "", fmt.Errorf("ground spawn ID required") + } + + var spawnID int32 + if _, err := fmt.Sscanf(args[0], "%d", &spawnID); err != nil { + return "", fmt.Errorf("invalid ground spawn ID: %s", args[0]) + } + + gs := m.GetGroundSpawn(spawnID) + if gs == nil { + return fmt.Sprintf("Ground spawn %d not found.", spawnID), nil + } + + result := fmt.Sprintf("Ground Spawn Information:\n") + result += fmt.Sprintf("ID: %d\n", gs.GetID()) + result += fmt.Sprintf("Name: %s\n", gs.GetName()) + result += fmt.Sprintf("Collection Skill: %s\n", gs.GetCollectionSkill()) + result += fmt.Sprintf("Harvests Remaining: %d\n", gs.GetNumberHarvests()) + result += fmt.Sprintf("Attempts per Harvest: %d\n", gs.GetAttemptsPerHarvest()) + result += fmt.Sprintf("Ground Spawn Entry ID: %d\n", gs.GetGroundSpawnEntryID()) + result += fmt.Sprintf("Available: %v\n", gs.IsAvailable()) + result += fmt.Sprintf("Depleted: %v\n", gs.IsDepleted()) + + return result, nil +} + +// handleReloadCommand reloads ground spawns from database +func (m *Manager) handleReloadCommand(args []string) (string, error) { + if m.database == nil { + return "", fmt.Errorf("no database available") + } + + // Clear current data + m.mutex.Lock() + m.groundSpawns = make(map[int32]*GroundSpawn) + m.spawnsByZone = make(map[int32][]*GroundSpawn) + m.entriesByID = make(map[int32][]*GroundSpawnEntry) + m.itemsByID = make(map[int32][]*GroundSpawnEntryItem) + m.respawnQueue = make(map[int32]time.Time) + m.mutex.Unlock() + + // Reload from database + if err := m.Initialize(); err != nil { + return "", fmt.Errorf("failed to reload ground spawns: %w", err) + } + + count := m.GetGroundSpawnCount() + return fmt.Sprintf("Successfully reloaded %d ground spawns from database.", count), nil +} + +// Shutdown gracefully shuts down the manager +func (m *Manager) Shutdown() { + if m.logger != nil { + m.logger.LogInfo("Shutting down ground spawn manager...") + } + + m.mutex.Lock() + defer m.mutex.Unlock() + + // Clear all data + m.groundSpawns = make(map[int32]*GroundSpawn) + m.spawnsByZone = make(map[int32][]*GroundSpawn) + m.entriesByID = make(map[int32][]*GroundSpawnEntry) + m.itemsByID = make(map[int32][]*GroundSpawnEntryItem) + m.respawnQueue = make(map[int32]time.Time) +} \ No newline at end of file diff --git a/internal/ground_spawn/types.go b/internal/ground_spawn/types.go new file mode 100644 index 0000000..c3b4bf5 --- /dev/null +++ b/internal/ground_spawn/types.go @@ -0,0 +1,137 @@ +package ground_spawn + +import ( + "sync" + "time" + + "eq2emu/internal/common" + "eq2emu/internal/spawn" +) + +// GroundSpawn represents a harvestable resource node in the game world +type GroundSpawn struct { + *spawn.Spawn // Embed spawn for base functionality + + numberHarvests int8 // Number of harvests remaining + numAttemptsPerHarvest int8 // Attempts per harvest session + groundspawnID int32 // Database ID for this groundspawn entry + collectionSkill string // Required skill for harvesting + randomizeHeading bool // Whether to randomize heading on spawn + + harvestMutex sync.Mutex // Thread safety for harvest operations + harvestUseMutex sync.Mutex // Thread safety for use operations +} + +// GroundSpawnEntry represents harvest table data from database +type GroundSpawnEntry struct { + MinSkillLevel int16 // Minimum skill level required + MinAdventureLevel int16 // Minimum adventure level required + BonusTable bool // Whether this is a bonus table + Harvest1 float32 // Chance for 1 item (percentage) + Harvest3 float32 // Chance for 3 items (percentage) + Harvest5 float32 // Chance for 5 items (percentage) + HarvestImbue float32 // Chance for imbue item (percentage) + HarvestRare float32 // Chance for rare item (percentage) + Harvest10 float32 // Chance for 10 + rare items (percentage) + HarvestCoin float32 // Chance for coin reward (percentage) +} + +// GroundSpawnEntryItem represents items that can be harvested +type GroundSpawnEntryItem struct { + ItemID int32 // Item database ID + IsRare int8 // 0=normal, 1=rare, 2=imbue + GridID int32 // Grid restriction (0=any) + Quantity int16 // Item quantity (usually 1) +} + +// HarvestResult represents the outcome of a harvest attempt +type HarvestResult struct { + Success bool // Whether harvest succeeded + HarvestType int8 // Type of harvest achieved + ItemsAwarded []*HarvestedItem // Items given to player + MessageText string // Message to display to player + SkillGained bool // Whether skill was gained + Error error // Any error that occurred +} + +// HarvestedItem represents an item awarded from harvesting +type HarvestedItem struct { + ItemID int32 // Database item ID + Quantity int16 // Number of items + IsRare bool // Whether this is a rare item + Name string // Item name for messages +} + +// HarvestContext contains all data needed for a harvest operation +type HarvestContext struct { + Player *Player // Player attempting harvest + GroundSpawn *GroundSpawn // The ground spawn being harvested + PlayerSkill *Skill // Player's harvesting skill + TotalSkill int16 // Total skill including bonuses + GroundSpawnEntries []*GroundSpawnEntry // Available harvest tables + GroundSpawnItems []*GroundSpawnEntryItem // Available harvest items + IsCollection bool // Whether this is collection harvesting + MaxSkillRequired int16 // Maximum skill required for any table +} + +// SpawnLocation represents a spawn position with grid information +type SpawnLocation struct { + X float32 // World X coordinate + Y float32 // World Y coordinate + Z float32 // World Z coordinate + Heading float32 // Spawn heading/rotation + GridID int32 // Grid zone identifier +} + +// HarvestModifiers contains modifiers that affect harvesting +type HarvestModifiers struct { + SkillMultiplier float32 // Skill gain multiplier + RareChanceBonus float32 // Bonus to rare item chance + QuantityMultiplier float32 // Quantity multiplier + LuckModifier int16 // Player luck modifier +} + +// GroundSpawnConfig contains configuration for ground spawn creation +type GroundSpawnConfig struct { + GroundSpawnID int32 // Database entry ID + CollectionSkill string // Required harvesting skill + NumberHarvests int8 // Harvests before depletion + AttemptsPerHarvest int8 // Attempts per harvest session + RandomizeHeading bool // Randomize spawn heading + RespawnTimer time.Duration // Time before respawn + Location SpawnLocation // Spawn position + Name string // Display name + Description string // Spawn description +} + +// Manager manages all ground spawn operations +type Manager struct { + groundSpawns map[int32]*GroundSpawn // Active ground spawns by ID + spawnsByZone map[int32][]*GroundSpawn // Ground spawns by zone ID + entriesByID map[int32][]*GroundSpawnEntry // Harvest entries by groundspawn ID + itemsByID map[int32][]*GroundSpawnEntryItem // Harvest items by groundspawn ID + respawnQueue map[int32]time.Time // Respawn timestamps + + database Database // Database interface + logger Logger // Logging interface + + mutex sync.RWMutex // Thread safety + + // Statistics + totalHarvests int64 // Total harvest attempts + successfulHarvests int64 // Successful harvests + rareItemsHarvested int64 // Rare items harvested + skillUpsGenerated int64 // Skill increases given + harvestsBySkill map[string]int64 // Harvests by skill type +} + +// HarvestStatistics contains harvest system statistics +type HarvestStatistics struct { + TotalHarvests int64 `json:"total_harvests"` + SuccessfulHarvests int64 `json:"successful_harvests"` + RareItemsHarvested int64 `json:"rare_items_harvested"` + SkillUpsGenerated int64 `json:"skill_ups_generated"` + HarvestsBySkill map[string]int64 `json:"harvests_by_skill"` + ActiveGroundSpawns int `json:"active_ground_spawns"` + GroundSpawnsByZone map[int32]int `json:"ground_spawns_by_zone"` +} \ No newline at end of file diff --git a/internal/sign/constants.go b/internal/sign/constants.go new file mode 100644 index 0000000..bb73172 --- /dev/null +++ b/internal/sign/constants.go @@ -0,0 +1,31 @@ +package sign + +// Sign type constants +const ( + SignTypeGeneric = 0 + SignTypeZone = 1 +) + +// Default spawn settings for signs +const ( + DefaultSpawnType = 2 // Signs are spawn type 2 + DefaultActivityStatus = 64 // Activity status for signs + DefaultPosState = 1 // Position state + DefaultDifficulty = 0 // No difficulty for signs +) + +// Channel colors for messages (these would be defined elsewhere in a real implementation) +const ( + ChannelColorYellow = 15 // Yellow text channel +) + +// Sign database constants +const ( + MaxSignTitleLength = 255 + MaxSignDescriptionLength = 1024 +) + +// Distance checking constants +const ( + DefaultSignDistance = 0.0 // 0 = no distance limit +) \ No newline at end of file diff --git a/internal/sign/interfaces.go b/internal/sign/interfaces.go new file mode 100644 index 0000000..08d65e0 --- /dev/null +++ b/internal/sign/interfaces.go @@ -0,0 +1,226 @@ +package sign + +import "eq2emu/internal/spawn" + +// Player interface for sign interactions +type Player interface { + GetDistance(target *spawn.Spawn) float32 + SetX(x float32) + SetY(y float32) + SetZ(z float32) + SetHeading(heading float32) + GetZone() Zone + GetTarget() *spawn.Spawn +} + +// Client interface for sign interactions +type Client interface { + GetPlayer() Player + GetCharacterID() int32 + GetDatabase() Database + GetCurrentZone() Zone + SetTemporaryTransportID(id int32) + SimpleMessage(channel int32, message string) + Message(channel int32, format string, args ...interface{}) + CheckZoneAccess(zoneName string) bool + TryZoneInstance(zoneID int32, useDefaults bool) bool + Zone(zoneName string, useDefaults bool) error + ProcessTeleport(sign *Sign, destinations []TransportDestination, transporterID int32) error +} + +// Zone interface for sign interactions +type Zone interface { + GetTransporters(client Client, transporterID int32) ([]TransportDestination, error) + ProcessEntityCommand(command *EntityCommand, player Player, target *spawn.Spawn) error +} + +// Database interface for sign persistence +type Database interface { + GetZoneName(zoneID int32) (string, error) + GetCharacterName(charID int32) (string, error) + SaveSignMark(charID int32, widgetID int32, charName string, client Client) error + LoadSigns(zoneID int32) ([]*Sign, error) + SaveSign(sign *Sign) error + DeleteSign(signID int32) error +} + +// TransportDestination represents a transport destination +type TransportDestination struct { + ID int32 + Name string + Description string + ZoneID int32 + X float32 + Y float32 + Z float32 + Heading float32 +} + +// EntityCommand represents a command that can be executed on an entity +type EntityCommand struct { + ID int32 + Command string + Name string + Description string +} + +// Logger interface for sign logging +type Logger interface { + LogInfo(message string, args ...interface{}) + LogError(message string, args ...interface{}) + LogDebug(message string, args ...interface{}) + LogWarning(message string, args ...interface{}) +} + +// SignSpawn provides sign functionality for spawn entities +type SignSpawn struct { + *spawn.Spawn + *Sign +} + +// NewSignSpawn creates a new sign spawn wrapper +func NewSignSpawn(baseSpawn *spawn.Spawn) *SignSpawn { + sign := NewSign() + sign.Spawn = baseSpawn + + return &SignSpawn{ + Spawn: baseSpawn, + Sign: sign, + } +} + +// IsSign returns true since this is a sign +func (ss *SignSpawn) IsSign() bool { + return true +} + +// HandleUse delegates to the sign's HandleUse method +func (ss *SignSpawn) HandleUse(client Client, command string) error { + return ss.Sign.HandleUse(client, command) +} + +// Copy creates a copy of the sign spawn +func (ss *SignSpawn) Copy() *SignSpawn { + newSign := ss.Sign.Copy() + newSpawn := ss.Spawn.Copy() + + return &SignSpawn{ + Spawn: newSpawn, + Sign: newSign, + } +} + +// SignAware interface for entities that can interact with signs +type SignAware interface { + GetSign() *Sign + IsSign() bool + HandleSignUse(client Client, command string) error +} + +// SignAdapter provides sign functionality for any entity +type SignAdapter struct { + entity Entity + sign *Sign + logger Logger +} + +// Entity interface for things that can have sign functionality +type Entity interface { + GetID() int32 + GetName() string + GetDatabaseID() int32 +} + +// NewSignAdapter creates a new sign adapter +func NewSignAdapter(entity Entity, logger Logger) *SignAdapter { + return &SignAdapter{ + entity: entity, + sign: NewSign(), + logger: logger, + } +} + +// GetSign returns the sign +func (sa *SignAdapter) GetSign() *Sign { + return sa.sign +} + +// IsSign returns true since this has sign functionality +func (sa *SignAdapter) IsSign() bool { + return true +} + +// HandleSignUse handles sign usage +func (sa *SignAdapter) HandleSignUse(client Client, command string) error { + if sa.logger != nil { + sa.logger.LogDebug("Entity %d (%s): Handling sign use with command '%s'", + sa.entity.GetID(), sa.entity.GetName(), command) + } + + return sa.sign.HandleUse(client, command) +} + +// SetSignTitle sets the sign title +func (sa *SignAdapter) SetSignTitle(title string) { + sa.sign.SetSignTitle(title) + + if sa.logger != nil { + sa.logger.LogDebug("Entity %d (%s): Set sign title to '%s'", + sa.entity.GetID(), sa.entity.GetName(), title) + } +} + +// SetSignDescription sets the sign description +func (sa *SignAdapter) SetSignDescription(description string) { + sa.sign.SetSignDescription(description) + + if sa.logger != nil { + sa.logger.LogDebug("Entity %d (%s): Set sign description", + sa.entity.GetID(), sa.entity.GetName()) + } +} + +// SetSignType sets the sign type +func (sa *SignAdapter) SetSignType(signType int8) { + sa.sign.SetSignType(signType) + + if sa.logger != nil { + sa.logger.LogDebug("Entity %d (%s): Set sign type to %d", + sa.entity.GetID(), sa.entity.GetName(), signType) + } +} + +// SetZoneTransport configures the sign for zone transport +func (sa *SignAdapter) SetZoneTransport(zoneID int32, x, y, z, heading float32) { + sa.sign.SetSignType(SignTypeZone) + sa.sign.SetSignZoneID(zoneID) + sa.sign.SetSignZoneX(x) + sa.sign.SetSignZoneY(y) + sa.sign.SetSignZoneZ(z) + sa.sign.SetSignZoneHeading(heading) + + if sa.logger != nil { + sa.logger.LogDebug("Entity %d (%s): Configured zone transport to zone %d at (%.2f, %.2f, %.2f)", + sa.entity.GetID(), sa.entity.GetName(), zoneID, x, y, z) + } +} + +// SetSignDistance sets the interaction distance +func (sa *SignAdapter) SetSignDistance(distance float32) { + sa.sign.SetSignDistance(distance) + + if sa.logger != nil { + sa.logger.LogDebug("Entity %d (%s): Set sign distance to %.2f", + sa.entity.GetID(), sa.entity.GetName(), distance) + } +} + +// Validate validates the sign configuration +func (sa *SignAdapter) Validate() []string { + return sa.sign.Validate() +} + +// IsValid returns true if the sign is valid +func (sa *SignAdapter) IsValid() bool { + return sa.sign.IsValid() +} \ No newline at end of file diff --git a/internal/sign/manager.go b/internal/sign/manager.go new file mode 100644 index 0000000..46134ee --- /dev/null +++ b/internal/sign/manager.go @@ -0,0 +1,471 @@ +package sign + +import ( + "fmt" + "sync" +) + +// Manager provides high-level management of the sign system +type Manager struct { + signs map[int32]*Sign // Signs by ID + signsByZone map[int32][]*Sign // Signs by zone ID + signsByWidget map[int32]*Sign // Signs by widget ID + database Database + logger Logger + mutex sync.RWMutex + + // Statistics + totalSigns int64 + signsByType map[int8]int64 // Sign type -> count + signInteractions int64 + zoneTransports int64 + transporterUses int64 +} + +// NewManager creates a new sign manager +func NewManager(database Database, logger Logger) *Manager { + return &Manager{ + signs: make(map[int32]*Sign), + signsByZone: make(map[int32][]*Sign), + signsByWidget: make(map[int32]*Sign), + database: database, + logger: logger, + signsByType: make(map[int8]int64), + } +} + +// Initialize loads signs from database +func (m *Manager) Initialize() error { + if m.logger != nil { + m.logger.LogInfo("Initializing sign manager...") + } + + // TODO: Load all signs from database when database system is integrated + // This would typically iterate through all zones and load their signs + + return nil +} + +// LoadZoneSigns loads signs for a specific zone +func (m *Manager) LoadZoneSigns(zoneID int32) error { + if m.database == nil { + return fmt.Errorf("database is nil") + } + + signs, err := m.database.LoadSigns(zoneID) + if err != nil { + return fmt.Errorf("failed to load signs for zone %d: %w", zoneID, err) + } + + m.mutex.Lock() + defer m.mutex.Unlock() + + for _, sign := range signs { + m.addSignUnsafe(sign) + } + + if m.logger != nil { + m.logger.LogInfo("Loaded %d signs for zone %d", len(signs), zoneID) + } + + return nil +} + +// AddSign adds a sign to the manager +func (m *Manager) AddSign(sign *Sign) error { + if sign == nil { + return fmt.Errorf("sign is nil") + } + + // Validate the sign + if issues := sign.Validate(); len(issues) > 0 { + return fmt.Errorf("sign validation failed: %v", issues) + } + + m.mutex.Lock() + defer m.mutex.Unlock() + + m.addSignUnsafe(sign) + + if m.logger != nil { + m.logger.LogInfo("Added sign %d (widget %d) of type %d", + sign.Spawn.GetDatabaseID(), sign.GetWidgetID(), sign.GetSignType()) + } + + return nil +} + +// addSignUnsafe adds a sign without locking (internal use) +func (m *Manager) addSignUnsafe(sign *Sign) { + signID := sign.Spawn.GetDatabaseID() + widgetID := sign.GetWidgetID() + + // Add to main collection + m.signs[signID] = sign + + // Add to widget collection + if widgetID > 0 { + m.signsByWidget[widgetID] = sign + } + + // Add to zone collection + if sign.Spawn != nil { + zoneID := sign.Spawn.GetZone() + m.signsByZone[zoneID] = append(m.signsByZone[zoneID], sign) + } + + // Update statistics + m.totalSigns++ + m.signsByType[sign.GetSignType()]++ +} + +// RemoveSign removes a sign from the manager +func (m *Manager) RemoveSign(signID int32) bool { + m.mutex.Lock() + defer m.mutex.Unlock() + + sign, exists := m.signs[signID] + if !exists { + return false + } + + // Remove from main collection + delete(m.signs, signID) + + // Remove from widget collection + if sign.GetWidgetID() > 0 { + delete(m.signsByWidget, sign.GetWidgetID()) + } + + // Remove from zone collection + if sign.Spawn != nil { + zoneID := sign.Spawn.GetZone() + if zoneSigns, exists := m.signsByZone[zoneID]; exists { + for i, zoneSign := range zoneSigns { + if zoneSign == sign { + m.signsByZone[zoneID] = append(zoneSigns[:i], zoneSigns[i+1:]...) + break + } + } + } + } + + // Update statistics + m.totalSigns-- + m.signsByType[sign.GetSignType()]-- + + if m.logger != nil { + m.logger.LogInfo("Removed sign %d (widget %d)", signID, sign.GetWidgetID()) + } + + return true +} + +// GetSign returns a sign by ID +func (m *Manager) GetSign(signID int32) *Sign { + m.mutex.RLock() + defer m.mutex.RUnlock() + + return m.signs[signID] +} + +// GetSignByWidget returns a sign by widget ID +func (m *Manager) GetSignByWidget(widgetID int32) *Sign { + m.mutex.RLock() + defer m.mutex.RUnlock() + + return m.signsByWidget[widgetID] +} + +// GetZoneSigns returns all signs in a zone +func (m *Manager) GetZoneSigns(zoneID int32) []*Sign { + m.mutex.RLock() + defer m.mutex.RUnlock() + + signs := m.signsByZone[zoneID] + + // Return a copy to prevent external modification + result := make([]*Sign, len(signs)) + copy(result, signs) + + return result +} + +// GetSignsByType returns all signs of a specific type +func (m *Manager) GetSignsByType(signType int8) []*Sign { + m.mutex.RLock() + defer m.mutex.RUnlock() + + var result []*Sign + for _, sign := range m.signs { + if sign.GetSignType() == signType { + result = append(result, sign) + } + } + + return result +} + +// SaveSign saves a sign to database +func (m *Manager) SaveSign(sign *Sign) error { + if m.database == nil { + return fmt.Errorf("database is nil") + } + + if sign == nil { + return fmt.Errorf("sign is nil") + } + + err := m.database.SaveSign(sign) + if err != nil { + return fmt.Errorf("failed to save sign: %w", err) + } + + if m.logger != nil { + m.logger.LogDebug("Saved sign %d to database", sign.Spawn.GetDatabaseID()) + } + + return nil +} + +// HandleSignUse processes sign usage and records statistics +func (m *Manager) HandleSignUse(sign *Sign, client Client, command string) error { + if sign == nil { + return fmt.Errorf("sign is nil") + } + + m.mutex.Lock() + m.signInteractions++ + m.mutex.Unlock() + + err := sign.HandleUse(client, command) + + // Record specific interaction types for statistics + if err == nil { + m.mutex.Lock() + if sign.IsZoneSign() { + m.zoneTransports++ + } + if sign.Spawn != nil && sign.Spawn.GetTransporterID() > 0 { + m.transporterUses++ + } + m.mutex.Unlock() + } + + if m.logger != nil { + if err != nil { + m.logger.LogError("Sign %d use failed: %v", sign.Spawn.GetDatabaseID(), err) + } else { + m.logger.LogDebug("Sign %d used successfully by character %d", + sign.Spawn.GetDatabaseID(), client.GetCharacterID()) + } + } + + return err +} + +// GetStatistics returns sign system statistics +func (m *Manager) GetStatistics() map[string]interface{} { + m.mutex.RLock() + defer m.mutex.RUnlock() + + stats := make(map[string]interface{}) + stats["total_signs"] = m.totalSigns + stats["sign_interactions"] = m.signInteractions + stats["zone_transports"] = m.zoneTransports + stats["transporter_uses"] = m.transporterUses + + // Copy sign type statistics + typeStats := make(map[int8]int64) + for signType, count := range m.signsByType { + typeStats[signType] = count + } + stats["signs_by_type"] = typeStats + + // Zone statistics + zoneStats := make(map[int32]int) + for zoneID, signs := range m.signsByZone { + zoneStats[zoneID] = len(signs) + } + stats["signs_by_zone"] = zoneStats + + return stats +} + +// ResetStatistics resets all statistics +func (m *Manager) ResetStatistics() { + m.mutex.Lock() + defer m.mutex.Unlock() + + m.signInteractions = 0 + m.zoneTransports = 0 + m.transporterUses = 0 +} + +// ValidateAllSigns validates all signs in the system +func (m *Manager) ValidateAllSigns() map[int32][]string { + m.mutex.RLock() + defer m.mutex.RUnlock() + + issues := make(map[int32][]string) + + for signID, sign := range m.signs { + if signIssues := sign.Validate(); len(signIssues) > 0 { + issues[signID] = signIssues + } + } + + return issues +} + +// GetSignCount returns the total number of signs +func (m *Manager) GetSignCount() int64 { + m.mutex.RLock() + defer m.mutex.RUnlock() + + return m.totalSigns +} + +// GetSignTypeCount returns the number of signs of a specific type +func (m *Manager) GetSignTypeCount(signType int8) int64 { + m.mutex.RLock() + defer m.mutex.RUnlock() + + return m.signsByType[signType] +} + +// ProcessCommand handles sign-related commands +func (m *Manager) ProcessCommand(command string, args []string) (string, error) { + switch command { + case "stats": + return m.handleStatsCommand(args) + case "validate": + return m.handleValidateCommand(args) + case "list": + return m.handleListCommand(args) + case "info": + return m.handleInfoCommand(args) + default: + return "", fmt.Errorf("unknown sign command: %s", command) + } +} + +// handleStatsCommand shows sign system statistics +func (m *Manager) handleStatsCommand(args []string) (string, error) { + stats := m.GetStatistics() + + result := "Sign System Statistics:\n" + result += fmt.Sprintf("Total Signs: %d\n", stats["total_signs"]) + result += fmt.Sprintf("Sign Interactions: %d\n", stats["sign_interactions"]) + result += fmt.Sprintf("Zone Transports: %d\n", stats["zone_transports"]) + result += fmt.Sprintf("Transporter Uses: %d\n", stats["transporter_uses"]) + + typeStats := stats["signs_by_type"].(map[int8]int64) + result += fmt.Sprintf("Generic Signs: %d\n", typeStats[SignTypeGeneric]) + result += fmt.Sprintf("Zone Signs: %d\n", typeStats[SignTypeZone]) + + return result, nil +} + +// handleValidateCommand validates all signs +func (m *Manager) handleValidateCommand(args []string) (string, error) { + issues := m.ValidateAllSigns() + + if len(issues) == 0 { + return "All signs are valid.", nil + } + + result := fmt.Sprintf("Found issues with %d signs:\n", len(issues)) + count := 0 + for signID, signIssues := range issues { + if count >= 10 { // Limit output + result += "... (and more)\n" + break + } + result += fmt.Sprintf("Sign %d:\n", signID) + for _, issue := range signIssues { + result += fmt.Sprintf(" - %s\n", issue) + } + count++ + } + + return result, nil +} + +// handleListCommand lists signs +func (m *Manager) handleListCommand(args []string) (string, error) { + m.mutex.RLock() + defer m.mutex.RUnlock() + + if len(m.signs) == 0 { + return "No signs loaded.", nil + } + + result := fmt.Sprintf("Signs (%d):\n", len(m.signs)) + count := 0 + for signID, sign := range m.signs { + if count >= 20 { // Limit output + result += "... (and more)\n" + break + } + + typeName := "Generic" + if sign.GetSignType() == SignTypeZone { + typeName = "Zone" + } + + result += fmt.Sprintf(" %d: %s (%s, Widget: %d)\n", + signID, sign.GetSignTitle(), typeName, sign.GetWidgetID()) + count++ + } + + return result, nil +} + +// handleInfoCommand shows information about a specific sign +func (m *Manager) handleInfoCommand(args []string) (string, error) { + if len(args) == 0 { + return "", fmt.Errorf("sign ID required") + } + + // Parse sign ID + var signID int32 + if _, err := fmt.Sscanf(args[0], "%d", &signID); err != nil { + return "", fmt.Errorf("invalid sign ID: %s", args[0]) + } + + sign := m.GetSign(signID) + if sign == nil { + return fmt.Sprintf("Sign %d not found.", signID), nil + } + + result := fmt.Sprintf("Sign Information:\n") + result += fmt.Sprintf("ID: %d\n", signID) + result += fmt.Sprintf("Widget ID: %d\n", sign.GetWidgetID()) + result += fmt.Sprintf("Type: %d\n", sign.GetSignType()) + result += fmt.Sprintf("Title: %s\n", sign.GetSignTitle()) + result += fmt.Sprintf("Description: %s\n", sign.GetSignDescription()) + result += fmt.Sprintf("Language: %d\n", sign.GetLanguage()) + + if sign.IsZoneSign() { + result += fmt.Sprintf("Zone ID: %d\n", sign.GetSignZoneID()) + result += fmt.Sprintf("Zone Coords: (%.2f, %.2f, %.2f)\n", + sign.GetSignZoneX(), sign.GetSignZoneY(), sign.GetSignZoneZ()) + result += fmt.Sprintf("Zone Heading: %.2f\n", sign.GetSignZoneHeading()) + result += fmt.Sprintf("Distance: %.2f\n", sign.GetSignDistance()) + } + + result += fmt.Sprintf("Include Location: %t\n", sign.GetIncludeLocation()) + result += fmt.Sprintf("Include Heading: %t\n", sign.GetIncludeHeading()) + + return result, nil +} + +// Shutdown gracefully shuts down the manager +func (m *Manager) Shutdown() { + if m.logger != nil { + m.logger.LogInfo("Shutting down sign manager...") + } + + // Nothing to clean up currently, but placeholder for future cleanup +} \ No newline at end of file diff --git a/internal/sign/sign.go b/internal/sign/sign.go new file mode 100644 index 0000000..df4ce48 --- /dev/null +++ b/internal/sign/sign.go @@ -0,0 +1,297 @@ +package sign + +import ( + "fmt" + "math/rand" + "strings" + "eq2emu/internal/spawn" +) + +// Copy creates a deep copy of the sign with size randomization +func (s *Sign) Copy() *Sign { + newSign := NewSign() + + // Copy spawn data + if s.Spawn != nil { + // Handle size randomization like the C++ version + if s.Spawn.GetSizeOffset() > 0 { + offset := s.Spawn.GetSizeOffset() + 1 + tmpSize := int32(s.Spawn.GetSize()) + (rand.Int31n(int32(offset)) - rand.Int31n(int32(offset))) + + if tmpSize < 0 { + tmpSize = 1 + } else if tmpSize >= 0xFFFF { + tmpSize = 0xFFFF + } + + newSign.Spawn.SetSize(int16(tmpSize)) + } else { + newSign.Spawn.SetSize(s.Spawn.GetSize()) + } + + // Copy other spawn properties + newSign.Spawn.SetDatabaseID(s.Spawn.GetDatabaseID()) + newSign.Spawn.SetMerchantID(s.Spawn.GetMerchantID()) + newSign.Spawn.SetMerchantType(s.Spawn.GetMerchantType()) + // TODO: Copy appearance data when spawn system is fully integrated + // TODO: Copy command lists when command system is integrated + // TODO: Copy transporter ID, sounds, loot properties, etc. + } + + // Copy sign-specific properties + newSign.widgetID = s.widgetID + newSign.widgetX = s.widgetX + newSign.widgetY = s.widgetY + newSign.widgetZ = s.widgetZ + newSign.signType = s.signType + newSign.title = s.title + newSign.description = s.description + newSign.language = s.language + newSign.zoneX = s.zoneX + newSign.zoneY = s.zoneY + newSign.zoneZ = s.zoneZ + newSign.zoneHeading = s.zoneHeading + newSign.zoneID = s.zoneID + newSign.signDistance = s.signDistance + newSign.includeLocation = s.includeLocation + newSign.includeHeading = s.includeHeading + + return newSign +} + +// Serialize creates a packet for sending the sign to a client +func (s *Sign) Serialize(player Player, version int16) ([]byte, error) { + // Delegate to spawn serialization + if s.Spawn != nil { + return s.Spawn.Serialize(player, version) + } + + return nil, fmt.Errorf("spawn is nil") +} + +// HandleUse processes player interaction with the sign +func (s *Sign) HandleUse(client Client, command string) error { + if client == nil { + return fmt.Errorf("client is nil") + } + + player := client.GetPlayer() + if player == nil { + return fmt.Errorf("player is nil") + } + + // Check quest requirements if this is from a client (not script) + if !s.meetsQuestRequirements(client) { + return nil // Silently fail if quest requirements not met + } + + // Handle transporter functionality first + if s.Spawn != nil && s.Spawn.GetTransporterID() > 0 { + return s.handleTransporter(client) + } + + // Handle zone transport signs + if s.signType == SignTypeZone && s.zoneID > 0 { + return s.handleZoneTransport(client) + } + + // Handle entity commands + if len(command) > 0 { + return s.handleEntityCommand(client, command) + } + + return nil +} + +// meetsQuestRequirements checks if the player meets quest requirements to use the sign +func (s *Sign) meetsQuestRequirements(client Client) bool { + // This is a placeholder implementation + // In the full implementation, this would check: + // - MeetsSpawnAccessRequirements(client.GetPlayer()) + // - GetQuestsRequiredOverride() flags + // - appearance.show_command_icon + + // For now, assume all requirements are met + return true +} + +// handleTransporter processes transporter functionality +func (s *Sign) handleTransporter(client Client) error { + zone := client.GetPlayer().GetZone() + if zone == nil { + return fmt.Errorf("player not in zone") + } + + transporterID := s.Spawn.GetTransporterID() + + // Get transport destinations + destinations, err := zone.GetTransporters(client, transporterID) + if err != nil { + return fmt.Errorf("failed to get transporters: %w", err) + } + + if len(destinations) > 0 { + client.SetTemporaryTransportID(0) + return client.ProcessTeleport(s, destinations, transporterID) + } + + return nil +} + +// handleZoneTransport processes zone transport functionality +func (s *Sign) handleZoneTransport(client Client) error { + player := client.GetPlayer() + + // Check distance if sign has distance requirement + if s.signDistance > 0 { + distance := player.GetDistance(s.Spawn) + if distance > s.signDistance { + client.SimpleMessage(ChannelColorYellow, "You are too far away!") + return nil + } + } + + // Get zone name from database + zoneName, err := client.GetDatabase().GetZoneName(s.zoneID) + if err != nil || len(zoneName) == 0 { + client.Message(ChannelColorYellow, "Unable to find zone with ID: %d", s.zoneID) + return fmt.Errorf("zone not found: %d", s.zoneID) + } + + // Check zone access + if !client.CheckZoneAccess(zoneName) { + return nil // Access denied (client handles message) + } + + // Set coordinates if sign has valid zone coordinates + useZoneDefaults := !s.HasZoneCoordinates() + + if !useZoneDefaults { + player.SetX(s.zoneX) + player.SetY(s.zoneY) + player.SetZ(s.zoneZ) + player.SetHeading(s.zoneHeading) + } else { + client.SimpleMessage(ChannelColorYellow, "Invalid zone in coords, taking you to a safe point.") + } + + // Try instanced zone first, then regular zone + if !client.TryZoneInstance(s.zoneID, useZoneDefaults) { + return client.Zone(zoneName, useZoneDefaults) + } + + return nil +} + +// handleEntityCommand processes entity commands +func (s *Sign) handleEntityCommand(client Client, command string) error { + if s.Spawn == nil { + return fmt.Errorf("spawn is nil") + } + + entityCommand := s.Spawn.FindEntityCommand(command) + if entityCommand == nil { + return nil // Command not found + } + + // Handle mark command specially + if strings.ToLower(entityCommand.Command) == "mark" { + return s.handleMarkCommand(client) + } + + // Process the entity command + zone := client.GetCurrentZone() + if zone == nil { + return fmt.Errorf("player not in zone") + } + + player := client.GetPlayer() + target := player.GetTarget() + + return zone.ProcessEntityCommand(entityCommand, player, target) +} + +// handleMarkCommand processes the mark command for marking signs +func (s *Sign) handleMarkCommand(client Client) error { + charID := client.GetCharacterID() + charName, err := client.GetDatabase().GetCharacterName(charID) + if err != nil { + return fmt.Errorf("failed to get character name: %w", err) + } + + return client.GetDatabase().SaveSignMark(charID, s.widgetID, charName, client) +} + +// GetDisplayText returns the formatted display text for the sign +func (s *Sign) GetDisplayText() string { + var text strings.Builder + + if s.HasTitle() { + text.WriteString(s.title) + } + + if s.HasDescription() { + if text.Len() > 0 { + text.WriteByte('\n') + } + text.WriteString(s.description) + } + + // Add location information if requested + if s.includeLocation && s.HasZoneCoordinates() { + if text.Len() > 0 { + text.WriteByte('\n') + } + text.WriteString(fmt.Sprintf("Location: %.2f, %.2f, %.2f", s.zoneX, s.zoneY, s.zoneZ)) + } + + // Add heading information if requested + if s.includeHeading && s.zoneHeading != 0 { + if text.Len() > 0 { + text.WriteByte('\n') + } + text.WriteString(fmt.Sprintf("Heading: %.2f", s.zoneHeading)) + } + + return text.String() +} + +// Validate checks if the sign configuration is valid +func (s *Sign) Validate() []string { + var issues []string + + if s.Spawn == nil { + issues = append(issues, "Sign has no spawn data") + return issues + } + + if s.widgetID == 0 { + issues = append(issues, "Sign has no widget ID") + } + + if len(s.title) > MaxSignTitleLength { + issues = append(issues, fmt.Sprintf("Sign title too long: %d > %d", len(s.title), MaxSignTitleLength)) + } + + if len(s.description) > MaxSignDescriptionLength { + issues = append(issues, fmt.Sprintf("Sign description too long: %d > %d", len(s.description), MaxSignDescriptionLength)) + } + + if s.signType == SignTypeZone { + if s.zoneID == 0 { + issues = append(issues, "Zone sign has no zone ID") + } + + if s.signDistance < 0 { + issues = append(issues, "Sign distance cannot be negative") + } + } + + return issues +} + +// IsValid returns true if the sign configuration is valid +func (s *Sign) IsValid() bool { + issues := s.Validate() + return len(issues) == 0 +} \ No newline at end of file diff --git a/internal/sign/types.go b/internal/sign/types.go new file mode 100644 index 0000000..f4b1574 --- /dev/null +++ b/internal/sign/types.go @@ -0,0 +1,235 @@ +package sign + +import "eq2emu/internal/spawn" + +// Sign represents a clickable sign in the game world that extends Spawn +type Sign struct { + *spawn.Spawn // Embed spawn for basic functionality + + // Widget properties + widgetID int32 // Widget identifier + widgetX float32 // Widget X coordinate + widgetY float32 // Widget Y coordinate + widgetZ float32 // Widget Z coordinate + + // Sign properties + signType int8 // Type of sign (generic or zone) + title string // Sign title + description string // Sign description + language int8 // Language of the sign text + + // Zone transport properties + zoneX float32 // Target zone X coordinate + zoneY float32 // Target zone Y coordinate + zoneZ float32 // Target zone Z coordinate + zoneHeading float32 // Target zone heading + zoneID int32 // Target zone ID + signDistance float32 // Maximum interaction distance + + // Display options + includeLocation bool // Whether to include location in display + includeHeading bool // Whether to include heading in display +} + +// NewSign creates a new sign with default values +func NewSign() *Sign { + baseSpawn := spawn.NewSpawn() + + // Set spawn-specific defaults for signs + baseSpawn.SetSpawnType(DefaultSpawnType) + // TODO: Set appearance properties when spawn system is integrated + // appearance.pos.state = DefaultPosState + // appearance.difficulty = DefaultDifficulty + // appearance.activity_status = DefaultActivityStatus + + return &Sign{ + Spawn: baseSpawn, + widgetID: 0, + widgetX: 0, + widgetY: 0, + widgetZ: 0, + signType: SignTypeGeneric, + title: "", + description: "", + language: 0, + zoneX: 0, + zoneY: 0, + zoneZ: 0, + zoneHeading: 0, + zoneID: 0, + signDistance: DefaultSignDistance, + includeLocation: false, + includeHeading: false, + } +} + +// IsSign returns true since this is a sign +func (s *Sign) IsSign() bool { + return true +} + +// Widget ID methods +func (s *Sign) GetWidgetID() int32 { + return s.widgetID +} + +func (s *Sign) SetWidgetID(id int32) { + s.widgetID = id +} + +// Widget position methods +func (s *Sign) GetWidgetX() float32 { + return s.widgetX +} + +func (s *Sign) SetWidgetX(x float32) { + s.widgetX = x +} + +func (s *Sign) GetWidgetY() float32 { + return s.widgetY +} + +func (s *Sign) SetWidgetY(y float32) { + s.widgetY = y +} + +func (s *Sign) GetWidgetZ() float32 { + return s.widgetZ +} + +func (s *Sign) SetWidgetZ(z float32) { + s.widgetZ = z +} + +// Sign type methods +func (s *Sign) GetSignType() int8 { + return s.signType +} + +func (s *Sign) SetSignType(signType int8) { + s.signType = signType +} + +// Title and description methods +func (s *Sign) GetSignTitle() string { + return s.title +} + +func (s *Sign) SetSignTitle(title string) { + s.title = title +} + +func (s *Sign) GetSignDescription() string { + return s.description +} + +func (s *Sign) SetSignDescription(description string) { + s.description = description +} + +// Language methods +func (s *Sign) GetLanguage() int8 { + return s.language +} + +func (s *Sign) SetLanguage(language int8) { + s.language = language +} + +// Zone transport methods +func (s *Sign) GetSignZoneX() float32 { + return s.zoneX +} + +func (s *Sign) SetSignZoneX(x float32) { + s.zoneX = x +} + +func (s *Sign) GetSignZoneY() float32 { + return s.zoneY +} + +func (s *Sign) SetSignZoneY(y float32) { + s.zoneY = y +} + +func (s *Sign) GetSignZoneZ() float32 { + return s.zoneZ +} + +func (s *Sign) SetSignZoneZ(z float32) { + s.zoneZ = z +} + +func (s *Sign) GetSignZoneHeading() float32 { + return s.zoneHeading +} + +func (s *Sign) SetSignZoneHeading(heading float32) { + s.zoneHeading = heading +} + +func (s *Sign) GetSignZoneID() int32 { + return s.zoneID +} + +func (s *Sign) SetSignZoneID(zoneID int32) { + s.zoneID = zoneID +} + +func (s *Sign) GetSignDistance() float32 { + return s.signDistance +} + +func (s *Sign) SetSignDistance(distance float32) { + s.signDistance = distance +} + +// Display option methods +func (s *Sign) GetIncludeLocation() bool { + return s.includeLocation +} + +func (s *Sign) SetIncludeLocation(include bool) { + s.includeLocation = include +} + +func (s *Sign) GetIncludeHeading() bool { + return s.includeHeading +} + +func (s *Sign) SetIncludeHeading(include bool) { + s.includeHeading = include +} + +// SetSignIcon sets the sign's icon (delegates to spawn appearance) +func (s *Sign) SetSignIcon(icon int8) { + // TODO: Implement when spawn appearance system is integrated + // s.appearance.icon = icon +} + +// HasZoneCoordinates returns true if the sign has valid zone coordinates +func (s *Sign) HasZoneCoordinates() bool { + return !(s.zoneX == 0 && s.zoneY == 0 && s.zoneZ == 0 && s.zoneHeading == 0) +} + +// HasTitle returns true if the sign has a title +func (s *Sign) HasTitle() bool { + return len(s.title) > 0 +} + +// HasDescription returns true if the sign has a description +func (s *Sign) HasDescription() bool { + return len(s.description) > 0 +} + +// IsZoneSign returns true if this is a zone transport sign +func (s *Sign) IsZoneSign() bool { + return s.signType == SignTypeZone +} + +// IsGenericSign returns true if this is a generic sign +func (s *Sign) IsGenericSign() bool { + return s.signType == SignTypeGeneric +} \ No newline at end of file diff --git a/internal/skills/constants.go b/internal/skills/constants.go new file mode 100644 index 0000000..987615d --- /dev/null +++ b/internal/skills/constants.go @@ -0,0 +1,74 @@ +package skills + +// Skill type constants +const ( + SkillTypeWeaponry = 1 + SkillTypeSpellcasting = 2 + SkillTypeAvoidance = 3 + SkillTypeArmor = 4 + SkillTypeShield = 5 + SkillTypeHarvesting = 6 + SkillTypeArtisan = 7 + SkillTypeCraftsman = 8 + SkillTypeOutfitter = 9 + SkillTypeScholar = 10 + SkillTypeGeneral = 13 + SkillTypeLanguage = 14 + SkillTypeClass = 15 + SkillTypeCombat = 16 + SkillTypeWeapon = 17 + SkillTypeTSKnowledge = 18 +) + +// DoF (Desert of Flames) skill type constants +const ( + SkillTypeGeneralDoF = 11 + SkillTypeLanguageDoF = 12 + SkillTypeClassDoF = 13 + SkillTypeCombatDoF = 14 + SkillTypeWeaponDoF = 15 + SkillTypeTSKnowledgeDoF = 16 +) + +// Special skill IDs +const ( + SkillIDSculpting = 1039865549 + SkillIDArtistry = 3881305672 + SkillIDFletching = 3076004370 + SkillIDMetalworking = 4032608519 + SkillIDMetalshaping = 3108933728 + SkillIDTailoring = 2082133324 + SkillIDChemistry = 2557647574 + SkillIDArtificing = 3330500131 + SkillIDScribing = 773137566 +) + +// Skills that update current_value to max_value when max_value is updated +const ( + SkillIDDualwield = 1852383242 + SkillIDFists = 3177806075 + SkillIDDestroying = 3429135390 + SkillIDMagicAffinity = 2072844078 +) + +// Weapon skill IDs +const ( + SkillIDGreatsword = 2292577688 // 2h slashing + SkillIDGreatspear = 2380184628 // 2h piercing + SkillIDStaff = 3180399725 // 2h crushing +) + +// Disarm skill check results +const ( + DisarmSuccess = 1 + DisarmFail = 0 + DisarmTrigger = -1 +) + +// Skill increase constants +const ( + // Base skill increase chance percentage (at skill level 1) + BaseSkillIncreasePercent = 20 + // Max skill level for calculating increase chances + MaxSkillLevelForIncrease = 400 +) \ No newline at end of file diff --git a/internal/skills/integration.go b/internal/skills/integration.go new file mode 100644 index 0000000..b9eaf29 --- /dev/null +++ b/internal/skills/integration.go @@ -0,0 +1,253 @@ +package skills + +import "fmt" + +// SkillAware interface for entities that have skills +type SkillAware interface { + GetSkillList() *PlayerSkillList + GetSkillByName(name string) *Skill + GetSkill(skillID int32) *Skill + HasSkill(skillID int32) bool + IncreaseSkill(skillName string, amount int32) error +} + +// PacketSender interface for sending skill packets to clients +type PacketSender interface { + QueuePacket(packet []byte) + GetVersion() int32 +} + +// Database interface for skill persistence +type Database interface { + LoadPlayerSkills(characterID int32) ([]*Skill, error) + SavePlayerSkill(characterID int32, skill *Skill) error + LoadMasterSkills() ([]*Skill, error) + SaveMasterSkill(skill *Skill) error +} + +// Logger interface for skill system logging +type Logger interface { + LogInfo(message string, args ...interface{}) + LogError(message string, args ...interface{}) + LogDebug(message string, args ...interface{}) +} + +// EntitySkillAdapter provides skill functionality for entities +type EntitySkillAdapter struct { + skillList *PlayerSkillList + entityID int32 + logger Logger +} + +// NewEntitySkillAdapter creates a new entity skill adapter +func NewEntitySkillAdapter(entityID int32, logger Logger) *EntitySkillAdapter { + return &EntitySkillAdapter{ + skillList: NewPlayerSkillList(), + entityID: entityID, + logger: logger, + } +} + +// GetSkillList returns the player's skill list +func (esa *EntitySkillAdapter) GetSkillList() *PlayerSkillList { + return esa.skillList +} + +// GetSkillByName returns a skill by name +func (esa *EntitySkillAdapter) GetSkillByName(name string) *Skill { + return esa.skillList.GetSkillByName(name) +} + +// GetSkill returns a skill by ID +func (esa *EntitySkillAdapter) GetSkill(skillID int32) *Skill { + return esa.skillList.GetSkill(skillID) +} + +// HasSkill checks if the entity has a skill +func (esa *EntitySkillAdapter) HasSkill(skillID int32) bool { + return esa.skillList.HasSkill(skillID) +} + +// IncreaseSkill increases a skill by name +func (esa *EntitySkillAdapter) IncreaseSkill(skillName string, amount int32) error { + skill := esa.skillList.GetSkillByName(skillName) + if skill == nil { + if esa.logger != nil { + esa.logger.LogError("Entity %d: Skill '%s' not found for increase", esa.entityID, skillName) + } + return fmt.Errorf("skill '%s' not found", skillName) + } + + esa.skillList.IncreaseSkill(skill, int16(amount)) + + if esa.logger != nil { + esa.logger.LogDebug("Entity %d: Increased skill '%s' by %d (now %d/%d)", + esa.entityID, skillName, amount, skill.CurrentVal, skill.MaxVal) + } + + return nil +} + +// AddSkill adds a new skill to the entity +func (esa *EntitySkillAdapter) AddSkill(skill *Skill) { + if skill == nil { + return + } + + esa.skillList.AddSkill(skill) + + if esa.logger != nil { + esa.logger.LogDebug("Entity %d: Added skill '%s' (ID: %d)", esa.entityID, skill.Name.Data, skill.SkillID) + } +} + +// RemoveSkill removes a skill from the entity +func (esa *EntitySkillAdapter) RemoveSkill(skill *Skill) { + if skill == nil { + return + } + + esa.skillList.RemoveSkill(skill) + + if esa.logger != nil { + esa.logger.LogDebug("Entity %d: Removed skill '%s' (ID: %d)", esa.entityID, skill.Name.Data, skill.SkillID) + } +} + +// GetSkillValue returns a skill's current value including bonuses +func (esa *EntitySkillAdapter) GetSkillValue(skillID int32) int16 { + skill := esa.skillList.GetSkill(skillID) + if skill == nil { + return 0 + } + + return esa.skillList.CalculateSkillValue(skillID, skill.CurrentVal) +} + +// GetSkillMaxValue returns a skill's max value including bonuses +func (esa *EntitySkillAdapter) GetSkillMaxValue(skillID int32) int16 { + skill := esa.skillList.GetSkill(skillID) + if skill == nil { + return 0 + } + + return esa.skillList.CalculateSkillMaxValue(skillID, skill.MaxVal) +} + +// ApplySkillBonus applies a skill bonus from a spell +func (esa *EntitySkillAdapter) ApplySkillBonus(spellID int32, skillID int32, value float32) { + esa.skillList.AddSkillBonus(spellID, skillID, value) + + if esa.logger != nil { + esa.logger.LogDebug("Entity %d: Applied skill bonus from spell %d to skill %d: %f", + esa.entityID, spellID, skillID, value) + } +} + +// RemoveSkillBonus removes skill bonuses from a spell +func (esa *EntitySkillAdapter) RemoveSkillBonus(spellID int32) { + esa.skillList.RemoveSkillBonus(spellID) + + if esa.logger != nil { + esa.logger.LogDebug("Entity %d: Removed skill bonuses from spell %d", esa.entityID, spellID) + } +} + +// CheckSkillIncrease attempts to increase a skill +func (esa *EntitySkillAdapter) CheckSkillIncrease(skillID int32) bool { + skill := esa.skillList.GetSkill(skillID) + if skill == nil { + return false + } + + increased := esa.skillList.CheckSkillIncrease(skill) + + if increased && esa.logger != nil { + esa.logger.LogInfo("Entity %d: Skill '%s' increased to %d/%d", + esa.entityID, skill.Name.Data, skill.CurrentVal, skill.MaxVal) + } + + return increased +} + +// GetSaveNeededSkills returns skills that need database saving +func (esa *EntitySkillAdapter) GetSaveNeededSkills() []*Skill { + return esa.skillList.GetSaveNeededSkills() +} + +// GetSkillUpdates returns skills that need client updates +func (esa *EntitySkillAdapter) GetSkillUpdates() []*Skill { + return esa.skillList.GetSkillUpdates() +} + +// HasSkillUpdates returns whether there are pending skill updates +func (esa *EntitySkillAdapter) HasSkillUpdates() bool { + return esa.skillList.HasSkillUpdates() +} + +// SendSkillPacket sends skill updates to a client +func (esa *EntitySkillAdapter) SendSkillPacket(sender PacketSender) error { + if sender == nil { + return fmt.Errorf("packet sender is nil") + } + + packet, err := esa.skillList.GetSkillPacket(int16(sender.GetVersion())) + if err != nil { + return fmt.Errorf("failed to build skill packet: %w", err) + } + + sender.QueuePacket(packet) + + if esa.logger != nil { + esa.logger.LogDebug("Entity %d: Sent skill packet to client (version %d)", + esa.entityID, sender.GetVersion()) + } + + return nil +} + +// LoadSkillsFromDatabase loads skills from database (placeholder) +func (esa *EntitySkillAdapter) LoadSkillsFromDatabase(db Database, characterID int32) error { + if db == nil { + return fmt.Errorf("database is nil") + } + + skills, err := db.LoadPlayerSkills(characterID) + if err != nil { + return fmt.Errorf("failed to load player skills: %w", err) + } + + for _, skill := range skills { + esa.skillList.AddSkill(skill) + } + + if esa.logger != nil { + esa.logger.LogInfo("Entity %d: Loaded %d skills from database", esa.entityID, len(skills)) + } + + return nil +} + +// SaveSkillsToDatabase saves skills to database (placeholder) +func (esa *EntitySkillAdapter) SaveSkillsToDatabase(db Database, characterID int32) error { + if db == nil { + return fmt.Errorf("database is nil") + } + + saveSkills := esa.GetSaveNeededSkills() + + for _, skill := range saveSkills { + if err := db.SavePlayerSkill(characterID, skill); err != nil { + if esa.logger != nil { + esa.logger.LogError("Entity %d: Failed to save skill %s: %v", esa.entityID, skill.Name.Data, err) + } + return fmt.Errorf("failed to save skill %s: %w", skill.Name.Data, err) + } + } + + if len(saveSkills) > 0 && esa.logger != nil { + esa.logger.LogInfo("Entity %d: Saved %d skills to database", esa.entityID, len(saveSkills)) + } + + return nil +} \ No newline at end of file diff --git a/internal/skills/manager.go b/internal/skills/manager.go new file mode 100644 index 0000000..c24bdd0 --- /dev/null +++ b/internal/skills/manager.go @@ -0,0 +1,283 @@ +package skills + +import ( + "fmt" + "sync" +) + +// Manager provides high-level management of the skills system +type Manager struct { + masterSkillList *MasterSkillList + mutex sync.RWMutex + + // Statistics + totalSkillUps int64 + skillUpsByType map[int32]int64 // Skill type -> count + skillUpsBySkill map[int32]int64 // Skill ID -> count + playersWithSkills int64 +} + +// NewManager creates a new skills manager +func NewManager() *Manager { + return &Manager{ + masterSkillList: NewMasterSkillList(), + skillUpsByType: make(map[int32]int64), + skillUpsBySkill: make(map[int32]int64), + } +} + +// Initialize loads skills data (placeholder for database loading) +func (m *Manager) Initialize() error { + // TODO: Load skills from database when database system is integrated + // This would typically load all skills from a skills table + return nil +} + +// GetMasterSkillList returns the master skill list +func (m *Manager) GetMasterSkillList() *MasterSkillList { + return m.masterSkillList +} + +// AddSkillToMaster adds a skill to the master list +func (m *Manager) AddSkillToMaster(skill *Skill) { + m.masterSkillList.AddSkill(skill) +} + +// GetSkill returns a skill from the master list by ID +func (m *Manager) GetSkill(skillID int32) *Skill { + return m.masterSkillList.GetSkill(skillID) +} + +// GetSkillByName returns a skill from the master list by name +func (m *Manager) GetSkillByName(skillName string) *Skill { + return m.masterSkillList.GetSkillByName(skillName) +} + +// CreatePlayerSkillList creates a new player skill list +func (m *Manager) CreatePlayerSkillList() *PlayerSkillList { + m.mutex.Lock() + m.playersWithSkills++ + m.mutex.Unlock() + + return NewPlayerSkillList() +} + +// RecordSkillUp records a skill increase for statistics +func (m *Manager) RecordSkillUp(skillID int32, skillType int32) { + m.mutex.Lock() + defer m.mutex.Unlock() + + m.totalSkillUps++ + m.skillUpsByType[skillType]++ + m.skillUpsBySkill[skillID]++ +} + +// GetStatistics returns skill system statistics +func (m *Manager) GetStatistics() map[string]interface{} { + m.mutex.RLock() + defer m.mutex.RUnlock() + + stats := make(map[string]interface{}) + stats["total_skill_ups"] = m.totalSkillUps + stats["players_with_skills"] = m.playersWithSkills + stats["total_skills_in_master"] = m.masterSkillList.GetSkillCount() + + // Copy skill type statistics + typeStats := make(map[int32]int64) + for skillType, count := range m.skillUpsByType { + typeStats[skillType] = count + } + stats["skill_ups_by_type"] = typeStats + + // Copy individual skill statistics + skillStats := make(map[int32]int64) + for skillID, count := range m.skillUpsBySkill { + skillStats[skillID] = count + } + stats["skill_ups_by_skill"] = skillStats + + return stats +} + +// ResetStatistics resets all statistics +func (m *Manager) ResetStatistics() { + m.mutex.Lock() + defer m.mutex.Unlock() + + m.totalSkillUps = 0 + m.playersWithSkills = 0 + m.skillUpsByType = make(map[int32]int64) + m.skillUpsBySkill = make(map[int32]int64) +} + +// GetSkillsByType returns all skills of a specific type +func (m *Manager) GetSkillsByType(skillType int32) []*Skill { + return m.masterSkillList.GetSkillsByType(skillType) +} + +// GetSkillTypeCount returns the number of skills of a specific type +func (m *Manager) GetSkillTypeCount(skillType int32) int { + skills := m.GetSkillsByType(skillType) + return len(skills) +} + +// GetSkillUpCount returns the total number of skill ups for a skill +func (m *Manager) GetSkillUpCount(skillID int32) int64 { + m.mutex.RLock() + defer m.mutex.RUnlock() + + return m.skillUpsBySkill[skillID] +} + +// GetSkillTypeUpCount returns the total number of skill ups for a skill type +func (m *Manager) GetSkillTypeUpCount(skillType int32) int64 { + m.mutex.RLock() + defer m.mutex.RUnlock() + + return m.skillUpsByType[skillType] +} + +// ValidateSkillData validates that all skills in the master list are properly configured +func (m *Manager) ValidateSkillData() []string { + skills := m.masterSkillList.GetAllSkills() + issues := make([]string, 0) + + if len(skills) == 0 { + issues = append(issues, "No skills configured in master list") + return issues + } + + for skillID, skill := range skills { + if skill == nil { + issues = append(issues, fmt.Sprintf("Skill ID %d is nil", skillID)) + continue + } + + if skill.SkillID != skillID { + issues = append(issues, fmt.Sprintf("Skill %d has mismatched ID: %d", skillID, skill.SkillID)) + } + + if skill.Name.Data == "" { + issues = append(issues, fmt.Sprintf("Skill %d has empty name", skillID)) + } + + if skill.SkillType == 0 { + issues = append(issues, fmt.Sprintf("Skill %d (%s) has no skill type", skillID, skill.Name.Data)) + } + + if skill.MaxVal < 0 { + issues = append(issues, fmt.Sprintf("Skill %d (%s) has negative max value: %d", skillID, skill.Name.Data, skill.MaxVal)) + } + + if skill.CurrentVal < 0 { + issues = append(issues, fmt.Sprintf("Skill %d (%s) has negative current value: %d", skillID, skill.Name.Data, skill.CurrentVal)) + } + + if skill.CurrentVal > skill.MaxVal { + issues = append(issues, fmt.Sprintf("Skill %d (%s) has current value (%d) greater than max value (%d)", + skillID, skill.Name.Data, skill.CurrentVal, skill.MaxVal)) + } + } + + return issues +} + +// ProcessCommand handles skill-related commands +func (m *Manager) ProcessCommand(command string, args []string) (string, error) { + switch command { + case "stats": + return m.handleStatsCommand(args) + case "validate": + return m.handleValidateCommand(args) + case "list": + return m.handleListCommand(args) + case "info": + return m.handleInfoCommand(args) + default: + return "", fmt.Errorf("unknown skills command: %s", command) + } +} + +// handleStatsCommand shows skill system statistics +func (m *Manager) handleStatsCommand(args []string) (string, error) { + stats := m.GetStatistics() + + result := "Skills System Statistics:\n" + result += fmt.Sprintf("Total Skills in Master List: %d\n", stats["total_skills_in_master"]) + result += fmt.Sprintf("Players with Skills: %d\n", stats["players_with_skills"]) + result += fmt.Sprintf("Total Skill Ups: %d\n", stats["total_skill_ups"]) + + return result, nil +} + +// handleValidateCommand validates skill data +func (m *Manager) handleValidateCommand(args []string) (string, error) { + issues := m.ValidateSkillData() + + if len(issues) == 0 { + return "All skill data is valid.", nil + } + + result := fmt.Sprintf("Found %d issues with skill data:\n", len(issues)) + for i, issue := range issues { + result += fmt.Sprintf("%d. %s\n", i+1, issue) + } + + return result, nil +} + +// handleListCommand lists skills +func (m *Manager) handleListCommand(args []string) (string, error) { + skills := m.masterSkillList.GetAllSkills() + + if len(skills) == 0 { + return "No skills configured.", nil + } + + result := fmt.Sprintf("Skills (%d):\n", len(skills)) + count := 0 + for _, skill := range skills { + if count >= 20 { // Limit output + result += "... (and more)\n" + break + } + result += fmt.Sprintf(" %d: %s (Type: %d)\n", skill.SkillID, skill.Name.Data, skill.SkillType) + count++ + } + + return result, nil +} + +// handleInfoCommand shows information about a specific skill +func (m *Manager) handleInfoCommand(args []string) (string, error) { + if len(args) == 0 { + return "", fmt.Errorf("skill name or ID required") + } + + skillName := args[0] + skill := m.GetSkillByName(skillName) + + if skill == nil { + return fmt.Sprintf("Skill '%s' not found.", skillName), nil + } + + result := fmt.Sprintf("Skill Information:\n") + result += fmt.Sprintf("ID: %d\n", skill.SkillID) + result += fmt.Sprintf("Name: %s\n", skill.Name.Data) + result += fmt.Sprintf("Short Name: %s\n", skill.ShortName.Data) + result += fmt.Sprintf("Type: %d\n", skill.SkillType) + result += fmt.Sprintf("Description: %s\n", skill.Description.Data) + result += fmt.Sprintf("Max Value: %d\n", skill.MaxVal) + result += fmt.Sprintf("Current Value: %d\n", skill.CurrentVal) + result += fmt.Sprintf("Active: %t\n", skill.ActiveSkill) + + upCount := m.GetSkillUpCount(skill.SkillID) + result += fmt.Sprintf("Total Skill Ups Recorded: %d\n", upCount) + + return result, nil +} + +// Shutdown gracefully shuts down the manager +func (m *Manager) Shutdown() { + // Nothing to clean up currently, but placeholder for future cleanup +} \ No newline at end of file diff --git a/internal/skills/master_skill_list.go b/internal/skills/master_skill_list.go new file mode 100644 index 0000000..a5a3eb5 --- /dev/null +++ b/internal/skills/master_skill_list.go @@ -0,0 +1,202 @@ +package skills + +import ( + "sync" +) + +// MasterSkillList manages the master list of all available skills +type MasterSkillList struct { + skills map[int32]*Skill // All skills by ID + populatePackets map[int16][]byte // Cached packets by version + mutex sync.RWMutex // Thread safety +} + +// NewMasterSkillList creates a new master skill list +func NewMasterSkillList() *MasterSkillList { + return &MasterSkillList{ + skills: make(map[int32]*Skill), + populatePackets: make(map[int16][]byte), + } +} + +// AddSkill adds a skill to the master list +func (msl *MasterSkillList) AddSkill(skill *Skill) { + if skill == nil { + return + } + + msl.mutex.Lock() + defer msl.mutex.Unlock() + + msl.skills[skill.SkillID] = skill + + // Clear cached packets when skills change + msl.populatePackets = make(map[int16][]byte) +} + +// GetSkillCount returns the total number of skills +func (msl *MasterSkillList) GetSkillCount() int16 { + msl.mutex.RLock() + defer msl.mutex.RUnlock() + + return int16(len(msl.skills)) +} + +// GetAllSkills returns a copy of all skills +func (msl *MasterSkillList) GetAllSkills() map[int32]*Skill { + msl.mutex.RLock() + defer msl.mutex.RUnlock() + + // Return a copy to prevent external modification + skills := make(map[int32]*Skill) + for id, skill := range msl.skills { + skills[id] = skill + } + + return skills +} + +// GetSkill returns a skill by ID +func (msl *MasterSkillList) GetSkill(skillID int32) *Skill { + msl.mutex.RLock() + defer msl.mutex.RUnlock() + + if skill, exists := msl.skills[skillID]; exists { + return skill + } + + return nil +} + +// GetSkillByName returns a skill by name (case-insensitive) +func (msl *MasterSkillList) GetSkillByName(skillName string) *Skill { + msl.mutex.RLock() + defer msl.mutex.RUnlock() + + // Convert to lowercase for comparison + lowerName := toLower(skillName) + + for _, skill := range msl.skills { + if toLower(skill.Name.Data) == lowerName { + return skill + } + } + + return nil +} + +// GetPopulateSkillsPacket builds a packet containing all skills for a client version +func (msl *MasterSkillList) GetPopulateSkillsPacket(version int16) ([]byte, error) { + msl.mutex.Lock() + defer msl.mutex.Unlock() + + // Check if we have a cached packet for this version + if packet, exists := msl.populatePackets[version]; exists { + // Return a copy of the cached packet + result := make([]byte, len(packet)) + copy(result, packet) + return result, nil + } + + // Build the packet - this is a placeholder implementation + // In the full implementation, this would use the PacketStruct system + // to build a proper WS_SkillMap packet for the given version + + packet := msl.buildSkillMapPacket(version) + + // Cache the packet + msl.populatePackets[version] = packet + + // Return a copy + result := make([]byte, len(packet)) + copy(result, packet) + return result, nil +} + +// buildSkillMapPacket builds a WS_SkillMap packet +func (msl *MasterSkillList) buildSkillMapPacket(version int16) []byte { + // This is a placeholder implementation + // In a real implementation, this would use the PacketStruct system: + // packet := configReader.getStruct("WS_SkillMap", version) + // packet.setArrayLengthByName("skill_count", len(msl.skills)) + // for i, skill := range msl.skills { + // packet.setArrayDataByName("skill_id", skill.SkillID, i) + // packet.setArrayDataByName("short_name", &skill.ShortName, i) + // packet.setArrayDataByName("name", &skill.Name, i) + // packet.setArrayDataByName("description", &skill.Description, i) + // } + // return packet.serialize() + + // For now, return an empty packet + return make([]byte, 0) +} + +// RemoveSkill removes a skill from the master list +func (msl *MasterSkillList) RemoveSkill(skillID int32) { + msl.mutex.Lock() + defer msl.mutex.Unlock() + + delete(msl.skills, skillID) + + // Clear cached packets when skills change + msl.populatePackets = make(map[int16][]byte) +} + +// ClearSkills removes all skills from the master list +func (msl *MasterSkillList) ClearSkills() { + msl.mutex.Lock() + defer msl.mutex.Unlock() + + msl.skills = make(map[int32]*Skill) + msl.populatePackets = make(map[int16][]byte) +} + +// GetSkillsByType returns all skills of a specific type +func (msl *MasterSkillList) GetSkillsByType(skillType int32) []*Skill { + msl.mutex.RLock() + defer msl.mutex.RUnlock() + + var skills []*Skill + for _, skill := range msl.skills { + if skill.SkillType == skillType { + skills = append(skills, skill) + } + } + + return skills +} + +// HasSkill checks if a skill exists in the master list +func (msl *MasterSkillList) HasSkill(skillID int32) bool { + msl.mutex.RLock() + defer msl.mutex.RUnlock() + + _, exists := msl.skills[skillID] + return exists +} + +// GetSkillIDs returns all skill IDs +func (msl *MasterSkillList) GetSkillIDs() []int32 { + msl.mutex.RLock() + defer msl.mutex.RUnlock() + + ids := make([]int32, 0, len(msl.skills)) + for id := range msl.skills { + ids = append(ids, id) + } + + return ids +} + +// toLower converts a string to lowercase (simple implementation) +func toLower(s string) string { + result := make([]byte, len(s)) + for i, c := range []byte(s) { + if c >= 'A' && c <= 'Z' { + result[i] = c + 32 + } else { + result[i] = c + } + } + return string(result) +} \ No newline at end of file diff --git a/internal/skills/player_skill_list.go b/internal/skills/player_skill_list.go new file mode 100644 index 0000000..dc94bea --- /dev/null +++ b/internal/skills/player_skill_list.go @@ -0,0 +1,420 @@ +package skills + +import ( + "math/rand" + "sync" +) + +// PlayerSkillList manages skills for a specific player +type PlayerSkillList struct { + skills map[int32]*Skill // Player's skills by ID + nameSkillMap map[string]*Skill // Skills by name for quick lookup + skillUpdates []*Skill // Skills needing updates + skillBonusList map[int32]*SkillBonus // Skill bonuses by spell ID + + // Packet data for skill updates + origPacket []byte + xorPacket []byte + origPacketSize int16 + packetCount int16 + + hasUpdates bool + mutex sync.RWMutex // Thread safety for skills/nameMap + updatesMutex sync.Mutex // Thread safety for updates + bonusMutex sync.RWMutex // Thread safety for bonuses +} + +// NewPlayerSkillList creates a new player skill list +func NewPlayerSkillList() *PlayerSkillList { + return &PlayerSkillList{ + skills: make(map[int32]*Skill), + nameSkillMap: make(map[string]*Skill), + skillUpdates: make([]*Skill, 0), + skillBonusList: make(map[int32]*SkillBonus), + hasUpdates: false, + } +} + +// AddSkill adds a skill to the player's skill list +func (psl *PlayerSkillList) AddSkill(newSkill *Skill) { + if newSkill == nil { + return + } + + psl.mutex.Lock() + defer psl.mutex.Unlock() + + // Remove old skill if it exists + if oldSkill, exists := psl.skills[newSkill.SkillID]; exists { + // TODO: Set Lua user data stale when LuaInterface is integrated + _ = oldSkill + } + + psl.skills[newSkill.SkillID] = newSkill + + // Clear name map cache so it gets rebuilt + psl.nameSkillMap = make(map[string]*Skill) +} + +// RemoveSkill removes a skill from the player's skill list +func (psl *PlayerSkillList) RemoveSkill(skill *Skill) { + if skill == nil { + return + } + + psl.mutex.Lock() + defer psl.mutex.Unlock() + + // TODO: Set Lua user data stale when LuaInterface is integrated + skill.ActiveSkill = false + + // Clear name map cache + psl.nameSkillMap = make(map[string]*Skill) +} + +// GetAllSkills returns all player skills +func (psl *PlayerSkillList) GetAllSkills() map[int32]*Skill { + psl.mutex.RLock() + defer psl.mutex.RUnlock() + + // Return a copy to prevent external modification + skills := make(map[int32]*Skill) + for id, skill := range psl.skills { + skills[id] = skill + } + + return skills +} + +// HasSkill checks if player has a specific skill +func (psl *PlayerSkillList) HasSkill(skillID int32) bool { + psl.mutex.RLock() + defer psl.mutex.RUnlock() + + skill, exists := psl.skills[skillID] + return exists && skill.ActiveSkill +} + +// GetSkill returns a skill by ID +func (psl *PlayerSkillList) GetSkill(skillID int32) *Skill { + psl.mutex.RLock() + defer psl.mutex.RUnlock() + + if skill, exists := psl.skills[skillID]; exists && skill.ActiveSkill { + return skill + } + + return nil +} + +// GetSkillByName returns a skill by name +func (psl *PlayerSkillList) GetSkillByName(name string) *Skill { + psl.mutex.Lock() + defer psl.mutex.Unlock() + + // Build name map if empty + if len(psl.nameSkillMap) == 0 { + for _, skill := range psl.skills { + if skill.ActiveSkill { + psl.nameSkillMap[skill.Name.Data] = skill + } + } + } + + if skill, exists := psl.nameSkillMap[name]; exists { + return skill + } + + return nil +} + +// IncreaseSkill increases a skill's current value +func (psl *PlayerSkillList) IncreaseSkill(skill *Skill, amount int16) { + if skill == nil { + return + } + + skill.PreviousVal = skill.CurrentVal + skill.CurrentVal += amount + + if skill.CurrentVal > skill.MaxVal { + skill.MaxVal = skill.CurrentVal + } + + psl.AddSkillUpdateNeeded(skill) + skill.SaveNeeded = true +} + +// IncreaseSkillByID increases a skill's current value by ID +func (psl *PlayerSkillList) IncreaseSkillByID(skillID int32, amount int16) { + skill := psl.GetSkill(skillID) + psl.IncreaseSkill(skill, amount) +} + +// DecreaseSkill decreases a skill's current value +func (psl *PlayerSkillList) DecreaseSkill(skill *Skill, amount int16) { + if skill == nil { + return + } + + skill.PreviousVal = skill.CurrentVal + + if skill.CurrentVal < amount { + skill.CurrentVal = 0 + } else { + skill.CurrentVal -= amount + } + + skill.SaveNeeded = true + psl.AddSkillUpdateNeeded(skill) +} + +// DecreaseSkillByID decreases a skill's current value by ID +func (psl *PlayerSkillList) DecreaseSkillByID(skillID int32, amount int16) { + skill := psl.GetSkill(skillID) + psl.DecreaseSkill(skill, amount) +} + +// SetSkill sets a skill's current value +func (psl *PlayerSkillList) SetSkill(skill *Skill, value int16, sendUpdate bool) { + if skill == nil { + return + } + + skill.PreviousVal = skill.CurrentVal + skill.CurrentVal = value + + if skill.CurrentVal > skill.MaxVal { + skill.MaxVal = skill.CurrentVal + } + + skill.SaveNeeded = true + + if sendUpdate { + psl.AddSkillUpdateNeeded(skill) + } +} + +// SetSkillByID sets a skill's current value by ID +func (psl *PlayerSkillList) SetSkillByID(skillID int32, value int16, sendUpdate bool) { + skill := psl.GetSkill(skillID) + psl.SetSkill(skill, value, sendUpdate) +} + +// IncreaseSkillCap increases a skill's maximum value +func (psl *PlayerSkillList) IncreaseSkillCap(skill *Skill, amount int16) { + if skill == nil { + return + } + + skill.MaxVal += amount + skill.SaveNeeded = true +} + +// IncreaseSkillCapByID increases a skill's maximum value by ID +func (psl *PlayerSkillList) IncreaseSkillCapByID(skillID int32, amount int16) { + skill := psl.GetSkill(skillID) + psl.IncreaseSkillCap(skill, amount) +} + +// DecreaseSkillCap decreases a skill's maximum value +func (psl *PlayerSkillList) DecreaseSkillCap(skill *Skill, amount int16) { + if skill == nil { + return + } + + if skill.MaxVal < amount { + skill.MaxVal = 0 + } else { + skill.MaxVal -= amount + } + + // Adjust current value if it exceeds new max + if skill.CurrentVal > skill.MaxVal { + skill.PreviousVal = skill.CurrentVal + skill.CurrentVal = skill.MaxVal + } + + psl.AddSkillUpdateNeeded(skill) + skill.SaveNeeded = true +} + +// DecreaseSkillCapByID decreases a skill's maximum value by ID +func (psl *PlayerSkillList) DecreaseSkillCapByID(skillID int32, amount int16) { + skill := psl.GetSkill(skillID) + psl.DecreaseSkillCap(skill, amount) +} + +// SetSkillCap sets a skill's maximum value +func (psl *PlayerSkillList) SetSkillCap(skill *Skill, value int16) { + if skill == nil { + return + } + + skill.MaxVal = value + + // Adjust current value if it exceeds new max + if skill.CurrentVal > skill.MaxVal { + skill.PreviousVal = skill.CurrentVal + skill.CurrentVal = skill.MaxVal + } + + psl.AddSkillUpdateNeeded(skill) + skill.SaveNeeded = true +} + +// SetSkillCapByID sets a skill's maximum value by ID +func (psl *PlayerSkillList) SetSkillCapByID(skillID int32, value int16) { + skill := psl.GetSkill(skillID) + psl.SetSkillCap(skill, value) +} + +// SetSkillValuesByType sets all skills of a type to a specific value +func (psl *PlayerSkillList) SetSkillValuesByType(skillType int8, value int16, sendUpdate bool) { + psl.mutex.RLock() + defer psl.mutex.RUnlock() + + for _, skill := range psl.skills { + if skill != nil && skill.SkillType == int32(skillType) { + psl.SetSkill(skill, value, sendUpdate) + } + } +} + +// SetSkillCapsByType sets all skill caps of a type to a specific value +func (psl *PlayerSkillList) SetSkillCapsByType(skillType int8, value int16) { + psl.mutex.RLock() + defer psl.mutex.RUnlock() + + for _, skill := range psl.skills { + if skill != nil && skill.SkillType == int32(skillType) { + psl.SetSkillCap(skill, value) + } + } +} + +// IncreaseSkillCapsByType increases all skill caps of a type +func (psl *PlayerSkillList) IncreaseSkillCapsByType(skillType int8, value int16) { + psl.mutex.RLock() + defer psl.mutex.RUnlock() + + for _, skill := range psl.skills { + if skill != nil && skill.SkillType == int32(skillType) { + psl.IncreaseSkillCap(skill, value) + } + } +} + +// IncreaseAllSkillCaps increases all skill caps +func (psl *PlayerSkillList) IncreaseAllSkillCaps(value int16) { + psl.mutex.RLock() + defer psl.mutex.RUnlock() + + for _, skill := range psl.skills { + if skill != nil { + psl.IncreaseSkillCap(skill, value) + } + } +} + +// CheckSkillIncrease checks if a skill should increase and does so if successful +func (psl *PlayerSkillList) CheckSkillIncrease(skill *Skill) bool { + if skill == nil || skill.CurrentVal >= skill.MaxVal { + return false + } + + // Calculate increase chance: skill level 1 = 20%, 100 = 10%, 400 = 4% + percent := int8((100.0 / float32(50 + skill.CurrentVal)) * 10.0) + + if rand.Intn(100) < int(percent) { + psl.IncreaseSkill(skill, 1) + return true + } + + return false +} + +// AddSkillUpdateNeeded marks a skill as needing an update packet +func (psl *PlayerSkillList) AddSkillUpdateNeeded(skill *Skill) { + if skill == nil { + return + } + + psl.updatesMutex.Lock() + defer psl.updatesMutex.Unlock() + + psl.skillUpdates = append(psl.skillUpdates, skill) + psl.hasUpdates = true +} + +// HasSkillUpdates returns whether there are pending skill updates +func (psl *PlayerSkillList) HasSkillUpdates() bool { + psl.updatesMutex.Lock() + defer psl.updatesMutex.Unlock() + + return psl.hasUpdates +} + +// GetSkillUpdates returns and clears pending skill updates +func (psl *PlayerSkillList) GetSkillUpdates() []*Skill { + psl.updatesMutex.Lock() + defer psl.updatesMutex.Unlock() + + if len(psl.skillUpdates) == 0 { + return nil + } + + updates := make([]*Skill, len(psl.skillUpdates)) + copy(updates, psl.skillUpdates) + + // Clear the updates + psl.skillUpdates = psl.skillUpdates[:0] + psl.hasUpdates = false + + return updates +} + +// GetSaveNeededSkills returns skills that need to be saved to database +func (psl *PlayerSkillList) GetSaveNeededSkills() []*Skill { + psl.mutex.RLock() + defer psl.mutex.RUnlock() + + var saveNeeded []*Skill + + for _, skill := range psl.skills { + if skill.SaveNeeded { + saveNeeded = append(saveNeeded, skill) + skill.SaveNeeded = false // Clear the flag + } + } + + return saveNeeded +} + +// ResetPackets clears cached packet data +func (psl *PlayerSkillList) ResetPackets() { + psl.updatesMutex.Lock() + defer psl.updatesMutex.Unlock() + + psl.origPacket = nil + psl.xorPacket = nil + psl.origPacketSize = 0 + psl.packetCount = 0 +} + +// GetSkillPacket builds a skill update packet for the client +func (psl *PlayerSkillList) GetSkillPacket(version int16) ([]byte, error) { + psl.mutex.Lock() + defer psl.mutex.Unlock() + + // This is a placeholder implementation + // In the full implementation, this would use the PacketStruct system + // to build a WS_UpdateSkillBook packet with all player skills + + // TODO: Implement packet building using PacketStruct system + // packet := configReader.getStruct("WS_UpdateSkillBook", version) + // [complex packet building logic here] + + // For now, return empty packet + return make([]byte, 0), nil +} \ No newline at end of file diff --git a/internal/skills/skill_bonuses.go b/internal/skills/skill_bonuses.go new file mode 100644 index 0000000..f2388c7 --- /dev/null +++ b/internal/skills/skill_bonuses.go @@ -0,0 +1,175 @@ +package skills + +// AddSkillBonus adds a skill bonus from a spell +func (psl *PlayerSkillList) AddSkillBonus(spellID int32, skillID int32, value float32) { + if value == 0 { + return + } + + psl.bonusMutex.Lock() + defer psl.bonusMutex.Unlock() + + // Get or create skill bonus entry for this spell + skillBonus, exists := psl.skillBonusList[spellID] + if !exists { + skillBonus = &SkillBonus{ + SpellID: spellID, + Skills: make(map[int32]*SkillBonusValue), + } + psl.skillBonusList[spellID] = skillBonus + } + + // Add or update the skill bonus value + if skillBonus.Skills[skillID] == nil { + skillBonus.Skills[skillID] = &SkillBonusValue{ + SkillID: skillID, + Value: value, + } + } +} + +// GetSkillBonus returns skill bonus for a spell +func (psl *PlayerSkillList) GetSkillBonus(spellID int32) *SkillBonus { + psl.bonusMutex.RLock() + defer psl.bonusMutex.RUnlock() + + if bonus, exists := psl.skillBonusList[spellID]; exists { + return bonus + } + + return nil +} + +// RemoveSkillBonus removes all skill bonuses from a spell +func (psl *PlayerSkillList) RemoveSkillBonus(spellID int32) { + psl.bonusMutex.Lock() + defer psl.bonusMutex.Unlock() + + if skillBonus, exists := psl.skillBonusList[spellID]; exists { + // Clean up skill bonus values + for _, bonusValue := range skillBonus.Skills { + _ = bonusValue // In C++, this would be safe_delete(bonusValue) + } + + delete(psl.skillBonusList, spellID) + } +} + +// CalculateSkillValue calculates a skill's value including bonuses +func (psl *PlayerSkillList) CalculateSkillValue(skillID int32, currentVal int16) int16 { + if currentVal <= 5 { + return currentVal + } + + psl.bonusMutex.RLock() + defer psl.bonusMutex.RUnlock() + + newVal := currentVal + + // Apply all skill bonuses + for _, skillBonus := range psl.skillBonusList { + if bonusValue, exists := skillBonus.Skills[skillID]; exists { + newVal += int16(bonusValue.Value) + } + } + + return newVal +} + +// CalculateSkillMaxValue calculates a skill's max value including bonuses +func (psl *PlayerSkillList) CalculateSkillMaxValue(skillID int32, maxVal int16) int16 { + psl.bonusMutex.RLock() + defer psl.bonusMutex.RUnlock() + + newVal := maxVal + + // Apply all skill bonuses to max value + for _, skillBonus := range psl.skillBonusList { + if bonusValue, exists := skillBonus.Skills[skillID]; exists { + newVal += int16(bonusValue.Value) + } + } + + return newVal +} + +// GetAllSkillBonuses returns all skill bonuses (for debugging/admin) +func (psl *PlayerSkillList) GetAllSkillBonuses() map[int32]*SkillBonus { + psl.bonusMutex.RLock() + defer psl.bonusMutex.RUnlock() + + // Return a copy to prevent external modification + bonuses := make(map[int32]*SkillBonus) + for spellID, bonus := range psl.skillBonusList { + // Deep copy the skill bonus + newBonus := &SkillBonus{ + SpellID: bonus.SpellID, + Skills: make(map[int32]*SkillBonusValue), + } + + for skillID, bonusValue := range bonus.Skills { + newBonus.Skills[skillID] = &SkillBonusValue{ + SkillID: bonusValue.SkillID, + Value: bonusValue.Value, + } + } + + bonuses[spellID] = newBonus + } + + return bonuses +} + +// RemoveAllSkillBonuses removes all skill bonuses (for cleanup) +func (psl *PlayerSkillList) RemoveAllSkillBonuses() { + psl.bonusMutex.Lock() + defer psl.bonusMutex.Unlock() + + // Clean up all skill bonuses + for spellID := range psl.skillBonusList { + if skillBonus, exists := psl.skillBonusList[spellID]; exists { + for _, bonusValue := range skillBonus.Skills { + _ = bonusValue // In C++, this would be safe_delete(bonusValue) + } + } + } + + psl.skillBonusList = make(map[int32]*SkillBonus) +} + +// GetSkillBonusTotal returns the total bonus for a specific skill +func (psl *PlayerSkillList) GetSkillBonusTotal(skillID int32) float32 { + psl.bonusMutex.RLock() + defer psl.bonusMutex.RUnlock() + + var total float32 + + for _, skillBonus := range psl.skillBonusList { + if bonusValue, exists := skillBonus.Skills[skillID]; exists { + total += bonusValue.Value + } + } + + return total +} + +// HasSkillBonuses returns whether the player has any skill bonuses +func (psl *PlayerSkillList) HasSkillBonuses() bool { + psl.bonusMutex.RLock() + defer psl.bonusMutex.RUnlock() + + return len(psl.skillBonusList) > 0 +} + +// GetSpellsWithSkillBonuses returns all spell IDs that provide skill bonuses +func (psl *PlayerSkillList) GetSpellsWithSkillBonuses() []int32 { + psl.bonusMutex.RLock() + defer psl.bonusMutex.RUnlock() + + spellIDs := make([]int32, 0, len(psl.skillBonusList)) + for spellID := range psl.skillBonusList { + spellIDs = append(spellIDs, spellID) + } + + return spellIDs +} \ No newline at end of file diff --git a/internal/skills/types.go b/internal/skills/types.go new file mode 100644 index 0000000..6f53b40 --- /dev/null +++ b/internal/skills/types.go @@ -0,0 +1,156 @@ +package skills + +import "eq2emu/internal/common" + +// SkillBonusValue represents a single skill bonus value +type SkillBonusValue struct { + SkillID int32 // Skill being modified + Value float32 // Bonus value +} + +// SkillBonus represents skill bonuses from a spell +type SkillBonus struct { + SpellID int32 // Spell providing the bonus + Skills map[int32]*SkillBonusValue // Map of skill ID to bonus value +} + +// Skill represents a character skill +type Skill struct { + SkillID int32 // Unique skill identifier + CurrentVal int16 // Current skill value + PreviousVal int16 // Previous skill value (for deltas) + MaxVal int16 // Maximum skill value + SkillType int32 // Skill category type + Display int8 // Display setting + ShortName common.EQ2String16 // Short skill name + Name common.EQ2String16 // Full skill name + Description common.EQ2String16 // Skill description + SaveNeeded bool // Whether skill needs database save + ActiveSkill bool // Whether skill is active/usable +} + +// NewSkill creates a new skill with default values +func NewSkill() *Skill { + return &Skill{ + SkillID: 0, + CurrentVal: 0, + PreviousVal: 0, + MaxVal: 0, + SkillType: 0, + Display: 0, + SaveNeeded: false, + ActiveSkill: true, + } +} + +// NewSkillFromSkill creates a copy of an existing skill +func NewSkillFromSkill(skill *Skill) *Skill { + if skill == nil { + return NewSkill() + } + + return &Skill{ + SkillID: skill.SkillID, + CurrentVal: skill.CurrentVal, + PreviousVal: skill.CurrentVal, // Copy current as previous + MaxVal: skill.MaxVal, + SkillType: skill.SkillType, + Display: skill.Display, + ShortName: skill.ShortName, + Name: skill.Name, + Description: skill.Description, + SaveNeeded: false, + ActiveSkill: true, + } +} + +// CheckDisarmSkill checks disarm skill against a chest +// Returns 1 for success, 0 for fail (no trigger), -1 for fail with trigger +func (s *Skill) CheckDisarmSkill(targetLevel int16, chestDifficulty int8) int { + if chestDifficulty < 2 { + return DisarmSuccess // No triggers on easy chests + } + + if targetLevel < 1 { + targetLevel = 1 + } + + chestDiffResult := int32(targetLevel) * int32(chestDifficulty) + baseDifficulty := float32(15.0) + failThreshold := float32(10.0) + + // Calculate success chance + chance := (100.0 - baseDifficulty) * (float32(s.CurrentVal) / float32(chestDiffResult)) + + if chance > (100.0 - baseDifficulty) { + chance = 100.0 - baseDifficulty + } + + // Roll d100 + roll := makeRandomFloat(0, 100) + + if roll <= chance { + return DisarmSuccess + } else if roll > (chance + failThreshold) { + return DisarmTrigger + } + + return DisarmFail +} + +// GetCurrentValue returns the current skill value +func (s *Skill) GetCurrentValue() int32 { + return int32(s.CurrentVal) +} + +// GetMaxValue returns the maximum skill value +func (s *Skill) GetMaxValue() int32 { + return int32(s.MaxVal) +} + +// GetName returns the skill name +func (s *Skill) GetName() string { + return s.Name.Data +} + +// GetShortName returns the skill short name +func (s *Skill) GetShortName() string { + return s.ShortName.Data +} + +// GetDescription returns the skill description +func (s *Skill) GetDescription() string { + return s.Description.Data +} + +// GetSkillType returns the skill type +func (s *Skill) GetSkillType() int32 { + return s.SkillType +} + +// IsActive returns whether the skill is active +func (s *Skill) IsActive() bool { + return s.ActiveSkill +} + +// NeedsSave returns whether the skill needs to be saved +func (s *Skill) NeedsSave() bool { + return s.SaveNeeded +} + +// SetSaveNeeded marks the skill as needing to be saved +func (s *Skill) SetSaveNeeded(needed bool) { + s.SaveNeeded = needed +} + +// SetActive sets whether the skill is active +func (s *Skill) SetActive(active bool) { + s.ActiveSkill = active +} + +// makeRandomFloat generates a random float between min and max +// TODO: Replace with proper random number generation when integrated +func makeRandomFloat(min, max float32) float32 { + // Placeholder implementation + return min + ((max - min) / 2.0) +} \ No newline at end of file diff --git a/internal/transmute/constants.go b/internal/transmute/constants.go new file mode 100644 index 0000000..f3783fe --- /dev/null +++ b/internal/transmute/constants.go @@ -0,0 +1,54 @@ +package transmute + +// Item flags that disqualify items from transmutation +const ( + NoZone = 1 << 0 // NO_ZONE flag + NoValue = 1 << 1 // NO_VALUE flag + Temporary = 1 << 2 // TEMPORARY flag + NoDestroy = 1 << 3 // NO_DESTROY flag + NoTransmute = 1 << 14 // NO_TRANSMUTE flag (16384) +) + +// Item flags2 that disqualify items from transmutation +const ( + Ornate = 1 << 0 // ORNATE flag +) + +// Item tiers/rarities +const ( + ItemTagTreasured = 4 + ItemTagLegendary = 5 + ItemTagFabled = 6 + ItemTagMythical = 7 + ItemTagCelestial = 8 +) + +// Transmutation probabilities (percentages) +const ( + BothItemsChancePercent = 15 // Chance to get both common and rare materials + CommonMatChancePercent = 75 // Chance to get common material (if not both) + RareMatChancePercent = 25 // Chance to get rare material (if not both) +) + +// Skill up constants +const ( + SkillUpPercentChanceMax = 50 // Base chance for skill up at max item level + SkillUpLevelDifPenalty = 20 // Percent decrease per level difference + MaxSkillUpLevelDif = 10 // Maximum level difference for skill up +) + +// Spell constants +const ( + TransmuteItemSpellID = 5163 // Spell ID for the transmute item spell +) + +// Message channel constants (from C++) +const ( + ChannelColorRed = 0 + ChannelYellow = 89 +) + +// Request types +const ( + RequestTypeTransmuteItem = 1 +) \ No newline at end of file diff --git a/internal/transmute/database.go b/internal/transmute/database.go new file mode 100644 index 0000000..fd541ce --- /dev/null +++ b/internal/transmute/database.go @@ -0,0 +1,249 @@ +package transmute + +import ( + "fmt" +) + +// DatabaseImpl provides a default implementation of the Database interface +type DatabaseImpl struct { + // Database connection or query executor would go here + // This is a placeholder implementation +} + +// NewDatabase creates a new database implementation +func NewDatabase() *DatabaseImpl { + return &DatabaseImpl{} +} + +// LoadTransmutingTiers loads transmuting tiers from the database +func (db *DatabaseImpl) LoadTransmutingTiers() ([]*TransmutingTier, error) { + // This is a placeholder implementation + // In a real implementation, this would query the database: + // SELECT min_level, max_level, fragment, powder, infusion, mana FROM transmuting + + // For now, return some example tiers that match typical EQ2 level ranges + tiers := []*TransmutingTier{ + { + MinLevel: 1, + MaxLevel: 9, + FragmentID: 1001, // Example fragment item ID + PowderID: 1002, // Example powder item ID + InfusionID: 1003, // Example infusion item ID + ManaID: 1004, // Example mana item ID + }, + { + MinLevel: 10, + MaxLevel: 19, + FragmentID: 1005, + PowderID: 1006, + InfusionID: 1007, + ManaID: 1008, + }, + { + MinLevel: 20, + MaxLevel: 29, + FragmentID: 1009, + PowderID: 1010, + InfusionID: 1011, + ManaID: 1012, + }, + { + MinLevel: 30, + MaxLevel: 39, + FragmentID: 1013, + PowderID: 1014, + InfusionID: 1015, + ManaID: 1016, + }, + { + MinLevel: 40, + MaxLevel: 49, + FragmentID: 1017, + PowderID: 1018, + InfusionID: 1019, + ManaID: 1020, + }, + { + MinLevel: 50, + MaxLevel: 59, + FragmentID: 1021, + PowderID: 1022, + InfusionID: 1023, + ManaID: 1024, + }, + { + MinLevel: 60, + MaxLevel: 69, + FragmentID: 1025, + PowderID: 1026, + InfusionID: 1027, + ManaID: 1028, + }, + { + MinLevel: 70, + MaxLevel: 79, + FragmentID: 1029, + PowderID: 1030, + InfusionID: 1031, + ManaID: 1032, + }, + { + MinLevel: 80, + MaxLevel: 89, + FragmentID: 1033, + PowderID: 1034, + InfusionID: 1035, + ManaID: 1036, + }, + { + MinLevel: 90, + MaxLevel: 100, + FragmentID: 1037, + PowderID: 1038, + InfusionID: 1039, + ManaID: 1040, + }, + } + + return tiers, nil +} + +// TODO: When integrating with a real database system, replace this with actual database queries +// Example SQL implementation would look like: +/* +func (db *DatabaseImpl) LoadTransmutingTiers() ([]*TransmutingTier, error) { + query := `SELECT min_level, max_level, fragment, powder, infusion, mana FROM transmuting ORDER BY min_level` + + rows, err := db.connection.Query(query) + if err != nil { + return nil, fmt.Errorf("failed to query transmuting tiers: %w", err) + } + defer rows.Close() + + var tiers []*TransmutingTier + + for rows.Next() { + tier := &TransmutingTier{} + err := rows.Scan( + &tier.MinLevel, + &tier.MaxLevel, + &tier.FragmentID, + &tier.PowderID, + &tier.InfusionID, + &tier.ManaID, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan transmuting tier: %w", err) + } + + tiers = append(tiers, tier) + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating transmuting tiers: %w", err) + } + + return tiers, nil +} +*/ + +// SaveTransmutingTier saves a transmuting tier to the database +func (db *DatabaseImpl) SaveTransmutingTier(tier *TransmutingTier) error { + // Placeholder implementation + // In a real implementation: + // INSERT INTO transmuting (min_level, max_level, fragment, powder, infusion, mana) VALUES (?, ?, ?, ?, ?, ?) + // OR UPDATE if exists + + if tier == nil { + return fmt.Errorf("tier cannot be nil") + } + + // Validate tier data + if tier.MinLevel <= 0 || tier.MaxLevel <= 0 { + return fmt.Errorf("invalid level range: %d-%d", tier.MinLevel, tier.MaxLevel) + } + + if tier.MinLevel > tier.MaxLevel { + return fmt.Errorf("min level (%d) cannot be greater than max level (%d)", tier.MinLevel, tier.MaxLevel) + } + + if tier.FragmentID <= 0 || tier.PowderID <= 0 || tier.InfusionID <= 0 || tier.ManaID <= 0 { + return fmt.Errorf("all material IDs must be positive") + } + + // TODO: Actual database save operation + return nil +} + +// DeleteTransmutingTier deletes a transmuting tier from the database +func (db *DatabaseImpl) DeleteTransmutingTier(minLevel, maxLevel int32) error { + // Placeholder implementation + // In a real implementation: + // DELETE FROM transmuting WHERE min_level = ? AND max_level = ? + + if minLevel <= 0 || maxLevel <= 0 { + return fmt.Errorf("invalid level range: %d-%d", minLevel, maxLevel) + } + + // TODO: Actual database delete operation + return nil +} + +// GetTransmutingTierByLevel gets a specific transmuting tier by level range +func (db *DatabaseImpl) GetTransmutingTierByLevel(itemLevel int32) (*TransmutingTier, error) { + // Placeholder implementation + // In a real implementation: + // SELECT min_level, max_level, fragment, powder, infusion, mana FROM transmuting WHERE min_level <= ? AND max_level >= ? + + tiers, err := db.LoadTransmutingTiers() + if err != nil { + return nil, err + } + + for _, tier := range tiers { + if tier.MinLevel <= itemLevel && tier.MaxLevel >= itemLevel { + return tier, nil + } + } + + return nil, fmt.Errorf("no transmuting tier found for level %d", itemLevel) +} + +// UpdateTransmutingTier updates an existing transmuting tier +func (db *DatabaseImpl) UpdateTransmutingTier(oldMinLevel, oldMaxLevel int32, newTier *TransmutingTier) error { + // Placeholder implementation + // In a real implementation: + // UPDATE transmuting SET min_level=?, max_level=?, fragment=?, powder=?, infusion=?, mana=? WHERE min_level=? AND max_level=? + + if newTier == nil { + return fmt.Errorf("new tier cannot be nil") + } + + // Validate the new tier + if err := db.SaveTransmutingTier(newTier); err != nil { + return fmt.Errorf("invalid new tier data: %w", err) + } + + // TODO: Actual database update operation + return nil +} + +// TransmutingTierExists checks if a transmuting tier exists for the given level range +func (db *DatabaseImpl) TransmutingTierExists(minLevel, maxLevel int32) (bool, error) { + // Placeholder implementation + // In a real implementation: + // SELECT COUNT(*) FROM transmuting WHERE min_level = ? AND max_level = ? + + tiers, err := db.LoadTransmutingTiers() + if err != nil { + return false, err + } + + for _, tier := range tiers { + if tier.MinLevel == minLevel && tier.MaxLevel == maxLevel { + return true, nil + } + } + + return false, nil +} \ No newline at end of file diff --git a/internal/transmute/manager.go b/internal/transmute/manager.go new file mode 100644 index 0000000..8a449e1 --- /dev/null +++ b/internal/transmute/manager.go @@ -0,0 +1,350 @@ +package transmute + +import ( + "fmt" + "sync" + "time" +) + +// Manager provides high-level management of the transmutation system +type Manager struct { + transmuter *Transmuter + database Database + requestTimeout time.Duration + cleanupTicker *time.Ticker + mutex sync.RWMutex + + // Statistics + totalTransmutes int64 + successfulTransmutes int64 + failedTransmutes int64 + materialCounts map[int32]int64 // Material ID -> count produced +} + +// NewManager creates a new transmutation manager +func NewManager(database Database, itemMaster ItemMaster, spellMaster SpellMaster, packetBuilder PacketBuilder) *Manager { + transmuter := NewTransmuter(itemMaster, spellMaster, packetBuilder) + + manager := &Manager{ + transmuter: transmuter, + database: database, + requestTimeout: 5 * time.Minute, // Requests expire after 5 minutes + materialCounts: make(map[int32]int64), + } + + // Start cleanup routine + manager.cleanupTicker = time.NewTicker(1 * time.Minute) + go manager.cleanupRoutine() + + return manager +} + +// Initialize loads transmuting data from database +func (m *Manager) Initialize() error { + return m.transmuter.LoadTransmutingTiers(m.database) +} + +// CreateItemRequest creates a new transmutation item selection request +func (m *Manager) CreateItemRequest(client Client, player Player) (int32, error) { + return m.transmuter.CreateItemRequest(client, player) +} + +// HandleItemResponse handles the player's item selection response +func (m *Manager) HandleItemResponse(client Client, player Player, requestID int32, itemID int32) error { + return m.transmuter.HandleItemResponse(client, player, requestID, itemID) +} + +// HandleConfirmResponse handles the player's confirmation response +func (m *Manager) HandleConfirmResponse(client Client, player Player, itemID int32) error { + return m.transmuter.HandleConfirmResponse(client, player, itemID) +} + +// CompleteTransmutation completes the transmutation process +func (m *Manager) CompleteTransmutation(client Client, player Player) error { + m.mutex.Lock() + m.totalTransmutes++ + m.mutex.Unlock() + + err := m.transmuter.CompleteTransmutation(client, player) + + m.mutex.Lock() + if err != nil { + m.failedTransmutes++ + } else { + m.successfulTransmutes++ + } + m.mutex.Unlock() + + return err +} + +// IsItemTransmutable checks if an item can be transmuted +func (m *Manager) IsItemTransmutable(item Item) bool { + return m.transmuter.IsItemTransmutable(item) +} + +// GetTransmutingTiers returns the current transmuting tiers +func (m *Manager) GetTransmutingTiers() []*TransmutingTier { + return m.transmuter.GetTransmutingTiers() +} + +// ReloadTransmutingTiers reloads transmuting tiers from database +func (m *Manager) ReloadTransmutingTiers() error { + return m.transmuter.LoadTransmutingTiers(m.database) +} + +// GetStatistics returns transmutation statistics +func (m *Manager) GetStatistics() map[string]interface{} { + m.mutex.RLock() + defer m.mutex.RUnlock() + + stats := make(map[string]interface{}) + stats["total_transmutes"] = m.totalTransmutes + stats["successful_transmutes"] = m.successfulTransmutes + stats["failed_transmutes"] = m.failedTransmutes + + if m.totalTransmutes > 0 { + stats["success_rate"] = float64(m.successfulTransmutes) / float64(m.totalTransmutes) * 100 + } + + // Copy material counts + materialStats := make(map[int32]int64) + for matID, count := range m.materialCounts { + materialStats[matID] = count + } + stats["material_counts"] = materialStats + + return stats +} + +// RecordMaterialProduced records that a material was produced (for statistics) +func (m *Manager) RecordMaterialProduced(materialID int32, count int32) { + m.mutex.Lock() + defer m.mutex.Unlock() + + m.materialCounts[materialID] += int64(count) +} + +// GetMaterialProductionCount returns how many of a material have been produced +func (m *Manager) GetMaterialProductionCount(materialID int32) int64 { + m.mutex.RLock() + defer m.mutex.RUnlock() + + return m.materialCounts[materialID] +} + +// ResetStatistics resets all statistics +func (m *Manager) ResetStatistics() { + m.mutex.Lock() + defer m.mutex.Unlock() + + m.totalTransmutes = 0 + m.successfulTransmutes = 0 + m.failedTransmutes = 0 + m.materialCounts = make(map[int32]int64) +} + +// ValidateTransmutingSetup validates that all transmuting tiers are properly configured +func (m *Manager) ValidateTransmutingSetup() []string { + tiers := m.GetTransmutingTiers() + issues := make([]string, 0) + + if len(tiers) == 0 { + issues = append(issues, "No transmuting tiers configured") + return issues + } + + // Check for gaps or overlaps in level ranges + for i, tier := range tiers { + if tier.MinLevel <= 0 { + issues = append(issues, fmt.Sprintf("Tier %d has invalid min level: %d", i, tier.MinLevel)) + } + + if tier.MaxLevel < tier.MinLevel { + issues = append(issues, fmt.Sprintf("Tier %d has max level (%d) less than min level (%d)", + i, tier.MaxLevel, tier.MinLevel)) + } + + if tier.FragmentID <= 0 { + issues = append(issues, fmt.Sprintf("Tier %d has invalid fragment ID: %d", i, tier.FragmentID)) + } + + if tier.PowderID <= 0 { + issues = append(issues, fmt.Sprintf("Tier %d has invalid powder ID: %d", i, tier.PowderID)) + } + + if tier.InfusionID <= 0 { + issues = append(issues, fmt.Sprintf("Tier %d has invalid infusion ID: %d", i, tier.InfusionID)) + } + + if tier.ManaID <= 0 { + issues = append(issues, fmt.Sprintf("Tier %d has invalid mana ID: %d", i, tier.ManaID)) + } + + // Check for overlaps with other tiers + for j, otherTier := range tiers { + if i != j { + if (tier.MinLevel <= otherTier.MaxLevel && tier.MaxLevel >= otherTier.MinLevel) { + issues = append(issues, fmt.Sprintf("Tier %d (levels %d-%d) overlaps with tier %d (levels %d-%d)", + i, tier.MinLevel, tier.MaxLevel, j, otherTier.MinLevel, otherTier.MaxLevel)) + } + } + } + } + + return issues +} + +// GetTierForItemLevel returns the transmuting tier for a given item level +func (m *Manager) GetTierForItemLevel(itemLevel int32) *TransmutingTier { + tiers := m.GetTransmutingTiers() + + for _, tier := range tiers { + if tier.MinLevel <= itemLevel && tier.MaxLevel >= itemLevel { + return tier + } + } + + return nil +} + +// GetTransmutableItems returns all transmutable items from a player's inventory +func (m *Manager) GetTransmutableItems(player Player) []Item { + itemList := player.GetItemList() + transmutable := make([]Item, 0) + + for _, item := range itemList { + if item != nil && m.IsItemTransmutable(item) { + transmutable = append(transmutable, item) + } + } + + return transmutable +} + +// CalculateRequiredSkill calculates the transmuting skill required for an item +func (m *Manager) CalculateRequiredSkill(item Item) int32 { + itemLevel := item.GetAdventureDefaultLevel() + if itemLevel <= 5 { + return 0 + } + return (itemLevel - 5) * 5 +} + +// CanPlayerTransmuteItem checks if a player can transmute a specific item +func (m *Manager) CanPlayerTransmuteItem(player Player, item Item) (bool, string) { + if !m.IsItemTransmutable(item) { + return false, fmt.Sprintf("%s is not transmutable", item.GetName()) + } + + requiredSkill := m.CalculateRequiredSkill(item) + skill := player.GetSkillByName("Transmuting") + + currentSkill := int32(0) + if skill != nil { + currentSkill = skill.GetCurrentValue() + player.GetStat(ItemStatTransmuting) + } + + if currentSkill < requiredSkill { + return false, fmt.Sprintf("Need %d Transmuting skill, have %d", requiredSkill, currentSkill) + } + + return true, "" +} + +// cleanupRoutine runs periodically to cleanup expired requests +func (m *Manager) cleanupRoutine() { + for range m.cleanupTicker.C { + // TODO: Implement request cleanup based on timestamps + // For now, this is a placeholder for future cleanup logic + // In a full implementation, we'd track request timestamps + // and remove requests older than the timeout period + } +} + +// Shutdown gracefully shuts down the manager +func (m *Manager) Shutdown() { + if m.cleanupTicker != nil { + m.cleanupTicker.Stop() + } +} + +// ProcessCommand handles transmutation-related commands +func (m *Manager) ProcessCommand(command string, args []string, client Client, player Player) (string, error) { + switch command { + case "stats": + return m.handleStatsCommand(args) + case "validate": + return m.handleValidateCommand(args) + case "reload": + return m.handleReloadCommand(args) + case "tiers": + return m.handleTiersCommand(args) + default: + return "", fmt.Errorf("unknown transmute command: %s", command) + } +} + +// handleStatsCommand shows transmutation statistics +func (m *Manager) handleStatsCommand(args []string) (string, error) { + stats := m.GetStatistics() + + result := "Transmutation Statistics:\n" + result += fmt.Sprintf("Total Transmutes: %d\n", stats["total_transmutes"]) + result += fmt.Sprintf("Successful: %d\n", stats["successful_transmutes"]) + result += fmt.Sprintf("Failed: %d\n", stats["failed_transmutes"]) + + if successRate, exists := stats["success_rate"]; exists { + result += fmt.Sprintf("Success Rate: %.1f%%\n", successRate) + } + + return result, nil +} + +// handleValidateCommand validates the transmuting setup +func (m *Manager) handleValidateCommand(args []string) (string, error) { + issues := m.ValidateTransmutingSetup() + + if len(issues) == 0 { + return "Transmuting setup is valid.", nil + } + + result := fmt.Sprintf("Found %d issues with transmuting setup:\n", len(issues)) + for i, issue := range issues { + result += fmt.Sprintf("%d. %s\n", i+1, issue) + } + + return result, nil +} + +// handleReloadCommand reloads transmuting data +func (m *Manager) handleReloadCommand(args []string) (string, error) { + err := m.ReloadTransmutingTiers() + if err != nil { + return "", fmt.Errorf("failed to reload transmuting tiers: %w", err) + } + + return "Transmuting tiers reloaded successfully.", nil +} + +// handleTiersCommand shows transmuting tier information +func (m *Manager) handleTiersCommand(args []string) (string, error) { + tiers := m.GetTransmutingTiers() + + if len(tiers) == 0 { + return "No transmuting tiers configured.", nil + } + + result := fmt.Sprintf("Transmuting Tiers (%d):\n", len(tiers)) + for i, tier := range tiers { + result += fmt.Sprintf("%d. Levels %d-%d: Fragment(%d) Powder(%d) Infusion(%d) Mana(%d)\n", + i+1, tier.MinLevel, tier.MaxLevel, tier.FragmentID, tier.PowderID, tier.InfusionID, tier.ManaID) + } + + return result, nil +} + +// Constants for stat types - these would typically be defined elsewhere +const ( + ItemStatTransmuting = 1 // Placeholder - actual value depends on stat system +) \ No newline at end of file diff --git a/internal/transmute/packet_builder.go b/internal/transmute/packet_builder.go new file mode 100644 index 0000000..ced8cd6 --- /dev/null +++ b/internal/transmute/packet_builder.go @@ -0,0 +1,168 @@ +package transmute + +import ( + "fmt" +) + +// PacketBuilderImpl provides a default implementation of the PacketBuilder interface +type PacketBuilderImpl struct { + // Packet configuration or builder would go here + // This is a placeholder implementation +} + +// NewPacketBuilder creates a new packet builder implementation +func NewPacketBuilder() *PacketBuilderImpl { + return &PacketBuilderImpl{} +} + +// BuildItemRequestPacket builds a packet for transmutable item selection +func (pb *PacketBuilderImpl) BuildItemRequestPacket(requestID int32, items []int32, version int32) ([]byte, error) { + // This is a placeholder implementation + // In a real implementation, this would use the PacketStruct system: + // PacketStruct* p = configReader.getStruct("WS_EqTargetItemCmd", version) + // p->setDataByName("request_id", requestID) + // p->setDataByName("request_type", REQUEST_TYPE_TRANSMUTE_ITEM) + // p->setDataByName("unknownff", 0xff) + // p->setArrayLengthByName("item_array_size", len(items)) + // for i, itemID := range items { + // p->setArrayDataByName("item_id", itemID, i) + // } + // return p->serialize() + + if len(items) == 0 { + return nil, fmt.Errorf("no transmutable items found") + } + + // TODO: Build actual packet using packet structure system + // For now, return a placeholder packet + packet := make([]byte, 0) + return packet, nil +} + +// BuildConfirmationPacket builds a confirmation dialog packet +func (pb *PacketBuilderImpl) BuildConfirmationPacket(requestID int32, item Item, version int32) ([]byte, error) { + // This is a placeholder implementation + // In a real implementation, this would use the PacketStruct system: + // PacketStruct* p = configReader.getStruct("WS_ChoiceWindow", version) + // message := fmt.Sprintf("Are you sure you want to transmute the %s?", item.GetName()) + // p->setMediumStringByName("text", message) + // p->setMediumStringByName("accept_text", "OK") + // acceptCommand := fmt.Sprintf("targetitem %d %d 1", requestID, item.GetUniqueID()) + // cancelCommand := fmt.Sprintf("targetitem %d %d", requestID, item.GetUniqueID()) + // p->setMediumStringByName("accept_command", acceptCommand) + // p->setMediumStringByName("cancel_text", "Cancel") + // p->setMediumStringByName("cancel_command", cancelCommand) + // return p->serialize() + + if item == nil { + return nil, fmt.Errorf("item cannot be nil") + } + + // TODO: Build actual packet using packet structure system + // For now, return a placeholder packet + packet := make([]byte, 0) + return packet, nil +} + +// BuildRewardPacket builds a quest completion/reward packet +func (pb *PacketBuilderImpl) BuildRewardPacket(items []Item, version int32) ([]byte, error) { + // This is a placeholder implementation + // In a real implementation, this would use the PacketStruct system: + // PacketStruct* packet = configReader.getStruct("WS_QuestComplete", version) + // packet->setDataByName("title", "Item Transmuted!") + // packet->setArrayLengthByName("num_rewards", len(items)) + // for i, item := range items { + // packet->setArrayDataByName("reward_id", item.GetID(), i) + // if version < 860 { + // packet->setItemArrayDataByName("item", item, player, i, 0, -1) + // } else if version < 1193 { + // packet->setItemArrayDataByName("item", item, player, i) + // } else { + // packet->setItemArrayDataByName("item", item, player, i, 0, 2) + // } + // } + // return packet->serialize() + + if len(items) == 0 { + return nil, fmt.Errorf("no reward items provided") + } + + // TODO: Build actual packet using packet structure system + // For now, return a placeholder packet + packet := make([]byte, 0) + return packet, nil +} + +// TODO: When integrating with the real packet system, these methods would look like: + +/* +// Example of actual packet building using the EQ2 packet structure system +func (pb *PacketBuilderImpl) BuildItemRequestPacket(requestID int32, items []int32, version int32) ([]byte, error) { + // Get the packet structure for this version + packetStruct := pb.configReader.GetStruct("WS_EqTargetItemCmd", version) + if packetStruct == nil { + return nil, fmt.Errorf("could not find packet struct WS_EqTargetItemCmd for version %d", version) + } + + // Set the basic fields + packetStruct.SetDataByName("request_id", requestID) + packetStruct.SetDataByName("request_type", REQUEST_TYPE_TRANSMUTE_ITEM) + packetStruct.SetDataByName("unknownff", 0xff) + + // Set the item array + packetStruct.SetArrayLengthByName("item_array_size", len(items)) + for i, itemID := range items { + packetStruct.SetArrayDataByName("item_id", itemID, i) + } + + // Serialize and return + return packetStruct.Serialize() +} + +func (pb *PacketBuilderImpl) BuildConfirmationPacket(requestID int32, item Item, version int32) ([]byte, error) { + packetStruct := pb.configReader.GetStruct("WS_ChoiceWindow", version) + if packetStruct == nil { + return nil, fmt.Errorf("could not find packet struct WS_ChoiceWindow for version %d", version) + } + + // Build the confirmation message + message := fmt.Sprintf("Are you sure you want to transmute the %s?", item.GetName()) + packetStruct.SetMediumStringByName("text", message) + packetStruct.SetMediumStringByName("accept_text", "OK") + + // Build the command strings + acceptCommand := fmt.Sprintf("targetitem %d %d 1", requestID, item.GetUniqueID()) + cancelCommand := fmt.Sprintf("targetitem %d %d", requestID, item.GetUniqueID()) + + packetStruct.SetMediumStringByName("accept_command", acceptCommand) + packetStruct.SetMediumStringByName("cancel_text", "Cancel") + packetStruct.SetMediumStringByName("cancel_command", cancelCommand) + + return packetStruct.Serialize() +} + +func (pb *PacketBuilderImpl) BuildRewardPacket(items []Item, version int32) ([]byte, error) { + packetStruct := pb.configReader.GetStruct("WS_QuestComplete", version) + if packetStruct == nil { + return nil, fmt.Errorf("could not find packet struct WS_QuestComplete for version %d", version) + } + + packetStruct.SetDataByName("title", "Item Transmuted!") + packetStruct.SetArrayLengthByName("num_rewards", len(items)) + + for i, item := range items { + packetStruct.SetArrayDataByName("reward_id", item.GetID(), i) + + // Version-specific item serialization + if version < 860 { + packetStruct.SetItemArrayDataByName("item", item, nil, i, 0, -1) + } else if version < 1193 { + packetStruct.SetItemArrayDataByName("item", item, nil, i) + } else { + packetStruct.SetItemArrayDataByName("item", item, nil, i, 0, 2) + } + } + + return packetStruct.Serialize() +} +*/ \ No newline at end of file diff --git a/internal/transmute/transmute.go b/internal/transmute/transmute.go new file mode 100644 index 0000000..d092d72 --- /dev/null +++ b/internal/transmute/transmute.go @@ -0,0 +1,415 @@ +package transmute + +import ( + "fmt" + "math" + "math/rand" + "sync" +) + +// Transmuter manages the transmutation system +type Transmuter struct { + tiers []*TransmutingTier + activeRequests map[int32]*TransmuteRequest + itemMaster ItemMaster + spellMaster SpellMaster + packetBuilder PacketBuilder + mutex sync.RWMutex + requestMutex sync.Mutex +} + +// SpellMaster represents the spell system interface +type SpellMaster interface { + GetSpell(spellID int32, tier int32) Spell +} + +// NewTransmuter creates a new transmuter instance +func NewTransmuter(itemMaster ItemMaster, spellMaster SpellMaster, packetBuilder PacketBuilder) *Transmuter { + return &Transmuter{ + tiers: make([]*TransmutingTier, 0), + activeRequests: make(map[int32]*TransmuteRequest), + itemMaster: itemMaster, + spellMaster: spellMaster, + packetBuilder: packetBuilder, + } +} + +// LoadTransmutingTiers loads transmuting tiers from database +func (t *Transmuter) LoadTransmutingTiers(database Database) error { + t.mutex.Lock() + defer t.mutex.Unlock() + + tiers, err := database.LoadTransmutingTiers() + if err != nil { + return fmt.Errorf("failed to load transmuting tiers: %w", err) + } + + t.tiers = tiers + return nil +} + +// GetTransmutingTiers returns a copy of the transmuting tiers +func (t *Transmuter) GetTransmutingTiers() []*TransmutingTier { + t.mutex.RLock() + defer t.mutex.RUnlock() + + // Return a copy to prevent external modification + tiers := make([]*TransmutingTier, len(t.tiers)) + for i, tier := range t.tiers { + tiers[i] = &TransmutingTier{ + MinLevel: tier.MinLevel, + MaxLevel: tier.MaxLevel, + FragmentID: tier.FragmentID, + PowderID: tier.PowderID, + InfusionID: tier.InfusionID, + ManaID: tier.ManaID, + } + } + + return tiers +} + +// IsItemTransmutable checks if an item can be transmuted +func (t *Transmuter) IsItemTransmutable(item Item) bool { + // Item level > 0 AND Item is not LORE_EQUP, LORE, NO_VALUE etc AND item rarity is >= 5 + // (4 is treasured but the rarity used for journeyman spells) + // Flag 16384 is NO-TRANSMUTE + + disqualifyFlags := NoZone | NoValue | Temporary | NoDestroy | NoTransmute + disqualifyFlags2 := Ornate + + if item.GetAdventureDefaultLevel() > 0 && + (item.GetItemFlags()&disqualifyFlags) == 0 && + (item.GetItemFlags2()&disqualifyFlags2) == 0 && + item.GetTier() >= ItemTagLegendary && + item.GetStackCount() <= 1 { + return true + } + + return false +} + +// CreateItemRequest creates a new transmutation item selection request +func (t *Transmuter) CreateItemRequest(client Client, player Player) (int32, error) { + // Generate unique request ID + var requestID int32 + for { + // Generate random signed 32-bit integer (excluding 0) + requestID = rand.Int31() + if requestID != 0 && rand.Intn(2) == 1 { + requestID = -requestID // Make it negative sometimes like C++ + } + if requestID != 0 { + break + } + } + + // Get player's item list + itemList := player.GetItemList() + transmutables := make([]int32, 0) + + // Find all transmutable items + for itemID, item := range itemList { + if item != nil && t.IsItemTransmutable(item) { + transmutables = append(transmutables, itemID) + } + } + + // Build and send packet + packet, err := t.packetBuilder.BuildItemRequestPacket(requestID, transmutables, client.GetVersion()) + if err != nil { + return 0, fmt.Errorf("failed to build item request packet: %w", err) + } + + client.QueuePacket(packet) + client.SetTransmuteID(requestID) + + // Store the request + t.requestMutex.Lock() + t.activeRequests[requestID] = &TransmuteRequest{ + RequestID: requestID, + ClientID: 0, // TODO: Get client ID when available + Phase: PhaseItemSelection, + } + t.requestMutex.Unlock() + + return requestID, nil +} + +// HandleItemResponse handles the player's item selection response +func (t *Transmuter) HandleItemResponse(client Client, player Player, requestID int32, itemID int32) error { + // Find the item + item := player.GetItemFromUniqueID(itemID) + if item == nil { + client.SimpleMessage(ChannelColorRed, "Could not find the item you wish to transmute. Please try again.") + return fmt.Errorf("item not found: %d", itemID) + } + + // Verify item is transmutable + if !t.IsItemTransmutable(item) { + client.Message(ChannelColorRed, "%s is not transmutable.", item.GetName()) + return fmt.Errorf("item not transmutable: %s", item.GetName()) + } + + // Check transmuting skill requirement + itemLevel := item.GetAdventureDefaultLevel() + skill := player.GetSkillByName("Transmuting") + + requiredSkill := int32(math.Max(float64(itemLevel-5), 0) * 5) + itemStatBonus := player.GetStat(ItemStatTransmuting) // TODO: Define this constant + currentSkill := int32(0) + if skill != nil { + currentSkill = skill.GetCurrentValue() + itemStatBonus + } + + if skill == nil || currentSkill < requiredSkill { + client.Message(ChannelColorRed, "You need at least %d Transmuting skill to transmute the %s. You have %d Transmuting skill.", + requiredSkill, item.GetName(), currentSkill) + return fmt.Errorf("insufficient transmuting skill: need %d, have %d", requiredSkill, currentSkill) + } + + // Update request state + t.requestMutex.Lock() + if request, exists := t.activeRequests[requestID]; exists { + request.ItemID = itemID + request.Phase = PhaseConfirmation + } + t.requestMutex.Unlock() + + client.SetTransmuteID(itemID) + + // Send confirmation request + return t.SendConfirmRequest(client, requestID, item) +} + +// SendConfirmRequest sends a confirmation dialog to the client +func (t *Transmuter) SendConfirmRequest(client Client, requestID int32, item Item) error { + packet, err := t.packetBuilder.BuildConfirmationPacket(requestID, item, client.GetVersion()) + if err != nil { + client.SimpleMessage(ChannelColorRed, "Struct error for transmutation. Let a dev know.") + return fmt.Errorf("failed to build confirmation packet: %w", err) + } + + client.QueuePacket(packet) + return nil +} + +// HandleConfirmResponse handles the player's confirmation response +func (t *Transmuter) HandleConfirmResponse(client Client, player Player, itemID int32) error { + // Find the item + item := player.GetItemFromUniqueID(itemID) + if item == nil { + client.SimpleMessage(ChannelColorRed, "Item no longer exists!") + return fmt.Errorf("item no longer exists: %d", itemID) + } + + client.SetTransmuteID(itemID) + + // Get the zone + zone := player.GetZone() + if zone == nil { + return fmt.Errorf("player not in zone") + } + + // Get the transmute spell + spell := t.spellMaster.GetSpell(TransmuteItemSpellID, 1) + if spell == nil { + return fmt.Errorf("could not find transmute item spell: %d", TransmuteItemSpellID) + } + + // Process the spell (this will call CompleteTransmutation when finished) + return zone.ProcessSpell(spell, player) +} + +// CompleteTransmutation completes the transmutation process +func (t *Transmuter) CompleteTransmutation(client Client, player Player) error { + itemID := client.GetTransmuteID() + item := player.GetItemFromUniqueID(itemID) + if item == nil { + client.SimpleMessage(ChannelColorRed, "Item no longer exists!") + return fmt.Errorf("item no longer exists: %d", itemID) + } + + // Determine materials based on item level and tier + result, err := t.calculateTransmuteResult(item) + if err != nil { + client.SimpleMessage(ChannelColorRed, "Could not complete transmutation! Tell a dev!") + return fmt.Errorf("failed to calculate transmute result: %w", err) + } + + if !result.Success { + client.SimpleMessage(ChannelColorRed, result.ErrorMessage) + return fmt.Errorf("transmutation failed: %s", result.ErrorMessage) + } + + // Remove the original item + if !player.RemoveItem(item, true) { + return fmt.Errorf("failed to remove transmuted item") + } + + // Send completion message + client.Message(ChannelYellow, "You transmute %s and create: ", item.CreateItemLink(client.GetVersion(), false)) + + // Add the resulting materials + rewardItems := make([]Item, 0, 2) + if result.CommonMaterial != nil { + result.CommonMaterial.SetCount(1) + client.Message(ChannelYellow, " %s", result.CommonMaterial.CreateItemLink(client.GetVersion(), false)) + + var itemDeleted bool + if err := client.AddItem(result.CommonMaterial, &itemDeleted); err != nil { + return fmt.Errorf("failed to add common material: %w", err) + } + if !itemDeleted { + rewardItems = append(rewardItems, result.CommonMaterial) + } + } + + if result.RareMaterial != nil { + result.RareMaterial.SetCount(1) + client.Message(ChannelYellow, " %s", result.RareMaterial.CreateItemLink(client.GetVersion(), false)) + + var itemDeleted bool + if err := client.AddItem(result.RareMaterial, &itemDeleted); err != nil { + return fmt.Errorf("failed to add rare material: %w", err) + } + if !itemDeleted { + rewardItems = append(rewardItems, result.RareMaterial) + } + } + + // Send reward packet if there are items + if len(rewardItems) > 0 { + packet, err := t.packetBuilder.BuildRewardPacket(rewardItems, client.GetVersion()) + if err == nil { + client.QueuePacket(packet) + } + } + + // Handle skill up + return t.handleSkillUp(player, item) +} + +// calculateTransmuteResult determines what materials are produced from transmutation +func (t *Transmuter) calculateTransmuteResult(item Item) (*TransmuteResult, error) { + t.mutex.RLock() + defer t.mutex.RUnlock() + + itemLevel := item.GetAdventureDefaultLevel() + var tier *TransmutingTier + + // Find the correct tier + for _, t := range t.tiers { + if t.MinLevel <= itemLevel && t.MaxLevel >= itemLevel { + tier = t + break + } + } + + if tier == nil { + return &TransmuteResult{ + Success: false, + ErrorMessage: "No transmuting tier found for item level", + }, nil + } + + // Determine material types based on item tier + itemTier := item.GetTier() + var commonMatID, rareMatID int32 + + if itemTier >= ItemTagFabled { + commonMatID = tier.InfusionID + rareMatID = tier.ManaID + } else if itemTier >= ItemTagLegendary { + commonMatID = tier.PowderID + rareMatID = tier.InfusionID + } else { + commonMatID = tier.FragmentID + rareMatID = tier.PowderID + } + + if commonMatID == 0 || rareMatID == 0 { + return &TransmuteResult{ + Success: false, + ErrorMessage: "Invalid material IDs for transmutation", + }, nil + } + + // Do the loot roll + result := &TransmuteResult{Success: true} + roll := rand.Intn(100) + 1 + + if roll <= BothItemsChancePercent { + // Both items + result.CommonMaterial = t.itemMaster.CreateItem(commonMatID) + result.RareMaterial = t.itemMaster.CreateItem(rareMatID) + } else if roll <= CommonMatChancePercent { + // Common material only + result.CommonMaterial = t.itemMaster.CreateItem(commonMatID) + } else { + // Rare material only + result.RareMaterial = t.itemMaster.CreateItem(rareMatID) + } + + return result, nil +} + +// handleSkillUp processes potential skill increases from transmutation +func (t *Transmuter) handleSkillUp(player Player, item Item) error { + skill := player.GetSkillByName("Transmuting") + if skill == nil { + return fmt.Errorf("unable to find transmuting skill for player %s", player.GetName()) + } + + // Calculate skill up chance + itemLevel := item.GetAdventureDefaultLevel() + maxTransLevel := skill.GetCurrentValue()/5 + 5 + levelDif := int32(maxTransLevel) - itemLevel + + // No skill up if level difference is too high or skill is maxed + if levelDif > MaxSkillUpLevelDif || skill.GetCurrentValue() >= skill.GetMaxValue() { + return nil + } + + // Calculate skill up probability + // 50% base chance at max item level, 20% decrease per level difference + baseChance := float64(SkillUpPercentChanceMax) + penalty := 0.0 + if itemLevel > 5 { + penalty = float64(levelDif) * 0.2 + } + requiredRoll := int32(baseChance * (1.0 - penalty)) + + roll := rand.Intn(100) + 1 + if int32(roll) <= requiredRoll { + return player.IncreaseSkill("Transmuting", 1) + } + + return nil +} + +// CleanupRequest removes a completed or expired request +func (t *Transmuter) CleanupRequest(requestID int32) { + t.requestMutex.Lock() + defer t.requestMutex.Unlock() + + delete(t.activeRequests, requestID) +} + +// GetActiveRequest returns an active request by ID +func (t *Transmuter) GetActiveRequest(requestID int32) *TransmuteRequest { + t.requestMutex.Lock() + defer t.requestMutex.Unlock() + + if request, exists := t.activeRequests[requestID]; exists { + // Return a copy to prevent external modification + return &TransmuteRequest{ + RequestID: request.RequestID, + ClientID: request.ClientID, + ItemID: request.ItemID, + Phase: request.Phase, + } + } + + return nil +} \ No newline at end of file diff --git a/internal/transmute/types.go b/internal/transmute/types.go new file mode 100644 index 0000000..449731e --- /dev/null +++ b/internal/transmute/types.go @@ -0,0 +1,111 @@ +package transmute + +// TransmutingTier represents a level range and associated material IDs for transmutation +type TransmutingTier struct { + MinLevel int32 // Minimum item level for this tier + MaxLevel int32 // Maximum item level for this tier + FragmentID int32 // Item ID for fragments (lowest tier materials) + PowderID int32 // Item ID for powder (mid tier materials) + InfusionID int32 // Item ID for infusions (high tier materials) + ManaID int32 // Item ID for mana (highest tier materials) +} + +// TransmuteRequest represents an active transmutation request +type TransmuteRequest struct { + RequestID int32 // Unique request identifier + ClientID int32 // Client making the request + ItemID int32 // Item being transmuted (if in confirmation phase) + Phase TransmutePhase +} + +// TransmutePhase represents the current phase of transmutation +type TransmutePhase int + +const ( + PhaseItemSelection TransmutePhase = iota // Player selecting item to transmute + PhaseConfirmation // Player confirming transmutation + PhaseProcessing // Transmutation in progress + PhaseComplete // Transmutation completed +) + +// TransmuteResult represents the outcome of a transmutation +type TransmuteResult struct { + Success bool // Whether transmutation was successful + CommonMaterial *Item // Common material received (if any) + RareMaterial *Item // Rare material received (if any) + ErrorMessage string // Error message if unsuccessful + SkillIncrease bool // Whether player received skill increase +} + +// Item represents the minimal item interface needed for transmutation +type Item interface { + GetID() int32 + GetUniqueID() int32 + GetName() string + GetAdventureDefaultLevel() int32 + GetItemFlags() int32 + GetItemFlags2() int32 + GetTier() int32 + GetStackCount() int32 + CreateItemLink(version int32, detailed bool) string + SetCount(count int32) +} + +// Player represents the minimal player interface needed for transmutation +type Player interface { + GetItemList() map[int32]Item + GetItemFromUniqueID(uniqueID int32) Item + GetSkillByName(skillName string) Skill + GetStat(statType int32) int32 + GetName() string + GetZone() Zone + RemoveItem(item Item, deleteItem bool) bool + AddItem(item Item) (bool, error) + IncreaseSkill(skillName string, amount int32) error +} + +// Client represents the minimal client interface needed for transmutation +type Client interface { + GetVersion() int32 + GetTransmuteID() int32 + SetTransmuteID(id int32) + QueuePacket(packet []byte) + SimpleMessage(channel int32, message string) + Message(channel int32, format string, args ...interface{}) + AddItem(item Item, itemDeleted *bool) error +} + +// Skill represents a player skill +type Skill interface { + GetCurrentValue() int32 + GetMaxValue() int32 +} + +// Zone represents a game zone +type Zone interface { + ProcessSpell(spell Spell, caster Player) error +} + +// Spell represents a spell that can be cast +type Spell interface { + GetID() int32 + GetName() string +} + +// Database represents the database interface for transmutation +type Database interface { + LoadTransmutingTiers() ([]*TransmutingTier, error) +} + +// PacketBuilder represents the interface for building packets +type PacketBuilder interface { + BuildItemRequestPacket(requestID int32, items []int32, version int32) ([]byte, error) + BuildConfirmationPacket(requestID int32, item Item, version int32) ([]byte, error) + BuildRewardPacket(items []Item, version int32) ([]byte, error) +} + +// ItemMaster represents the master item list interface +type ItemMaster interface { + GetItem(itemID int32) Item + CreateItem(itemID int32) Item +} \ No newline at end of file diff --git a/internal/widget/actions.go b/internal/widget/actions.go new file mode 100644 index 0000000..c226f9b --- /dev/null +++ b/internal/widget/actions.go @@ -0,0 +1,307 @@ +package widget + +import ( + "eq2emu/internal/spawn" +) + +// WidgetAction represents an action that can be performed on a widget +type WidgetAction interface { + Execute(w *Widget, caller *spawn.Spawn) error +} + +// OpenDoor opens the widget (door/lift) +func (w *Widget) OpenDoor() { + w.mutex.Lock() + defer w.mutex.Unlock() + + // Set heading if specified + if w.openHeading >= 0 { + w.SetHeading(w.openHeading) + } + + // Handle position changes + openX := w.openX + openY := w.openY + openZ := w.openZ + + if openX != 0 || openY != 0 || openZ != 0 { + x := w.GetX() + y := w.GetY() + z := w.GetZ() + + // Use open positions if specified + if openX != 0 { + x = openX + } + if openY != 0 { + y = openY + } + if openZ != 0 { + z = openZ + } + + // Add movement to the open position + // Speed of 4 units per second (from C++) + w.AddRunningLocation(x, y, z, 4) + + // Calculate movement duration + diff := calculateDistance(w.GetX(), w.GetY(), w.GetZ(), x, y, z) + if diff < 0 { + diff = -diff + } + + // Schedule timer for movement completion + // TODO: Zone will need to handle widget timers + // GetZone()->AddWidgetTimer(this, diff / 4) + } + + // Update activity status for non-lifts + if w.widgetType != WidgetTypeLift { + w.SetActivityStatus(DefaultActivityOpen) + } + + // Set open state + w.isOpen = true + + // Schedule auto-close timer if duration is set + if w.openDuration > 0 { + // TODO: Zone will need to handle widget timers + // GetZone()->AddWidgetTimer(this, open_duration) + } + + // TODO: Notify zone of spawn changes + // GetZone()->SendSpawnChanges(this) +} + +// CloseDoor closes the widget (door/lift) +func (w *Widget) CloseDoor() { + w.mutex.Lock() + defer w.mutex.Unlock() + + // Set heading + if w.closedHeading > 0 { + w.SetHeading(w.closedHeading) + } else if w.openHeading >= 0 { + // Fall back to original heading + w.SetHeading(w.GetSpawnOrigHeading()) + } + + // Update activity status for non-lifts + if w.widgetType != WidgetTypeLift { + w.SetActivityStatus(DefaultActivityClosed) + } + + // Handle position changes + if w.closeX != 0 || w.closeY != 0 || w.closeZ != 0 || w.openX != 0 || w.openY != 0 || w.openZ != 0 { + // Default to original spawn position + x := w.GetSpawnOrigX() + y := w.GetSpawnOrigY() + z := w.GetSpawnOrigZ() + + // Use close positions if specified + if w.closeX != 0 { + x = w.closeX + } + if w.closeY != 0 { + y = w.closeY + } + if w.closeZ != 0 { + z = w.closeZ + } + + // Add movement to the close position + w.AddRunningLocation(x, y, z, 4) + + // Calculate movement duration + diff := calculateDistance(w.GetX(), w.GetY(), w.GetZ(), x, y, z) + if diff < 0 { + diff = -diff + } + + // Schedule timer for movement completion + // TODO: Zone will need to handle widget timers + // GetZone()->AddWidgetTimer(this, diff / 4) + } + + // Set closed state + w.isOpen = false + + // TODO: Notify zone of spawn changes + // GetZone()->SendSpawnChanges(this) +} + +// ProcessUse processes the use of this widget +func (w *Widget) ProcessUse(caller *spawn.Spawn) { + // Skip if this is a lift that's currently in use + if w.widgetType == WidgetTypeLift { + // TODO: Check if widget has active timer in zone + // if GetZone()->HasWidgetTimer(this) return + } + + // TODO: Call spawn script for custom handling + // if GetZone()->CallSpawnScript(this, SPAWN_SCRIPT_USEDOOR, caller, "", is_open) + // return // handled in lua + + // Default behavior: toggle open/closed state + wasOpen := w.IsOpen() + if wasOpen { + w.CloseDoor() + } else { + w.OpenDoor() + } + + // Play appropriate sound + if w.IsOpen() && w.openSound != "" { + // TODO: Play sound through zone + // GetZone()->PlaySoundFile(0, openSound, widgetX, widgetY, widgetZ) + } else if !w.IsOpen() && w.closeSound != "" { + // TODO: Play sound through zone + // GetZone()->PlaySoundFile(0, closeSound, widgetX, widgetY, widgetZ) + } +} + +// HandleTimerUpdate handles widget timer updates +func (w *Widget) HandleTimerUpdate() { + // Lifts don't auto-close + if w.widgetType == WidgetTypeLift { + return + } + + // Auto-close open doors + if w.widgetType == WidgetTypeDoor && w.IsOpen() { + w.HandleUse(nil, "") + } +} + +// HandleUse handles widget interaction from a client +func (w *Widget) HandleUse(client ClientInterface, command string) { + // Handle override widget type for scripted behavior + overrideWidgetType := w.widgetType + + // Client validation + if client != nil { + // TODO: Check quest requirements + // meetsQuestReqs := w.MeetsSpawnAccessRequirements(client.GetPlayer()) + // if !meetsQuestReqs && (w.GetQuestsRequiredOverride() & 2) == 0 { + // return + // } + // if meetsQuestReqs && w.GetShowCommandIcon() != 1 { + // return + // } + } + + // Handle transporter functionality + if client != nil && w.GetTransporterID() > 0 { + // TODO: Handle transporter destinations + // client.SetTemporaryTransportID(0) + // destinations := GetZone()->GetTransporters(client, w.GetTransporterID()) + // if len(destinations) > 0 { + // client.ProcessTeleport(w, destinations, w.GetTransporterID()) + // return + // } + } + + // Skip house commands for certain operations + skipHouseCommands := isCommand(command, "access") || isCommand(command, "visit") + + // Handle door/lift widgets + if !skipHouseCommands && (overrideWidgetType == WidgetTypeDoor || overrideWidgetType == WidgetTypeLift) { + // Resolve action spawn if needed + if w.actionSpawn == nil && w.actionSpawnID > 0 { + // TODO: Get spawn from zone + // spawn := GetZone()->GetSpawnByDatabaseID(w.actionSpawnID) + // if spawn != nil && spawn.IsWidget() { + // w.actionSpawn = spawn.(*Widget) + // } + } + + // Resolve linked spawn if needed + if w.linkedSpawn == nil && w.linkedSpawnID > 0 { + // TODO: Get spawn from zone + // spawn := GetZone()->GetSpawnByDatabaseID(w.linkedSpawnID) + // if spawn != nil && spawn.IsWidget() { + // w.linkedSpawn = spawn.(*Widget) + // } + } + + // Process linked spawns + widget := w + if w.linkedSpawn != nil { + widget = w.linkedSpawn + // Fire the first door + var caller *spawn.Spawn + if client != nil { + caller = client.GetPlayer() + } + w.ProcessUse(caller) + } else if w.actionSpawn != nil { + widget = w.actionSpawn + // Resolve action spawn's linked spawn if needed + if widget.linkedSpawn == nil && widget.linkedSpawnID > 0 { + // TODO: Get spawn from zone + // spawn := GetZone()->GetSpawnByDatabaseID(widget.linkedSpawnID) + // if spawn != nil && spawn.IsWidget() { + // widget.linkedSpawn = spawn.(*Widget) + // } + } + + // Process linked spawn first + if widget.linkedSpawn != nil { + var caller *spawn.Spawn + if client != nil { + caller = client.GetPlayer() + } + widget.linkedSpawn.ProcessUse(caller) + } + } + + // Process the main widget + var caller *spawn.Spawn + if client != nil { + caller = client.GetPlayer() + } + widget.ProcessUse(caller) + } else if client != nil && isCommand(command, "access") && w.houseID > 0 { + // Handle house access + // TODO: Implement house access functionality + // This involves PlayerHouse and HouseZone systems + } else if client != nil && isCommand(command, "visit") && w.houseID > 0 { + // Handle house visit + // TODO: Implement house visit functionality + } else if client != nil && command != "" { + // Handle other entity commands + // TODO: Process entity commands + // entityCommand := w.FindEntityCommand(command) + // if entityCommand != nil { + // GetZone()->ProcessEntityCommand(entityCommand, client.GetPlayer(), client.GetPlayer().GetTarget()) + // } + } +} + +// SetLinkedSpawn sets the linked spawn widget +func (w *Widget) SetLinkedSpawn(linked *Widget) { + w.mutex.Lock() + defer w.mutex.Unlock() + w.linkedSpawn = linked +} + +// GetLinkedSpawn returns the linked spawn widget +func (w *Widget) GetLinkedSpawn() *Widget { + w.mutex.RLock() + defer w.mutex.RUnlock() + return w.linkedSpawn +} + +// SetActionSpawn sets the action spawn widget +func (w *Widget) SetActionSpawn(action *Widget) { + w.mutex.Lock() + defer w.mutex.Unlock() + w.actionSpawn = action +} + +// GetActionSpawn returns the action spawn widget +func (w *Widget) GetActionSpawn() *Widget { + w.mutex.RLock() + defer w.mutex.RUnlock() + return w.actionSpawn +} \ No newline at end of file diff --git a/internal/widget/constants.go b/internal/widget/constants.go new file mode 100644 index 0000000..9691403 --- /dev/null +++ b/internal/widget/constants.go @@ -0,0 +1,38 @@ +package widget + +// Widget type constants +const ( + WidgetTypeGeneric = 0 + WidgetTypeDoor = 1 + WidgetTypeLift = 2 +) + +// Widget state constants +const ( + WidgetStateClosed = 0 + WidgetStateOpen = 1 +) + +// Default widget values +const ( + DefaultOpenHeading = -1 + DefaultClosedHeading = -1 + DefaultOpenDuration = 0 + DefaultActivityOpen = 0 + DefaultActivityClosed = 64 +) + +// Widget type names for display +var WidgetTypeNames = map[int8]string{ + WidgetTypeGeneric: "Generic", + WidgetTypeDoor: "Door", + WidgetTypeLift: "Lift", +} + +// GetWidgetTypeNameByTypeID returns the display name for a widget type +func GetWidgetTypeNameByTypeID(typeID int8) string { + if name, exists := WidgetTypeNames[typeID]; exists { + return name + } + return "Generic" +} \ No newline at end of file diff --git a/internal/widget/interfaces.go b/internal/widget/interfaces.go new file mode 100644 index 0000000..82cb285 --- /dev/null +++ b/internal/widget/interfaces.go @@ -0,0 +1,109 @@ +package widget + +import ( + "eq2emu/internal/spawn" +) + +// ClientInterface represents the minimal client interface needed by widgets +type ClientInterface interface { + GetPlayer() *spawn.Spawn + SetTemporaryTransportID(id int32) + ProcessTeleport(widget *Widget, destinations []interface{}, transporterID int32) + GetVersion() int32 + GetCurrentZone() ZoneInterface +} + +// ZoneInterface represents the minimal zone interface needed by widgets +type ZoneInterface interface { + HasWidgetTimer(widget *Widget) bool + AddWidgetTimer(widget *Widget, duration float32) + SendSpawnChanges(s *spawn.Spawn) + PlaySoundFile(unknown int32, soundFile string, x, y, z float32) + CallSpawnScript(s *spawn.Spawn, scriptType string, caller *spawn.Spawn, extra string, state bool) bool + GetSpawnByDatabaseID(id int32) *spawn.Spawn + GetTransporters(client ClientInterface, transporterID int32) []interface{} + ProcessEntityCommand(command interface{}, player *spawn.Spawn, target *spawn.Spawn) + GetInstanceID() int32 + GetInstanceType() int32 + SendHouseItems(client ClientInterface) +} + +// WidgetSpawn provides integration between Widget and Spawn systems +type WidgetSpawn struct { + *spawn.Spawn + *Widget +} + +// NewWidgetSpawn creates a new widget spawn wrapper +func NewWidgetSpawn() *WidgetSpawn { + widget := NewWidget() + return &WidgetSpawn{ + Spawn: widget.Spawn, + Widget: widget, + } +} + +// IsWidget returns true for widget spawns +func (ws *WidgetSpawn) IsWidget() bool { + return true +} + +// Copy creates a copy of the widget spawn +func (ws *WidgetSpawn) Copy() *WidgetSpawn { + newWidget := ws.Widget.Copy() + return &WidgetSpawn{ + Spawn: newWidget.Spawn, + Widget: newWidget, + } +} + +// WidgetManager interface for managing widgets in a zone +type WidgetManager interface { + AddWidget(widget *Widget) + RemoveWidget(widgetID int32) + GetWidget(widgetID int32) *Widget + GetWidgetByDatabaseID(databaseID int32) *Widget + GetAllWidgets() []*Widget + ProcessWidgetTimers() +} + +// WidgetTimer represents a timer for widget actions +type WidgetTimer struct { + Widget *Widget + Duration float32 + Callback func(*Widget) +} + +// WidgetState represents the current state of a widget +type WidgetState struct { + IsOpen bool + Position spawn.Position + Heading float32 + ActivityStatus int32 +} + +// GetState returns the current state of the widget +func (w *Widget) GetState() WidgetState { + w.mutex.RLock() + defer w.mutex.RUnlock() + + return WidgetState{ + IsOpen: w.isOpen, + Position: spawn.Position{X: w.GetX(), Y: w.GetY(), Z: w.GetZ()}, + Heading: w.GetHeading(), + ActivityStatus: w.GetActivityStatus(), + } +} + +// RestoreState restores the widget to a previous state +func (w *Widget) RestoreState(state WidgetState) { + w.mutex.Lock() + defer w.mutex.Unlock() + + w.isOpen = state.IsOpen + w.SetX(state.Position.X) + w.SetY(state.Position.Y) + w.SetZ(state.Position.Z) + w.SetHeading(state.Heading) + w.SetActivityStatus(state.ActivityStatus) +} \ No newline at end of file diff --git a/internal/widget/manager.go b/internal/widget/manager.go new file mode 100644 index 0000000..a80f35f --- /dev/null +++ b/internal/widget/manager.go @@ -0,0 +1,313 @@ +package widget + +import ( + "sync" + "time" +) + +// Manager manages widgets within a zone +type Manager struct { + widgets map[int32]*Widget // Widget ID -> Widget + widgetsByDBID map[int32]*Widget // Database ID -> Widget + widgetTimers map[*Widget]*time.Timer // Active timers for widgets + timerCallbacks map[*Widget]func() // Timer callbacks + mutex sync.RWMutex + timerMutex sync.Mutex +} + +// NewManager creates a new widget manager +func NewManager() *Manager { + return &Manager{ + widgets: make(map[int32]*Widget), + widgetsByDBID: make(map[int32]*Widget), + widgetTimers: make(map[*Widget]*time.Timer), + timerCallbacks: make(map[*Widget]func()), + } +} + +// AddWidget adds a widget to the manager +func (m *Manager) AddWidget(widget *Widget) { + m.mutex.Lock() + defer m.mutex.Unlock() + + if widget.GetWidgetID() > 0 { + m.widgets[widget.GetWidgetID()] = widget + } + + if widget.GetDatabaseID() > 0 { + m.widgetsByDBID[widget.GetDatabaseID()] = widget + } +} + +// RemoveWidget removes a widget from the manager +func (m *Manager) RemoveWidget(widgetID int32) { + m.mutex.Lock() + defer m.mutex.Unlock() + + if widget, exists := m.widgets[widgetID]; exists { + // Cancel any active timers + m.cancelWidgetTimer(widget) + + // Remove from maps + delete(m.widgets, widgetID) + if widget.GetDatabaseID() > 0 { + delete(m.widgetsByDBID, widget.GetDatabaseID()) + } + } +} + +// GetWidget gets a widget by ID +func (m *Manager) GetWidget(widgetID int32) *Widget { + m.mutex.RLock() + defer m.mutex.RUnlock() + + return m.widgets[widgetID] +} + +// GetWidgetByDatabaseID gets a widget by database ID +func (m *Manager) GetWidgetByDatabaseID(databaseID int32) *Widget { + m.mutex.RLock() + defer m.mutex.RUnlock() + + return m.widgetsByDBID[databaseID] +} + +// GetAllWidgets returns all widgets +func (m *Manager) GetAllWidgets() []*Widget { + m.mutex.RLock() + defer m.mutex.RUnlock() + + widgets := make([]*Widget, 0, len(m.widgets)) + for _, widget := range m.widgets { + widgets = append(widgets, widget) + } + + return widgets +} + +// GetWidgetsByType returns all widgets of a specific type +func (m *Manager) GetWidgetsByType(widgetType int8) []*Widget { + m.mutex.RLock() + defer m.mutex.RUnlock() + + widgets := make([]*Widget, 0) + for _, widget := range m.widgets { + if widget.GetWidgetType() == widgetType { + widgets = append(widgets, widget) + } + } + + return widgets +} + +// AddWidgetTimer adds a timer for a widget +func (m *Manager) AddWidgetTimer(widget *Widget, seconds float32, callback func()) { + m.timerMutex.Lock() + defer m.timerMutex.Unlock() + + // Cancel existing timer if any + m.cancelWidgetTimerLocked(widget) + + // Create new timer + duration := time.Duration(seconds * float32(time.Second)) + timer := time.AfterFunc(duration, func() { + m.handleWidgetTimer(widget) + }) + + m.widgetTimers[widget] = timer + if callback != nil { + m.timerCallbacks[widget] = callback + } else { + // Default callback for auto-close + m.timerCallbacks[widget] = func() { + widget.HandleTimerUpdate() + } + } +} + +// HasWidgetTimer checks if a widget has an active timer +func (m *Manager) HasWidgetTimer(widget *Widget) bool { + m.timerMutex.Lock() + defer m.timerMutex.Unlock() + + _, exists := m.widgetTimers[widget] + return exists +} + +// CancelWidgetTimer cancels a widget's timer +func (m *Manager) CancelWidgetTimer(widget *Widget) { + m.timerMutex.Lock() + defer m.timerMutex.Unlock() + + m.cancelWidgetTimerLocked(widget) +} + +// cancelWidgetTimerLocked cancels a timer (must hold timerMutex) +func (m *Manager) cancelWidgetTimerLocked(widget *Widget) { + if timer, exists := m.widgetTimers[widget]; exists { + timer.Stop() + delete(m.widgetTimers, widget) + delete(m.timerCallbacks, widget) + } +} + +// cancelWidgetTimer cancels a timer (must hold timerMutex) +func (m *Manager) cancelWidgetTimer(widget *Widget) { + m.timerMutex.Lock() + defer m.timerMutex.Unlock() + + m.cancelWidgetTimerLocked(widget) +} + +// handleWidgetTimer handles a widget timer expiration +func (m *Manager) handleWidgetTimer(widget *Widget) { + m.timerMutex.Lock() + callback, exists := m.timerCallbacks[widget] + if exists { + delete(m.widgetTimers, widget) + delete(m.timerCallbacks, widget) + } + m.timerMutex.Unlock() + + // Execute callback outside of lock + if callback != nil { + callback() + } +} + +// ProcessWidgetTimers processes all widget timers (called periodically) +func (m *Manager) ProcessWidgetTimers() { + // Timers are handled automatically by time.AfterFunc + // This method is here for compatibility but doesn't need to do anything +} + +// GetDoorWidgets returns all door widgets +func (m *Manager) GetDoorWidgets() []*Widget { + return m.GetWidgetsByType(WidgetTypeDoor) +} + +// GetLiftWidgets returns all lift widgets +func (m *Manager) GetLiftWidgets() []*Widget { + return m.GetWidgetsByType(WidgetTypeLift) +} + +// GetOpenWidgets returns all open widgets +func (m *Manager) GetOpenWidgets() []*Widget { + m.mutex.RLock() + defer m.mutex.RUnlock() + + widgets := make([]*Widget, 0) + for _, widget := range m.widgets { + if widget.IsOpen() { + widgets = append(widgets, widget) + } + } + + return widgets +} + +// GetWidgetsByHouseID returns all widgets for a house +func (m *Manager) GetWidgetsByHouseID(houseID int32) []*Widget { + m.mutex.RLock() + defer m.mutex.RUnlock() + + widgets := make([]*Widget, 0) + for _, widget := range m.widgets { + if widget.GetHouseID() == houseID { + widgets = append(widgets, widget) + } + } + + return widgets +} + +// GetLinkedWidgets returns all widgets linked to the given widget +func (m *Manager) GetLinkedWidgets(widget *Widget) []*Widget { + m.mutex.RLock() + defer m.mutex.RUnlock() + + linked := make([]*Widget, 0) + + // Add linked spawn + if widget.GetLinkedSpawnID() > 0 { + if linkedWidget := m.widgetsByDBID[widget.GetLinkedSpawnID()]; linkedWidget != nil { + linked = append(linked, linkedWidget) + } + } + + // Add action spawn + if widget.GetActionSpawnID() > 0 { + if actionWidget := m.widgetsByDBID[widget.GetActionSpawnID()]; actionWidget != nil { + linked = append(linked, actionWidget) + } + } + + // Find widgets that link to this one + for _, w := range m.widgets { + if w.GetLinkedSpawnID() == widget.GetDatabaseID() || + w.GetActionSpawnID() == widget.GetDatabaseID() { + linked = append(linked, w) + } + } + + return linked +} + +// ResolveLinkedSpawns resolves all linked spawn references +func (m *Manager) ResolveLinkedSpawns() { + m.mutex.RLock() + defer m.mutex.RUnlock() + + for _, widget := range m.widgets { + // Resolve linked spawn + if widget.GetLinkedSpawnID() > 0 && widget.GetLinkedSpawn() == nil { + if linked := m.widgetsByDBID[widget.GetLinkedSpawnID()]; linked != nil { + widget.SetLinkedSpawn(linked) + } + } + + // Resolve action spawn + if widget.GetActionSpawnID() > 0 && widget.GetActionSpawn() == nil { + if action := m.widgetsByDBID[widget.GetActionSpawnID()]; action != nil { + widget.SetActionSpawn(action) + } + } + } +} + +// Clear removes all widgets and cancels all timers +func (m *Manager) Clear() { + m.mutex.Lock() + defer m.mutex.Unlock() + + // Cancel all timers + m.timerMutex.Lock() + for widget, timer := range m.widgetTimers { + timer.Stop() + delete(m.widgetTimers, widget) + delete(m.timerCallbacks, widget) + } + m.timerMutex.Unlock() + + // Clear maps + m.widgets = make(map[int32]*Widget) + m.widgetsByDBID = make(map[int32]*Widget) +} + +// GetStatistics returns widget statistics +func (m *Manager) GetStatistics() map[string]interface{} { + m.mutex.RLock() + defer m.mutex.RUnlock() + + stats := make(map[string]interface{}) + stats["total_widgets"] = len(m.widgets) + stats["door_count"] = len(m.GetDoorWidgets()) + stats["lift_count"] = len(m.GetLiftWidgets()) + stats["open_count"] = len(m.GetOpenWidgets()) + + m.timerMutex.Lock() + stats["active_timers"] = len(m.widgetTimers) + m.timerMutex.Unlock() + + return stats +} \ No newline at end of file diff --git a/internal/widget/widget.go b/internal/widget/widget.go new file mode 100644 index 0000000..b995e8d --- /dev/null +++ b/internal/widget/widget.go @@ -0,0 +1,498 @@ +package widget + +import ( + "math" + "strings" + "sync" + + "eq2emu/internal/spawn" +) + +// Widget represents an interactive spawn object like doors and lifts +// Extends the base Spawn with widget-specific functionality +type Widget struct { + *spawn.Spawn // Embedded spawn for basic functionality + + // Widget identification + widgetID int32 // Unique widget identifier + widgetType int8 // Type of widget (door, lift, etc.) + + // Widget positioning + widgetX float32 // Widget-specific X coordinate + widgetY float32 // Widget-specific Y coordinate + widgetZ float32 // Widget-specific Z coordinate + includeLocation bool // Whether to include location in updates + includeHeading bool // Whether to include heading in updates + + // Door/movement states + isOpen bool // Current open/closed state + openHeading float32 // Heading when open + closedHeading float32 // Heading when closed + openX float32 // X position when open + openY float32 // Y position when open + openZ float32 // Z position when open + closeX float32 // X position when closed + closeY float32 // Y position when closed (from close_y in C++) + closeZ float32 // Z position when closed + + // Linked widgets + actionSpawn *Widget // Spawn triggered by this widget + actionSpawnID int32 // ID of action spawn + linkedSpawn *Widget // Linked widget (opens/closes together) + linkedSpawnID int32 // ID of linked spawn + + // Sounds and timing + openSound string // Sound played when opening + closeSound string // Sound played when closing + openDuration int16 // How long widget stays open (seconds) + + // House integration + houseID int32 // Associated house ID + + // Multi-floor lift support + multiFloorLift bool // Whether this is a multi-floor lift + + // Thread safety + mutex sync.RWMutex +} + +// NewWidget creates a new widget instance +func NewWidget() *Widget { + w := &Widget{ + Spawn: spawn.NewSpawn(), + widgetID: 0, + widgetType: WidgetTypeGeneric, + widgetX: 0, + widgetY: 0, + widgetZ: 0, + includeLocation: true, + includeHeading: true, + isOpen: false, + openHeading: DefaultOpenHeading, + closedHeading: DefaultClosedHeading, + openX: 0, + openY: 0, + openZ: 0, + closeX: 0, + closeY: 0, + closeZ: 0, + openDuration: DefaultOpenDuration, + multiFloorLift: false, + } + + // Set spawn-specific defaults for widgets + w.SetSpawnType(2) // Widget spawn type + w.SetActivityStatus(DefaultActivityClosed) + w.SetPosState(1) + + return w +} + +// IsWidget returns true to identify this as a widget +func (w *Widget) IsWidget() bool { + return true +} + +// Widget ID methods + +// GetWidgetID returns the widget ID +func (w *Widget) GetWidgetID() int32 { + w.mutex.RLock() + defer w.mutex.RUnlock() + return w.widgetID +} + +// SetWidgetID sets the widget ID +func (w *Widget) SetWidgetID(id int32) { + w.mutex.Lock() + defer w.mutex.Unlock() + w.widgetID = id +} + +// Widget position methods + +// GetWidgetX returns the widget X coordinate +func (w *Widget) GetWidgetX() float32 { + w.mutex.RLock() + defer w.mutex.RUnlock() + return w.widgetX +} + +// SetWidgetX sets the widget X coordinate +func (w *Widget) SetWidgetX(x float32) { + w.mutex.Lock() + defer w.mutex.Unlock() + w.widgetX = x +} + +// GetWidgetY returns the widget Y coordinate +func (w *Widget) GetWidgetY() float32 { + w.mutex.RLock() + defer w.mutex.RUnlock() + return w.widgetY +} + +// SetWidgetY sets the widget Y coordinate +func (w *Widget) SetWidgetY(y float32) { + w.mutex.Lock() + defer w.mutex.Unlock() + w.widgetY = y +} + +// GetWidgetZ returns the widget Z coordinate +func (w *Widget) GetWidgetZ() float32 { + w.mutex.RLock() + defer w.mutex.RUnlock() + return w.widgetZ +} + +// SetWidgetZ sets the widget Z coordinate +func (w *Widget) SetWidgetZ(z float32) { + w.mutex.Lock() + defer w.mutex.Unlock() + w.widgetZ = z +} + +// Location/heading inclusion methods + +// GetIncludeLocation returns whether to include location in updates +func (w *Widget) GetIncludeLocation() bool { + w.mutex.RLock() + defer w.mutex.RUnlock() + return w.includeLocation +} + +// SetIncludeLocation sets whether to include location in updates +func (w *Widget) SetIncludeLocation(include bool) { + w.mutex.Lock() + defer w.mutex.Unlock() + w.includeLocation = include +} + +// GetIncludeHeading returns whether to include heading in updates +func (w *Widget) GetIncludeHeading() bool { + w.mutex.RLock() + defer w.mutex.RUnlock() + return w.includeHeading +} + +// SetIncludeHeading sets whether to include heading in updates +func (w *Widget) SetIncludeHeading(include bool) { + w.mutex.Lock() + defer w.mutex.Unlock() + w.includeHeading = include +} + +// Widget type methods + +// GetWidgetType returns the widget type +func (w *Widget) GetWidgetType() int8 { + w.mutex.RLock() + defer w.mutex.RUnlock() + return w.widgetType +} + +// SetWidgetType sets the widget type +func (w *Widget) SetWidgetType(widgetType int8) { + w.mutex.Lock() + defer w.mutex.Unlock() + w.widgetType = widgetType +} + +// SetWidgetIcon sets the widget icon (appearance) +func (w *Widget) SetWidgetIcon(icon int8) { + w.SetIcon(icon) +} + +// Open/close state methods + +// IsOpen returns whether the widget is open +func (w *Widget) IsOpen() bool { + w.mutex.RLock() + defer w.mutex.RUnlock() + return w.isOpen +} + +// setOpenState sets the open state (internal use) +func (w *Widget) setOpenState(open bool) { + w.isOpen = open +} + +// Heading methods + +// GetOpenHeading returns the heading when open +func (w *Widget) GetOpenHeading() float32 { + w.mutex.RLock() + defer w.mutex.RUnlock() + return w.openHeading +} + +// SetOpenHeading sets the heading when open +func (w *Widget) SetOpenHeading(heading float32) { + w.mutex.Lock() + defer w.mutex.Unlock() + w.openHeading = heading +} + +// GetClosedHeading returns the heading when closed +func (w *Widget) GetClosedHeading() float32 { + w.mutex.RLock() + defer w.mutex.RUnlock() + return w.closedHeading +} + +// SetClosedHeading sets the heading when closed +func (w *Widget) SetClosedHeading(heading float32) { + w.mutex.Lock() + defer w.mutex.Unlock() + w.closedHeading = heading +} + +// Position methods for open/close states + +// GetOpenX returns the X position when open +func (w *Widget) GetOpenX() float32 { + w.mutex.RLock() + defer w.mutex.RUnlock() + return w.openX +} + +// SetOpenX sets the X position when open +func (w *Widget) SetOpenX(x float32) { + w.mutex.Lock() + defer w.mutex.Unlock() + w.openX = x +} + +// GetOpenY returns the Y position when open +func (w *Widget) GetOpenY() float32 { + w.mutex.RLock() + defer w.mutex.RUnlock() + return w.openY +} + +// SetOpenY sets the Y position when open +func (w *Widget) SetOpenY(y float32) { + w.mutex.Lock() + defer w.mutex.Unlock() + w.openY = y +} + +// GetOpenZ returns the Z position when open +func (w *Widget) GetOpenZ() float32 { + w.mutex.RLock() + defer w.mutex.RUnlock() + return w.openZ +} + +// SetOpenZ sets the Z position when open +func (w *Widget) SetOpenZ(z float32) { + w.mutex.Lock() + defer w.mutex.Unlock() + w.openZ = z +} + +// GetCloseX returns the X position when closed +func (w *Widget) GetCloseX() float32 { + w.mutex.RLock() + defer w.mutex.RUnlock() + return w.closeX +} + +// SetCloseX sets the X position when closed +func (w *Widget) SetCloseX(x float32) { + w.mutex.Lock() + defer w.mutex.Unlock() + w.closeX = x +} + +// GetCloseY returns the Y position when closed +func (w *Widget) GetCloseY() float32 { + w.mutex.RLock() + defer w.mutex.RUnlock() + return w.closeY +} + +// SetCloseY sets the Y position when closed +func (w *Widget) SetCloseY(y float32) { + w.mutex.Lock() + defer w.mutex.Unlock() + w.closeY = y +} + +// GetCloseZ returns the Z position when closed +func (w *Widget) GetCloseZ() float32 { + w.mutex.RLock() + defer w.mutex.RUnlock() + return w.closeZ +} + +// SetCloseZ sets the Z position when closed +func (w *Widget) SetCloseZ(z float32) { + w.mutex.Lock() + defer w.mutex.Unlock() + w.closeZ = z +} + +// Linked spawn methods + +// GetActionSpawnID returns the action spawn ID +func (w *Widget) GetActionSpawnID() int32 { + w.mutex.RLock() + defer w.mutex.RUnlock() + return w.actionSpawnID +} + +// SetActionSpawnID sets the action spawn ID +func (w *Widget) SetActionSpawnID(id int32) { + w.mutex.Lock() + defer w.mutex.Unlock() + w.actionSpawnID = id +} + +// GetLinkedSpawnID returns the linked spawn ID +func (w *Widget) GetLinkedSpawnID() int32 { + w.mutex.RLock() + defer w.mutex.RUnlock() + return w.linkedSpawnID +} + +// SetLinkedSpawnID sets the linked spawn ID +func (w *Widget) SetLinkedSpawnID(id int32) { + w.mutex.Lock() + defer w.mutex.Unlock() + w.linkedSpawnID = id +} + +// Sound methods + +// GetOpenSound returns the sound played when opening +func (w *Widget) GetOpenSound() string { + w.mutex.RLock() + defer w.mutex.RUnlock() + return w.openSound +} + +// SetOpenSound sets the sound played when opening +func (w *Widget) SetOpenSound(sound string) { + w.mutex.Lock() + defer w.mutex.Unlock() + w.openSound = sound +} + +// GetCloseSound returns the sound played when closing +func (w *Widget) GetCloseSound() string { + w.mutex.RLock() + defer w.mutex.RUnlock() + return w.closeSound +} + +// SetCloseSound sets the sound played when closing +func (w *Widget) SetCloseSound(sound string) { + w.mutex.Lock() + defer w.mutex.Unlock() + w.closeSound = sound +} + +// Duration methods + +// GetOpenDuration returns how long the widget stays open +func (w *Widget) GetOpenDuration() int16 { + w.mutex.RLock() + defer w.mutex.RUnlock() + return w.openDuration +} + +// SetOpenDuration sets how long the widget stays open +func (w *Widget) SetOpenDuration(duration int16) { + w.mutex.Lock() + defer w.mutex.Unlock() + w.openDuration = duration +} + +// House methods + +// GetHouseID returns the associated house ID +func (w *Widget) GetHouseID() int32 { + w.mutex.RLock() + defer w.mutex.RUnlock() + return w.houseID +} + +// SetHouseID sets the associated house ID +func (w *Widget) SetHouseID(id int32) { + w.mutex.Lock() + defer w.mutex.Unlock() + w.houseID = id +} + +// Multi-floor lift methods + +// GetMultiFloorLift returns whether this is a multi-floor lift +func (w *Widget) GetMultiFloorLift() bool { + w.mutex.RLock() + defer w.mutex.RUnlock() + return w.multiFloorLift +} + +// SetMultiFloorLift sets whether this is a multi-floor lift +func (w *Widget) SetMultiFloorLift(multiFloor bool) { + w.mutex.Lock() + defer w.mutex.Unlock() + w.multiFloorLift = multiFloor +} + +// Copy creates a copy of this widget +func (w *Widget) Copy() *Widget { + w.mutex.RLock() + defer w.mutex.RUnlock() + + newWidget := NewWidget() + + // Copy spawn data + w.CopySpawnData(newWidget.Spawn) + + // Handle open state for appearance + if w.openY > 0 { + newWidget.SetPosState(0) + } + + // Copy widget-specific data + newWidget.widgetID = w.widgetID + newWidget.widgetType = w.widgetType + newWidget.widgetX = w.widgetX + newWidget.widgetY = w.widgetY + newWidget.widgetZ = w.widgetZ + newWidget.includeLocation = w.includeLocation + newWidget.includeHeading = w.includeHeading + newWidget.openHeading = w.openHeading + newWidget.closedHeading = w.closedHeading + newWidget.openX = w.openX + newWidget.openY = w.openY + newWidget.openZ = w.openZ + newWidget.closeX = w.closeX + newWidget.closeY = w.closeY + newWidget.closeZ = w.closeZ + newWidget.openSound = w.openSound + newWidget.closeSound = w.closeSound + newWidget.openDuration = w.openDuration + newWidget.actionSpawnID = w.actionSpawnID + newWidget.linkedSpawnID = w.linkedSpawnID + newWidget.houseID = w.houseID + newWidget.multiFloorLift = w.multiFloorLift + + return newWidget +} + +// calculateDistance calculates 3D distance between two points +func calculateDistance(x1, y1, z1, x2, y2, z2 float32) float32 { + dx := x2 - x1 + dy := y2 - y1 + dz := z2 - z1 + return float32(math.Sqrt(float64(dx*dx + dy*dy + dz*dz))) +} + +// Helper method to check if a string command matches (case-insensitive) +func isCommand(command, expected string) bool { + return strings.EqualFold(strings.TrimSpace(command), expected) +} \ No newline at end of file