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