convert more internals
This commit is contained in:
parent
1082b47942
commit
a4f2ad4156
39
CLAUDE.md
39
CLAUDE.md
@ -91,6 +91,7 @@ go run golang.org/x/vuln/cmd/govulncheck@latest ./...
|
||||
- `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
|
||||
- `languages/` - Multilingual character communication system with master language registry and player-specific language knowledge
|
||||
|
||||
### Network Protocol
|
||||
EverQuest II UDP protocol with reliability layer, RC4 encryption, CRC validation, connection management, packet combining.
|
||||
@ -227,6 +228,36 @@ XML-driven packet definitions with version-specific formats, conditional fields,
|
||||
- `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
|
||||
|
||||
**Languages System:**
|
||||
- `internal/languages/constants.go`: Language ID constants, validation limits, and system configuration values
|
||||
- `internal/languages/types.go`: Core Language struct, master language list, player language list, and statistics tracking
|
||||
- `internal/languages/manager.go`: High-level language management with database integration, statistics, and command processing
|
||||
- `internal/languages/interfaces.go`: Integration interfaces with database, players, chat processing, and event handling systems
|
||||
|
||||
**Quests System:**
|
||||
- `internal/quests/constants.go`: Quest step types, display status flags, sharing constants, and validation limits
|
||||
- `internal/quests/types.go`: Core Quest and QuestStep structures with complete quest data management and thread-safe operations
|
||||
- `internal/quests/quest.go`: Core quest functionality including step management, progress tracking, update checking, and validation
|
||||
- `internal/quests/prerequisites.go`: Quest prerequisite management with class, race, level, faction, and quest requirements
|
||||
- `internal/quests/rewards.go`: Quest reward system with coins, experience, status, faction rewards, and level-based calculations
|
||||
- `internal/quests/actions.go`: Quest action system for Lua script execution on completion, progress, and failure events
|
||||
- `internal/quests/manager.go`: MasterQuestList and QuestManager for system-wide quest management and player quest tracking
|
||||
- `internal/quests/interfaces.go`: Integration interfaces with player, client, spawn, item systems and QuestSystemAdapter for complete quest lifecycle management
|
||||
|
||||
**NPC System:**
|
||||
- `internal/npc/constants.go`: AI strategy constants, randomization flags, pet types, cast types, and system limits
|
||||
- `internal/npc/types.go`: Core NPC struct extending Entity, NPCSpell configurations, skill bonuses, movement locations, brain system
|
||||
- `internal/npc/npc.go`: Complete NPC functionality with AI, combat, spell casting, movement, appearance randomization, and validation
|
||||
- `internal/npc/manager.go`: High-level NPC management with zone indexing, appearance tracking, combat processing, and statistics
|
||||
- `internal/npc/interfaces.go`: Integration interfaces with database, spell/skill/appearance systems, combat, movement, and entity adapters
|
||||
|
||||
**NPC AI System:**
|
||||
- `internal/npc/ai/constants.go`: AI timing constants, combat ranges, hate limits, brain types, and decision parameters
|
||||
- `internal/npc/ai/types.go`: HateList and EncounterList management, BrainState tracking, performance statistics, and thread-safe operations
|
||||
- `internal/npc/ai/brain.go`: BaseBrain with complete AI logic including hate management, encounter tracking, spell/melee processing, and combat decisions
|
||||
- `internal/npc/ai/variants.go`: Specialized brain types (CombatPet, NonCombatPet, Blank, Lua, DumbFire) with unique behaviors and factory functions
|
||||
- `internal/npc/ai/interfaces.go`: Integration interfaces with NPC/Entity systems, AIManager for brain lifecycle, adapters, and debugging utilities
|
||||
|
||||
**Packet Definitions:**
|
||||
- `internal/packets/xml/`: XML packet structure definitions
|
||||
- `internal/packets/PARSER.md`: Packet definition language documentation
|
||||
@ -307,6 +338,14 @@ Command-line flags override JSON configuration.
|
||||
|
||||
**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.
|
||||
|
||||
**Languages System**: Multilingual character communication system managing language learning and chat processing. Features master language registry with ID-based and name-based lookups, individual player language collections with thread-safe operations, language validation and persistence, and comprehensive multilingual chat processing. Supports all EverQuest II racial languages (Common, Elvish, Dwarven, Halfling, Gnomish, Iksar, Trollish, Ogrish, Fae, Arasai, Sarnak, Froglok), language learning/forgetting mechanics, primary language selection, and message scrambling for unknown languages. Includes PlayerLanguageAdapter for seamless player integration, ChatLanguageProcessor for multilingual communication, and statistics tracking for language usage patterns. Thread-safe operations with efficient hash-based lookups and comprehensive validation systems.
|
||||
|
||||
**Quests System**: Complete quest management with quest definitions, step system, and real-time progress tracking. Features quest system with multiple step types (kill, chat, obtain item, location, spell, normal, craft, harvest, kill race requirements), comprehensive prerequisite system (level, class, race, faction, quest dependencies), flexible reward system (coins, experience, status points, faction reputation, items), step-based progress tracking with percentage-based success chances, task group organization for complex quests, Lua action system for completion/progress/failure events, quest sharing system with configurable sharing rules, repeatable quest support, player quest management with active quest tracking, master quest list with categorization and search, validation system for quest integrity, and thread-safe operations with proper mutex usage. Includes QuestSystemAdapter for complete quest lifecycle management and integration with player, client, spawn, and item systems.
|
||||
|
||||
**NPC System**: Non-player character system extending Entity with complete AI, combat, and spell casting capabilities. Features NPC struct with brain system, spell management (cast-on-spawn/aggro triggers), skill bonuses, movement with runback mechanics, appearance randomization (33+ flags for race/gender/colors/features), AI strategies (balanced/offensive/defensive), and combat state management. Includes NPCSpell configurations with HP ratio requirements, skill bonus system with spell-based modifications, movement locations with navigation pathfinding, timer system for pause/movement control, and comprehensive appearance randomization covering all EQ2 races and visual elements. Manager provides zone-based indexing, appearance tracking, combat processing, AI processing, statistics collection, and command interface. Integration interfaces support database persistence, spell/skill/appearance systems, combat management, movement control, and entity adapters for seamless system integration. Thread-safe operations with proper mutex usage and atomic flags for state management.
|
||||
|
||||
**NPC AI System**: Comprehensive artificial intelligence system for NPCs with hate management, encounter tracking, and specialized brain types. Features BaseBrain with complete AI logic including target selection, spell/melee processing, combat decisions, movement control, and runback mechanics. HateList provides thread-safe hate value tracking with percentage calculations and most-hated selection. EncounterList manages player/group participation for loot rights and rewards with character ID mapping. Specialized brain variants include CombatPetBrain (follows owner, assists in combat), NonCombatPetBrain (cosmetic pet following), BlankBrain (minimal processing), LuaBrain (script-controlled AI), and DumbFirePetBrain (temporary combat pets with expiration). BrainState tracks timing, spell recovery, active status, and debug levels. AIManager provides centralized brain lifecycle management with type-based creation, active brain processing, and performance statistics. Integration interfaces support NPC/Entity systems, Lua scripting, zone operations, and debugging utilities. Thread-safe operations with proper mutex usage and performance tracking for all AI operations.
|
||||
|
||||
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.
|
||||
|
@ -1,582 +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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#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<GroundSpawnEntry*>* groundspawn_entries = GetZone()->GetGroundSpawnEntries(groundspawn_id);
|
||||
vector<GroundSpawnEntryItem*>* 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<GroundSpawnEntry*> mod_groundspawn_entries;
|
||||
|
||||
if (groundspawn_entries) {
|
||||
vector<GroundSpawnEntry*> highest_match;
|
||||
vector<GroundSpawnEntry*>::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<GroundSpawnEntry*>::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<GroundSpawnEntryItem*> mod_groundspawn_items;
|
||||
vector<GroundSpawnEntryItem*> mod_groundspawn_rares;
|
||||
vector<GroundSpawnEntryItem*> mod_groundspawn_imbue;
|
||||
|
||||
vector<GroundSpawnEntryItem*>::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();
|
||||
}
|
@ -1,86 +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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#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
|
||||
|
7945
internal/Player.cpp
Normal file
7945
internal/Player.cpp
Normal file
File diff suppressed because it is too large
Load Diff
1276
internal/Player.h
Normal file
1276
internal/Player.h
Normal file
File diff suppressed because it is too large
Load Diff
39
internal/languages/constants.go
Normal file
39
internal/languages/constants.go
Normal file
@ -0,0 +1,39 @@
|
||||
package languages
|
||||
|
||||
// Language system constants
|
||||
const (
|
||||
// Maximum language name length
|
||||
MaxLanguageNameLength = 50
|
||||
|
||||
// Special language IDs (common in EQ2)
|
||||
LanguageIDCommon = 0 // Common tongue (default)
|
||||
LanguageIDElvish = 1 // Elvish
|
||||
LanguageIDDwarven = 2 // Dwarven
|
||||
LanguageIDHalfling = 3 // Halfling
|
||||
LanguageIDGnomish = 4 // Gnomish
|
||||
LanguageIDIksar = 5 // Iksar
|
||||
LanguageIDTrollish = 6 // Trollish
|
||||
LanguageIDOgrish = 7 // Ogrish
|
||||
LanguageIDFae = 8 // Fae
|
||||
LanguageIDArasai = 9 // Arasai
|
||||
LanguageIDSarnak = 10 // Sarnak
|
||||
LanguageIDFroglok = 11 // Froglok
|
||||
)
|
||||
|
||||
// Language validation constants
|
||||
const (
|
||||
MinLanguageID = 0
|
||||
MaxLanguageID = 999999 // Reasonable upper bound
|
||||
)
|
||||
|
||||
// Database operation constants
|
||||
const (
|
||||
SaveStatusUnchanged = false
|
||||
SaveStatusNeeded = true
|
||||
)
|
||||
|
||||
// System limits
|
||||
const (
|
||||
MaxLanguagesPerPlayer = 100 // Reasonable limit to prevent abuse
|
||||
MaxTotalLanguages = 1000 // System-wide language limit
|
||||
)
|
396
internal/languages/interfaces.go
Normal file
396
internal/languages/interfaces.go
Normal file
@ -0,0 +1,396 @@
|
||||
package languages
|
||||
|
||||
// Database interface for language persistence
|
||||
type Database interface {
|
||||
LoadAllLanguages() ([]*Language, error)
|
||||
SaveLanguage(language *Language) error
|
||||
DeleteLanguage(languageID int32) error
|
||||
LoadPlayerLanguages(playerID int32) ([]*Language, error)
|
||||
SavePlayerLanguage(playerID int32, languageID int32) error
|
||||
DeletePlayerLanguage(playerID int32, languageID int32) error
|
||||
}
|
||||
|
||||
// Logger interface for language 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 language-related player operations
|
||||
type Player interface {
|
||||
GetCharacterID() int32
|
||||
GetName() string
|
||||
GetLanguages() *PlayerLanguagesList
|
||||
KnowsLanguage(languageID int32) bool
|
||||
LearnLanguage(languageID int32) error
|
||||
ForgetLanguage(languageID int32) error
|
||||
SendMessage(message string)
|
||||
}
|
||||
|
||||
// Client interface for language-related client operations
|
||||
type Client interface {
|
||||
GetPlayer() *Player
|
||||
GetVersion() int16
|
||||
SendLanguageUpdate(languageData []byte) error
|
||||
}
|
||||
|
||||
// LanguageProvider interface for systems that provide language functionality
|
||||
type LanguageProvider interface {
|
||||
GetMasterLanguagesList() *MasterLanguagesList
|
||||
GetLanguage(languageID int32) *Language
|
||||
GetLanguageByName(name string) *Language
|
||||
CreatePlayerLanguagesList() *PlayerLanguagesList
|
||||
}
|
||||
|
||||
// LanguageAware interface for entities that can understand languages
|
||||
type LanguageAware interface {
|
||||
GetKnownLanguages() *PlayerLanguagesList
|
||||
KnowsLanguage(languageID int32) bool
|
||||
GetPrimaryLanguage() int32
|
||||
SetPrimaryLanguage(languageID int32)
|
||||
CanUnderstand(languageID int32) bool
|
||||
}
|
||||
|
||||
// LanguageHandler interface for handling language events
|
||||
type LanguageHandler interface {
|
||||
OnLanguageLearned(player *Player, languageID int32) error
|
||||
OnLanguageForgotten(player *Player, languageID int32) error
|
||||
OnLanguageUsed(player *Player, languageID int32, message string) error
|
||||
}
|
||||
|
||||
// ChatProcessor interface for processing multilingual chat
|
||||
type ChatProcessor interface {
|
||||
ProcessMessage(speaker *Player, message string, languageID int32) (string, error)
|
||||
FilterMessage(listener *Player, message string, languageID int32) string
|
||||
GetLanguageSkramble(message string, comprehension float32) string
|
||||
}
|
||||
|
||||
// PlayerLanguageAdapter provides language functionality for players
|
||||
type PlayerLanguageAdapter struct {
|
||||
player *Player
|
||||
languages *PlayerLanguagesList
|
||||
primaryLang int32
|
||||
manager *Manager
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// NewPlayerLanguageAdapter creates a new player language adapter
|
||||
func NewPlayerLanguageAdapter(player *Player, manager *Manager, logger Logger) *PlayerLanguageAdapter {
|
||||
return &PlayerLanguageAdapter{
|
||||
player: player,
|
||||
languages: manager.CreatePlayerLanguagesList(),
|
||||
primaryLang: LanguageIDCommon, // Default to common
|
||||
manager: manager,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetKnownLanguages returns the player's known languages
|
||||
func (pla *PlayerLanguageAdapter) GetKnownLanguages() *PlayerLanguagesList {
|
||||
return pla.languages
|
||||
}
|
||||
|
||||
// KnowsLanguage checks if the player knows a specific language
|
||||
func (pla *PlayerLanguageAdapter) KnowsLanguage(languageID int32) bool {
|
||||
return pla.languages.HasLanguage(languageID)
|
||||
}
|
||||
|
||||
// GetPrimaryLanguage returns the player's primary language
|
||||
func (pla *PlayerLanguageAdapter) GetPrimaryLanguage() int32 {
|
||||
return pla.primaryLang
|
||||
}
|
||||
|
||||
// SetPrimaryLanguage sets the player's primary language
|
||||
func (pla *PlayerLanguageAdapter) SetPrimaryLanguage(languageID int32) {
|
||||
// Only allow setting to a known language
|
||||
if pla.languages.HasLanguage(languageID) {
|
||||
pla.primaryLang = languageID
|
||||
|
||||
if pla.logger != nil {
|
||||
lang := pla.manager.GetLanguage(languageID)
|
||||
langName := "Unknown"
|
||||
if lang != nil {
|
||||
langName = lang.GetName()
|
||||
}
|
||||
pla.logger.LogDebug("Player %s set primary language to %s (%d)",
|
||||
pla.player.GetName(), langName, languageID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CanUnderstand checks if the player can understand a language
|
||||
func (pla *PlayerLanguageAdapter) CanUnderstand(languageID int32) bool {
|
||||
// Common language is always understood
|
||||
if languageID == LanguageIDCommon {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if player knows the language
|
||||
return pla.languages.HasLanguage(languageID)
|
||||
}
|
||||
|
||||
// LearnLanguage teaches the player a new language
|
||||
func (pla *PlayerLanguageAdapter) LearnLanguage(languageID int32) error {
|
||||
// Get the language from master list
|
||||
language := pla.manager.GetLanguage(languageID)
|
||||
if language == nil {
|
||||
return fmt.Errorf("language with ID %d does not exist", languageID)
|
||||
}
|
||||
|
||||
// Check if already known
|
||||
if pla.languages.HasLanguage(languageID) {
|
||||
return fmt.Errorf("player already knows language %s", language.GetName())
|
||||
}
|
||||
|
||||
// Create a copy for the player
|
||||
playerLang := language.Copy()
|
||||
playerLang.SetSaveNeeded(true)
|
||||
|
||||
// Add to player's languages
|
||||
if err := pla.languages.Add(playerLang); err != nil {
|
||||
return fmt.Errorf("failed to add language to player: %w", err)
|
||||
}
|
||||
|
||||
// Record usage statistics
|
||||
pla.manager.RecordLanguageUsage(languageID)
|
||||
|
||||
if pla.logger != nil {
|
||||
pla.logger.LogInfo("Player %s learned language %s (%d)",
|
||||
pla.player.GetName(), language.GetName(), languageID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForgetLanguage makes the player forget a language
|
||||
func (pla *PlayerLanguageAdapter) ForgetLanguage(languageID int32) error {
|
||||
// Cannot forget common language
|
||||
if languageID == LanguageIDCommon {
|
||||
return fmt.Errorf("cannot forget common language")
|
||||
}
|
||||
|
||||
// Check if player knows the language
|
||||
if !pla.languages.HasLanguage(languageID) {
|
||||
return fmt.Errorf("player does not know language %d", languageID)
|
||||
}
|
||||
|
||||
// Get language name for logging
|
||||
language := pla.manager.GetLanguage(languageID)
|
||||
langName := "Unknown"
|
||||
if language != nil {
|
||||
langName = language.GetName()
|
||||
}
|
||||
|
||||
// Remove from player's languages
|
||||
if !pla.languages.RemoveLanguage(languageID) {
|
||||
return fmt.Errorf("failed to remove language from player")
|
||||
}
|
||||
|
||||
// Reset primary language if this was it
|
||||
if pla.primaryLang == languageID {
|
||||
pla.primaryLang = LanguageIDCommon
|
||||
}
|
||||
|
||||
if pla.logger != nil {
|
||||
pla.logger.LogInfo("Player %s forgot language %s (%d)",
|
||||
pla.player.GetName(), langName, languageID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadPlayerLanguages loads the player's languages from database
|
||||
func (pla *PlayerLanguageAdapter) LoadPlayerLanguages(database Database) error {
|
||||
if database == nil {
|
||||
return fmt.Errorf("database is nil")
|
||||
}
|
||||
|
||||
playerID := pla.player.GetCharacterID()
|
||||
languages, err := database.LoadPlayerLanguages(playerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load player languages: %w", err)
|
||||
}
|
||||
|
||||
// Clear current languages
|
||||
pla.languages.Clear()
|
||||
|
||||
// Add loaded languages
|
||||
for _, lang := range languages {
|
||||
if err := pla.languages.Add(lang); err != nil && pla.logger != nil {
|
||||
pla.logger.LogWarning("Failed to add loaded language %d to player %s: %v",
|
||||
lang.GetID(), pla.player.GetName(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure player knows common language
|
||||
if !pla.languages.HasLanguage(LanguageIDCommon) {
|
||||
commonLang := pla.manager.GetLanguage(LanguageIDCommon)
|
||||
if commonLang != nil {
|
||||
playerCommon := commonLang.Copy()
|
||||
pla.languages.Add(playerCommon)
|
||||
}
|
||||
}
|
||||
|
||||
if pla.logger != nil {
|
||||
pla.logger.LogDebug("Loaded %d languages for player %s",
|
||||
len(languages), pla.player.GetName())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SavePlayerLanguages saves the player's languages to database
|
||||
func (pla *PlayerLanguageAdapter) SavePlayerLanguages(database Database) error {
|
||||
if database == nil {
|
||||
return fmt.Errorf("database is nil")
|
||||
}
|
||||
|
||||
playerID := pla.player.GetCharacterID()
|
||||
languages := pla.languages.GetAllLanguages()
|
||||
|
||||
// Save each language that needs saving
|
||||
for _, lang := range languages {
|
||||
if lang.GetSaveNeeded() {
|
||||
if err := database.SavePlayerLanguage(playerID, lang.GetID()); err != nil {
|
||||
return fmt.Errorf("failed to save player language %d: %w", lang.GetID(), err)
|
||||
}
|
||||
lang.SetSaveNeeded(false)
|
||||
}
|
||||
}
|
||||
|
||||
if pla.logger != nil {
|
||||
pla.logger.LogDebug("Saved languages for player %s", pla.player.GetName())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChatLanguageProcessor handles multilingual chat processing
|
||||
type ChatLanguageProcessor struct {
|
||||
manager *Manager
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// NewChatLanguageProcessor creates a new chat language processor
|
||||
func NewChatLanguageProcessor(manager *Manager, logger Logger) *ChatLanguageProcessor {
|
||||
return &ChatLanguageProcessor{
|
||||
manager: manager,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessMessage processes a chat message in a specific language
|
||||
func (clp *ChatLanguageProcessor) ProcessMessage(speaker *Player, message string, languageID int32) (string, error) {
|
||||
if speaker == nil {
|
||||
return "", fmt.Errorf("speaker cannot be nil")
|
||||
}
|
||||
|
||||
// Validate language exists
|
||||
language := clp.manager.GetLanguage(languageID)
|
||||
if language == nil {
|
||||
return "", fmt.Errorf("language %d does not exist", languageID)
|
||||
}
|
||||
|
||||
// Check if speaker knows the language
|
||||
if !speaker.KnowsLanguage(languageID) {
|
||||
return "", fmt.Errorf("speaker does not know language %s", language.GetName())
|
||||
}
|
||||
|
||||
// Record language usage
|
||||
clp.manager.RecordLanguageUsage(languageID)
|
||||
|
||||
return message, nil
|
||||
}
|
||||
|
||||
// FilterMessage filters a message for a listener based on language comprehension
|
||||
func (clp *ChatLanguageProcessor) FilterMessage(listener *Player, message string, languageID int32) string {
|
||||
if listener == nil {
|
||||
return message
|
||||
}
|
||||
|
||||
// Common language is always understood
|
||||
if languageID == LanguageIDCommon {
|
||||
return message
|
||||
}
|
||||
|
||||
// Check if listener knows the language
|
||||
if listener.KnowsLanguage(languageID) {
|
||||
return message
|
||||
}
|
||||
|
||||
// Scramble the message for unknown languages
|
||||
return clp.GetLanguageSkramble(message, 0.0)
|
||||
}
|
||||
|
||||
// GetLanguageSkramble scrambles a message based on comprehension level
|
||||
func (clp *ChatLanguageProcessor) GetLanguageSkramble(message string, comprehension float32) string {
|
||||
if comprehension >= 1.0 {
|
||||
return message
|
||||
}
|
||||
|
||||
if comprehension <= 0.0 {
|
||||
// Complete scramble - replace with gibberish
|
||||
runes := []rune(message)
|
||||
scrambled := make([]rune, len(runes))
|
||||
|
||||
for i, r := range runes {
|
||||
if r == ' ' {
|
||||
scrambled[i] = ' '
|
||||
} else if r >= 'a' && r <= 'z' {
|
||||
scrambled[i] = 'a' + rune((int(r-'a')+7)%26)
|
||||
} else if r >= 'A' && r <= 'Z' {
|
||||
scrambled[i] = 'A' + rune((int(r-'A')+7)%26)
|
||||
} else {
|
||||
scrambled[i] = r
|
||||
}
|
||||
}
|
||||
|
||||
return string(scrambled)
|
||||
}
|
||||
|
||||
// Partial comprehension - scramble some words
|
||||
// This is a simplified implementation
|
||||
return message
|
||||
}
|
||||
|
||||
// LanguageEventAdapter handles language-related events
|
||||
type LanguageEventAdapter struct {
|
||||
handler LanguageHandler
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// NewLanguageEventAdapter creates a new language event adapter
|
||||
func NewLanguageEventAdapter(handler LanguageHandler, logger Logger) *LanguageEventAdapter {
|
||||
return &LanguageEventAdapter{
|
||||
handler: handler,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessLanguageEvent processes a language-related event
|
||||
func (lea *LanguageEventAdapter) ProcessLanguageEvent(eventType string, player *Player, languageID int32, data interface{}) {
|
||||
if lea.handler == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch eventType {
|
||||
case "language_learned":
|
||||
if err := lea.handler.OnLanguageLearned(player, languageID); err != nil && lea.logger != nil {
|
||||
lea.logger.LogError("Language learned handler failed: %v", err)
|
||||
}
|
||||
|
||||
case "language_forgotten":
|
||||
if err := lea.handler.OnLanguageForgotten(player, languageID); err != nil && lea.logger != nil {
|
||||
lea.logger.LogError("Language forgotten handler failed: %v", err)
|
||||
}
|
||||
|
||||
case "language_used":
|
||||
if message, ok := data.(string); ok {
|
||||
if err := lea.handler.OnLanguageUsed(player, languageID, message); err != nil && lea.logger != nil {
|
||||
lea.logger.LogError("Language used handler failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
525
internal/languages/manager.go
Normal file
525
internal/languages/manager.go
Normal file
@ -0,0 +1,525 @@
|
||||
package languages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Manager provides high-level management of the language system
|
||||
type Manager struct {
|
||||
masterLanguagesList *MasterLanguagesList
|
||||
database Database
|
||||
logger Logger
|
||||
mutex sync.RWMutex
|
||||
|
||||
// Statistics
|
||||
languageLookups int64
|
||||
playersWithLanguages int64
|
||||
languageUsageCount map[int32]int64 // Language ID -> usage count
|
||||
}
|
||||
|
||||
// NewManager creates a new language manager
|
||||
func NewManager(database Database, logger Logger) *Manager {
|
||||
return &Manager{
|
||||
masterLanguagesList: NewMasterLanguagesList(),
|
||||
database: database,
|
||||
logger: logger,
|
||||
languageUsageCount: make(map[int32]int64),
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize loads languages from database
|
||||
func (m *Manager) Initialize() error {
|
||||
if m.logger != nil {
|
||||
m.logger.LogInfo("Initializing language manager...")
|
||||
}
|
||||
|
||||
if m.database == nil {
|
||||
if m.logger != nil {
|
||||
m.logger.LogWarning("No database provided, starting with empty language list")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load languages from database
|
||||
languages, err := m.database.LoadAllLanguages()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load languages from database: %w", err)
|
||||
}
|
||||
|
||||
for _, language := range languages {
|
||||
if err := m.masterLanguagesList.AddLanguage(language); err != nil {
|
||||
if m.logger != nil {
|
||||
m.logger.LogError("Failed to add language %d (%s): %v", language.GetID(), language.GetName(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if m.logger != nil {
|
||||
m.logger.LogInfo("Loaded %d languages from database", len(languages))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMasterLanguagesList returns the master language list
|
||||
func (m *Manager) GetMasterLanguagesList() *MasterLanguagesList {
|
||||
return m.masterLanguagesList
|
||||
}
|
||||
|
||||
// CreatePlayerLanguagesList creates a new player language list
|
||||
func (m *Manager) CreatePlayerLanguagesList() *PlayerLanguagesList {
|
||||
m.mutex.Lock()
|
||||
m.playersWithLanguages++
|
||||
m.mutex.Unlock()
|
||||
|
||||
return NewPlayerLanguagesList()
|
||||
}
|
||||
|
||||
// GetLanguage returns a language by ID
|
||||
func (m *Manager) GetLanguage(id int32) *Language {
|
||||
m.mutex.Lock()
|
||||
m.languageLookups++
|
||||
m.mutex.Unlock()
|
||||
|
||||
return m.masterLanguagesList.GetLanguage(id)
|
||||
}
|
||||
|
||||
// GetLanguageByName returns a language by name
|
||||
func (m *Manager) GetLanguageByName(name string) *Language {
|
||||
m.mutex.Lock()
|
||||
m.languageLookups++
|
||||
m.mutex.Unlock()
|
||||
|
||||
return m.masterLanguagesList.GetLanguageByName(name)
|
||||
}
|
||||
|
||||
// AddLanguage adds a new language to the system
|
||||
func (m *Manager) AddLanguage(language *Language) error {
|
||||
if language == nil {
|
||||
return fmt.Errorf("language cannot be nil")
|
||||
}
|
||||
|
||||
// Add to master list
|
||||
if err := m.masterLanguagesList.AddLanguage(language); err != nil {
|
||||
return fmt.Errorf("failed to add language to master list: %w", err)
|
||||
}
|
||||
|
||||
// Save to database if available
|
||||
if m.database != nil {
|
||||
if err := m.database.SaveLanguage(language); err != nil {
|
||||
// Remove from master list if database save failed
|
||||
m.masterLanguagesList.RemoveLanguage(language.GetID())
|
||||
return fmt.Errorf("failed to save language to database: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if m.logger != nil {
|
||||
m.logger.LogInfo("Added language %d: %s", language.GetID(), language.GetName())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateLanguage updates an existing language
|
||||
func (m *Manager) UpdateLanguage(language *Language) error {
|
||||
if language == nil {
|
||||
return fmt.Errorf("language cannot be nil")
|
||||
}
|
||||
|
||||
// Update in master list
|
||||
if err := m.masterLanguagesList.UpdateLanguage(language); err != nil {
|
||||
return fmt.Errorf("failed to update language in master list: %w", err)
|
||||
}
|
||||
|
||||
// Save to database if available
|
||||
if m.database != nil {
|
||||
if err := m.database.SaveLanguage(language); err != nil {
|
||||
return fmt.Errorf("failed to save language to database: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if m.logger != nil {
|
||||
m.logger.LogInfo("Updated language %d: %s", language.GetID(), language.GetName())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveLanguage removes a language from the system
|
||||
func (m *Manager) RemoveLanguage(id int32) error {
|
||||
// Check if language exists
|
||||
if !m.masterLanguagesList.HasLanguage(id) {
|
||||
return fmt.Errorf("language with ID %d does not exist", id)
|
||||
}
|
||||
|
||||
// Remove from database first if available
|
||||
if m.database != nil {
|
||||
if err := m.database.DeleteLanguage(id); err != nil {
|
||||
return fmt.Errorf("failed to delete language from database: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from master list
|
||||
if !m.masterLanguagesList.RemoveLanguage(id) {
|
||||
return fmt.Errorf("failed to remove language from master list")
|
||||
}
|
||||
|
||||
if m.logger != nil {
|
||||
m.logger.LogInfo("Removed language %d", id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordLanguageUsage records language usage for statistics
|
||||
func (m *Manager) RecordLanguageUsage(languageID int32) {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
m.languageUsageCount[languageID]++
|
||||
}
|
||||
|
||||
// GetStatistics returns language system statistics
|
||||
func (m *Manager) GetStatistics() *LanguageStatistics {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
// Create language name mapping
|
||||
languagesByName := make(map[string]int32)
|
||||
allLanguages := m.masterLanguagesList.GetAllLanguages()
|
||||
for _, lang := range allLanguages {
|
||||
languagesByName[lang.GetName()] = lang.GetID()
|
||||
}
|
||||
|
||||
// Copy usage count
|
||||
usageCount := make(map[int32]int64)
|
||||
for id, count := range m.languageUsageCount {
|
||||
usageCount[id] = count
|
||||
}
|
||||
|
||||
return &LanguageStatistics{
|
||||
TotalLanguages: len(allLanguages),
|
||||
PlayersWithLanguages: int(m.playersWithLanguages),
|
||||
LanguageUsageCount: usageCount,
|
||||
LanguageLookups: m.languageLookups,
|
||||
LanguagesByName: languagesByName,
|
||||
}
|
||||
}
|
||||
|
||||
// ResetStatistics resets all statistics
|
||||
func (m *Manager) ResetStatistics() {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
m.languageLookups = 0
|
||||
m.playersWithLanguages = 0
|
||||
m.languageUsageCount = make(map[int32]int64)
|
||||
}
|
||||
|
||||
// ValidateAllLanguages validates all languages in the system
|
||||
func (m *Manager) ValidateAllLanguages() []string {
|
||||
allLanguages := m.masterLanguagesList.GetAllLanguages()
|
||||
var issues []string
|
||||
|
||||
for _, lang := range allLanguages {
|
||||
if !lang.IsValid() {
|
||||
issues = append(issues, fmt.Sprintf("Language %d (%s) is invalid", lang.GetID(), lang.GetName()))
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
// ReloadFromDatabase reloads all languages from database
|
||||
func (m *Manager) ReloadFromDatabase() error {
|
||||
if m.database == nil {
|
||||
return fmt.Errorf("no database available")
|
||||
}
|
||||
|
||||
// Clear current languages
|
||||
m.masterLanguagesList.Clear()
|
||||
|
||||
// Reload from database
|
||||
return m.Initialize()
|
||||
}
|
||||
|
||||
// GetLanguageCount returns the total number of languages
|
||||
func (m *Manager) GetLanguageCount() int32 {
|
||||
return m.masterLanguagesList.Size()
|
||||
}
|
||||
|
||||
// ProcessCommand handles language-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 "add":
|
||||
return m.handleAddCommand(args)
|
||||
case "remove":
|
||||
return m.handleRemoveCommand(args)
|
||||
case "search":
|
||||
return m.handleSearchCommand(args)
|
||||
default:
|
||||
return "", fmt.Errorf("unknown language command: %s", command)
|
||||
}
|
||||
}
|
||||
|
||||
// handleStatsCommand shows language system statistics
|
||||
func (m *Manager) handleStatsCommand(args []string) (string, error) {
|
||||
stats := m.GetStatistics()
|
||||
|
||||
result := "Language System Statistics:\n"
|
||||
result += fmt.Sprintf("Total Languages: %d\n", stats.TotalLanguages)
|
||||
result += fmt.Sprintf("Players with Languages: %d\n", stats.PlayersWithLanguages)
|
||||
result += fmt.Sprintf("Language Lookups: %d\n", stats.LanguageLookups)
|
||||
|
||||
if len(stats.LanguageUsageCount) > 0 {
|
||||
result += "\nMost Used Languages:\n"
|
||||
// Show top 5 most used languages
|
||||
count := 0
|
||||
for langID, usage := range stats.LanguageUsageCount {
|
||||
if count >= 5 {
|
||||
break
|
||||
}
|
||||
lang := m.GetLanguage(langID)
|
||||
name := "Unknown"
|
||||
if lang != nil {
|
||||
name = lang.GetName()
|
||||
}
|
||||
result += fmt.Sprintf(" %s (ID: %d): %d uses\n", name, langID, usage)
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// handleValidateCommand validates all languages
|
||||
func (m *Manager) handleValidateCommand(args []string) (string, error) {
|
||||
issues := m.ValidateAllLanguages()
|
||||
|
||||
if len(issues) == 0 {
|
||||
return "All languages are valid.", nil
|
||||
}
|
||||
|
||||
result := fmt.Sprintf("Found %d issues with languages:\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 languages
|
||||
func (m *Manager) handleListCommand(args []string) (string, error) {
|
||||
languages := m.masterLanguagesList.GetAllLanguages()
|
||||
|
||||
if len(languages) == 0 {
|
||||
return "No languages loaded.", nil
|
||||
}
|
||||
|
||||
result := fmt.Sprintf("Languages (%d):\n", len(languages))
|
||||
count := 0
|
||||
for _, language := range languages {
|
||||
if count >= 20 { // Limit output
|
||||
result += "... (and more)\n"
|
||||
break
|
||||
}
|
||||
result += fmt.Sprintf(" %d: %s\n", language.GetID(), language.GetName())
|
||||
count++
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// handleInfoCommand shows information about a specific language
|
||||
func (m *Manager) handleInfoCommand(args []string) (string, error) {
|
||||
if len(args) == 0 {
|
||||
return "", fmt.Errorf("language ID or name required")
|
||||
}
|
||||
|
||||
var language *Language
|
||||
|
||||
// Try to parse as ID first
|
||||
var languageID int32
|
||||
if _, err := fmt.Sscanf(args[0], "%d", &languageID); err == nil {
|
||||
language = m.GetLanguage(languageID)
|
||||
} else {
|
||||
// Try as name
|
||||
language = m.GetLanguageByName(args[0])
|
||||
}
|
||||
|
||||
if language == nil {
|
||||
return fmt.Sprintf("Language '%s' not found.", args[0]), nil
|
||||
}
|
||||
|
||||
result := fmt.Sprintf("Language Information:\n")
|
||||
result += fmt.Sprintf("ID: %d\n", language.GetID())
|
||||
result += fmt.Sprintf("Name: %s\n", language.GetName())
|
||||
result += fmt.Sprintf("Save Needed: %v\n", language.GetSaveNeeded())
|
||||
|
||||
// Show usage statistics if available
|
||||
m.mutex.RLock()
|
||||
if usage, exists := m.languageUsageCount[language.GetID()]; exists {
|
||||
result += fmt.Sprintf("Usage Count: %d\n", usage)
|
||||
} else {
|
||||
result += "Usage Count: 0\n"
|
||||
}
|
||||
m.mutex.RUnlock()
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// handleReloadCommand reloads languages from database
|
||||
func (m *Manager) handleReloadCommand(args []string) (string, error) {
|
||||
if err := m.ReloadFromDatabase(); err != nil {
|
||||
return "", fmt.Errorf("failed to reload languages: %w", err)
|
||||
}
|
||||
|
||||
count := m.GetLanguageCount()
|
||||
return fmt.Sprintf("Successfully reloaded %d languages from database.", count), nil
|
||||
}
|
||||
|
||||
// handleAddCommand adds a new language
|
||||
func (m *Manager) handleAddCommand(args []string) (string, error) {
|
||||
if len(args) < 2 {
|
||||
return "", fmt.Errorf("usage: add <id> <name>")
|
||||
}
|
||||
|
||||
var id int32
|
||||
if _, err := fmt.Sscanf(args[0], "%d", &id); err != nil {
|
||||
return "", fmt.Errorf("invalid language ID: %s", args[0])
|
||||
}
|
||||
|
||||
name := args[1]
|
||||
|
||||
language := NewLanguage()
|
||||
language.SetID(id)
|
||||
language.SetName(name)
|
||||
language.SetSaveNeeded(true)
|
||||
|
||||
if err := m.AddLanguage(language); err != nil {
|
||||
return "", fmt.Errorf("failed to add language: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Successfully added language %d: %s", id, name), nil
|
||||
}
|
||||
|
||||
// handleRemoveCommand removes a language
|
||||
func (m *Manager) handleRemoveCommand(args []string) (string, error) {
|
||||
if len(args) == 0 {
|
||||
return "", fmt.Errorf("language ID required")
|
||||
}
|
||||
|
||||
var id int32
|
||||
if _, err := fmt.Sscanf(args[0], "%d", &id); err != nil {
|
||||
return "", fmt.Errorf("invalid language ID: %s", args[0])
|
||||
}
|
||||
|
||||
if err := m.RemoveLanguage(id); err != nil {
|
||||
return "", fmt.Errorf("failed to remove language: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Successfully removed language %d", id), nil
|
||||
}
|
||||
|
||||
// handleSearchCommand searches for languages by name
|
||||
func (m *Manager) handleSearchCommand(args []string) (string, error) {
|
||||
if len(args) == 0 {
|
||||
return "", fmt.Errorf("search term required")
|
||||
}
|
||||
|
||||
searchTerm := args[0]
|
||||
languages := m.masterLanguagesList.GetAllLanguages()
|
||||
var results []*Language
|
||||
|
||||
// Search by name (case-insensitive partial match)
|
||||
for _, language := range languages {
|
||||
if contains(language.GetName(), searchTerm) {
|
||||
results = append(results, language)
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return fmt.Sprintf("No languages found matching '%s'.", searchTerm), nil
|
||||
}
|
||||
|
||||
result := fmt.Sprintf("Found %d languages matching '%s':\n", len(results), searchTerm)
|
||||
for i, language := range results {
|
||||
if i >= 20 { // Limit output
|
||||
result += "... (and more)\n"
|
||||
break
|
||||
}
|
||||
result += fmt.Sprintf(" %d: %s\n", language.GetID(), language.GetName())
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the manager
|
||||
func (m *Manager) Shutdown() {
|
||||
if m.logger != nil {
|
||||
m.logger.LogInfo("Shutting down language manager...")
|
||||
}
|
||||
|
||||
// Clear languages
|
||||
m.masterLanguagesList.Clear()
|
||||
}
|
||||
|
||||
// contains checks if a string contains a substring (case-insensitive)
|
||||
func contains(str, substr string) bool {
|
||||
if len(substr) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(str) < len(substr) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Convert to lowercase for case-insensitive comparison
|
||||
strLower := make([]byte, len(str))
|
||||
substrLower := make([]byte, len(substr))
|
||||
|
||||
for i := 0; i < len(str); i++ {
|
||||
if str[i] >= 'A' && str[i] <= 'Z' {
|
||||
strLower[i] = str[i] + 32
|
||||
} else {
|
||||
strLower[i] = str[i]
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < len(substr); i++ {
|
||||
if substr[i] >= 'A' && substr[i] <= 'Z' {
|
||||
substrLower[i] = substr[i] + 32
|
||||
} else {
|
||||
substrLower[i] = substr[i]
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i <= len(strLower)-len(substrLower); i++ {
|
||||
match := true
|
||||
for j := 0; j < len(substrLower); j++ {
|
||||
if strLower[i+j] != substrLower[j] {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
460
internal/languages/types.go
Normal file
460
internal/languages/types.go
Normal file
@ -0,0 +1,460 @@
|
||||
package languages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Language represents a single language that can be learned by players
|
||||
type Language struct {
|
||||
id int32 // Unique language identifier
|
||||
name string // Language name
|
||||
saveNeeded bool // Whether this language needs to be saved to database
|
||||
mutex sync.RWMutex // Thread safety
|
||||
}
|
||||
|
||||
// NewLanguage creates a new language with default values
|
||||
func NewLanguage() *Language {
|
||||
return &Language{
|
||||
id: 0,
|
||||
name: "",
|
||||
saveNeeded: false,
|
||||
}
|
||||
}
|
||||
|
||||
// NewLanguageFromExisting creates a copy of an existing language
|
||||
func NewLanguageFromExisting(source *Language) *Language {
|
||||
if source == nil {
|
||||
return NewLanguage()
|
||||
}
|
||||
|
||||
source.mutex.RLock()
|
||||
defer source.mutex.RUnlock()
|
||||
|
||||
return &Language{
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
saveNeeded: source.saveNeeded,
|
||||
}
|
||||
}
|
||||
|
||||
// GetID returns the language's unique identifier
|
||||
func (l *Language) GetID() int32 {
|
||||
l.mutex.RLock()
|
||||
defer l.mutex.RUnlock()
|
||||
|
||||
return l.id
|
||||
}
|
||||
|
||||
// SetID updates the language's unique identifier
|
||||
func (l *Language) SetID(id int32) {
|
||||
l.mutex.Lock()
|
||||
defer l.mutex.Unlock()
|
||||
|
||||
l.id = id
|
||||
}
|
||||
|
||||
// GetName returns the language name
|
||||
func (l *Language) GetName() string {
|
||||
l.mutex.RLock()
|
||||
defer l.mutex.RUnlock()
|
||||
|
||||
return l.name
|
||||
}
|
||||
|
||||
// SetName updates the language name
|
||||
func (l *Language) SetName(name string) {
|
||||
l.mutex.Lock()
|
||||
defer l.mutex.Unlock()
|
||||
|
||||
// Truncate if too long
|
||||
if len(name) > MaxLanguageNameLength {
|
||||
name = name[:MaxLanguageNameLength]
|
||||
}
|
||||
|
||||
l.name = name
|
||||
}
|
||||
|
||||
// GetSaveNeeded returns whether this language needs database saving
|
||||
func (l *Language) GetSaveNeeded() bool {
|
||||
l.mutex.RLock()
|
||||
defer l.mutex.RUnlock()
|
||||
|
||||
return l.saveNeeded
|
||||
}
|
||||
|
||||
// SetSaveNeeded updates the save status
|
||||
func (l *Language) SetSaveNeeded(needed bool) {
|
||||
l.mutex.Lock()
|
||||
defer l.mutex.Unlock()
|
||||
|
||||
l.saveNeeded = needed
|
||||
}
|
||||
|
||||
// IsValid validates the language data
|
||||
func (l *Language) IsValid() bool {
|
||||
l.mutex.RLock()
|
||||
defer l.mutex.RUnlock()
|
||||
|
||||
if l.id < MinLanguageID || l.id > MaxLanguageID {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(l.name) == 0 || len(l.name) > MaxLanguageNameLength {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// String returns a string representation of the language
|
||||
func (l *Language) String() string {
|
||||
l.mutex.RLock()
|
||||
defer l.mutex.RUnlock()
|
||||
|
||||
return fmt.Sprintf("Language{ID: %d, Name: %s, SaveNeeded: %v}", l.id, l.name, l.saveNeeded)
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of the language
|
||||
func (l *Language) Copy() *Language {
|
||||
return NewLanguageFromExisting(l)
|
||||
}
|
||||
|
||||
// MasterLanguagesList manages the global list of all available languages
|
||||
type MasterLanguagesList struct {
|
||||
languages map[int32]*Language // Languages indexed by ID for fast lookup
|
||||
nameIndex map[string]*Language // Languages indexed by name for name lookups
|
||||
mutex sync.RWMutex // Thread safety
|
||||
}
|
||||
|
||||
// NewMasterLanguagesList creates a new master languages list
|
||||
func NewMasterLanguagesList() *MasterLanguagesList {
|
||||
return &MasterLanguagesList{
|
||||
languages: make(map[int32]*Language),
|
||||
nameIndex: make(map[string]*Language),
|
||||
}
|
||||
}
|
||||
|
||||
// Clear removes all languages from the list
|
||||
func (mll *MasterLanguagesList) Clear() {
|
||||
mll.mutex.Lock()
|
||||
defer mll.mutex.Unlock()
|
||||
|
||||
mll.languages = make(map[int32]*Language)
|
||||
mll.nameIndex = make(map[string]*Language)
|
||||
}
|
||||
|
||||
// Size returns the number of languages in the list
|
||||
func (mll *MasterLanguagesList) Size() int32 {
|
||||
mll.mutex.RLock()
|
||||
defer mll.mutex.RUnlock()
|
||||
|
||||
return int32(len(mll.languages))
|
||||
}
|
||||
|
||||
// AddLanguage adds a new language to the master list
|
||||
func (mll *MasterLanguagesList) AddLanguage(language *Language) error {
|
||||
if language == nil {
|
||||
return fmt.Errorf("language cannot be nil")
|
||||
}
|
||||
|
||||
if !language.IsValid() {
|
||||
return fmt.Errorf("language is not valid: %s", language.String())
|
||||
}
|
||||
|
||||
mll.mutex.Lock()
|
||||
defer mll.mutex.Unlock()
|
||||
|
||||
// Check for duplicate ID
|
||||
if _, exists := mll.languages[language.GetID()]; exists {
|
||||
return fmt.Errorf("language with ID %d already exists", language.GetID())
|
||||
}
|
||||
|
||||
// Check for duplicate name
|
||||
name := language.GetName()
|
||||
if _, exists := mll.nameIndex[name]; exists {
|
||||
return fmt.Errorf("language with name '%s' already exists", name)
|
||||
}
|
||||
|
||||
// Add to both indexes
|
||||
mll.languages[language.GetID()] = language
|
||||
mll.nameIndex[name] = language
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLanguage retrieves a language by ID
|
||||
func (mll *MasterLanguagesList) GetLanguage(id int32) *Language {
|
||||
mll.mutex.RLock()
|
||||
defer mll.mutex.RUnlock()
|
||||
|
||||
return mll.languages[id]
|
||||
}
|
||||
|
||||
// GetLanguageByName retrieves a language by name
|
||||
func (mll *MasterLanguagesList) GetLanguageByName(name string) *Language {
|
||||
mll.mutex.RLock()
|
||||
defer mll.mutex.RUnlock()
|
||||
|
||||
return mll.nameIndex[name]
|
||||
}
|
||||
|
||||
// GetAllLanguages returns a copy of all languages
|
||||
func (mll *MasterLanguagesList) GetAllLanguages() []*Language {
|
||||
mll.mutex.RLock()
|
||||
defer mll.mutex.RUnlock()
|
||||
|
||||
result := make([]*Language, 0, len(mll.languages))
|
||||
for _, lang := range mll.languages {
|
||||
result = append(result, lang)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// HasLanguage checks if a language exists by ID
|
||||
func (mll *MasterLanguagesList) HasLanguage(id int32) bool {
|
||||
mll.mutex.RLock()
|
||||
defer mll.mutex.RUnlock()
|
||||
|
||||
_, exists := mll.languages[id]
|
||||
return exists
|
||||
}
|
||||
|
||||
// HasLanguageByName checks if a language exists by name
|
||||
func (mll *MasterLanguagesList) HasLanguageByName(name string) bool {
|
||||
mll.mutex.RLock()
|
||||
defer mll.mutex.RUnlock()
|
||||
|
||||
_, exists := mll.nameIndex[name]
|
||||
return exists
|
||||
}
|
||||
|
||||
// RemoveLanguage removes a language by ID
|
||||
func (mll *MasterLanguagesList) RemoveLanguage(id int32) bool {
|
||||
mll.mutex.Lock()
|
||||
defer mll.mutex.Unlock()
|
||||
|
||||
language, exists := mll.languages[id]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
// Remove from both indexes
|
||||
delete(mll.languages, id)
|
||||
delete(mll.nameIndex, language.GetName())
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// UpdateLanguage updates an existing language
|
||||
func (mll *MasterLanguagesList) UpdateLanguage(language *Language) error {
|
||||
if language == nil {
|
||||
return fmt.Errorf("language cannot be nil")
|
||||
}
|
||||
|
||||
if !language.IsValid() {
|
||||
return fmt.Errorf("language is not valid: %s", language.String())
|
||||
}
|
||||
|
||||
mll.mutex.Lock()
|
||||
defer mll.mutex.Unlock()
|
||||
|
||||
id := language.GetID()
|
||||
oldLanguage, exists := mll.languages[id]
|
||||
if !exists {
|
||||
return fmt.Errorf("language with ID %d does not exist", id)
|
||||
}
|
||||
|
||||
// Remove old name index if name changed
|
||||
oldName := oldLanguage.GetName()
|
||||
newName := language.GetName()
|
||||
if oldName != newName {
|
||||
delete(mll.nameIndex, oldName)
|
||||
|
||||
// Check for name conflicts
|
||||
if _, exists := mll.nameIndex[newName]; exists {
|
||||
return fmt.Errorf("language with name '%s' already exists", newName)
|
||||
}
|
||||
|
||||
mll.nameIndex[newName] = language
|
||||
}
|
||||
|
||||
// Update language
|
||||
mll.languages[id] = language
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLanguageNames returns all language names
|
||||
func (mll *MasterLanguagesList) GetLanguageNames() []string {
|
||||
mll.mutex.RLock()
|
||||
defer mll.mutex.RUnlock()
|
||||
|
||||
names := make([]string, 0, len(mll.nameIndex))
|
||||
for name := range mll.nameIndex {
|
||||
names = append(names, name)
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
// PlayerLanguagesList manages languages known by a specific player
|
||||
type PlayerLanguagesList struct {
|
||||
languages map[int32]*Language // Player's languages indexed by ID
|
||||
nameIndex map[string]*Language // Player's languages indexed by name
|
||||
mutex sync.RWMutex // Thread safety
|
||||
}
|
||||
|
||||
// NewPlayerLanguagesList creates a new player languages list
|
||||
func NewPlayerLanguagesList() *PlayerLanguagesList {
|
||||
return &PlayerLanguagesList{
|
||||
languages: make(map[int32]*Language),
|
||||
nameIndex: make(map[string]*Language),
|
||||
}
|
||||
}
|
||||
|
||||
// Clear removes all languages from the player's list
|
||||
func (pll *PlayerLanguagesList) Clear() {
|
||||
pll.mutex.Lock()
|
||||
defer pll.mutex.Unlock()
|
||||
|
||||
pll.languages = make(map[int32]*Language)
|
||||
pll.nameIndex = make(map[string]*Language)
|
||||
}
|
||||
|
||||
// Add adds a language to the player's known languages
|
||||
func (pll *PlayerLanguagesList) Add(language *Language) error {
|
||||
if language == nil {
|
||||
return fmt.Errorf("language cannot be nil")
|
||||
}
|
||||
|
||||
pll.mutex.Lock()
|
||||
defer pll.mutex.Unlock()
|
||||
|
||||
id := language.GetID()
|
||||
name := language.GetName()
|
||||
|
||||
// Check if already known
|
||||
if _, exists := pll.languages[id]; exists {
|
||||
return fmt.Errorf("player already knows language with ID %d", id)
|
||||
}
|
||||
|
||||
// Check player language limit
|
||||
if len(pll.languages) >= MaxLanguagesPerPlayer {
|
||||
return fmt.Errorf("player has reached maximum language limit (%d)", MaxLanguagesPerPlayer)
|
||||
}
|
||||
|
||||
// Add to both indexes
|
||||
pll.languages[id] = language
|
||||
pll.nameIndex[name] = language
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLanguage retrieves a language the player knows by ID
|
||||
func (pll *PlayerLanguagesList) GetLanguage(id int32) *Language {
|
||||
pll.mutex.RLock()
|
||||
defer pll.mutex.RUnlock()
|
||||
|
||||
return pll.languages[id]
|
||||
}
|
||||
|
||||
// GetLanguageByName retrieves a language the player knows by name
|
||||
func (pll *PlayerLanguagesList) GetLanguageByName(name string) *Language {
|
||||
pll.mutex.RLock()
|
||||
defer pll.mutex.RUnlock()
|
||||
|
||||
return pll.nameIndex[name]
|
||||
}
|
||||
|
||||
// GetAllLanguages returns a copy of all languages the player knows
|
||||
func (pll *PlayerLanguagesList) GetAllLanguages() []*Language {
|
||||
pll.mutex.RLock()
|
||||
defer pll.mutex.RUnlock()
|
||||
|
||||
result := make([]*Language, 0, len(pll.languages))
|
||||
for _, lang := range pll.languages {
|
||||
result = append(result, lang)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// HasLanguage checks if the player knows a language by ID
|
||||
func (pll *PlayerLanguagesList) HasLanguage(id int32) bool {
|
||||
pll.mutex.RLock()
|
||||
defer pll.mutex.RUnlock()
|
||||
|
||||
_, exists := pll.languages[id]
|
||||
return exists
|
||||
}
|
||||
|
||||
// HasLanguageByName checks if the player knows a language by name
|
||||
func (pll *PlayerLanguagesList) HasLanguageByName(name string) bool {
|
||||
pll.mutex.RLock()
|
||||
defer pll.mutex.RUnlock()
|
||||
|
||||
_, exists := pll.nameIndex[name]
|
||||
return exists
|
||||
}
|
||||
|
||||
// RemoveLanguage removes a language from the player's known languages
|
||||
func (pll *PlayerLanguagesList) RemoveLanguage(id int32) bool {
|
||||
pll.mutex.Lock()
|
||||
defer pll.mutex.Unlock()
|
||||
|
||||
language, exists := pll.languages[id]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
// Remove from both indexes
|
||||
delete(pll.languages, id)
|
||||
delete(pll.nameIndex, language.GetName())
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Size returns the number of languages the player knows
|
||||
func (pll *PlayerLanguagesList) Size() int32 {
|
||||
pll.mutex.RLock()
|
||||
defer pll.mutex.RUnlock()
|
||||
|
||||
return int32(len(pll.languages))
|
||||
}
|
||||
|
||||
// GetLanguageIDs returns all language IDs the player knows
|
||||
func (pll *PlayerLanguagesList) GetLanguageIDs() []int32 {
|
||||
pll.mutex.RLock()
|
||||
defer pll.mutex.RUnlock()
|
||||
|
||||
ids := make([]int32, 0, len(pll.languages))
|
||||
for id := range pll.languages {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
// GetLanguageNames returns all language names the player knows
|
||||
func (pll *PlayerLanguagesList) GetLanguageNames() []string {
|
||||
pll.mutex.RLock()
|
||||
defer pll.mutex.RUnlock()
|
||||
|
||||
names := make([]string, 0, len(pll.nameIndex))
|
||||
for name := range pll.nameIndex {
|
||||
names = append(names, name)
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
// LanguageStatistics contains language system statistics
|
||||
type LanguageStatistics struct {
|
||||
TotalLanguages int `json:"total_languages"`
|
||||
PlayersWithLanguages int `json:"players_with_languages"`
|
||||
LanguageUsageCount map[int32]int64 `json:"language_usage_count"`
|
||||
LanguageLookups int64 `json:"language_lookups"`
|
||||
LanguagesByName map[string]int32 `json:"languages_by_name"`
|
||||
}
|
635
internal/npc/ai/brain.go
Normal file
635
internal/npc/ai/brain.go
Normal file
@ -0,0 +1,635 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Brain interface defines the core AI functionality
|
||||
type Brain interface {
|
||||
// Core AI methods
|
||||
Think() error
|
||||
GetBrainType() int8
|
||||
|
||||
// State management
|
||||
IsActive() bool
|
||||
SetActive(bool)
|
||||
GetState() int32
|
||||
SetState(int32)
|
||||
|
||||
// Timing
|
||||
GetThinkTick() int32
|
||||
SetThinkTick(int32)
|
||||
GetLastThink() int64
|
||||
SetLastThink(int64)
|
||||
|
||||
// Body management
|
||||
GetBody() NPC
|
||||
SetBody(NPC)
|
||||
|
||||
// Hate management
|
||||
AddHate(entityID int32, hate int32)
|
||||
GetHate(entityID int32) int32
|
||||
ClearHate()
|
||||
ClearHateForEntity(entityID int32)
|
||||
GetMostHated() int32
|
||||
GetHatePercentage(entityID int32) int8
|
||||
GetHateList() map[int32]*HateEntry
|
||||
|
||||
// Encounter management
|
||||
AddToEncounter(entityID, characterID int32, isPlayer, isBot bool) bool
|
||||
IsEntityInEncounter(entityID int32) bool
|
||||
IsPlayerInEncounter(characterID int32) bool
|
||||
HasPlayerInEncounter() bool
|
||||
GetEncounterSize() int
|
||||
ClearEncounter()
|
||||
CheckLootAllowed(entityID int32) bool
|
||||
|
||||
// Combat methods
|
||||
ProcessSpell(target Entity, distance float32) bool
|
||||
ProcessMelee(target Entity, distance float32)
|
||||
CheckBuffs() bool
|
||||
HasRecovered() bool
|
||||
MoveCloser(target Spawn)
|
||||
|
||||
// Statistics
|
||||
GetStatistics() *BrainStatistics
|
||||
ResetStatistics()
|
||||
}
|
||||
|
||||
// BaseBrain provides the default AI implementation
|
||||
type BaseBrain struct {
|
||||
npc NPC // The NPC this brain controls
|
||||
brainType int8 // Type of brain
|
||||
state *BrainState // Brain state management
|
||||
hateList *HateList // Hate management
|
||||
encounterList *EncounterList // Encounter management
|
||||
statistics *BrainStatistics // Performance statistics
|
||||
logger Logger // Logger interface
|
||||
mutex sync.RWMutex // Thread safety
|
||||
}
|
||||
|
||||
// NewBaseBrain creates a new base brain
|
||||
func NewBaseBrain(npc NPC, logger Logger) *BaseBrain {
|
||||
return &BaseBrain{
|
||||
npc: npc,
|
||||
brainType: BrainTypeDefault,
|
||||
state: NewBrainState(),
|
||||
hateList: NewHateList(),
|
||||
encounterList: NewEncounterList(),
|
||||
statistics: NewBrainStatistics(),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Think implements the main AI logic
|
||||
func (bb *BaseBrain) Think() error {
|
||||
if !bb.IsActive() {
|
||||
return nil
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
defer func() {
|
||||
// Update statistics
|
||||
bb.mutex.Lock()
|
||||
bb.statistics.ThinkCycles++
|
||||
thinkTime := float64(time.Since(startTime).Nanoseconds()) / 1000000.0 // Convert to milliseconds
|
||||
bb.statistics.AverageThinkTime = (bb.statistics.AverageThinkTime + thinkTime) / 2.0
|
||||
bb.statistics.LastThinkTime = time.Now().UnixMilli()
|
||||
bb.mutex.Unlock()
|
||||
|
||||
bb.state.SetLastThink(time.Now().UnixMilli())
|
||||
}()
|
||||
|
||||
if bb.npc == nil {
|
||||
return fmt.Errorf("brain has no body")
|
||||
}
|
||||
|
||||
// Handle pet ID registration for players
|
||||
if bb.npc.IsPet() && bb.npc.GetOwner() != nil && bb.npc.GetOwner().IsPlayer() {
|
||||
// TODO: Register pet ID with player's info struct
|
||||
}
|
||||
|
||||
// Get the most hated target
|
||||
mostHatedID := bb.hateList.GetMostHated()
|
||||
var target Entity
|
||||
|
||||
if mostHatedID > 0 {
|
||||
target = bb.getEntityByID(mostHatedID)
|
||||
// Remove dead targets from hate list
|
||||
if target != nil && target.GetHP() <= 0 {
|
||||
bb.hateList.RemoveHate(mostHatedID)
|
||||
target = nil
|
||||
// Try again to get most hated
|
||||
mostHatedID = bb.hateList.GetMostHated()
|
||||
if mostHatedID > 0 {
|
||||
target = bb.getEntityByID(mostHatedID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if mezzed, stunned, or feared
|
||||
if bb.npc.IsMezzedOrStunned() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get runback distance
|
||||
runbackDistance := bb.npc.GetRunbackDistance()
|
||||
|
||||
if target != nil {
|
||||
// We have a target to fight
|
||||
if bb.logger != nil && bb.state.GetDebugLevel() >= DebugLevelDetailed {
|
||||
bb.logger.LogDebug("NPC %s has target %s", bb.npc.GetName(), target.GetName())
|
||||
}
|
||||
|
||||
// Set target if not already set
|
||||
if bb.npc.GetTarget() != target {
|
||||
bb.npc.SetTarget(target)
|
||||
}
|
||||
|
||||
// Face the target
|
||||
bb.npc.FaceTarget(target, false)
|
||||
|
||||
// Enter combat if not already in combat
|
||||
if !bb.npc.GetInCombat() {
|
||||
bb.npc.ClearRunningLocations()
|
||||
bb.npc.InCombat(true)
|
||||
bb.npc.SetCastOnAggroCompleted(false)
|
||||
// TODO: Call spawn script for aggro
|
||||
}
|
||||
|
||||
// Check chase distance and water restrictions
|
||||
if bb.shouldBreakPursuit(target, runbackDistance) {
|
||||
// Break pursuit - clear hate and encounter
|
||||
if bb.logger != nil {
|
||||
bb.logger.LogDebug("NPC %s breaking pursuit (distance: %.2f)", bb.npc.GetName(), runbackDistance)
|
||||
}
|
||||
|
||||
// TODO: Send encounter break messages to players
|
||||
bb.hateList.Clear()
|
||||
bb.encounterList.Clear()
|
||||
} else {
|
||||
// Continue combat
|
||||
distance := bb.npc.GetDistance(target)
|
||||
|
||||
// Try to cast spells first, then melee
|
||||
if !bb.npc.IsCasting() && (!bb.HasRecovered() || !bb.ProcessSpell(target, distance)) {
|
||||
if bb.logger != nil && bb.state.GetDebugLevel() >= DebugLevelDetailed {
|
||||
bb.logger.LogDebug("NPC %s attempting melee on %s", bb.npc.GetName(), target.GetName())
|
||||
}
|
||||
bb.npc.FaceTarget(target, false)
|
||||
bb.ProcessMelee(target, distance)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No target - handle out of combat behavior
|
||||
wasInCombat := bb.npc.GetInCombat()
|
||||
|
||||
if bb.npc.GetInCombat() {
|
||||
bb.npc.InCombat(false)
|
||||
|
||||
// Restore HP for non-player pets
|
||||
if !bb.npc.IsPet() || (bb.npc.IsPet() && bb.npc.GetOwner() != nil && !bb.npc.GetOwner().IsPlayer()) {
|
||||
bb.npc.SetHP(bb.npc.GetTotalHP())
|
||||
}
|
||||
}
|
||||
|
||||
// Check for buffs when not in combat
|
||||
bb.CheckBuffs()
|
||||
|
||||
// Handle runback if needed
|
||||
if !bb.npc.GetInCombat() && !bb.npc.IsPauseMovementTimerActive() {
|
||||
if runbackDistance > RunbackThreshold || (bb.npc.ShouldCallRunback() && !bb.npc.IsFollowing()) {
|
||||
bb.npc.SetEncounterState(EncounterStateBroken)
|
||||
bb.npc.Runback(runbackDistance)
|
||||
bb.npc.SetCallRunback(false)
|
||||
} else if bb.npc.GetRunbackLocation() != nil {
|
||||
bb.handleRunbackStages()
|
||||
}
|
||||
}
|
||||
|
||||
// Clear encounter if any entities remain
|
||||
if bb.encounterList.Size() > 0 {
|
||||
bb.encounterList.Clear()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBrainType returns the brain type
|
||||
func (bb *BaseBrain) GetBrainType() int8 {
|
||||
return bb.brainType
|
||||
}
|
||||
|
||||
// IsActive returns whether the brain is active
|
||||
func (bb *BaseBrain) IsActive() bool {
|
||||
return bb.state.IsActive()
|
||||
}
|
||||
|
||||
// SetActive sets the brain's active state
|
||||
func (bb *BaseBrain) SetActive(active bool) {
|
||||
bb.state.SetActive(active)
|
||||
}
|
||||
|
||||
// GetState returns the current AI state
|
||||
func (bb *BaseBrain) GetState() int32 {
|
||||
return bb.state.GetState()
|
||||
}
|
||||
|
||||
// SetState sets the current AI state
|
||||
func (bb *BaseBrain) SetState(state int32) {
|
||||
bb.state.SetState(state)
|
||||
}
|
||||
|
||||
// GetThinkTick returns the think tick interval
|
||||
func (bb *BaseBrain) GetThinkTick() int32 {
|
||||
return bb.state.GetThinkTick()
|
||||
}
|
||||
|
||||
// SetThinkTick sets the think tick interval
|
||||
func (bb *BaseBrain) SetThinkTick(tick int32) {
|
||||
bb.state.SetThinkTick(tick)
|
||||
}
|
||||
|
||||
// GetLastThink returns the timestamp of the last think cycle
|
||||
func (bb *BaseBrain) GetLastThink() int64 {
|
||||
return bb.state.GetLastThink()
|
||||
}
|
||||
|
||||
// SetLastThink sets the timestamp of the last think cycle
|
||||
func (bb *BaseBrain) SetLastThink(timestamp int64) {
|
||||
bb.state.SetLastThink(timestamp)
|
||||
}
|
||||
|
||||
// GetBody returns the NPC this brain controls
|
||||
func (bb *BaseBrain) GetBody() NPC {
|
||||
bb.mutex.RLock()
|
||||
defer bb.mutex.RUnlock()
|
||||
return bb.npc
|
||||
}
|
||||
|
||||
// SetBody sets the NPC this brain controls
|
||||
func (bb *BaseBrain) SetBody(npc NPC) {
|
||||
bb.mutex.Lock()
|
||||
defer bb.mutex.Unlock()
|
||||
bb.npc = npc
|
||||
}
|
||||
|
||||
// AddHate adds hate for an entity
|
||||
func (bb *BaseBrain) AddHate(entityID int32, hate int32) {
|
||||
// Don't add hate while running back
|
||||
if bb.npc != nil && bb.npc.IsRunningBack() {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't add hate if owner is attacking pet
|
||||
if bb.npc != nil && bb.npc.IsPet() && bb.npc.GetOwner() != nil {
|
||||
if bb.npc.GetOwner().GetID() == entityID {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check for taunt immunity
|
||||
// TODO: Implement immunity checking
|
||||
|
||||
bb.hateList.AddHate(entityID, hate)
|
||||
|
||||
// Update statistics
|
||||
bb.mutex.Lock()
|
||||
bb.statistics.HateEvents++
|
||||
bb.mutex.Unlock()
|
||||
|
||||
// TODO: Add to entity's HatedBy list
|
||||
|
||||
// Add pet owner to hate list if not already present
|
||||
entity := bb.getEntityByID(entityID)
|
||||
if entity != nil && entity.IsPet() && entity.GetOwner() != nil {
|
||||
ownerID := entity.GetOwner().GetID()
|
||||
if bb.hateList.GetHate(ownerID) == 0 {
|
||||
bb.hateList.AddHate(ownerID, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetHate returns the hate value for an entity
|
||||
func (bb *BaseBrain) GetHate(entityID int32) int32 {
|
||||
return bb.hateList.GetHate(entityID)
|
||||
}
|
||||
|
||||
// ClearHate removes all hate entries
|
||||
func (bb *BaseBrain) ClearHate() {
|
||||
bb.hateList.Clear()
|
||||
// TODO: Update entities' HatedBy lists
|
||||
}
|
||||
|
||||
// ClearHateForEntity removes hate for a specific entity
|
||||
func (bb *BaseBrain) ClearHateForEntity(entityID int32) {
|
||||
bb.hateList.RemoveHate(entityID)
|
||||
// TODO: Update entity's HatedBy list
|
||||
}
|
||||
|
||||
// GetMostHated returns the ID of the most hated entity
|
||||
func (bb *BaseBrain) GetMostHated() int32 {
|
||||
return bb.hateList.GetMostHated()
|
||||
}
|
||||
|
||||
// GetHatePercentage returns the hate percentage for an entity
|
||||
func (bb *BaseBrain) GetHatePercentage(entityID int32) int8 {
|
||||
return bb.hateList.GetHatePercentage(entityID)
|
||||
}
|
||||
|
||||
// GetHateList returns a copy of the hate list
|
||||
func (bb *BaseBrain) GetHateList() map[int32]*HateEntry {
|
||||
return bb.hateList.GetAllEntries()
|
||||
}
|
||||
|
||||
// AddToEncounter adds an entity to the encounter list
|
||||
func (bb *BaseBrain) AddToEncounter(entityID, characterID int32, isPlayer, isBot bool) bool {
|
||||
success := bb.encounterList.AddEntity(entityID, characterID, isPlayer, isBot)
|
||||
if success {
|
||||
bb.mutex.Lock()
|
||||
bb.statistics.EncounterEvents++
|
||||
bb.mutex.Unlock()
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
// IsEntityInEncounter checks if an entity is in the encounter
|
||||
func (bb *BaseBrain) IsEntityInEncounter(entityID int32) bool {
|
||||
return bb.encounterList.IsEntityInEncounter(entityID)
|
||||
}
|
||||
|
||||
// IsPlayerInEncounter checks if a player is in the encounter
|
||||
func (bb *BaseBrain) IsPlayerInEncounter(characterID int32) bool {
|
||||
return bb.encounterList.IsPlayerInEncounter(characterID)
|
||||
}
|
||||
|
||||
// HasPlayerInEncounter returns whether any player is in the encounter
|
||||
func (bb *BaseBrain) HasPlayerInEncounter() bool {
|
||||
return bb.encounterList.HasPlayerInEncounter()
|
||||
}
|
||||
|
||||
// GetEncounterSize returns the size of the encounter list
|
||||
func (bb *BaseBrain) GetEncounterSize() int {
|
||||
return bb.encounterList.Size()
|
||||
}
|
||||
|
||||
// ClearEncounter removes all entities from the encounter
|
||||
func (bb *BaseBrain) ClearEncounter() {
|
||||
bb.encounterList.Clear()
|
||||
// TODO: Remove spells from NPC
|
||||
}
|
||||
|
||||
// CheckLootAllowed checks if an entity can loot this NPC
|
||||
func (bb *BaseBrain) CheckLootAllowed(entityID int32) bool {
|
||||
// TODO: Implement loot method checking, chest timers, etc.
|
||||
|
||||
// Basic check - is entity in encounter?
|
||||
return bb.encounterList.IsEntityInEncounter(entityID)
|
||||
}
|
||||
|
||||
// ProcessSpell attempts to cast a spell
|
||||
func (bb *BaseBrain) ProcessSpell(target Entity, distance float32) bool {
|
||||
if bb.npc == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check cast percentage and conditions
|
||||
castChance := bb.npc.GetCastPercentage()
|
||||
if castChance <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// TODO: Implement random chance checking
|
||||
// TODO: Check for stifled, feared conditions
|
||||
|
||||
// Get next spell to cast
|
||||
spell := bb.npc.GetNextSpell(target, distance)
|
||||
if spell == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Determine spell target
|
||||
var spellTarget Spawn = target
|
||||
if spell.IsFriendlySpell() {
|
||||
// TODO: Find best friendly target (lowest HP group member)
|
||||
spellTarget = bb.npc
|
||||
}
|
||||
|
||||
// Cast the spell
|
||||
success := bb.castSpell(spell, spellTarget, false)
|
||||
if success {
|
||||
bb.mutex.Lock()
|
||||
bb.statistics.SpellsCast++
|
||||
bb.mutex.Unlock()
|
||||
}
|
||||
|
||||
return success
|
||||
}
|
||||
|
||||
// ProcessMelee handles melee combat
|
||||
func (bb *BaseBrain) ProcessMelee(target Entity, distance float32) {
|
||||
if bb.npc == nil || target == nil {
|
||||
return
|
||||
}
|
||||
|
||||
maxCombatRange := bb.getMaxCombatRange()
|
||||
|
||||
if distance > maxCombatRange {
|
||||
bb.MoveCloser(target)
|
||||
} else {
|
||||
if bb.logger != nil && bb.state.GetDebugLevel() >= DebugLevelDetailed {
|
||||
bb.logger.LogDebug("NPC %s is within melee range of %s", bb.npc.GetName(), target.GetName())
|
||||
}
|
||||
|
||||
// Check if attack is allowed
|
||||
if !bb.npc.AttackAllowed(target) {
|
||||
return
|
||||
}
|
||||
|
||||
currentTime := time.Now().UnixMilli()
|
||||
|
||||
// Primary weapon attack
|
||||
if bb.npc.PrimaryWeaponReady() && !bb.npc.IsDazed() && !bb.npc.IsFeared() {
|
||||
if bb.logger != nil && bb.state.GetDebugLevel() >= DebugLevelVerbose {
|
||||
bb.logger.LogDebug("NPC %s swings primary weapon at %s", bb.npc.GetName(), target.GetName())
|
||||
}
|
||||
|
||||
bb.npc.SetPrimaryLastAttackTime(currentTime)
|
||||
bb.npc.MeleeAttack(target, distance, true)
|
||||
|
||||
bb.mutex.Lock()
|
||||
bb.statistics.MeleeAttacks++
|
||||
bb.mutex.Unlock()
|
||||
|
||||
// TODO: Call spawn script for auto attack tick
|
||||
}
|
||||
|
||||
// Secondary weapon attack
|
||||
if bb.npc.SecondaryWeaponReady() && !bb.npc.IsDazed() {
|
||||
bb.npc.SetSecondaryLastAttackTime(currentTime)
|
||||
bb.npc.MeleeAttack(target, distance, false)
|
||||
|
||||
bb.mutex.Lock()
|
||||
bb.statistics.MeleeAttacks++
|
||||
bb.mutex.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CheckBuffs checks and casts buff spells
|
||||
func (bb *BaseBrain) CheckBuffs() bool {
|
||||
if bb.npc == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Don't buff in combat, while casting, stunned, etc.
|
||||
if bb.npc.GetInCombat() || bb.npc.IsCasting() || bb.npc.IsMezzedOrStunned() ||
|
||||
!bb.npc.IsAlive() || bb.npc.IsStifled() || !bb.HasRecovered() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get next buff spell
|
||||
buffSpell := bb.npc.GetNextBuffSpell(bb.npc)
|
||||
if buffSpell == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try to cast on self first
|
||||
if bb.castSpell(buffSpell, bb.npc, false) {
|
||||
return true
|
||||
}
|
||||
|
||||
// TODO: Try to buff group members
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// HasRecovered checks if the brain has recovered from spell casting
|
||||
func (bb *BaseBrain) HasRecovered() bool {
|
||||
return bb.state.HasRecovered()
|
||||
}
|
||||
|
||||
// MoveCloser moves the NPC closer to a target
|
||||
func (bb *BaseBrain) MoveCloser(target Spawn) {
|
||||
if bb.npc == nil || target == nil {
|
||||
return
|
||||
}
|
||||
|
||||
maxCombatRange := bb.getMaxCombatRange()
|
||||
|
||||
if bb.npc.GetFollowTarget() != target {
|
||||
bb.npc.SetFollowTarget(target, maxCombatRange)
|
||||
}
|
||||
|
||||
if bb.npc.GetFollowTarget() != nil && !bb.npc.IsFollowing() {
|
||||
bb.npc.CalculateRunningLocation(true)
|
||||
bb.npc.SetFollowing(true)
|
||||
}
|
||||
}
|
||||
|
||||
// GetStatistics returns brain performance statistics
|
||||
func (bb *BaseBrain) GetStatistics() *BrainStatistics {
|
||||
bb.mutex.RLock()
|
||||
defer bb.mutex.RUnlock()
|
||||
|
||||
// Return a copy
|
||||
return &BrainStatistics{
|
||||
ThinkCycles: bb.statistics.ThinkCycles,
|
||||
SpellsCast: bb.statistics.SpellsCast,
|
||||
MeleeAttacks: bb.statistics.MeleeAttacks,
|
||||
HateEvents: bb.statistics.HateEvents,
|
||||
EncounterEvents: bb.statistics.EncounterEvents,
|
||||
AverageThinkTime: bb.statistics.AverageThinkTime,
|
||||
LastThinkTime: bb.statistics.LastThinkTime,
|
||||
TotalActiveTime: bb.statistics.TotalActiveTime,
|
||||
}
|
||||
}
|
||||
|
||||
// ResetStatistics resets all performance statistics
|
||||
func (bb *BaseBrain) ResetStatistics() {
|
||||
bb.mutex.Lock()
|
||||
defer bb.mutex.Unlock()
|
||||
|
||||
bb.statistics = NewBrainStatistics()
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
// shouldBreakPursuit checks if the NPC should break pursuit of a target
|
||||
func (bb *BaseBrain) shouldBreakPursuit(target Entity, runbackDistance float32) bool {
|
||||
if target == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check max chase distance
|
||||
maxChase := bb.getMaxChaseDistance()
|
||||
if runbackDistance > maxChase {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check water creature restrictions
|
||||
if bb.npc != nil && bb.npc.IsWaterCreature() && !bb.npc.IsFlyingCreature() && !target.InWater() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// castSpell casts a spell on a target
|
||||
func (bb *BaseBrain) castSpell(spell Spell, target Spawn, calculateRunLoc bool) bool {
|
||||
if spell == nil || bb.npc == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if calculateRunLoc {
|
||||
bb.npc.CalculateRunningLocation(true)
|
||||
}
|
||||
|
||||
// TODO: Process spell through zone
|
||||
// bb.npc.GetZone().ProcessSpell(spell, bb.npc, target)
|
||||
|
||||
// Set spell recovery time
|
||||
castTime := spell.GetCastTime() * RecoveryTimeMultiple
|
||||
recoveryTime := spell.GetRecoveryTime() * RecoveryTimeMultiple
|
||||
totalRecovery := time.Now().UnixMilli() + int64(castTime) + int64(recoveryTime) + int64(SpellRecoveryBuffer)
|
||||
|
||||
bb.state.SetSpellRecovery(totalRecovery)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// handleRunbackStages handles the various stages of runback
|
||||
func (bb *BaseBrain) handleRunbackStages() {
|
||||
if bb.npc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
runbackLoc := bb.npc.GetRunbackLocation()
|
||||
if runbackLoc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Implement runback stage handling
|
||||
// This would involve movement management and position updates
|
||||
}
|
||||
|
||||
// getEntityByID retrieves an entity by ID (placeholder)
|
||||
func (bb *BaseBrain) getEntityByID(entityID int32) Entity {
|
||||
// TODO: Implement entity lookup through zone
|
||||
return nil
|
||||
}
|
||||
|
||||
// getMaxChaseDistance returns the maximum chase distance
|
||||
func (bb *BaseBrain) getMaxChaseDistance() float32 {
|
||||
// TODO: Check NPC info struct and zone rules
|
||||
return MaxChaseDistance
|
||||
}
|
||||
|
||||
// getMaxCombatRange returns the maximum combat range
|
||||
func (bb *BaseBrain) getMaxCombatRange() float32 {
|
||||
// TODO: Check zone rules
|
||||
return MaxCombatRange
|
||||
}
|
90
internal/npc/ai/constants.go
Normal file
90
internal/npc/ai/constants.go
Normal file
@ -0,0 +1,90 @@
|
||||
package ai
|
||||
|
||||
// AI tick constants
|
||||
const (
|
||||
DefaultThinkTick int32 = 250 // Default think tick in milliseconds (1/4 second)
|
||||
FastThinkTick int32 = 100 // Fast think tick for active AI
|
||||
SlowThinkTick int32 = 1000 // Slow think tick for idle AI
|
||||
BlankBrainTick int32 = 50000 // Very slow tick for blank brain
|
||||
MaxThinkTick int32 = 60000 // Maximum think tick (1 minute)
|
||||
)
|
||||
|
||||
// Combat constants
|
||||
const (
|
||||
MaxChaseDistance float32 = 150.0 // Default max chase distance
|
||||
MaxCombatRange float32 = 25.0 // Default max combat range
|
||||
RunbackThreshold float32 = 1.0 // Distance threshold for runback
|
||||
)
|
||||
|
||||
// Hate system constants
|
||||
const (
|
||||
MinHateValue int32 = 1 // Minimum hate value (0 or negative is invalid)
|
||||
MaxHateValue int32 = 2147483647 // Maximum hate value (INT_MAX)
|
||||
DefaultHateValue int32 = 100 // Default hate amount
|
||||
MaxHateListSize int = 100 // Maximum entities in hate list
|
||||
)
|
||||
|
||||
// Encounter system constants
|
||||
const (
|
||||
MaxEncounterSize int = 50 // Maximum entities in encounter list
|
||||
)
|
||||
|
||||
// Spell recovery constants
|
||||
const (
|
||||
SpellRecoveryBuffer int32 = 2000 // Additional recovery time buffer (2 seconds)
|
||||
)
|
||||
|
||||
// Brain type constants for identification
|
||||
const (
|
||||
BrainTypeDefault int8 = 0
|
||||
BrainTypeCombatPet int8 = 1
|
||||
BrainTypeNonCombatPet int8 = 2
|
||||
BrainTypeBlank int8 = 3
|
||||
BrainTypeLua int8 = 4
|
||||
BrainTypeDumbFire int8 = 5
|
||||
)
|
||||
|
||||
// Pet movement constants
|
||||
const (
|
||||
PetMovementFollow int8 = 0
|
||||
PetMovementStay int8 = 1
|
||||
PetMovementGuard int8 = 2
|
||||
)
|
||||
|
||||
// Encounter state constants
|
||||
const (
|
||||
EncounterStateAvailable int8 = 0
|
||||
EncounterStateLocked int8 = 1
|
||||
EncounterStateBroken int8 = 2
|
||||
)
|
||||
|
||||
// Combat decision constants
|
||||
const (
|
||||
MeleeAttackChance int = 70 // Base chance for melee attack
|
||||
SpellCastChance int = 30 // Base chance for spell casting
|
||||
BuffCheckChance int = 50 // Chance to check for buffs
|
||||
)
|
||||
|
||||
// AI state flags
|
||||
const (
|
||||
AIStateIdle int32 = 0
|
||||
AIStateCombat int32 = 1
|
||||
AIStateFollowing int32 = 2
|
||||
AIStateRunback int32 = 3
|
||||
AIStateCasting int32 = 4
|
||||
AIStateMoving int32 = 5
|
||||
)
|
||||
|
||||
// Debug levels
|
||||
const (
|
||||
DebugLevelNone int8 = 0
|
||||
DebugLevelBasic int8 = 1
|
||||
DebugLevelDetailed int8 = 2
|
||||
DebugLevelVerbose int8 = 3
|
||||
)
|
||||
|
||||
// Timer constants
|
||||
const (
|
||||
MillisecondsPerSecond int32 = 1000
|
||||
RecoveryTimeMultiple int32 = 10 // Multiply cast/recovery times by 10
|
||||
)
|
483
internal/npc/ai/interfaces.go
Normal file
483
internal/npc/ai/interfaces.go
Normal file
@ -0,0 +1,483 @@
|
||||
package ai
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Logger interface for AI logging
|
||||
type Logger interface {
|
||||
LogInfo(message string, args ...interface{})
|
||||
LogError(message string, args ...interface{})
|
||||
LogDebug(message string, args ...interface{})
|
||||
LogWarning(message string, args ...interface{})
|
||||
}
|
||||
|
||||
// NPC interface defines the required NPC functionality for AI
|
||||
type NPC interface {
|
||||
// Basic NPC information
|
||||
GetID() int32
|
||||
GetName() string
|
||||
GetHP() int32
|
||||
GetTotalHP() int32
|
||||
SetHP(int32)
|
||||
IsAlive() bool
|
||||
|
||||
// Combat state
|
||||
GetInCombat() bool
|
||||
InCombat(bool)
|
||||
GetTarget() Entity
|
||||
SetTarget(Entity)
|
||||
|
||||
// Pet functionality
|
||||
IsPet() bool
|
||||
GetOwner() Entity
|
||||
|
||||
// Movement and positioning
|
||||
GetX() float32
|
||||
GetY() float32
|
||||
GetZ() float32
|
||||
GetDistance(Entity) float32
|
||||
FaceTarget(Entity, bool)
|
||||
IsFollowing() bool
|
||||
SetFollowing(bool)
|
||||
GetFollowTarget() Spawn
|
||||
SetFollowTarget(Spawn, float32)
|
||||
CalculateRunningLocation(bool)
|
||||
ClearRunningLocations()
|
||||
|
||||
// Runback functionality
|
||||
IsRunningBack() bool
|
||||
GetRunbackLocation() *MovementLocation
|
||||
GetRunbackDistance() float32
|
||||
Runback(float32)
|
||||
ShouldCallRunback() bool
|
||||
SetCallRunback(bool)
|
||||
|
||||
// Status effects
|
||||
IsMezzedOrStunned() bool
|
||||
IsCasting() bool
|
||||
IsDazed() bool
|
||||
IsFeared() bool
|
||||
IsStifled() bool
|
||||
InWater() bool
|
||||
IsWaterCreature() bool
|
||||
IsFlyingCreature() bool
|
||||
|
||||
// Combat mechanics
|
||||
AttackAllowed(Entity) bool
|
||||
PrimaryWeaponReady() bool
|
||||
SecondaryWeaponReady() bool
|
||||
SetPrimaryLastAttackTime(int64)
|
||||
SetSecondaryLastAttackTime(int64)
|
||||
MeleeAttack(Entity, float32, bool)
|
||||
|
||||
// Spell casting
|
||||
GetCastPercentage() int8
|
||||
GetNextSpell(Entity, float32) Spell
|
||||
GetNextBuffSpell(Spawn) Spell
|
||||
SetCastOnAggroCompleted(bool)
|
||||
CheckLoS(Entity) bool
|
||||
|
||||
// Movement pausing
|
||||
IsPauseMovementTimerActive() bool
|
||||
|
||||
// Encounter state
|
||||
SetEncounterState(int8)
|
||||
|
||||
// Scripts
|
||||
GetSpawnScript() string
|
||||
|
||||
// Utility
|
||||
KillSpawn(NPC)
|
||||
}
|
||||
|
||||
// Entity interface for combat entities
|
||||
type Entity interface {
|
||||
Spawn
|
||||
GetID() int32
|
||||
GetName() string
|
||||
GetHP() int32
|
||||
GetTotalHP() int32
|
||||
IsPlayer() bool
|
||||
IsBot() bool
|
||||
IsPet() bool
|
||||
GetOwner() Entity
|
||||
InWater() bool
|
||||
}
|
||||
|
||||
// Spawn interface for basic spawn functionality
|
||||
type Spawn interface {
|
||||
GetID() int32
|
||||
GetName() string
|
||||
GetX() float32
|
||||
GetY() float32
|
||||
GetZ() float32
|
||||
}
|
||||
|
||||
// Spell interface for spell data
|
||||
type Spell interface {
|
||||
GetSpellID() int32
|
||||
GetName() string
|
||||
IsFriendlySpell() bool
|
||||
GetCastTime() int32
|
||||
GetRecoveryTime() int32
|
||||
GetRange() float32
|
||||
GetMinRange() float32
|
||||
}
|
||||
|
||||
// MovementLocation represents a location for movement/runback
|
||||
type MovementLocation struct {
|
||||
X float32
|
||||
Y float32
|
||||
Z float32
|
||||
GridID int32
|
||||
Stage int32
|
||||
}
|
||||
|
||||
// LuaInterface defines Lua script integration
|
||||
type LuaInterface interface {
|
||||
RunSpawnScript(script, function string, npc NPC, target Entity) error
|
||||
}
|
||||
|
||||
// Zone interface for zone-related AI operations
|
||||
type Zone interface {
|
||||
GetSpawnByID(int32) Spawn
|
||||
ProcessSpell(spell Spell, caster NPC, target Spawn) error
|
||||
CallSpawnScript(npc NPC, scriptType string, args ...interface{}) error
|
||||
}
|
||||
|
||||
// AIManager provides high-level management of the AI system
|
||||
type AIManager struct {
|
||||
brains map[int32]Brain // Map of NPC ID to brain
|
||||
activeCount int64 // Number of active brains
|
||||
totalThinks int64 // Total think cycles processed
|
||||
logger Logger // Logger for AI events
|
||||
luaInterface LuaInterface // Lua script interface
|
||||
}
|
||||
|
||||
// NewAIManager creates a new AI manager
|
||||
func NewAIManager(logger Logger, luaInterface LuaInterface) *AIManager {
|
||||
return &AIManager{
|
||||
brains: make(map[int32]Brain),
|
||||
activeCount: 0,
|
||||
totalThinks: 0,
|
||||
logger: logger,
|
||||
luaInterface: luaInterface,
|
||||
}
|
||||
}
|
||||
|
||||
// AddBrain adds a brain for an NPC
|
||||
func (am *AIManager) AddBrain(npcID int32, brain Brain) error {
|
||||
if brain == nil {
|
||||
return fmt.Errorf("brain cannot be nil")
|
||||
}
|
||||
|
||||
if _, exists := am.brains[npcID]; exists {
|
||||
return fmt.Errorf("brain already exists for NPC %d", npcID)
|
||||
}
|
||||
|
||||
am.brains[npcID] = brain
|
||||
if brain.IsActive() {
|
||||
am.activeCount++
|
||||
}
|
||||
|
||||
if am.logger != nil {
|
||||
am.logger.LogDebug("Added brain for NPC %d (type: %d)", npcID, brain.GetBrainType())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveBrain removes a brain for an NPC
|
||||
func (am *AIManager) RemoveBrain(npcID int32) {
|
||||
if brain, exists := am.brains[npcID]; exists {
|
||||
if brain.IsActive() {
|
||||
am.activeCount--
|
||||
}
|
||||
delete(am.brains, npcID)
|
||||
|
||||
if am.logger != nil {
|
||||
am.logger.LogDebug("Removed brain for NPC %d", npcID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetBrain retrieves a brain for an NPC
|
||||
func (am *AIManager) GetBrain(npcID int32) Brain {
|
||||
return am.brains[npcID]
|
||||
}
|
||||
|
||||
// CreateBrainForNPC creates and adds the appropriate brain for an NPC
|
||||
func (am *AIManager) CreateBrainForNPC(npc NPC, brainType int8, options ...interface{}) error {
|
||||
if npc == nil {
|
||||
return fmt.Errorf("NPC cannot be nil")
|
||||
}
|
||||
|
||||
npcID := npc.GetID()
|
||||
|
||||
// Create brain based on type
|
||||
var brain Brain
|
||||
switch brainType {
|
||||
case BrainTypeCombatPet:
|
||||
brain = NewCombatPetBrain(npc, am.logger)
|
||||
|
||||
case BrainTypeNonCombatPet:
|
||||
brain = NewNonCombatPetBrain(npc, am.logger)
|
||||
|
||||
case BrainTypeBlank:
|
||||
brain = NewBlankBrain(npc, am.logger)
|
||||
|
||||
case BrainTypeLua:
|
||||
brain = NewLuaBrain(npc, am.logger, am.luaInterface)
|
||||
|
||||
case BrainTypeDumbFire:
|
||||
if len(options) >= 2 {
|
||||
if target, ok := options[0].(Entity); ok {
|
||||
if expireTime, ok := options[1].(int32); ok {
|
||||
brain = NewDumbFirePetBrain(npc, target, expireTime, am.logger)
|
||||
}
|
||||
}
|
||||
}
|
||||
if brain == nil {
|
||||
return fmt.Errorf("invalid options for dumbfire brain")
|
||||
}
|
||||
|
||||
default:
|
||||
brain = NewBaseBrain(npc, am.logger)
|
||||
}
|
||||
|
||||
return am.AddBrain(npcID, brain)
|
||||
}
|
||||
|
||||
// ProcessAllBrains runs think cycles for all active brains
|
||||
func (am *AIManager) ProcessAllBrains() {
|
||||
currentTime := currentTimeMillis()
|
||||
|
||||
for npcID, brain := range am.brains {
|
||||
if !brain.IsActive() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's time to think
|
||||
lastThink := brain.GetLastThink()
|
||||
thinkTick := brain.GetThinkTick()
|
||||
|
||||
if currentTime-lastThink >= int64(thinkTick) {
|
||||
if err := brain.Think(); err != nil {
|
||||
if am.logger != nil {
|
||||
am.logger.LogError("Brain think error for NPC %d: %v", npcID, err)
|
||||
}
|
||||
}
|
||||
am.totalThinks++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetBrainActive sets the active state of a brain
|
||||
func (am *AIManager) SetBrainActive(npcID int32, active bool) {
|
||||
if brain := am.brains[npcID]; brain != nil {
|
||||
wasActive := brain.IsActive()
|
||||
brain.SetActive(active)
|
||||
|
||||
// Update active count
|
||||
if wasActive && !active {
|
||||
am.activeCount--
|
||||
} else if !wasActive && active {
|
||||
am.activeCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetActiveCount returns the number of active brains
|
||||
func (am *AIManager) GetActiveCount() int64 {
|
||||
return am.activeCount
|
||||
}
|
||||
|
||||
// GetTotalThinks returns the total number of think cycles processed
|
||||
func (am *AIManager) GetTotalThinks() int64 {
|
||||
return am.totalThinks
|
||||
}
|
||||
|
||||
// GetBrainCount returns the total number of brains
|
||||
func (am *AIManager) GetBrainCount() int {
|
||||
return len(am.brains)
|
||||
}
|
||||
|
||||
// GetBrainsByType returns all brains of a specific type
|
||||
func (am *AIManager) GetBrainsByType(brainType int8) []Brain {
|
||||
var result []Brain
|
||||
for _, brain := range am.brains {
|
||||
if brain.GetBrainType() == brainType {
|
||||
result = append(result, brain)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ClearAllBrains removes all brains
|
||||
func (am *AIManager) ClearAllBrains() {
|
||||
am.brains = make(map[int32]Brain)
|
||||
am.activeCount = 0
|
||||
|
||||
if am.logger != nil {
|
||||
am.logger.LogInfo("Cleared all AI brains")
|
||||
}
|
||||
}
|
||||
|
||||
// GetStatistics returns overall AI system statistics
|
||||
func (am *AIManager) GetStatistics() *AIStatistics {
|
||||
return &AIStatistics{
|
||||
TotalBrains: len(am.brains),
|
||||
ActiveBrains: int(am.activeCount),
|
||||
TotalThinks: am.totalThinks,
|
||||
BrainsByType: am.getBrainCountsByType(),
|
||||
}
|
||||
}
|
||||
|
||||
// getBrainCountsByType returns counts of brains by type
|
||||
func (am *AIManager) getBrainCountsByType() map[string]int {
|
||||
counts := make(map[string]int)
|
||||
|
||||
for _, brain := range am.brains {
|
||||
typeName := getBrainTypeName(brain.GetBrainType())
|
||||
counts[typeName]++
|
||||
}
|
||||
|
||||
return counts
|
||||
}
|
||||
|
||||
// AIStatistics contains AI system statistics
|
||||
type AIStatistics struct {
|
||||
TotalBrains int `json:"total_brains"`
|
||||
ActiveBrains int `json:"active_brains"`
|
||||
TotalThinks int64 `json:"total_thinks"`
|
||||
BrainsByType map[string]int `json:"brains_by_type"`
|
||||
}
|
||||
|
||||
// AIBrainAdapter provides NPC functionality for brains
|
||||
type AIBrainAdapter struct {
|
||||
npc NPC
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// NewAIBrainAdapter creates a new brain adapter
|
||||
func NewAIBrainAdapter(npc NPC, logger Logger) *AIBrainAdapter {
|
||||
return &AIBrainAdapter{
|
||||
npc: npc,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetNPC returns the adapted NPC
|
||||
func (aba *AIBrainAdapter) GetNPC() NPC {
|
||||
return aba.npc
|
||||
}
|
||||
|
||||
// ProcessAI processes AI for the NPC using its brain
|
||||
func (aba *AIBrainAdapter) ProcessAI(brain Brain) error {
|
||||
if brain == nil {
|
||||
return fmt.Errorf("brain is nil")
|
||||
}
|
||||
|
||||
if !brain.IsActive() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return brain.Think()
|
||||
}
|
||||
|
||||
// SetupDefaultBrain sets up a default brain for the NPC
|
||||
func (aba *AIBrainAdapter) SetupDefaultBrain() Brain {
|
||||
return NewBaseBrain(aba.npc, aba.logger)
|
||||
}
|
||||
|
||||
// SetupPetBrain sets up a pet brain based on pet type
|
||||
func (aba *AIBrainAdapter) SetupPetBrain(combatPet bool) Brain {
|
||||
if combatPet {
|
||||
return NewCombatPetBrain(aba.npc, aba.logger)
|
||||
}
|
||||
return NewNonCombatPetBrain(aba.npc, aba.logger)
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
|
||||
// getBrainTypeName returns the string name for a brain type
|
||||
func getBrainTypeName(brainType int8) string {
|
||||
switch brainType {
|
||||
case BrainTypeDefault:
|
||||
return "default"
|
||||
case BrainTypeCombatPet:
|
||||
return "combat_pet"
|
||||
case BrainTypeNonCombatPet:
|
||||
return "non_combat_pet"
|
||||
case BrainTypeBlank:
|
||||
return "blank"
|
||||
case BrainTypeLua:
|
||||
return "lua"
|
||||
case BrainTypeDumbFire:
|
||||
return "dumbfire"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// currentTimeMillis returns current time in milliseconds
|
||||
func currentTimeMillis() int64 {
|
||||
return time.Now().UnixMilli()
|
||||
}
|
||||
|
||||
// HateListDebugger provides debugging functionality for hate lists
|
||||
type HateListDebugger struct {
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// NewHateListDebugger creates a new hate list debugger
|
||||
func NewHateListDebugger(logger Logger) *HateListDebugger {
|
||||
return &HateListDebugger{
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// PrintHateList prints a formatted hate list
|
||||
func (hld *HateListDebugger) PrintHateList(npcName string, hateList map[int32]*HateEntry) {
|
||||
if hld.logger == nil {
|
||||
return
|
||||
}
|
||||
|
||||
hld.logger.LogInfo("%s's Hate List", npcName)
|
||||
hld.logger.LogInfo("-------------------")
|
||||
|
||||
if len(hateList) == 0 {
|
||||
hld.logger.LogInfo("(empty)")
|
||||
} else {
|
||||
for entityID, entry := range hateList {
|
||||
hld.logger.LogInfo("Entity %d: %d hate", entityID, entry.HateValue)
|
||||
}
|
||||
}
|
||||
|
||||
hld.logger.LogInfo("-------------------")
|
||||
}
|
||||
|
||||
// PrintEncounterList prints a formatted encounter list
|
||||
func (hld *HateListDebugger) PrintEncounterList(npcName string, encounterList map[int32]*EncounterEntry) {
|
||||
if hld.logger == nil {
|
||||
return
|
||||
}
|
||||
|
||||
hld.logger.LogInfo("%s's Encounter List", npcName)
|
||||
hld.logger.LogInfo("-------------------")
|
||||
|
||||
if len(encounterList) == 0 {
|
||||
hld.logger.LogInfo("(empty)")
|
||||
} else {
|
||||
for entityID, entry := range encounterList {
|
||||
entryType := "NPC"
|
||||
if entry.IsPlayer {
|
||||
entryType = "Player"
|
||||
} else if entry.IsBot {
|
||||
entryType = "Bot"
|
||||
}
|
||||
hld.logger.LogInfo("Entity %d (%s)", entityID, entryType)
|
||||
}
|
||||
}
|
||||
|
||||
hld.logger.LogInfo("-------------------")
|
||||
}
|
504
internal/npc/ai/types.go
Normal file
504
internal/npc/ai/types.go
Normal file
@ -0,0 +1,504 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HateEntry represents a single hate entry in the hate list
|
||||
type HateEntry struct {
|
||||
EntityID int32 // ID of the hated entity
|
||||
HateValue int32 // Amount of hate (must be >= 1)
|
||||
LastUpdated int64 // Timestamp of last hate update
|
||||
}
|
||||
|
||||
// NewHateEntry creates a new hate entry
|
||||
func NewHateEntry(entityID, hateValue int32) *HateEntry {
|
||||
if hateValue < MinHateValue {
|
||||
hateValue = MinHateValue
|
||||
}
|
||||
|
||||
return &HateEntry{
|
||||
EntityID: entityID,
|
||||
HateValue: hateValue,
|
||||
LastUpdated: time.Now().UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
// HateList manages the hate list for an NPC brain
|
||||
type HateList struct {
|
||||
entries map[int32]*HateEntry // Map of entity ID to hate entry
|
||||
mutex sync.RWMutex // Thread safety
|
||||
}
|
||||
|
||||
// NewHateList creates a new hate list
|
||||
func NewHateList() *HateList {
|
||||
return &HateList{
|
||||
entries: make(map[int32]*HateEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// AddHate adds or updates hate for an entity
|
||||
func (hl *HateList) AddHate(entityID, hateValue int32) {
|
||||
hl.mutex.Lock()
|
||||
defer hl.mutex.Unlock()
|
||||
|
||||
if len(hl.entries) >= MaxHateListSize {
|
||||
// Remove oldest entry if at capacity
|
||||
hl.removeOldestEntry()
|
||||
}
|
||||
|
||||
if entry, exists := hl.entries[entityID]; exists {
|
||||
// Update existing entry
|
||||
entry.HateValue += hateValue
|
||||
if entry.HateValue < MinHateValue {
|
||||
entry.HateValue = MinHateValue
|
||||
}
|
||||
entry.LastUpdated = time.Now().UnixMilli()
|
||||
} else {
|
||||
// Create new entry
|
||||
if hateValue < MinHateValue {
|
||||
hateValue = MinHateValue
|
||||
}
|
||||
hl.entries[entityID] = NewHateEntry(entityID, hateValue)
|
||||
}
|
||||
}
|
||||
|
||||
// GetHate returns the hate value for an entity
|
||||
func (hl *HateList) GetHate(entityID int32) int32 {
|
||||
hl.mutex.RLock()
|
||||
defer hl.mutex.RUnlock()
|
||||
|
||||
if entry, exists := hl.entries[entityID]; exists {
|
||||
return entry.HateValue
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// RemoveHate removes hate for a specific entity
|
||||
func (hl *HateList) RemoveHate(entityID int32) {
|
||||
hl.mutex.Lock()
|
||||
defer hl.mutex.Unlock()
|
||||
|
||||
delete(hl.entries, entityID)
|
||||
}
|
||||
|
||||
// Clear removes all hate entries
|
||||
func (hl *HateList) Clear() {
|
||||
hl.mutex.Lock()
|
||||
defer hl.mutex.Unlock()
|
||||
|
||||
hl.entries = make(map[int32]*HateEntry)
|
||||
}
|
||||
|
||||
// GetMostHated returns the entity ID with the highest hate
|
||||
func (hl *HateList) GetMostHated() int32 {
|
||||
hl.mutex.RLock()
|
||||
defer hl.mutex.RUnlock()
|
||||
|
||||
var mostHated int32 = 0
|
||||
var highestHate int32 = 0
|
||||
|
||||
for entityID, entry := range hl.entries {
|
||||
if entry.HateValue > highestHate {
|
||||
highestHate = entry.HateValue
|
||||
mostHated = entityID
|
||||
}
|
||||
}
|
||||
|
||||
return mostHated
|
||||
}
|
||||
|
||||
// GetHatePercentage returns the percentage of hate for an entity
|
||||
func (hl *HateList) GetHatePercentage(entityID int32) int8 {
|
||||
hl.mutex.RLock()
|
||||
defer hl.mutex.RUnlock()
|
||||
|
||||
entry, exists := hl.entries[entityID]
|
||||
if !exists || entry.HateValue <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Calculate total hate
|
||||
var totalHate int32 = 0
|
||||
for _, e := range hl.entries {
|
||||
totalHate += e.HateValue
|
||||
}
|
||||
|
||||
if totalHate <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
percentage := float32(entry.HateValue) / float32(totalHate) * 100.0
|
||||
return int8(percentage)
|
||||
}
|
||||
|
||||
// GetAllEntries returns a copy of all hate entries
|
||||
func (hl *HateList) GetAllEntries() map[int32]*HateEntry {
|
||||
hl.mutex.RLock()
|
||||
defer hl.mutex.RUnlock()
|
||||
|
||||
result := make(map[int32]*HateEntry)
|
||||
for id, entry := range hl.entries {
|
||||
result[id] = &HateEntry{
|
||||
EntityID: entry.EntityID,
|
||||
HateValue: entry.HateValue,
|
||||
LastUpdated: entry.LastUpdated,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Size returns the number of entries in the hate list
|
||||
func (hl *HateList) Size() int {
|
||||
hl.mutex.RLock()
|
||||
defer hl.mutex.RUnlock()
|
||||
|
||||
return len(hl.entries)
|
||||
}
|
||||
|
||||
// removeOldestEntry removes the oldest hate entry (internal use only)
|
||||
func (hl *HateList) removeOldestEntry() {
|
||||
if len(hl.entries) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var oldestID int32
|
||||
var oldestTime int64 = time.Now().UnixMilli()
|
||||
|
||||
for id, entry := range hl.entries {
|
||||
if entry.LastUpdated < oldestTime {
|
||||
oldestTime = entry.LastUpdated
|
||||
oldestID = id
|
||||
}
|
||||
}
|
||||
|
||||
if oldestID != 0 {
|
||||
delete(hl.entries, oldestID)
|
||||
}
|
||||
}
|
||||
|
||||
// EncounterEntry represents a single encounter participant
|
||||
type EncounterEntry struct {
|
||||
EntityID int32 // ID of the entity
|
||||
CharacterID int32 // Character ID for players (0 for NPCs)
|
||||
AddedTime int64 // When entity was added to encounter
|
||||
IsPlayer bool // Whether this is a player entity
|
||||
IsBot bool // Whether this is a bot entity
|
||||
}
|
||||
|
||||
// NewEncounterEntry creates a new encounter entry
|
||||
func NewEncounterEntry(entityID, characterID int32, isPlayer, isBot bool) *EncounterEntry {
|
||||
return &EncounterEntry{
|
||||
EntityID: entityID,
|
||||
CharacterID: characterID,
|
||||
AddedTime: time.Now().UnixMilli(),
|
||||
IsPlayer: isPlayer,
|
||||
IsBot: isBot,
|
||||
}
|
||||
}
|
||||
|
||||
// EncounterList manages the encounter list for an NPC brain
|
||||
type EncounterList struct {
|
||||
entries map[int32]*EncounterEntry // Map of entity ID to encounter entry
|
||||
playerEntries map[int32]int32 // Map of character ID to entity ID
|
||||
playerInEncounter bool // Whether any player is in encounter
|
||||
mutex sync.RWMutex // Thread safety
|
||||
}
|
||||
|
||||
// NewEncounterList creates a new encounter list
|
||||
func NewEncounterList() *EncounterList {
|
||||
return &EncounterList{
|
||||
entries: make(map[int32]*EncounterEntry),
|
||||
playerEntries: make(map[int32]int32),
|
||||
playerInEncounter: false,
|
||||
}
|
||||
}
|
||||
|
||||
// AddEntity adds an entity to the encounter list
|
||||
func (el *EncounterList) AddEntity(entityID, characterID int32, isPlayer, isBot bool) bool {
|
||||
el.mutex.Lock()
|
||||
defer el.mutex.Unlock()
|
||||
|
||||
if len(el.entries) >= MaxEncounterSize {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if already in encounter
|
||||
if _, exists := el.entries[entityID]; exists {
|
||||
return false
|
||||
}
|
||||
|
||||
// Add entry
|
||||
entry := NewEncounterEntry(entityID, characterID, isPlayer, isBot)
|
||||
el.entries[entityID] = entry
|
||||
|
||||
// Track player entries separately
|
||||
if isPlayer && characterID > 0 {
|
||||
el.playerEntries[characterID] = entityID
|
||||
el.playerInEncounter = true
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// RemoveEntity removes an entity from the encounter list
|
||||
func (el *EncounterList) RemoveEntity(entityID int32) {
|
||||
el.mutex.Lock()
|
||||
defer el.mutex.Unlock()
|
||||
|
||||
if entry, exists := el.entries[entityID]; exists {
|
||||
// Remove from player entries if it's a player
|
||||
if entry.IsPlayer && entry.CharacterID > 0 {
|
||||
delete(el.playerEntries, entry.CharacterID)
|
||||
}
|
||||
|
||||
// Remove main entry
|
||||
delete(el.entries, entityID)
|
||||
|
||||
// Update player in encounter flag
|
||||
el.updatePlayerInEncounter()
|
||||
}
|
||||
}
|
||||
|
||||
// Clear removes all entities from the encounter list
|
||||
func (el *EncounterList) Clear() {
|
||||
el.mutex.Lock()
|
||||
defer el.mutex.Unlock()
|
||||
|
||||
el.entries = make(map[int32]*EncounterEntry)
|
||||
el.playerEntries = make(map[int32]int32)
|
||||
el.playerInEncounter = false
|
||||
}
|
||||
|
||||
// IsEntityInEncounter checks if an entity is in the encounter
|
||||
func (el *EncounterList) IsEntityInEncounter(entityID int32) bool {
|
||||
el.mutex.RLock()
|
||||
defer el.mutex.RUnlock()
|
||||
|
||||
_, exists := el.entries[entityID]
|
||||
return exists
|
||||
}
|
||||
|
||||
// IsPlayerInEncounter checks if a player (by character ID) is in the encounter
|
||||
func (el *EncounterList) IsPlayerInEncounter(characterID int32) bool {
|
||||
el.mutex.RLock()
|
||||
defer el.mutex.RUnlock()
|
||||
|
||||
_, exists := el.playerEntries[characterID]
|
||||
return exists
|
||||
}
|
||||
|
||||
// HasPlayerInEncounter returns whether any player is in the encounter
|
||||
func (el *EncounterList) HasPlayerInEncounter() bool {
|
||||
el.mutex.RLock()
|
||||
defer el.mutex.RUnlock()
|
||||
|
||||
return el.playerInEncounter
|
||||
}
|
||||
|
||||
// Size returns the number of entities in the encounter
|
||||
func (el *EncounterList) Size() int {
|
||||
el.mutex.RLock()
|
||||
defer el.mutex.RUnlock()
|
||||
|
||||
return len(el.entries)
|
||||
}
|
||||
|
||||
// CountPlayerBots returns the number of players and bots in encounter
|
||||
func (el *EncounterList) CountPlayerBots() int {
|
||||
el.mutex.RLock()
|
||||
defer el.mutex.RUnlock()
|
||||
|
||||
count := 0
|
||||
for _, entry := range el.entries {
|
||||
if entry.IsPlayer || entry.IsBot {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// GetAllEntityIDs returns a copy of all entity IDs in the encounter
|
||||
func (el *EncounterList) GetAllEntityIDs() []int32 {
|
||||
el.mutex.RLock()
|
||||
defer el.mutex.RUnlock()
|
||||
|
||||
result := make([]int32, 0, len(el.entries))
|
||||
for entityID := range el.entries {
|
||||
result = append(result, entityID)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAllEntries returns a copy of all encounter entries
|
||||
func (el *EncounterList) GetAllEntries() map[int32]*EncounterEntry {
|
||||
el.mutex.RLock()
|
||||
defer el.mutex.RUnlock()
|
||||
|
||||
result := make(map[int32]*EncounterEntry)
|
||||
for id, entry := range el.entries {
|
||||
result[id] = &EncounterEntry{
|
||||
EntityID: entry.EntityID,
|
||||
CharacterID: entry.CharacterID,
|
||||
AddedTime: entry.AddedTime,
|
||||
IsPlayer: entry.IsPlayer,
|
||||
IsBot: entry.IsBot,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// updatePlayerInEncounter updates the player in encounter flag (internal use only)
|
||||
func (el *EncounterList) updatePlayerInEncounter() {
|
||||
el.playerInEncounter = len(el.playerEntries) > 0
|
||||
}
|
||||
|
||||
// BrainState represents the current state of a brain
|
||||
type BrainState struct {
|
||||
State int32 // Current AI state
|
||||
LastThink int64 // Timestamp of last think cycle
|
||||
ThinkTick int32 // Time between think cycles in milliseconds
|
||||
SpellRecovery int64 // Timestamp when spell recovery completes
|
||||
IsActive bool // Whether the brain is active
|
||||
DebugLevel int8 // Debug output level
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewBrainState creates a new brain state
|
||||
func NewBrainState() *BrainState {
|
||||
return &BrainState{
|
||||
State: AIStateIdle,
|
||||
LastThink: time.Now().UnixMilli(),
|
||||
ThinkTick: DefaultThinkTick,
|
||||
SpellRecovery: 0,
|
||||
IsActive: true,
|
||||
DebugLevel: DebugLevelNone,
|
||||
}
|
||||
}
|
||||
|
||||
// GetState returns the current AI state
|
||||
func (bs *BrainState) GetState() int32 {
|
||||
bs.mutex.RLock()
|
||||
defer bs.mutex.RUnlock()
|
||||
return bs.State
|
||||
}
|
||||
|
||||
// SetState sets the current AI state
|
||||
func (bs *BrainState) SetState(state int32) {
|
||||
bs.mutex.Lock()
|
||||
defer bs.mutex.Unlock()
|
||||
bs.State = state
|
||||
}
|
||||
|
||||
// GetLastThink returns the timestamp of the last think cycle
|
||||
func (bs *BrainState) GetLastThink() int64 {
|
||||
bs.mutex.RLock()
|
||||
defer bs.mutex.RUnlock()
|
||||
return bs.LastThink
|
||||
}
|
||||
|
||||
// SetLastThink sets the timestamp of the last think cycle
|
||||
func (bs *BrainState) SetLastThink(timestamp int64) {
|
||||
bs.mutex.Lock()
|
||||
defer bs.mutex.Unlock()
|
||||
bs.LastThink = timestamp
|
||||
}
|
||||
|
||||
// GetThinkTick returns the think tick interval
|
||||
func (bs *BrainState) GetThinkTick() int32 {
|
||||
bs.mutex.RLock()
|
||||
defer bs.mutex.RUnlock()
|
||||
return bs.ThinkTick
|
||||
}
|
||||
|
||||
// SetThinkTick sets the think tick interval
|
||||
func (bs *BrainState) SetThinkTick(tick int32) {
|
||||
bs.mutex.Lock()
|
||||
defer bs.mutex.Unlock()
|
||||
|
||||
if tick < 1 {
|
||||
tick = 1
|
||||
} else if tick > MaxThinkTick {
|
||||
tick = MaxThinkTick
|
||||
}
|
||||
|
||||
bs.ThinkTick = tick
|
||||
}
|
||||
|
||||
// GetSpellRecovery returns the spell recovery timestamp
|
||||
func (bs *BrainState) GetSpellRecovery() int64 {
|
||||
bs.mutex.RLock()
|
||||
defer bs.mutex.RUnlock()
|
||||
return bs.SpellRecovery
|
||||
}
|
||||
|
||||
// SetSpellRecovery sets the spell recovery timestamp
|
||||
func (bs *BrainState) SetSpellRecovery(timestamp int64) {
|
||||
bs.mutex.Lock()
|
||||
defer bs.mutex.Unlock()
|
||||
bs.SpellRecovery = timestamp
|
||||
}
|
||||
|
||||
// HasRecovered checks if the brain has recovered from spell casting
|
||||
func (bs *BrainState) HasRecovered() bool {
|
||||
bs.mutex.RLock()
|
||||
defer bs.mutex.RUnlock()
|
||||
|
||||
currentTime := time.Now().UnixMilli()
|
||||
return bs.SpellRecovery <= currentTime
|
||||
}
|
||||
|
||||
// IsActive returns whether the brain is active
|
||||
func (bs *BrainState) IsActive() bool {
|
||||
bs.mutex.RLock()
|
||||
defer bs.mutex.RUnlock()
|
||||
return bs.IsActive
|
||||
}
|
||||
|
||||
// SetActive sets the brain's active state
|
||||
func (bs *BrainState) SetActive(active bool) {
|
||||
bs.mutex.Lock()
|
||||
defer bs.mutex.Unlock()
|
||||
bs.IsActive = active
|
||||
}
|
||||
|
||||
// GetDebugLevel returns the debug level
|
||||
func (bs *BrainState) GetDebugLevel() int8 {
|
||||
bs.mutex.RLock()
|
||||
defer bs.mutex.RUnlock()
|
||||
return bs.DebugLevel
|
||||
}
|
||||
|
||||
// SetDebugLevel sets the debug level
|
||||
func (bs *BrainState) SetDebugLevel(level int8) {
|
||||
bs.mutex.Lock()
|
||||
defer bs.mutex.Unlock()
|
||||
bs.DebugLevel = level
|
||||
}
|
||||
|
||||
// BrainStatistics contains brain performance statistics
|
||||
type BrainStatistics struct {
|
||||
ThinkCycles int64 `json:"think_cycles"`
|
||||
SpellsCast int64 `json:"spells_cast"`
|
||||
MeleeAttacks int64 `json:"melee_attacks"`
|
||||
HateEvents int64 `json:"hate_events"`
|
||||
EncounterEvents int64 `json:"encounter_events"`
|
||||
AverageThinkTime float64 `json:"average_think_time_ms"`
|
||||
LastThinkTime int64 `json:"last_think_time"`
|
||||
TotalActiveTime int64 `json:"total_active_time_ms"`
|
||||
}
|
||||
|
||||
// NewBrainStatistics creates new brain statistics
|
||||
func NewBrainStatistics() *BrainStatistics {
|
||||
return &BrainStatistics{
|
||||
ThinkCycles: 0,
|
||||
SpellsCast: 0,
|
||||
MeleeAttacks: 0,
|
||||
HateEvents: 0,
|
||||
EncounterEvents: 0,
|
||||
AverageThinkTime: 0.0,
|
||||
LastThinkTime: time.Now().UnixMilli(),
|
||||
TotalActiveTime: 0,
|
||||
}
|
||||
}
|
324
internal/npc/ai/variants.go
Normal file
324
internal/npc/ai/variants.go
Normal file
@ -0,0 +1,324 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CombatPetBrain extends the base brain for combat pets
|
||||
type CombatPetBrain struct {
|
||||
*BaseBrain
|
||||
}
|
||||
|
||||
// NewCombatPetBrain creates a new combat pet brain
|
||||
func NewCombatPetBrain(npc NPC, logger Logger) *CombatPetBrain {
|
||||
brain := &CombatPetBrain{
|
||||
BaseBrain: NewBaseBrain(npc, logger),
|
||||
}
|
||||
brain.brainType = BrainTypeCombatPet
|
||||
return brain
|
||||
}
|
||||
|
||||
// Think implements pet-specific AI logic
|
||||
func (cpb *CombatPetBrain) Think() error {
|
||||
// Call parent Think() for default combat behavior
|
||||
if err := cpb.BaseBrain.Think(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Additional pet-specific logic
|
||||
if cpb.npc.GetInCombat() || !cpb.npc.IsPet() || cpb.npc.IsMezzedOrStunned() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if cpb.logger != nil && cpb.state.GetDebugLevel() >= DebugLevelDetailed {
|
||||
cpb.logger.LogDebug("Combat pet AI for %s", cpb.npc.GetName())
|
||||
}
|
||||
|
||||
// Check if owner has stay command set
|
||||
owner := cpb.npc.GetOwner()
|
||||
if owner != nil && owner.IsPlayer() {
|
||||
// TODO: Check player's pet movement setting
|
||||
// if player.GetInfoStruct().GetPetMovement() == PetMovementStay {
|
||||
// return nil
|
||||
// }
|
||||
}
|
||||
|
||||
// Follow owner
|
||||
if owner != nil {
|
||||
cpb.npc.SetTarget(owner)
|
||||
distance := cpb.npc.GetDistance(owner)
|
||||
|
||||
maxRange := cpb.getMaxCombatRange()
|
||||
if distance > maxRange {
|
||||
cpb.MoveCloser(owner)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NonCombatPetBrain handles non-combat pets (cosmetic pets)
|
||||
type NonCombatPetBrain struct {
|
||||
*BaseBrain
|
||||
}
|
||||
|
||||
// NewNonCombatPetBrain creates a new non-combat pet brain
|
||||
func NewNonCombatPetBrain(npc NPC, logger Logger) *NonCombatPetBrain {
|
||||
brain := &NonCombatPetBrain{
|
||||
BaseBrain: NewBaseBrain(npc, logger),
|
||||
}
|
||||
brain.brainType = BrainTypeNonCombatPet
|
||||
return brain
|
||||
}
|
||||
|
||||
// Think implements non-combat pet AI (just following)
|
||||
func (ncpb *NonCombatPetBrain) Think() error {
|
||||
// Non-combat pets don't do combat AI
|
||||
if !ncpb.npc.IsPet() || ncpb.npc.IsMezzedOrStunned() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if ncpb.logger != nil && ncpb.state.GetDebugLevel() >= DebugLevelDetailed {
|
||||
ncpb.logger.LogDebug("Non-combat pet AI for %s", ncpb.npc.GetName())
|
||||
}
|
||||
|
||||
// Just follow owner
|
||||
owner := ncpb.npc.GetOwner()
|
||||
if owner != nil {
|
||||
ncpb.npc.SetTarget(owner)
|
||||
distance := ncpb.npc.GetDistance(owner)
|
||||
|
||||
maxRange := ncpb.getMaxCombatRange()
|
||||
if distance > maxRange {
|
||||
ncpb.MoveCloser(owner)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BlankBrain provides a minimal AI that does nothing
|
||||
type BlankBrain struct {
|
||||
*BaseBrain
|
||||
}
|
||||
|
||||
// NewBlankBrain creates a new blank brain
|
||||
func NewBlankBrain(npc NPC, logger Logger) *BlankBrain {
|
||||
brain := &BlankBrain{
|
||||
BaseBrain: NewBaseBrain(npc, logger),
|
||||
}
|
||||
brain.brainType = BrainTypeBlank
|
||||
brain.SetThinkTick(BlankBrainTick) // Very slow tick
|
||||
return brain
|
||||
}
|
||||
|
||||
// Think does nothing for blank brains
|
||||
func (bb *BlankBrain) Think() error {
|
||||
// Blank brain does nothing
|
||||
return nil
|
||||
}
|
||||
|
||||
// LuaBrain allows AI to be controlled by Lua scripts
|
||||
type LuaBrain struct {
|
||||
*BaseBrain
|
||||
scriptInterface LuaInterface
|
||||
}
|
||||
|
||||
// NewLuaBrain creates a new Lua-controlled brain
|
||||
func NewLuaBrain(npc NPC, logger Logger, luaInterface LuaInterface) *LuaBrain {
|
||||
brain := &LuaBrain{
|
||||
BaseBrain: NewBaseBrain(npc, logger),
|
||||
scriptInterface: luaInterface,
|
||||
}
|
||||
brain.brainType = BrainTypeLua
|
||||
return brain
|
||||
}
|
||||
|
||||
// Think calls the Lua script's Think function
|
||||
func (lb *LuaBrain) Think() error {
|
||||
if lb.scriptInterface == nil {
|
||||
return fmt.Errorf("no Lua interface available")
|
||||
}
|
||||
|
||||
if lb.npc == nil {
|
||||
return fmt.Errorf("brain has no body")
|
||||
}
|
||||
|
||||
script := lb.npc.GetSpawnScript()
|
||||
if script == "" {
|
||||
if lb.logger != nil {
|
||||
lb.logger.LogError("Lua brain set on spawn without script")
|
||||
}
|
||||
return fmt.Errorf("no spawn script available")
|
||||
}
|
||||
|
||||
// Call the Lua Think function
|
||||
target := lb.npc.GetTarget()
|
||||
err := lb.scriptInterface.RunSpawnScript(script, "Think", lb.npc, target)
|
||||
if err != nil {
|
||||
if lb.logger != nil {
|
||||
lb.logger.LogError("Lua script Think function failed: %v", err)
|
||||
}
|
||||
return fmt.Errorf("Lua Think function failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DumbFirePetBrain handles dumbfire pets (temporary combat pets)
|
||||
type DumbFirePetBrain struct {
|
||||
*BaseBrain
|
||||
expireTime int64
|
||||
}
|
||||
|
||||
// NewDumbFirePetBrain creates a new dumbfire pet brain
|
||||
func NewDumbFirePetBrain(npc NPC, target Entity, expireTimeMS int32, logger Logger) *DumbFirePetBrain {
|
||||
brain := &DumbFirePetBrain{
|
||||
BaseBrain: NewBaseBrain(npc, logger),
|
||||
expireTime: time.Now().UnixMilli() + int64(expireTimeMS),
|
||||
}
|
||||
brain.brainType = BrainTypeDumbFire
|
||||
|
||||
// Add maximum hate for the target
|
||||
if target != nil {
|
||||
brain.AddHate(target.GetID(), MaxHateValue)
|
||||
}
|
||||
|
||||
return brain
|
||||
}
|
||||
|
||||
// AddHate only allows hate for the initial target
|
||||
func (dfpb *DumbFirePetBrain) AddHate(entityID int32, hate int32) {
|
||||
// Only add hate if we don't already have a target
|
||||
if dfpb.GetMostHated() == 0 {
|
||||
dfpb.BaseBrain.AddHate(entityID, hate)
|
||||
}
|
||||
}
|
||||
|
||||
// Think implements dumbfire pet AI
|
||||
func (dfpb *DumbFirePetBrain) Think() error {
|
||||
// Check if expired
|
||||
if time.Now().UnixMilli() > dfpb.expireTime {
|
||||
if dfpb.npc != nil && dfpb.npc.GetHP() > 0 {
|
||||
if dfpb.logger != nil {
|
||||
dfpb.logger.LogDebug("Dumbfire pet %s expired", dfpb.npc.GetName())
|
||||
}
|
||||
dfpb.npc.KillSpawn(dfpb.npc)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get target
|
||||
targetID := dfpb.GetMostHated()
|
||||
if targetID == 0 {
|
||||
// No target, kill self
|
||||
if dfpb.npc != nil && dfpb.npc.GetHP() > 0 {
|
||||
if dfpb.logger != nil {
|
||||
dfpb.logger.LogDebug("Dumbfire pet %s has no target", dfpb.npc.GetName())
|
||||
}
|
||||
dfpb.npc.KillSpawn(dfpb.npc)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
target := dfpb.getEntityByID(targetID)
|
||||
if target == nil {
|
||||
// Target no longer exists, kill self
|
||||
if dfpb.npc != nil && dfpb.npc.GetHP() > 0 {
|
||||
dfpb.npc.KillSpawn(dfpb.npc)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip if mezzed or stunned
|
||||
if dfpb.npc.IsMezzedOrStunned() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set target if not already set
|
||||
if dfpb.npc.GetTarget() != target {
|
||||
dfpb.npc.SetTarget(target)
|
||||
dfpb.npc.FaceTarget(target, false)
|
||||
}
|
||||
|
||||
// Enter combat if not already
|
||||
if !dfpb.npc.GetInCombat() {
|
||||
dfpb.npc.CalculateRunningLocation(true)
|
||||
dfpb.npc.InCombat(true)
|
||||
}
|
||||
|
||||
distance := dfpb.npc.GetDistance(target)
|
||||
|
||||
// Try to cast spells if we have line of sight
|
||||
if dfpb.npc.CheckLoS(target) && !dfpb.npc.IsCasting() &&
|
||||
(!dfpb.HasRecovered() || !dfpb.ProcessSpell(target, distance)) {
|
||||
|
||||
if dfpb.logger != nil && dfpb.state.GetDebugLevel() >= DebugLevelDetailed {
|
||||
dfpb.logger.LogDebug("Dumbfire pet %s attempting melee on %s",
|
||||
dfpb.npc.GetName(), target.GetName())
|
||||
}
|
||||
|
||||
dfpb.npc.FaceTarget(target, false)
|
||||
dfpb.ProcessMelee(target, distance)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetExpireTime returns when this dumbfire pet will expire
|
||||
func (dfpb *DumbFirePetBrain) GetExpireTime() int64 {
|
||||
return dfpb.expireTime
|
||||
}
|
||||
|
||||
// SetExpireTime sets when this dumbfire pet will expire
|
||||
func (dfpb *DumbFirePetBrain) SetExpireTime(expireTime int64) {
|
||||
dfpb.expireTime = expireTime
|
||||
}
|
||||
|
||||
// IsExpired checks if the dumbfire pet has expired
|
||||
func (dfpb *DumbFirePetBrain) IsExpired() bool {
|
||||
return time.Now().UnixMilli() > dfpb.expireTime
|
||||
}
|
||||
|
||||
// ExtendExpireTime extends the expire time by the given duration
|
||||
func (dfpb *DumbFirePetBrain) ExtendExpireTime(durationMS int32) {
|
||||
dfpb.expireTime += int64(durationMS)
|
||||
}
|
||||
|
||||
// Brain factory functions
|
||||
|
||||
// CreateBrain creates the appropriate brain type for an NPC
|
||||
func CreateBrain(npc NPC, brainType int8, logger Logger, options ...interface{}) Brain {
|
||||
switch brainType {
|
||||
case BrainTypeCombatPet:
|
||||
return NewCombatPetBrain(npc, logger)
|
||||
|
||||
case BrainTypeNonCombatPet:
|
||||
return NewNonCombatPetBrain(npc, logger)
|
||||
|
||||
case BrainTypeBlank:
|
||||
return NewBlankBrain(npc, logger)
|
||||
|
||||
case BrainTypeLua:
|
||||
if len(options) > 0 {
|
||||
if luaInterface, ok := options[0].(LuaInterface); ok {
|
||||
return NewLuaBrain(npc, logger, luaInterface)
|
||||
}
|
||||
}
|
||||
return NewBaseBrain(npc, logger) // Fallback to default
|
||||
|
||||
case BrainTypeDumbFire:
|
||||
if len(options) >= 2 {
|
||||
if target, ok := options[0].(Entity); ok {
|
||||
if expireTime, ok := options[1].(int32); ok {
|
||||
return NewDumbFirePetBrain(npc, target, expireTime, logger)
|
||||
}
|
||||
}
|
||||
}
|
||||
return NewBaseBrain(npc, logger) // Fallback to default
|
||||
|
||||
default:
|
||||
return NewBaseBrain(npc, logger)
|
||||
}
|
||||
}
|
93
internal/npc/constants.go
Normal file
93
internal/npc/constants.go
Normal file
@ -0,0 +1,93 @@
|
||||
package npc
|
||||
|
||||
// AI Strategy constants
|
||||
const (
|
||||
AIStrategyBalanced int8 = 1
|
||||
AIStrategyOffensive int8 = 2
|
||||
AIStrategyDefensive int8 = 3
|
||||
)
|
||||
|
||||
// Randomize Appearances constants
|
||||
const (
|
||||
RandomizeGender int32 = 1
|
||||
RandomizeRace int32 = 2
|
||||
RandomizeModelType int32 = 4
|
||||
RandomizeFacialHairType int32 = 8
|
||||
RandomizeHairType int32 = 16
|
||||
RandomizeWingType int32 = 64
|
||||
RandomizeCheekType int32 = 128
|
||||
RandomizeChinType int32 = 256
|
||||
RandomizeEarType int32 = 512
|
||||
RandomizeEyeBrowType int32 = 1024
|
||||
RandomizeEyeType int32 = 2048
|
||||
RandomizeLipType int32 = 4096
|
||||
RandomizeNoseType int32 = 8192
|
||||
RandomizeEyeColor int32 = 16384
|
||||
RandomizeHairColor1 int32 = 32768
|
||||
RandomizeHairColor2 int32 = 65536
|
||||
RandomizeHairHighlight int32 = 131072
|
||||
RandomizeHairFaceColor int32 = 262144
|
||||
RandomizeHairFaceHigh int32 = 524288
|
||||
RandomizeHairTypeColor int32 = 1048576
|
||||
RandomizeHairTypeHigh int32 = 2097152
|
||||
RandomizeSkinColor int32 = 4194304
|
||||
RandomizeWingColor1 int32 = 8388608
|
||||
RandomizeWingColor2 int32 = 16777216
|
||||
RandomizeAll int32 = 33554431
|
||||
)
|
||||
|
||||
// Pet Type constants
|
||||
const (
|
||||
PetTypeCombat int8 = 1
|
||||
PetTypeCharmed int8 = 2
|
||||
PetTypeDeity int8 = 3
|
||||
PetTypeCosmetic int8 = 4
|
||||
PetTypeDumbfire int8 = 5
|
||||
)
|
||||
|
||||
// Cast Type constants
|
||||
const (
|
||||
CastOnSpawn int8 = 0
|
||||
CastOnAggro int8 = 1
|
||||
MaxCastTypes int8 = 2
|
||||
)
|
||||
|
||||
// Default values
|
||||
const (
|
||||
DefaultCastPercentage int8 = 25
|
||||
DefaultAggroRadius float32 = 10.0
|
||||
DefaultRunbackSpeed float32 = 2.0
|
||||
MaxSkillBonuses int = 100
|
||||
MaxNPCSpells int = 50
|
||||
MaxPauseTime int32 = 300000 // 5 minutes max pause
|
||||
)
|
||||
|
||||
// NPC validation constants
|
||||
const (
|
||||
MinNPCLevel int8 = 1
|
||||
MaxNPCLevel int8 = 100
|
||||
MaxNPCNameLen int = 64
|
||||
MinAppearanceID int32 = 0
|
||||
MaxAppearanceID int32 = 999999
|
||||
)
|
||||
|
||||
// Combat constants
|
||||
const (
|
||||
RunbackDistanceThreshold float32 = 5.0
|
||||
HPRatioMin int8 = -100
|
||||
HPRatioMax int8 = 100
|
||||
DefaultMaxPetLevel int8 = 20
|
||||
)
|
||||
|
||||
// Color randomization constants
|
||||
const (
|
||||
ColorRandomMin int8 = 0
|
||||
ColorRandomMax int8 = 255
|
||||
ColorVariation int8 = 30
|
||||
)
|
||||
|
||||
// Movement constants
|
||||
const (
|
||||
DefaultPauseCheckMS int32 = 100
|
||||
RunbackCheckMS int32 = 250
|
||||
)
|
570
internal/npc/interfaces.go
Normal file
570
internal/npc/interfaces.go
Normal file
@ -0,0 +1,570 @@
|
||||
package npc
|
||||
|
||||
// Database interface for NPC persistence
|
||||
type Database interface {
|
||||
LoadAllNPCs() ([]*NPC, error)
|
||||
SaveNPC(npc *NPC) error
|
||||
DeleteNPC(npcID int32) error
|
||||
LoadNPCSpells(npcID int32) ([]*NPCSpell, error)
|
||||
SaveNPCSpells(npcID int32, spells []*NPCSpell) error
|
||||
LoadNPCSkills(npcID int32) (map[string]*Skill, error)
|
||||
SaveNPCSkills(npcID int32, skills map[string]*Skill) error
|
||||
}
|
||||
|
||||
// Logger interface for NPC logging
|
||||
type Logger interface {
|
||||
LogInfo(message string, args ...interface{})
|
||||
LogError(message string, args ...interface{})
|
||||
LogDebug(message string, args ...interface{})
|
||||
LogWarning(message string, args ...interface{})
|
||||
}
|
||||
|
||||
// Client interface for NPC-related client operations
|
||||
type Client interface {
|
||||
GetPlayer() Player
|
||||
GetVersion() int16
|
||||
SendNPCUpdate(npcData []byte) error
|
||||
SendCombatUpdate(combatData []byte) error
|
||||
SendSpellCast(spellData []byte) error
|
||||
}
|
||||
|
||||
// Player interface for NPC-related player operations
|
||||
type Player interface {
|
||||
GetCharacterID() int32
|
||||
GetName() string
|
||||
GetLevel() int8
|
||||
GetZoneID() int32
|
||||
GetX() float32
|
||||
GetY() float32
|
||||
GetZ() float32
|
||||
IsInCombat() bool
|
||||
GetTarget() *NPC
|
||||
SendMessage(message string)
|
||||
}
|
||||
|
||||
// Zone interface for NPC zone operations
|
||||
type Zone interface {
|
||||
GetZoneID() int32
|
||||
GetNPCs() []*NPC
|
||||
AddNPC(npc *NPC) error
|
||||
RemoveNPC(npcID int32) error
|
||||
GetPlayersInRange(x, y, z, radius float32) []Player
|
||||
ProcessEntityCommand(command string, client Client, target *NPC) error
|
||||
CallSpawnScript(npc *NPC, scriptType string, args ...interface{}) error
|
||||
}
|
||||
|
||||
// SpellManager interface for spell system integration
|
||||
type SpellManager interface {
|
||||
GetSpell(spellID int32, tier int8) Spell
|
||||
CastSpell(caster *NPC, target interface{}, spell Spell) error
|
||||
GetSpellEffect(entity interface{}, spellID int32) SpellEffect
|
||||
ProcessSpell(spell Spell, caster *NPC, target interface{}) error
|
||||
}
|
||||
|
||||
// Spell interface for spell data
|
||||
type Spell interface {
|
||||
GetSpellID() int32
|
||||
GetName() string
|
||||
GetTier() int8
|
||||
GetRange() float32
|
||||
GetMinRange() float32
|
||||
GetPowerRequired() int32
|
||||
IsFriendlySpell() bool
|
||||
IsToggleSpell() bool
|
||||
GetCastTime() int32
|
||||
GetRecastTime() int32
|
||||
}
|
||||
|
||||
// SpellEffect interface for active spell effects
|
||||
type SpellEffect interface {
|
||||
GetSpellID() int32
|
||||
GetTier() int8
|
||||
GetDuration() int32
|
||||
GetRemainingTime() int32
|
||||
IsExpired() bool
|
||||
}
|
||||
|
||||
// SkillManager interface for skill system integration
|
||||
type SkillManager interface {
|
||||
GetSkill(skillID int32) MasterSkill
|
||||
GetSkillByName(name string) MasterSkill
|
||||
ApplySkillBonus(entity interface{}, skillID int32, bonus float32) error
|
||||
RemoveSkillBonus(entity interface{}, skillID int32, bonus float32) error
|
||||
}
|
||||
|
||||
// MasterSkill interface for skill definitions
|
||||
type MasterSkill interface {
|
||||
GetSkillID() int32
|
||||
GetName() string
|
||||
GetDescription() string
|
||||
GetMaxValue() int16
|
||||
}
|
||||
|
||||
// AppearanceManager interface for appearance system integration
|
||||
type AppearanceManager interface {
|
||||
GetAppearance(appearanceID int32) Appearance
|
||||
GetAppearancesByName(name string) []Appearance
|
||||
RandomizeAppearance(npc *NPC, flags int32) error
|
||||
}
|
||||
|
||||
// Appearance interface for appearance data
|
||||
type Appearance interface {
|
||||
GetAppearanceID() int32
|
||||
GetName() string
|
||||
GetModelType() int16
|
||||
GetRace() int16
|
||||
GetGender() int8
|
||||
}
|
||||
|
||||
// MovementManager interface for movement system integration
|
||||
type MovementManager interface {
|
||||
StartMovement(npc *NPC, x, y, z float32) error
|
||||
StopMovement(npc *NPC) error
|
||||
SetSpeed(npc *NPC, speed float32) error
|
||||
NavigateToLocation(npc *NPC, x, y, z float32) error
|
||||
IsMoving(npc *NPC) bool
|
||||
}
|
||||
|
||||
// CombatManager interface for combat system integration
|
||||
type CombatManager interface {
|
||||
StartCombat(npc *NPC, target interface{}) error
|
||||
EndCombat(npc *NPC) error
|
||||
ProcessCombatRound(npc *NPC) error
|
||||
CalculateDamage(attacker *NPC, target interface{}) int32
|
||||
ApplyDamage(target interface{}, damage int32) error
|
||||
}
|
||||
|
||||
// NPCAware interface for entities that can interact with NPCs
|
||||
type NPCAware interface {
|
||||
GetNPC() *NPC
|
||||
IsNPC() bool
|
||||
HandleNPCInteraction(npc *NPC, interactionType string) error
|
||||
ReceiveNPCCommand(npc *NPC, command string) error
|
||||
}
|
||||
|
||||
// EntityAdapter provides NPC functionality for entities
|
||||
type EntityAdapter struct {
|
||||
npc *NPC
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// NewEntityAdapter creates a new entity adapter
|
||||
func NewEntityAdapter(npc *NPC, logger Logger) *EntityAdapter {
|
||||
return &EntityAdapter{
|
||||
npc: npc,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetNPC returns the associated NPC
|
||||
func (ea *EntityAdapter) GetNPC() *NPC {
|
||||
return ea.npc
|
||||
}
|
||||
|
||||
// IsNPC always returns true for entity adapters
|
||||
func (ea *EntityAdapter) IsNPC() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// HandleNPCInteraction processes interactions with other NPCs
|
||||
func (ea *EntityAdapter) HandleNPCInteraction(otherNPC *NPC, interactionType string) error {
|
||||
if ea.npc == nil || otherNPC == nil {
|
||||
return fmt.Errorf("invalid NPC for interaction")
|
||||
}
|
||||
|
||||
// Handle different interaction types
|
||||
switch interactionType {
|
||||
case "aggro":
|
||||
return ea.handleAggroInteraction(otherNPC)
|
||||
case "assist":
|
||||
return ea.handleAssistInteraction(otherNPC)
|
||||
case "trade":
|
||||
return ea.handleTradeInteraction(otherNPC)
|
||||
default:
|
||||
if ea.logger != nil {
|
||||
ea.logger.LogWarning("Unknown NPC interaction type: %s", interactionType)
|
||||
}
|
||||
return fmt.Errorf("unknown interaction type: %s", interactionType)
|
||||
}
|
||||
}
|
||||
|
||||
// ReceiveNPCCommand processes commands from other NPCs
|
||||
func (ea *EntityAdapter) ReceiveNPCCommand(otherNPC *NPC, command string) error {
|
||||
if ea.npc == nil || otherNPC == nil {
|
||||
return fmt.Errorf("invalid NPC for command")
|
||||
}
|
||||
|
||||
// Process the command
|
||||
switch command {
|
||||
case "follow":
|
||||
return ea.handleFollowCommand(otherNPC)
|
||||
case "attack":
|
||||
return ea.handleAttackCommand(otherNPC)
|
||||
case "retreat":
|
||||
return ea.handleRetreatCommand(otherNPC)
|
||||
default:
|
||||
if ea.logger != nil {
|
||||
ea.logger.LogWarning("Unknown NPC command: %s", command)
|
||||
}
|
||||
return fmt.Errorf("unknown command: %s", command)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAggroInteraction processes aggro interactions
|
||||
func (ea *EntityAdapter) handleAggroInteraction(otherNPC *NPC) error {
|
||||
// TODO: Implement aggro logic between NPCs
|
||||
if ea.logger != nil {
|
||||
ea.logger.LogDebug("NPC %d received aggro from NPC %d",
|
||||
ea.npc.GetNPCID(), otherNPC.GetNPCID())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleAssistInteraction processes assist interactions
|
||||
func (ea *EntityAdapter) handleAssistInteraction(otherNPC *NPC) error {
|
||||
// TODO: Implement assist logic between NPCs
|
||||
if ea.logger != nil {
|
||||
ea.logger.LogDebug("NPC %d received assist request from NPC %d",
|
||||
ea.npc.GetNPCID(), otherNPC.GetNPCID())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleTradeInteraction processes trade interactions
|
||||
func (ea *EntityAdapter) handleTradeInteraction(otherNPC *NPC) error {
|
||||
// TODO: Implement trade logic between NPCs
|
||||
if ea.logger != nil {
|
||||
ea.logger.LogDebug("NPC %d received trade request from NPC %d",
|
||||
ea.npc.GetNPCID(), otherNPC.GetNPCID())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleFollowCommand processes follow commands
|
||||
func (ea *EntityAdapter) handleFollowCommand(otherNPC *NPC) error {
|
||||
// TODO: Implement follow logic
|
||||
if ea.logger != nil {
|
||||
ea.logger.LogDebug("NPC %d received follow command from NPC %d",
|
||||
ea.npc.GetNPCID(), otherNPC.GetNPCID())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleAttackCommand processes attack commands
|
||||
func (ea *EntityAdapter) handleAttackCommand(otherNPC *NPC) error {
|
||||
// TODO: Implement attack logic
|
||||
if ea.logger != nil {
|
||||
ea.logger.LogDebug("NPC %d received attack command from NPC %d",
|
||||
ea.npc.GetNPCID(), otherNPC.GetNPCID())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleRetreatCommand processes retreat commands
|
||||
func (ea *EntityAdapter) handleRetreatCommand(otherNPC *NPC) error {
|
||||
// TODO: Implement retreat logic
|
||||
if ea.logger != nil {
|
||||
ea.logger.LogDebug("NPC %d received retreat command from NPC %d",
|
||||
ea.npc.GetNPCID(), otherNPC.GetNPCID())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SpellCasterAdapter provides spell casting functionality for NPCs
|
||||
type SpellCasterAdapter struct {
|
||||
npc *NPC
|
||||
spellManager SpellManager
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// NewSpellCasterAdapter creates a new spell caster adapter
|
||||
func NewSpellCasterAdapter(npc *NPC, spellManager SpellManager, logger Logger) *SpellCasterAdapter {
|
||||
return &SpellCasterAdapter{
|
||||
npc: npc,
|
||||
spellManager: spellManager,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetNextSpell selects the next spell to cast based on AI strategy
|
||||
func (sca *SpellCasterAdapter) GetNextSpell(target interface{}, distance float32) Spell {
|
||||
if sca.npc == nil || sca.spellManager == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check cast-on-aggro spells first
|
||||
if !sca.npc.castOnAggroCompleted {
|
||||
spell := sca.getNextCastOnAggroSpell(target)
|
||||
if spell != nil {
|
||||
return spell
|
||||
}
|
||||
sca.npc.castOnAggroCompleted = true
|
||||
}
|
||||
|
||||
// Get spells based on AI strategy
|
||||
strategy := sca.npc.GetAIStrategy()
|
||||
return sca.getNextSpellByStrategy(target, distance, strategy)
|
||||
}
|
||||
|
||||
// GetNextBuffSpell selects the next buff spell to cast
|
||||
func (sca *SpellCasterAdapter) GetNextBuffSpell(target interface{}) Spell {
|
||||
if sca.npc == nil || sca.spellManager == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check cast-on-spawn spells first
|
||||
castOnSpells := sca.npc.castOnSpells[CastOnSpawn]
|
||||
for _, npcSpell := range castOnSpells {
|
||||
spell := sca.spellManager.GetSpell(npcSpell.GetSpellID(), npcSpell.GetTier())
|
||||
if spell != nil {
|
||||
// Check if target already has this effect
|
||||
if effect := sca.spellManager.GetSpellEffect(target, spell.GetSpellID()); effect != nil {
|
||||
if effect.GetTier() < spell.GetTier() {
|
||||
return spell // Upgrade existing effect
|
||||
}
|
||||
} else {
|
||||
return spell // New effect
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check regular spells for buffs
|
||||
for _, npcSpell := range sca.npc.spells {
|
||||
spell := sca.spellManager.GetSpell(npcSpell.GetSpellID(), npcSpell.GetTier())
|
||||
if spell != nil && spell.IsFriendlySpell() && spell.IsToggleSpell() {
|
||||
// Check if target already has this effect
|
||||
if effect := sca.spellManager.GetSpellEffect(target, spell.GetSpellID()); effect != nil {
|
||||
if effect.GetTier() < spell.GetTier() {
|
||||
return spell // Upgrade existing effect
|
||||
}
|
||||
} else {
|
||||
return spell // New effect
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CastSpell attempts to cast a spell
|
||||
func (sca *SpellCasterAdapter) CastSpell(target interface{}, spell Spell) error {
|
||||
if sca.npc == nil || sca.spellManager == nil || spell == nil {
|
||||
return fmt.Errorf("invalid parameters for spell casting")
|
||||
}
|
||||
|
||||
// Check casting conditions
|
||||
if err := sca.checkCastingConditions(spell); err != nil {
|
||||
return fmt.Errorf("casting conditions not met: %w", err)
|
||||
}
|
||||
|
||||
// Cast the spell
|
||||
if err := sca.spellManager.CastSpell(sca.npc, target, spell); err != nil {
|
||||
return fmt.Errorf("failed to cast spell: %w", err)
|
||||
}
|
||||
|
||||
if sca.logger != nil {
|
||||
sca.logger.LogDebug("NPC %d cast spell %s (%d)",
|
||||
sca.npc.GetNPCID(), spell.GetName(), spell.GetSpellID())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getNextCastOnAggroSpell selects cast-on-aggro spells
|
||||
func (sca *SpellCasterAdapter) getNextCastOnAggroSpell(target interface{}) Spell {
|
||||
castOnSpells := sca.npc.castOnSpells[CastOnAggro]
|
||||
for _, npcSpell := range castOnSpells {
|
||||
spell := sca.spellManager.GetSpell(npcSpell.GetSpellID(), npcSpell.GetTier())
|
||||
if spell != nil {
|
||||
// Check if target doesn't already have this effect
|
||||
if effect := sca.spellManager.GetSpellEffect(target, spell.GetSpellID()); effect == nil {
|
||||
return spell
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getNextSpellByStrategy selects spells based on AI strategy
|
||||
func (sca *SpellCasterAdapter) getNextSpellByStrategy(target interface{}, distance float32, strategy int8) Spell {
|
||||
// TODO: Implement more sophisticated spell selection based on strategy
|
||||
|
||||
for _, npcSpell := range sca.npc.spells {
|
||||
// Check HP ratio requirements
|
||||
if npcSpell.GetRequiredHPRatio() != 0 {
|
||||
// TODO: Implement HP ratio checking
|
||||
}
|
||||
|
||||
spell := sca.spellManager.GetSpell(npcSpell.GetSpellID(), npcSpell.GetTier())
|
||||
if spell == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check strategy compatibility
|
||||
if strategy == AIStrategyOffensive && spell.IsFriendlySpell() {
|
||||
continue
|
||||
}
|
||||
if strategy == AIStrategyDefensive && !spell.IsFriendlySpell() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check range and power requirements
|
||||
if distance <= spell.GetRange() && distance >= spell.GetMinRange() {
|
||||
// TODO: Check power requirements
|
||||
return spell
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkCastingConditions validates spell casting conditions
|
||||
func (sca *SpellCasterAdapter) checkCastingConditions(spell Spell) error {
|
||||
if sca.npc.Entity == nil {
|
||||
return fmt.Errorf("NPC entity is nil")
|
||||
}
|
||||
|
||||
// TODO: Implement power checking, cooldown checking, etc.
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CombatAdapter provides combat functionality for NPCs
|
||||
type CombatAdapter struct {
|
||||
npc *NPC
|
||||
combatManager CombatManager
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// NewCombatAdapter creates a new combat adapter
|
||||
func NewCombatAdapter(npc *NPC, combatManager CombatManager, logger Logger) *CombatAdapter {
|
||||
return &CombatAdapter{
|
||||
npc: npc,
|
||||
combatManager: combatManager,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// EnterCombat handles entering combat state
|
||||
func (ca *CombatAdapter) EnterCombat(target interface{}) error {
|
||||
if ca.npc == nil {
|
||||
return fmt.Errorf("NPC is nil")
|
||||
}
|
||||
|
||||
// Start combat through combat manager
|
||||
if ca.combatManager != nil {
|
||||
if err := ca.combatManager.StartCombat(ca.npc, target); err != nil {
|
||||
return fmt.Errorf("failed to start combat: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update NPC state
|
||||
ca.npc.InCombat(true)
|
||||
|
||||
if ca.logger != nil {
|
||||
ca.logger.LogDebug("NPC %d entered combat", ca.npc.GetNPCID())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExitCombat handles exiting combat state
|
||||
func (ca *CombatAdapter) ExitCombat() error {
|
||||
if ca.npc == nil {
|
||||
return fmt.Errorf("NPC is nil")
|
||||
}
|
||||
|
||||
// End combat through combat manager
|
||||
if ca.combatManager != nil {
|
||||
if err := ca.combatManager.EndCombat(ca.npc); err != nil {
|
||||
return fmt.Errorf("failed to end combat: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update NPC state
|
||||
ca.npc.InCombat(false)
|
||||
|
||||
if ca.logger != nil {
|
||||
ca.logger.LogDebug("NPC %d exited combat", ca.npc.GetNPCID())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessCombat handles combat processing
|
||||
func (ca *CombatAdapter) ProcessCombat() error {
|
||||
if ca.npc == nil {
|
||||
return fmt.Errorf("NPC is nil")
|
||||
}
|
||||
|
||||
if ca.combatManager != nil {
|
||||
return ca.combatManager.ProcessCombatRound(ca.npc)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MovementAdapter provides movement functionality for NPCs
|
||||
type MovementAdapter struct {
|
||||
npc *NPC
|
||||
movementManager MovementManager
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// NewMovementAdapter creates a new movement adapter
|
||||
func NewMovementAdapter(npc *NPC, movementManager MovementManager, logger Logger) *MovementAdapter {
|
||||
return &MovementAdapter{
|
||||
npc: npc,
|
||||
movementManager: movementManager,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// MoveToLocation moves the NPC to a specific location
|
||||
func (ma *MovementAdapter) MoveToLocation(x, y, z float32) error {
|
||||
if ma.npc == nil {
|
||||
return fmt.Errorf("NPC is nil")
|
||||
}
|
||||
|
||||
if ma.movementManager != nil {
|
||||
return ma.movementManager.NavigateToLocation(ma.npc, x, y, z)
|
||||
}
|
||||
|
||||
return fmt.Errorf("movement manager not available")
|
||||
}
|
||||
|
||||
// StopMovement stops the NPC's movement
|
||||
func (ma *MovementAdapter) StopMovement() error {
|
||||
if ma.npc == nil {
|
||||
return fmt.Errorf("NPC is nil")
|
||||
}
|
||||
|
||||
if ma.movementManager != nil {
|
||||
return ma.movementManager.StopMovement(ma.npc)
|
||||
}
|
||||
|
||||
return fmt.Errorf("movement manager not available")
|
||||
}
|
||||
|
||||
// IsMoving checks if the NPC is currently moving
|
||||
func (ma *MovementAdapter) IsMoving() bool {
|
||||
if ma.npc == nil || ma.movementManager == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return ma.movementManager.IsMoving(ma.npc)
|
||||
}
|
||||
|
||||
// RunbackToSpawn moves the NPC back to its spawn location
|
||||
func (ma *MovementAdapter) RunbackToSpawn() error {
|
||||
if ma.npc == nil {
|
||||
return fmt.Errorf("NPC is nil")
|
||||
}
|
||||
|
||||
runbackLocation := ma.npc.GetRunbackLocation()
|
||||
if runbackLocation == nil {
|
||||
return fmt.Errorf("no runback location set")
|
||||
}
|
||||
|
||||
return ma.MoveToLocation(runbackLocation.X, runbackLocation.Y, runbackLocation.Z)
|
||||
}
|
762
internal/npc/manager.go
Normal file
762
internal/npc/manager.go
Normal file
@ -0,0 +1,762 @@
|
||||
package npc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Manager provides high-level management of the NPC system
|
||||
type Manager struct {
|
||||
npcs map[int32]*NPC // NPCs indexed by ID
|
||||
npcsByZone map[int32][]*NPC // NPCs indexed by zone ID
|
||||
npcsByAppearance map[int32][]*NPC // NPCs indexed by appearance ID
|
||||
database Database // Database interface
|
||||
logger Logger // Logger interface
|
||||
spellManager SpellManager // Spell system interface
|
||||
skillManager SkillManager // Skill system interface
|
||||
appearanceManager AppearanceManager // Appearance system interface
|
||||
mutex sync.RWMutex // Thread safety
|
||||
|
||||
// Statistics
|
||||
totalNPCs int64
|
||||
npcsInCombat int64
|
||||
spellCastCount int64
|
||||
skillUsageCount int64
|
||||
runbackCount int64
|
||||
aiStrategyCounts map[int8]int64
|
||||
|
||||
// Configuration
|
||||
maxNPCs int32
|
||||
defaultAggroRadius float32
|
||||
enableAI bool
|
||||
}
|
||||
|
||||
// NewManager creates a new NPC manager
|
||||
func NewManager(database Database, logger Logger) *Manager {
|
||||
return &Manager{
|
||||
npcs: make(map[int32]*NPC),
|
||||
npcsByZone: make(map[int32][]*NPC),
|
||||
npcsByAppearance: make(map[int32][]*NPC),
|
||||
database: database,
|
||||
logger: logger,
|
||||
aiStrategyCounts: make(map[int8]int64),
|
||||
maxNPCs: 10000, // Default limit
|
||||
defaultAggroRadius: DefaultAggroRadius,
|
||||
enableAI: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize loads NPCs from database and sets up the system
|
||||
func (m *Manager) Initialize() error {
|
||||
if m.logger != nil {
|
||||
m.logger.LogInfo("Initializing NPC manager...")
|
||||
}
|
||||
|
||||
if m.database == nil {
|
||||
if m.logger != nil {
|
||||
m.logger.LogWarning("No database provided, starting with empty NPC list")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load NPCs from database
|
||||
npcs, err := m.database.LoadAllNPCs()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load NPCs from database: %w", err)
|
||||
}
|
||||
|
||||
for _, npc := range npcs {
|
||||
if err := m.addNPCInternal(npc); err != nil {
|
||||
if m.logger != nil {
|
||||
m.logger.LogError("Failed to add NPC %d: %v", npc.GetNPCID(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if m.logger != nil {
|
||||
m.logger.LogInfo("Loaded %d NPCs from database", len(npcs))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddNPC adds a new NPC to the system
|
||||
func (m *Manager) AddNPC(npc *NPC) error {
|
||||
if npc == nil {
|
||||
return fmt.Errorf("NPC cannot be nil")
|
||||
}
|
||||
|
||||
if !npc.IsValid() {
|
||||
return fmt.Errorf("NPC is not valid: %s", npc.String())
|
||||
}
|
||||
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
if len(m.npcs) >= int(m.maxNPCs) {
|
||||
return fmt.Errorf("maximum NPC limit reached (%d)", m.maxNPCs)
|
||||
}
|
||||
|
||||
return m.addNPCInternal(npc)
|
||||
}
|
||||
|
||||
// addNPCInternal adds an NPC without locking (internal use)
|
||||
func (m *Manager) addNPCInternal(npc *NPC) error {
|
||||
npcID := npc.GetNPCID()
|
||||
|
||||
// Check for duplicate ID
|
||||
if _, exists := m.npcs[npcID]; exists {
|
||||
return fmt.Errorf("NPC with ID %d already exists", npcID)
|
||||
}
|
||||
|
||||
// Add to main index
|
||||
m.npcs[npcID] = npc
|
||||
|
||||
// Add to zone index
|
||||
if npc.Entity != nil {
|
||||
zoneID := npc.Entity.GetZoneID()
|
||||
m.npcsByZone[zoneID] = append(m.npcsByZone[zoneID], npc)
|
||||
}
|
||||
|
||||
// Add to appearance index
|
||||
appearanceID := npc.GetAppearanceID()
|
||||
m.npcsByAppearance[appearanceID] = append(m.npcsByAppearance[appearanceID], npc)
|
||||
|
||||
// Update statistics
|
||||
m.totalNPCs++
|
||||
strategy := npc.GetAIStrategy()
|
||||
m.aiStrategyCounts[strategy]++
|
||||
|
||||
// Save to database if available
|
||||
if m.database != nil {
|
||||
if err := m.database.SaveNPC(npc); err != nil {
|
||||
// Remove from indexes if database save failed
|
||||
delete(m.npcs, npcID)
|
||||
m.removeFromZoneIndex(npc)
|
||||
m.removeFromAppearanceIndex(npc)
|
||||
m.totalNPCs--
|
||||
m.aiStrategyCounts[strategy]--
|
||||
return fmt.Errorf("failed to save NPC to database: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if m.logger != nil {
|
||||
m.logger.LogInfo("Added NPC %d: %s", npcID, npc.String())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetNPC retrieves an NPC by ID
|
||||
func (m *Manager) GetNPC(id int32) *NPC {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
return m.npcs[id]
|
||||
}
|
||||
|
||||
// GetNPCsByZone retrieves all NPCs in a specific zone
|
||||
func (m *Manager) GetNPCsByZone(zoneID int32) []*NPC {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
npcs := m.npcsByZone[zoneID]
|
||||
result := make([]*NPC, len(npcs))
|
||||
copy(result, npcs)
|
||||
return result
|
||||
}
|
||||
|
||||
// GetNPCsByAppearance retrieves all NPCs with a specific appearance
|
||||
func (m *Manager) GetNPCsByAppearance(appearanceID int32) []*NPC {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
npcs := m.npcsByAppearance[appearanceID]
|
||||
result := make([]*NPC, len(npcs))
|
||||
copy(result, npcs)
|
||||
return result
|
||||
}
|
||||
|
||||
// RemoveNPC removes an NPC from the system
|
||||
func (m *Manager) RemoveNPC(id int32) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
npc, exists := m.npcs[id]
|
||||
if !exists {
|
||||
return fmt.Errorf("NPC with ID %d does not exist", id)
|
||||
}
|
||||
|
||||
// Remove from database first if available
|
||||
if m.database != nil {
|
||||
if err := m.database.DeleteNPC(id); err != nil {
|
||||
return fmt.Errorf("failed to delete NPC from database: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from all indexes
|
||||
delete(m.npcs, id)
|
||||
m.removeFromZoneIndex(npc)
|
||||
m.removeFromAppearanceIndex(npc)
|
||||
|
||||
// Update statistics
|
||||
m.totalNPCs--
|
||||
strategy := npc.GetAIStrategy()
|
||||
if count := m.aiStrategyCounts[strategy]; count > 0 {
|
||||
m.aiStrategyCounts[strategy]--
|
||||
}
|
||||
|
||||
if m.logger != nil {
|
||||
m.logger.LogInfo("Removed NPC %d", id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateNPC updates an existing NPC
|
||||
func (m *Manager) UpdateNPC(npc *NPC) error {
|
||||
if npc == nil {
|
||||
return fmt.Errorf("NPC cannot be nil")
|
||||
}
|
||||
|
||||
if !npc.IsValid() {
|
||||
return fmt.Errorf("NPC is not valid: %s", npc.String())
|
||||
}
|
||||
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
npcID := npc.GetNPCID()
|
||||
oldNPC, exists := m.npcs[npcID]
|
||||
if !exists {
|
||||
return fmt.Errorf("NPC with ID %d does not exist", npcID)
|
||||
}
|
||||
|
||||
// Update indexes if zone or appearance changed
|
||||
if npc.Entity != nil && oldNPC.Entity != nil {
|
||||
if npc.Entity.GetZoneID() != oldNPC.Entity.GetZoneID() {
|
||||
m.removeFromZoneIndex(oldNPC)
|
||||
zoneID := npc.Entity.GetZoneID()
|
||||
m.npcsByZone[zoneID] = append(m.npcsByZone[zoneID], npc)
|
||||
}
|
||||
}
|
||||
|
||||
if npc.GetAppearanceID() != oldNPC.GetAppearanceID() {
|
||||
m.removeFromAppearanceIndex(oldNPC)
|
||||
appearanceID := npc.GetAppearanceID()
|
||||
m.npcsByAppearance[appearanceID] = append(m.npcsByAppearance[appearanceID], npc)
|
||||
}
|
||||
|
||||
// Update AI strategy statistics
|
||||
oldStrategy := oldNPC.GetAIStrategy()
|
||||
newStrategy := npc.GetAIStrategy()
|
||||
if oldStrategy != newStrategy {
|
||||
if count := m.aiStrategyCounts[oldStrategy]; count > 0 {
|
||||
m.aiStrategyCounts[oldStrategy]--
|
||||
}
|
||||
m.aiStrategyCounts[newStrategy]++
|
||||
}
|
||||
|
||||
// Update main index
|
||||
m.npcs[npcID] = npc
|
||||
|
||||
// Save to database if available
|
||||
if m.database != nil {
|
||||
if err := m.database.SaveNPC(npc); err != nil {
|
||||
return fmt.Errorf("failed to save NPC to database: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if m.logger != nil {
|
||||
m.logger.LogInfo("Updated NPC %d: %s", npcID, npc.String())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateNPCFromTemplate creates a new NPC from an existing template
|
||||
func (m *Manager) CreateNPCFromTemplate(templateID, newID int32) (*NPC, error) {
|
||||
template := m.GetNPC(templateID)
|
||||
if template == nil {
|
||||
return nil, fmt.Errorf("template NPC with ID %d not found", templateID)
|
||||
}
|
||||
|
||||
// Create new NPC from template
|
||||
newNPC := NewNPCFromExisting(template)
|
||||
newNPC.SetNPCID(newID)
|
||||
|
||||
// Add to system
|
||||
if err := m.AddNPC(newNPC); err != nil {
|
||||
return nil, fmt.Errorf("failed to add new NPC: %w", err)
|
||||
}
|
||||
|
||||
return newNPC, nil
|
||||
}
|
||||
|
||||
// GetRandomNPCByAppearance returns a random NPC with the specified appearance
|
||||
func (m *Manager) GetRandomNPCByAppearance(appearanceID int32) *NPC {
|
||||
npcs := m.GetNPCsByAppearance(appearanceID)
|
||||
if len(npcs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return npcs[rand.Intn(len(npcs))]
|
||||
}
|
||||
|
||||
// ProcessCombat handles combat processing for all NPCs
|
||||
func (m *Manager) ProcessCombat() {
|
||||
m.mutex.RLock()
|
||||
npcs := make([]*NPC, 0, len(m.npcs))
|
||||
for _, npc := range m.npcs {
|
||||
if npc.Entity != nil && npc.Entity.GetInCombat() {
|
||||
npcs = append(npcs, npc)
|
||||
}
|
||||
}
|
||||
m.mutex.RUnlock()
|
||||
|
||||
// Process combat for each NPC in combat
|
||||
for _, npc := range npcs {
|
||||
npc.ProcessCombat()
|
||||
}
|
||||
|
||||
// Update combat statistics
|
||||
m.mutex.Lock()
|
||||
m.npcsInCombat = int64(len(npcs))
|
||||
m.mutex.Unlock()
|
||||
}
|
||||
|
||||
// ProcessAI handles AI processing for all NPCs
|
||||
func (m *Manager) ProcessAI() {
|
||||
if !m.enableAI {
|
||||
return
|
||||
}
|
||||
|
||||
m.mutex.RLock()
|
||||
npcs := make([]*NPC, 0, len(m.npcs))
|
||||
for _, npc := range m.npcs {
|
||||
npcs = append(npcs, npc)
|
||||
}
|
||||
m.mutex.RUnlock()
|
||||
|
||||
// Process AI for each NPC
|
||||
for _, npc := range npcs {
|
||||
if brain := npc.GetBrain(); brain != nil && brain.IsActive() {
|
||||
if err := brain.Think(); err != nil && m.logger != nil {
|
||||
m.logger.LogError("AI brain error for NPC %d: %v", npc.GetNPCID(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessMovement handles movement processing for all NPCs
|
||||
func (m *Manager) ProcessMovement() {
|
||||
m.mutex.RLock()
|
||||
npcs := make([]*NPC, 0, len(m.npcs))
|
||||
for _, npc := range m.npcs {
|
||||
npcs = append(npcs, npc)
|
||||
}
|
||||
m.mutex.RUnlock()
|
||||
|
||||
// Process movement for each NPC
|
||||
for _, npc := range npcs {
|
||||
// Check pause timer
|
||||
if npc.IsPauseMovementTimerActive() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle runback if needed
|
||||
if npc.callRunback && npc.GetRunbackLocation() != nil {
|
||||
npc.callRunback = false
|
||||
npc.Runback(0, true)
|
||||
m.mutex.Lock()
|
||||
m.runbackCount++
|
||||
m.mutex.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetStatistics returns NPC system statistics
|
||||
func (m *Manager) GetStatistics() *NPCStatistics {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
// Create AI strategy counts by name
|
||||
aiCounts := make(map[string]int)
|
||||
for strategy, count := range m.aiStrategyCounts {
|
||||
switch strategy {
|
||||
case AIStrategyBalanced:
|
||||
aiCounts["balanced"] = int(count)
|
||||
case AIStrategyOffensive:
|
||||
aiCounts["offensive"] = int(count)
|
||||
case AIStrategyDefensive:
|
||||
aiCounts["defensive"] = int(count)
|
||||
default:
|
||||
aiCounts[fmt.Sprintf("unknown_%d", strategy)] = int(count)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate average aggro radius
|
||||
var totalAggro float32
|
||||
npcCount := 0
|
||||
for _, npc := range m.npcs {
|
||||
totalAggro += npc.GetAggroRadius()
|
||||
npcCount++
|
||||
}
|
||||
var avgAggro float32
|
||||
if npcCount > 0 {
|
||||
avgAggro = totalAggro / float32(npcCount)
|
||||
}
|
||||
|
||||
// Count NPCs with spells and skills
|
||||
npcsWithSpells := 0
|
||||
npcsWithSkills := 0
|
||||
for _, npc := range m.npcs {
|
||||
if npc.HasSpells() {
|
||||
npcsWithSpells++
|
||||
}
|
||||
if len(npc.skills) > 0 {
|
||||
npcsWithSkills++
|
||||
}
|
||||
}
|
||||
|
||||
return &NPCStatistics{
|
||||
TotalNPCs: int(m.totalNPCs),
|
||||
NPCsInCombat: int(m.npcsInCombat),
|
||||
NPCsWithSpells: npcsWithSpells,
|
||||
NPCsWithSkills: npcsWithSkills,
|
||||
AIStrategyCounts: aiCounts,
|
||||
SpellCastCount: m.spellCastCount,
|
||||
SkillUsageCount: m.skillUsageCount,
|
||||
RunbackCount: m.runbackCount,
|
||||
AverageAggroRadius: avgAggro,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateAllNPCs validates all NPCs in the system
|
||||
func (m *Manager) ValidateAllNPCs() []string {
|
||||
m.mutex.RLock()
|
||||
npcs := make([]*NPC, 0, len(m.npcs))
|
||||
for _, npc := range m.npcs {
|
||||
npcs = append(npcs, npc)
|
||||
}
|
||||
m.mutex.RUnlock()
|
||||
|
||||
var issues []string
|
||||
for _, npc := range npcs {
|
||||
if !npc.IsValid() {
|
||||
issues = append(issues, fmt.Sprintf("NPC %d is invalid: %s", npc.GetNPCID(), npc.String()))
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
// ProcessCommand handles NPC-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 "create":
|
||||
return m.handleCreateCommand(args)
|
||||
case "remove":
|
||||
return m.handleRemoveCommand(args)
|
||||
case "search":
|
||||
return m.handleSearchCommand(args)
|
||||
case "combat":
|
||||
return m.handleCombatCommand(args)
|
||||
default:
|
||||
return "", fmt.Errorf("unknown NPC command: %s", command)
|
||||
}
|
||||
}
|
||||
|
||||
// Command handlers
|
||||
func (m *Manager) handleStatsCommand(args []string) (string, error) {
|
||||
stats := m.GetStatistics()
|
||||
|
||||
result := "NPC System Statistics:\n"
|
||||
result += fmt.Sprintf("Total NPCs: %d\n", stats.TotalNPCs)
|
||||
result += fmt.Sprintf("NPCs in Combat: %d\n", stats.NPCsInCombat)
|
||||
result += fmt.Sprintf("NPCs with Spells: %d\n", stats.NPCsWithSpells)
|
||||
result += fmt.Sprintf("NPCs with Skills: %d\n", stats.NPCsWithSkills)
|
||||
result += fmt.Sprintf("Average Aggro Radius: %.2f\n", stats.AverageAggroRadius)
|
||||
result += fmt.Sprintf("Spell Casts: %d\n", stats.SpellCastCount)
|
||||
result += fmt.Sprintf("Skill Uses: %d\n", stats.SkillUsageCount)
|
||||
result += fmt.Sprintf("Runbacks: %d\n", stats.RunbackCount)
|
||||
|
||||
if len(stats.AIStrategyCounts) > 0 {
|
||||
result += "\nAI Strategy Distribution:\n"
|
||||
for strategy, count := range stats.AIStrategyCounts {
|
||||
result += fmt.Sprintf(" %s: %d\n", strategy, count)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Manager) handleValidateCommand(args []string) (string, error) {
|
||||
issues := m.ValidateAllNPCs()
|
||||
|
||||
if len(issues) == 0 {
|
||||
return "All NPCs are valid.", nil
|
||||
}
|
||||
|
||||
result := fmt.Sprintf("Found %d issues with NPCs:\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
|
||||
}
|
||||
|
||||
func (m *Manager) handleListCommand(args []string) (string, error) {
|
||||
m.mutex.RLock()
|
||||
npcs := make([]*NPC, 0, len(m.npcs))
|
||||
for _, npc := range m.npcs {
|
||||
npcs = append(npcs, npc)
|
||||
}
|
||||
m.mutex.RUnlock()
|
||||
|
||||
if len(npcs) == 0 {
|
||||
return "No NPCs loaded.", nil
|
||||
}
|
||||
|
||||
result := fmt.Sprintf("NPCs (%d):\n", len(npcs))
|
||||
count := 0
|
||||
for _, npc := range npcs {
|
||||
if count >= 20 { // Limit output
|
||||
result += "... (and more)\n"
|
||||
break
|
||||
}
|
||||
result += fmt.Sprintf(" %d: %s\n", npc.GetNPCID(), npc.String())
|
||||
count++
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Manager) handleInfoCommand(args []string) (string, error) {
|
||||
if len(args) == 0 {
|
||||
return "", fmt.Errorf("NPC ID required")
|
||||
}
|
||||
|
||||
var npcID int32
|
||||
if _, err := fmt.Sscanf(args[0], "%d", &npcID); err != nil {
|
||||
return "", fmt.Errorf("invalid NPC ID: %s", args[0])
|
||||
}
|
||||
|
||||
npc := m.GetNPC(npcID)
|
||||
if npc == nil {
|
||||
return fmt.Sprintf("NPC %d not found.", npcID), nil
|
||||
}
|
||||
|
||||
result := fmt.Sprintf("NPC Information:\n")
|
||||
result += fmt.Sprintf("ID: %d\n", npc.GetNPCID())
|
||||
result += fmt.Sprintf("Appearance ID: %d\n", npc.GetAppearanceID())
|
||||
result += fmt.Sprintf("AI Strategy: %d\n", npc.GetAIStrategy())
|
||||
result += fmt.Sprintf("Cast Percentage: %d%%\n", npc.GetCastPercentage())
|
||||
result += fmt.Sprintf("Aggro Radius: %.2f\n", npc.GetAggroRadius())
|
||||
result += fmt.Sprintf("Has Spells: %v\n", npc.HasSpells())
|
||||
result += fmt.Sprintf("Running Back: %v\n", npc.IsRunningBack())
|
||||
|
||||
if npc.Entity != nil {
|
||||
result += fmt.Sprintf("Name: %s\n", npc.Entity.GetName())
|
||||
result += fmt.Sprintf("Level: %d\n", npc.Entity.GetLevel())
|
||||
result += fmt.Sprintf("Zone: %d\n", npc.Entity.GetZoneID())
|
||||
result += fmt.Sprintf("In Combat: %v\n", npc.Entity.GetInCombat())
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Manager) handleCreateCommand(args []string) (string, error) {
|
||||
if len(args) < 2 {
|
||||
return "", fmt.Errorf("usage: create <template_id> <new_id>")
|
||||
}
|
||||
|
||||
var templateID, newID int32
|
||||
if _, err := fmt.Sscanf(args[0], "%d", &templateID); err != nil {
|
||||
return "", fmt.Errorf("invalid template ID: %s", args[0])
|
||||
}
|
||||
if _, err := fmt.Sscanf(args[1], "%d", &newID); err != nil {
|
||||
return "", fmt.Errorf("invalid new ID: %s", args[1])
|
||||
}
|
||||
|
||||
npc, err := m.CreateNPCFromTemplate(templateID, newID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create NPC: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Successfully created NPC %d from template %d", newID, templateID), nil
|
||||
}
|
||||
|
||||
func (m *Manager) handleRemoveCommand(args []string) (string, error) {
|
||||
if len(args) == 0 {
|
||||
return "", fmt.Errorf("NPC ID required")
|
||||
}
|
||||
|
||||
var npcID int32
|
||||
if _, err := fmt.Sscanf(args[0], "%d", &npcID); err != nil {
|
||||
return "", fmt.Errorf("invalid NPC ID: %s", args[0])
|
||||
}
|
||||
|
||||
if err := m.RemoveNPC(npcID); err != nil {
|
||||
return "", fmt.Errorf("failed to remove NPC: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Successfully removed NPC %d", npcID), nil
|
||||
}
|
||||
|
||||
func (m *Manager) handleSearchCommand(args []string) (string, error) {
|
||||
if len(args) == 0 {
|
||||
return "", fmt.Errorf("search term required")
|
||||
}
|
||||
|
||||
searchTerm := strings.ToLower(args[0])
|
||||
|
||||
m.mutex.RLock()
|
||||
var results []*NPC
|
||||
for _, npc := range m.npcs {
|
||||
if npc.Entity != nil {
|
||||
name := strings.ToLower(npc.Entity.GetName())
|
||||
if strings.Contains(name, searchTerm) {
|
||||
results = append(results, npc)
|
||||
}
|
||||
}
|
||||
}
|
||||
m.mutex.RUnlock()
|
||||
|
||||
if len(results) == 0 {
|
||||
return fmt.Sprintf("No NPCs found matching '%s'.", args[0]), nil
|
||||
}
|
||||
|
||||
result := fmt.Sprintf("Found %d NPCs matching '%s':\n", len(results), args[0])
|
||||
for i, npc := range results {
|
||||
if i >= 20 { // Limit output
|
||||
result += "... (and more)\n"
|
||||
break
|
||||
}
|
||||
result += fmt.Sprintf(" %d: %s\n", npc.GetNPCID(), npc.String())
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Manager) handleCombatCommand(args []string) (string, error) {
|
||||
result := "Combat Processing Status:\n"
|
||||
result += fmt.Sprintf("NPCs in Combat: %d\n", m.npcsInCombat)
|
||||
result += fmt.Sprintf("Total Spell Casts: %d\n", m.spellCastCount)
|
||||
result += fmt.Sprintf("Total Runbacks: %d\n", m.runbackCount)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
func (m *Manager) removeFromZoneIndex(npc *NPC) {
|
||||
if npc.Entity == nil {
|
||||
return
|
||||
}
|
||||
|
||||
zoneID := npc.Entity.GetZoneID()
|
||||
npcs := m.npcsByZone[zoneID]
|
||||
|
||||
for i, n := range npcs {
|
||||
if n == npc {
|
||||
// Remove from slice
|
||||
m.npcsByZone[zoneID] = append(npcs[:i], npcs[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up empty slices
|
||||
if len(m.npcsByZone[zoneID]) == 0 {
|
||||
delete(m.npcsByZone, zoneID)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) removeFromAppearanceIndex(npc *NPC) {
|
||||
appearanceID := npc.GetAppearanceID()
|
||||
npcs := m.npcsByAppearance[appearanceID]
|
||||
|
||||
for i, n := range npcs {
|
||||
if n == npc {
|
||||
// Remove from slice
|
||||
m.npcsByAppearance[appearanceID] = append(npcs[:i], npcs[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up empty slices
|
||||
if len(m.npcsByAppearance[appearanceID]) == 0 {
|
||||
delete(m.npcsByAppearance, appearanceID)
|
||||
}
|
||||
}
|
||||
|
||||
// SetManagers sets the external system managers
|
||||
func (m *Manager) SetManagers(spellMgr SpellManager, skillMgr SkillManager, appearanceMgr AppearanceManager) {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
m.spellManager = spellMgr
|
||||
m.skillManager = skillMgr
|
||||
m.appearanceManager = appearanceMgr
|
||||
}
|
||||
|
||||
// Configuration methods
|
||||
func (m *Manager) SetMaxNPCs(max int32) {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
m.maxNPCs = max
|
||||
}
|
||||
|
||||
func (m *Manager) SetDefaultAggroRadius(radius float32) {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
m.defaultAggroRadius = radius
|
||||
}
|
||||
|
||||
func (m *Manager) SetAIEnabled(enabled bool) {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
m.enableAI = enabled
|
||||
}
|
||||
|
||||
// GetNPCCount returns the total number of NPCs
|
||||
func (m *Manager) GetNPCCount() int32 {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
return int32(len(m.npcs))
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the manager
|
||||
func (m *Manager) Shutdown() {
|
||||
if m.logger != nil {
|
||||
m.logger.LogInfo("Shutting down NPC manager...")
|
||||
}
|
||||
|
||||
// Stop all AI brains
|
||||
m.mutex.Lock()
|
||||
for _, npc := range m.npcs {
|
||||
if brain := npc.GetBrain(); brain != nil {
|
||||
brain.SetActive(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all data
|
||||
m.npcs = make(map[int32]*NPC)
|
||||
m.npcsByZone = make(map[int32][]*NPC)
|
||||
m.npcsByAppearance = make(map[int32][]*NPC)
|
||||
m.mutex.Unlock()
|
||||
}
|
806
internal/npc/npc.go
Normal file
806
internal/npc/npc.go
Normal file
@ -0,0 +1,806 @@
|
||||
package npc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"eq2emu/internal/common"
|
||||
"eq2emu/internal/entity"
|
||||
"eq2emu/internal/spawn"
|
||||
)
|
||||
|
||||
// NewNPC creates a new NPC with default values
|
||||
func NewNPC() *NPC {
|
||||
npc := &NPC{
|
||||
Entity: entity.NewEntity(),
|
||||
appearanceID: 0,
|
||||
npcID: 0,
|
||||
aiStrategy: AIStrategyBalanced,
|
||||
attackType: 0,
|
||||
castPercentage: DefaultCastPercentage,
|
||||
maxPetLevel: DefaultMaxPetLevel,
|
||||
aggroRadius: DefaultAggroRadius,
|
||||
baseAggroRadius: DefaultAggroRadius,
|
||||
runback: nil,
|
||||
runningBack: false,
|
||||
runbackHeadingDir1: 0,
|
||||
runbackHeadingDir2: 0,
|
||||
pauseTimer: NewTimer(),
|
||||
primarySpellList: 0,
|
||||
secondarySpellList: 0,
|
||||
primarySkillList: 0,
|
||||
secondarySkillList: 0,
|
||||
equipmentListID: 0,
|
||||
skills: make(map[string]*Skill),
|
||||
spells: make([]*NPCSpell, 0),
|
||||
castOnSpells: make(map[int8][]*NPCSpell),
|
||||
skillBonuses: make(map[int32]*SkillBonus),
|
||||
hasSpells: false,
|
||||
castOnAggroCompleted: false,
|
||||
shardID: 0,
|
||||
shardCharID: 0,
|
||||
shardCreatedTimestamp: 0,
|
||||
callRunback: false,
|
||||
}
|
||||
|
||||
// Initialize cast-on spell arrays
|
||||
npc.castOnSpells[CastOnSpawn] = make([]*NPCSpell, 0)
|
||||
npc.castOnSpells[CastOnAggro] = make([]*NPCSpell, 0)
|
||||
|
||||
// Create default brain
|
||||
npc.brain = NewDefaultBrain(npc)
|
||||
|
||||
return npc
|
||||
}
|
||||
|
||||
// NewNPCFromExisting creates a copy of an existing NPC with randomization
|
||||
func NewNPCFromExisting(oldNPC *NPC) *NPC {
|
||||
if oldNPC == nil {
|
||||
return NewNPC()
|
||||
}
|
||||
|
||||
npc := NewNPC()
|
||||
|
||||
// Copy basic properties
|
||||
npc.npcID = oldNPC.npcID
|
||||
npc.appearanceID = oldNPC.appearanceID
|
||||
npc.aiStrategy = oldNPC.aiStrategy
|
||||
npc.attackType = oldNPC.attackType
|
||||
npc.castPercentage = oldNPC.castPercentage
|
||||
npc.maxPetLevel = oldNPC.maxPetLevel
|
||||
npc.baseAggroRadius = oldNPC.baseAggroRadius
|
||||
npc.aggroRadius = oldNPC.baseAggroRadius
|
||||
|
||||
// Copy spell lists
|
||||
npc.primarySpellList = oldNPC.primarySpellList
|
||||
npc.secondarySpellList = oldNPC.secondarySpellList
|
||||
npc.primarySkillList = oldNPC.primarySkillList
|
||||
npc.secondarySkillList = oldNPC.secondarySkillList
|
||||
npc.equipmentListID = oldNPC.equipmentListID
|
||||
|
||||
// Copy entity data (stats, appearance, etc.)
|
||||
if oldNPC.Entity != nil {
|
||||
npc.Entity = oldNPC.Entity.Copy().(*entity.Entity)
|
||||
}
|
||||
|
||||
// Handle level randomization
|
||||
if oldNPC.Entity != nil {
|
||||
minLevel := oldNPC.Entity.GetMinLevel()
|
||||
maxLevel := oldNPC.Entity.GetMaxLevel()
|
||||
if minLevel < maxLevel {
|
||||
randomLevel := minLevel + int8(rand.Intn(int(maxLevel-minLevel)+1))
|
||||
npc.Entity.SetLevel(randomLevel)
|
||||
}
|
||||
}
|
||||
|
||||
// Copy skills (deep copy)
|
||||
npc.copySkills(oldNPC)
|
||||
|
||||
// Copy spells (deep copy)
|
||||
npc.copySpells(oldNPC)
|
||||
|
||||
// Handle appearance randomization
|
||||
if oldNPC.Entity != nil && oldNPC.Entity.GetRandomize() > 0 {
|
||||
npc.randomizeAppearance(oldNPC.Entity.GetRandomize())
|
||||
}
|
||||
|
||||
return npc
|
||||
}
|
||||
|
||||
// IsNPC always returns true for NPC instances
|
||||
func (n *NPC) IsNPC() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// GetAppearanceID returns the appearance ID
|
||||
func (n *NPC) GetAppearanceID() int32 {
|
||||
n.mutex.RLock()
|
||||
defer n.mutex.RUnlock()
|
||||
return n.appearanceID
|
||||
}
|
||||
|
||||
// SetAppearanceID sets the appearance ID
|
||||
func (n *NPC) SetAppearanceID(id int32) {
|
||||
n.mutex.Lock()
|
||||
defer n.mutex.Unlock()
|
||||
n.appearanceID = id
|
||||
}
|
||||
|
||||
// GetNPCID returns the NPC database ID
|
||||
func (n *NPC) GetNPCID() int32 {
|
||||
n.mutex.RLock()
|
||||
defer n.mutex.RUnlock()
|
||||
return n.npcID
|
||||
}
|
||||
|
||||
// SetNPCID sets the NPC database ID
|
||||
func (n *NPC) SetNPCID(id int32) {
|
||||
n.mutex.Lock()
|
||||
defer n.mutex.Unlock()
|
||||
n.npcID = id
|
||||
}
|
||||
|
||||
// AI Strategy methods
|
||||
func (n *NPC) GetAIStrategy() int8 {
|
||||
n.mutex.RLock()
|
||||
defer n.mutex.RUnlock()
|
||||
return n.aiStrategy
|
||||
}
|
||||
|
||||
func (n *NPC) SetAIStrategy(strategy int8) {
|
||||
n.mutex.Lock()
|
||||
defer n.mutex.Unlock()
|
||||
n.aiStrategy = strategy
|
||||
}
|
||||
|
||||
// Attack Type methods
|
||||
func (n *NPC) GetAttackType() int8 {
|
||||
n.mutex.RLock()
|
||||
defer n.mutex.RUnlock()
|
||||
return n.attackType
|
||||
}
|
||||
|
||||
func (n *NPC) SetAttackType(attackType int8) {
|
||||
n.mutex.Lock()
|
||||
defer n.mutex.Unlock()
|
||||
n.attackType = attackType
|
||||
}
|
||||
|
||||
// Cast Percentage methods
|
||||
func (n *NPC) GetCastPercentage() int8 {
|
||||
n.mutex.RLock()
|
||||
defer n.mutex.RUnlock()
|
||||
return n.castPercentage
|
||||
}
|
||||
|
||||
func (n *NPC) SetCastPercentage(percentage int8) {
|
||||
n.mutex.Lock()
|
||||
defer n.mutex.Unlock()
|
||||
if percentage < 0 {
|
||||
percentage = 0
|
||||
} else if percentage > 100 {
|
||||
percentage = 100
|
||||
}
|
||||
n.castPercentage = percentage
|
||||
}
|
||||
|
||||
// Aggro Radius methods
|
||||
func (n *NPC) GetAggroRadius() float32 {
|
||||
n.mutex.RLock()
|
||||
defer n.mutex.RUnlock()
|
||||
return n.aggroRadius
|
||||
}
|
||||
|
||||
func (n *NPC) SetAggroRadius(radius float32, overrideBase bool) {
|
||||
n.mutex.Lock()
|
||||
defer n.mutex.Unlock()
|
||||
|
||||
if n.baseAggroRadius == 0.0 || overrideBase {
|
||||
n.baseAggroRadius = radius
|
||||
}
|
||||
n.aggroRadius = radius
|
||||
}
|
||||
|
||||
func (n *NPC) GetBaseAggroRadius() float32 {
|
||||
n.mutex.RLock()
|
||||
defer n.mutex.RUnlock()
|
||||
return n.baseAggroRadius
|
||||
}
|
||||
|
||||
// Pet Level methods
|
||||
func (n *NPC) GetMaxPetLevel() int8 {
|
||||
n.mutex.RLock()
|
||||
defer n.mutex.RUnlock()
|
||||
return n.maxPetLevel
|
||||
}
|
||||
|
||||
func (n *NPC) SetMaxPetLevel(level int8) {
|
||||
n.mutex.Lock()
|
||||
defer n.mutex.Unlock()
|
||||
n.maxPetLevel = level
|
||||
}
|
||||
|
||||
// Spell List methods
|
||||
func (n *NPC) GetPrimarySpellList() int32 {
|
||||
n.mutex.RLock()
|
||||
defer n.mutex.RUnlock()
|
||||
return n.primarySpellList
|
||||
}
|
||||
|
||||
func (n *NPC) SetPrimarySpellList(id int32) {
|
||||
n.mutex.Lock()
|
||||
defer n.mutex.Unlock()
|
||||
n.primarySpellList = id
|
||||
}
|
||||
|
||||
func (n *NPC) GetSecondarySpellList() int32 {
|
||||
n.mutex.RLock()
|
||||
defer n.mutex.RUnlock()
|
||||
return n.secondarySpellList
|
||||
}
|
||||
|
||||
func (n *NPC) SetSecondarySpellList(id int32) {
|
||||
n.mutex.Lock()
|
||||
defer n.mutex.Unlock()
|
||||
n.secondarySpellList = id
|
||||
}
|
||||
|
||||
// Skill List methods
|
||||
func (n *NPC) GetPrimarySkillList() int32 {
|
||||
n.mutex.RLock()
|
||||
defer n.mutex.RUnlock()
|
||||
return n.primarySkillList
|
||||
}
|
||||
|
||||
func (n *NPC) SetPrimarySkillList(id int32) {
|
||||
n.mutex.Lock()
|
||||
defer n.mutex.Unlock()
|
||||
n.primarySkillList = id
|
||||
}
|
||||
|
||||
func (n *NPC) GetSecondarySkillList() int32 {
|
||||
n.mutex.RLock()
|
||||
defer n.mutex.RUnlock()
|
||||
return n.secondarySkillList
|
||||
}
|
||||
|
||||
func (n *NPC) SetSecondarySkillList(id int32) {
|
||||
n.mutex.Lock()
|
||||
defer n.mutex.Unlock()
|
||||
n.secondarySkillList = id
|
||||
}
|
||||
|
||||
// Equipment List methods
|
||||
func (n *NPC) GetEquipmentListID() int32 {
|
||||
n.mutex.RLock()
|
||||
defer n.mutex.RUnlock()
|
||||
return n.equipmentListID
|
||||
}
|
||||
|
||||
func (n *NPC) SetEquipmentListID(id int32) {
|
||||
n.mutex.Lock()
|
||||
defer n.mutex.Unlock()
|
||||
n.equipmentListID = id
|
||||
}
|
||||
|
||||
// HasSpells returns whether the NPC has any spells
|
||||
func (n *NPC) HasSpells() bool {
|
||||
n.mutex.RLock()
|
||||
defer n.mutex.RUnlock()
|
||||
return n.hasSpells
|
||||
}
|
||||
|
||||
// GetSpells returns a copy of all spells
|
||||
func (n *NPC) GetSpells() []*NPCSpell {
|
||||
n.mutex.RLock()
|
||||
defer n.mutex.RUnlock()
|
||||
|
||||
result := make([]*NPCSpell, len(n.spells))
|
||||
for i, spell := range n.spells {
|
||||
result[i] = spell.Copy()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// SetSpells sets the NPC's spell list
|
||||
func (n *NPC) SetSpells(spells []*NPCSpell) {
|
||||
n.mutex.Lock()
|
||||
defer n.mutex.Unlock()
|
||||
|
||||
// Clear existing cast-on spells
|
||||
for i := int8(0); i < MaxCastTypes; i++ {
|
||||
n.castOnSpells[i] = make([]*NPCSpell, 0)
|
||||
}
|
||||
|
||||
// Clear existing spells
|
||||
n.spells = make([]*NPCSpell, 0)
|
||||
|
||||
if spells == nil || len(spells) == 0 {
|
||||
n.hasSpells = false
|
||||
return
|
||||
}
|
||||
|
||||
n.hasSpells = true
|
||||
|
||||
// Process spells and separate cast-on types
|
||||
for _, spell := range spells {
|
||||
if spell == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
spellCopy := spell.Copy()
|
||||
|
||||
if spellCopy.GetCastOnSpawn() {
|
||||
n.castOnSpells[CastOnSpawn] = append(n.castOnSpells[CastOnSpawn], spellCopy)
|
||||
} else if spellCopy.GetCastOnInitialAggro() {
|
||||
n.castOnSpells[CastOnAggro] = append(n.castOnSpells[CastOnAggro], spellCopy)
|
||||
} else {
|
||||
n.spells = append(n.spells, spellCopy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetSkillByName returns a skill by name
|
||||
func (n *NPC) GetSkillByName(name string, checkUpdate bool) *Skill {
|
||||
n.mutex.RLock()
|
||||
defer n.mutex.RUnlock()
|
||||
|
||||
skill, exists := n.skills[name]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Random skill increase (10% chance)
|
||||
if checkUpdate && skill.GetCurrentVal() < skill.MaxVal && rand.Intn(100) >= 90 {
|
||||
skill.IncreaseSkill()
|
||||
}
|
||||
|
||||
return skill
|
||||
}
|
||||
|
||||
// GetSkillByID returns a skill by ID (requires master skill list lookup)
|
||||
func (n *NPC) GetSkillByID(id int32, checkUpdate bool) *Skill {
|
||||
// TODO: Implement skill lookup by ID using master skill list
|
||||
// For now, return nil as we need the master skill list integration
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSkills sets the NPC's skills
|
||||
func (n *NPC) SetSkills(skills map[string]*Skill) {
|
||||
n.mutex.Lock()
|
||||
defer n.mutex.Unlock()
|
||||
|
||||
// Clear existing skills
|
||||
n.skills = make(map[string]*Skill)
|
||||
|
||||
// Copy skills
|
||||
if skills != nil {
|
||||
for name, skill := range skills {
|
||||
if skill != nil {
|
||||
n.skills[name] = &Skill{
|
||||
SkillID: skill.SkillID,
|
||||
Name: skill.Name,
|
||||
CurrentVal: skill.CurrentVal,
|
||||
MaxVal: skill.MaxVal,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AddSkillBonus adds a skill bonus from a spell
|
||||
func (n *NPC) AddSkillBonus(spellID, skillID int32, value float32) {
|
||||
if value == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
n.mutex.Lock()
|
||||
defer n.mutex.Unlock()
|
||||
|
||||
// Get or create skill bonus
|
||||
skillBonus, exists := n.skillBonuses[spellID]
|
||||
if !exists {
|
||||
skillBonus = NewSkillBonus(spellID)
|
||||
n.skillBonuses[spellID] = skillBonus
|
||||
}
|
||||
|
||||
// Add the skill bonus
|
||||
skillBonus.AddSkill(skillID, value)
|
||||
|
||||
// Apply bonus to existing skills
|
||||
for _, skill := range n.skills {
|
||||
if skill.SkillID == skillID {
|
||||
skill.CurrentVal += int16(value)
|
||||
skill.MaxVal += int16(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveSkillBonus removes skill bonuses from a spell
|
||||
func (n *NPC) RemoveSkillBonus(spellID int32) {
|
||||
n.mutex.Lock()
|
||||
defer n.mutex.Unlock()
|
||||
|
||||
skillBonus, exists := n.skillBonuses[spellID]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
// Remove bonuses from skills
|
||||
bonuses := skillBonus.GetSkills()
|
||||
for _, bonus := range bonuses {
|
||||
for _, skill := range n.skills {
|
||||
if skill.SkillID == bonus.SkillID {
|
||||
skill.CurrentVal -= int16(bonus.Value)
|
||||
skill.MaxVal -= int16(bonus.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the skill bonus
|
||||
delete(n.skillBonuses, spellID)
|
||||
}
|
||||
|
||||
// Runback Location methods
|
||||
func (n *NPC) SetRunbackLocation(x, y, z float32, gridID int32, resetHP bool) {
|
||||
n.mutex.Lock()
|
||||
defer n.mutex.Unlock()
|
||||
|
||||
n.runback = &MovementLocation{
|
||||
X: x,
|
||||
Y: y,
|
||||
Z: z,
|
||||
GridID: gridID,
|
||||
Stage: 0,
|
||||
ResetHPOnRunback: resetHP,
|
||||
UseNavPath: false,
|
||||
Mapped: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (n *NPC) GetRunbackLocation() *MovementLocation {
|
||||
n.mutex.RLock()
|
||||
defer n.mutex.RUnlock()
|
||||
|
||||
if n.runback == nil {
|
||||
return nil
|
||||
}
|
||||
return n.runback.Copy()
|
||||
}
|
||||
|
||||
func (n *NPC) GetRunbackDistance() float32 {
|
||||
n.mutex.RLock()
|
||||
defer n.mutex.RUnlock()
|
||||
|
||||
if n.runback == nil || n.Entity == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Calculate distance using basic distance formula
|
||||
dx := n.Entity.GetX() - n.runback.X
|
||||
dy := n.Entity.GetY() - n.runback.Y
|
||||
dz := n.Entity.GetZ() - n.runback.Z
|
||||
|
||||
return float32(math.Sqrt(float64(dx*dx + dy*dy + dz*dz)))
|
||||
}
|
||||
|
||||
func (n *NPC) ClearRunback() {
|
||||
n.mutex.Lock()
|
||||
defer n.mutex.Unlock()
|
||||
|
||||
n.runback = nil
|
||||
n.runningBack = false
|
||||
n.runbackHeadingDir1 = 0
|
||||
n.runbackHeadingDir2 = 0
|
||||
}
|
||||
|
||||
// StartRunback sets the current location as the runback point
|
||||
func (n *NPC) StartRunback(resetHP bool) {
|
||||
if n.GetRunbackLocation() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if n.Entity == nil {
|
||||
return
|
||||
}
|
||||
|
||||
n.mutex.Lock()
|
||||
defer n.mutex.Unlock()
|
||||
|
||||
n.runback = &MovementLocation{
|
||||
X: n.Entity.GetX(),
|
||||
Y: n.Entity.GetY(),
|
||||
Z: n.Entity.GetZ(),
|
||||
GridID: n.Entity.GetLocation(),
|
||||
Stage: 0,
|
||||
ResetHPOnRunback: resetHP,
|
||||
UseNavPath: false,
|
||||
Mapped: false,
|
||||
}
|
||||
|
||||
// Store original heading
|
||||
n.runbackHeadingDir1 = n.Entity.GetHeading()
|
||||
n.runbackHeadingDir2 = n.Entity.GetHeading() // In C++ these are separate values
|
||||
}
|
||||
|
||||
// Runback initiates runback movement
|
||||
func (n *NPC) Runback(distance float32, stopFollowing bool) {
|
||||
if n.runback == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if distance == 0.0 {
|
||||
distance = n.GetRunbackDistance()
|
||||
}
|
||||
|
||||
n.mutex.Lock()
|
||||
n.runningBack = true
|
||||
n.mutex.Unlock()
|
||||
|
||||
// TODO: Implement actual movement logic
|
||||
// This would integrate with the movement system
|
||||
|
||||
if stopFollowing && n.Entity != nil {
|
||||
n.Entity.SetFollowing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// IsRunningBack returns whether the NPC is currently running back
|
||||
func (n *NPC) IsRunningBack() bool {
|
||||
n.mutex.RLock()
|
||||
defer n.mutex.RUnlock()
|
||||
return n.runningBack
|
||||
}
|
||||
|
||||
// Movement pause methods
|
||||
func (n *NPC) PauseMovement(periodMS int32) bool {
|
||||
if periodMS < 1 {
|
||||
periodMS = 1
|
||||
}
|
||||
|
||||
if periodMS > MaxPauseTime {
|
||||
periodMS = MaxPauseTime
|
||||
}
|
||||
|
||||
// TODO: Integrate with movement system to stop movement
|
||||
// For now, just start the pause timer
|
||||
n.pauseTimer.Start(periodMS, true)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (n *NPC) IsPauseMovementTimerActive() bool {
|
||||
if n.pauseTimer.Check() {
|
||||
n.pauseTimer.Disable()
|
||||
n.callRunback = true
|
||||
}
|
||||
|
||||
return n.pauseTimer.Enabled()
|
||||
}
|
||||
|
||||
// Brain methods
|
||||
func (n *NPC) GetBrain() Brain {
|
||||
n.brainMutex.RLock()
|
||||
defer n.brainMutex.RUnlock()
|
||||
return n.brain
|
||||
}
|
||||
|
||||
func (n *NPC) SetBrain(brain Brain) {
|
||||
n.brainMutex.Lock()
|
||||
defer n.brainMutex.Unlock()
|
||||
|
||||
// Validate brain matches this NPC
|
||||
if brain != nil && brain.GetBody() != n {
|
||||
// TODO: Log error
|
||||
return
|
||||
}
|
||||
|
||||
n.brain = brain
|
||||
}
|
||||
|
||||
// Shard methods
|
||||
func (n *NPC) GetShardID() int32 {
|
||||
n.mutex.RLock()
|
||||
defer n.mutex.RUnlock()
|
||||
return n.shardID
|
||||
}
|
||||
|
||||
func (n *NPC) SetShardID(id int32) {
|
||||
n.mutex.Lock()
|
||||
defer n.mutex.Unlock()
|
||||
n.shardID = id
|
||||
}
|
||||
|
||||
func (n *NPC) GetShardCharID() int32 {
|
||||
n.mutex.RLock()
|
||||
defer n.mutex.RUnlock()
|
||||
return n.shardCharID
|
||||
}
|
||||
|
||||
func (n *NPC) SetShardCharID(id int32) {
|
||||
n.mutex.Lock()
|
||||
defer n.mutex.Unlock()
|
||||
n.shardCharID = id
|
||||
}
|
||||
|
||||
func (n *NPC) GetShardCreatedTimestamp() int64 {
|
||||
n.mutex.RLock()
|
||||
defer n.mutex.RUnlock()
|
||||
return n.shardCreatedTimestamp
|
||||
}
|
||||
|
||||
func (n *NPC) SetShardCreatedTimestamp(timestamp int64) {
|
||||
n.mutex.Lock()
|
||||
defer n.mutex.Unlock()
|
||||
n.shardCreatedTimestamp = timestamp
|
||||
}
|
||||
|
||||
// HandleUse processes entity command usage
|
||||
func (n *NPC) HandleUse(client Client, commandType string) bool {
|
||||
if client == nil || len(commandType) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if NPC shows command icons
|
||||
if n.Entity == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// TODO: Implement entity command processing
|
||||
// This would integrate with the command system
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// InCombat handles combat state changes
|
||||
func (n *NPC) InCombat(val bool) {
|
||||
if n.Entity == nil {
|
||||
return
|
||||
}
|
||||
|
||||
currentCombat := n.Entity.GetInCombat()
|
||||
if currentCombat == val {
|
||||
return
|
||||
}
|
||||
|
||||
n.Entity.SetInCombat(val)
|
||||
|
||||
if val {
|
||||
// Entering combat
|
||||
if n.GetRunbackLocation() == nil {
|
||||
n.StartRunback(true)
|
||||
}
|
||||
|
||||
// Set max speed for combat
|
||||
if n.Entity.GetMaxSpeed() > 0 {
|
||||
n.Entity.SetSpeed(n.Entity.GetMaxSpeed())
|
||||
}
|
||||
|
||||
// TODO: Add combat icon, call spawn scripts, etc.
|
||||
|
||||
} else {
|
||||
// Leaving combat
|
||||
// TODO: Remove combat icon, call combat reset scripts, etc.
|
||||
|
||||
if n.Entity.GetHP() > 0 {
|
||||
// TODO: Re-enable action states, stop heroic opportunities
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessCombat handles combat processing
|
||||
func (n *NPC) ProcessCombat() {
|
||||
// TODO: Implement combat processing logic
|
||||
// This would handle spell casting, AI decisions, etc.
|
||||
}
|
||||
|
||||
// Copy helper methods
|
||||
func (n *NPC) copySkills(oldNPC *NPC) {
|
||||
if oldNPC == nil {
|
||||
return
|
||||
}
|
||||
|
||||
oldNPC.mutex.RLock()
|
||||
oldSkills := make(map[string]*Skill)
|
||||
for name, skill := range oldNPC.skills {
|
||||
if skill != nil {
|
||||
oldSkills[name] = &Skill{
|
||||
SkillID: skill.SkillID,
|
||||
Name: skill.Name,
|
||||
CurrentVal: skill.CurrentVal,
|
||||
MaxVal: skill.MaxVal,
|
||||
}
|
||||
}
|
||||
}
|
||||
oldNPC.mutex.RUnlock()
|
||||
|
||||
n.SetSkills(oldSkills)
|
||||
}
|
||||
|
||||
func (n *NPC) copySpells(oldNPC *NPC) {
|
||||
if oldNPC == nil {
|
||||
return
|
||||
}
|
||||
|
||||
oldNPC.mutex.RLock()
|
||||
oldSpells := make([]*NPCSpell, len(oldNPC.spells))
|
||||
for i, spell := range oldNPC.spells {
|
||||
if spell != nil {
|
||||
oldSpells[i] = spell.Copy()
|
||||
}
|
||||
}
|
||||
|
||||
// Also copy cast-on spells
|
||||
for castType, spells := range oldNPC.castOnSpells {
|
||||
for _, spell := range spells {
|
||||
if spell != nil {
|
||||
oldSpells = append(oldSpells, spell.Copy())
|
||||
}
|
||||
}
|
||||
}
|
||||
oldNPC.mutex.RUnlock()
|
||||
|
||||
n.SetSpells(oldSpells)
|
||||
}
|
||||
|
||||
// randomizeAppearance applies appearance randomization
|
||||
func (n *NPC) randomizeAppearance(flags int32) {
|
||||
// TODO: Implement full appearance randomization
|
||||
// This is a complex system that would integrate with the appearance system
|
||||
|
||||
// For now, just implement basic randomization
|
||||
if n.Entity == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Random gender
|
||||
if flags&RandomizeGender != 0 {
|
||||
gender := int8(rand.Intn(2) + 1) // 1 or 2
|
||||
n.Entity.SetGender(gender)
|
||||
}
|
||||
|
||||
// Random race (simplified)
|
||||
if flags&RandomizeRace != 0 {
|
||||
// TODO: Implement race randomization based on alignment
|
||||
race := int16(rand.Intn(21)) // 0-20 for basic races
|
||||
n.Entity.SetRace(race)
|
||||
}
|
||||
|
||||
// Color randomization
|
||||
if flags&RandomizeSkinColor != 0 {
|
||||
// TODO: Implement skin color randomization
|
||||
}
|
||||
|
||||
// More randomization options would be implemented here
|
||||
}
|
||||
|
||||
// Validation methods
|
||||
func (n *NPC) IsValid() bool {
|
||||
if n.Entity == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Basic validation
|
||||
if n.Entity.GetLevel() < MinNPCLevel || n.Entity.GetLevel() > MaxNPCLevel {
|
||||
return false
|
||||
}
|
||||
|
||||
if n.appearanceID < MinAppearanceID || n.appearanceID > MaxAppearanceID {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// String returns a string representation of the NPC
|
||||
func (n *NPC) String() string {
|
||||
if n.Entity == nil {
|
||||
return fmt.Sprintf("NPC{ID: %d, AppearanceID: %d, Entity: nil}", n.npcID, n.appearanceID)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("NPC{ID: %d, Name: %s, Level: %d, AppearanceID: %d}",
|
||||
n.npcID, n.Entity.GetName(), n.Entity.GetLevel(), n.appearanceID)
|
||||
}
|
440
internal/npc/types.go
Normal file
440
internal/npc/types.go
Normal file
@ -0,0 +1,440 @@
|
||||
package npc
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"eq2emu/internal/common"
|
||||
"eq2emu/internal/entity"
|
||||
"eq2emu/internal/spawn"
|
||||
)
|
||||
|
||||
// NPCSpell represents a spell configuration for NPCs
|
||||
type NPCSpell struct {
|
||||
ListID int32 // Spell list identifier
|
||||
SpellID int32 // Spell ID from master spell list
|
||||
Tier int8 // Spell tier
|
||||
CastOnSpawn bool // Cast when NPC spawns
|
||||
CastOnInitialAggro bool // Cast when first entering combat
|
||||
RequiredHPRatio int8 // HP ratio requirement for casting (-100 to 100)
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewNPCSpell creates a new NPCSpell
|
||||
func NewNPCSpell() *NPCSpell {
|
||||
return &NPCSpell{
|
||||
ListID: 0,
|
||||
SpellID: 0,
|
||||
Tier: 1,
|
||||
CastOnSpawn: false,
|
||||
CastOnInitialAggro: false,
|
||||
RequiredHPRatio: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of the NPCSpell
|
||||
func (ns *NPCSpell) Copy() *NPCSpell {
|
||||
ns.mutex.RLock()
|
||||
defer ns.mutex.RUnlock()
|
||||
|
||||
return &NPCSpell{
|
||||
ListID: ns.ListID,
|
||||
SpellID: ns.SpellID,
|
||||
Tier: ns.Tier,
|
||||
CastOnSpawn: ns.CastOnSpawn,
|
||||
CastOnInitialAggro: ns.CastOnInitialAggro,
|
||||
RequiredHPRatio: ns.RequiredHPRatio,
|
||||
}
|
||||
}
|
||||
|
||||
// Getters
|
||||
func (ns *NPCSpell) GetListID() int32 {
|
||||
ns.mutex.RLock()
|
||||
defer ns.mutex.RUnlock()
|
||||
return ns.ListID
|
||||
}
|
||||
|
||||
func (ns *NPCSpell) GetSpellID() int32 {
|
||||
ns.mutex.RLock()
|
||||
defer ns.mutex.RUnlock()
|
||||
return ns.SpellID
|
||||
}
|
||||
|
||||
func (ns *NPCSpell) GetTier() int8 {
|
||||
ns.mutex.RLock()
|
||||
defer ns.mutex.RUnlock()
|
||||
return ns.Tier
|
||||
}
|
||||
|
||||
func (ns *NPCSpell) GetCastOnSpawn() bool {
|
||||
ns.mutex.RLock()
|
||||
defer ns.mutex.RUnlock()
|
||||
return ns.CastOnSpawn
|
||||
}
|
||||
|
||||
func (ns *NPCSpell) GetCastOnInitialAggro() bool {
|
||||
ns.mutex.RLock()
|
||||
defer ns.mutex.RUnlock()
|
||||
return ns.CastOnInitialAggro
|
||||
}
|
||||
|
||||
func (ns *NPCSpell) GetRequiredHPRatio() int8 {
|
||||
ns.mutex.RLock()
|
||||
defer ns.mutex.RUnlock()
|
||||
return ns.RequiredHPRatio
|
||||
}
|
||||
|
||||
// Setters
|
||||
func (ns *NPCSpell) SetListID(id int32) {
|
||||
ns.mutex.Lock()
|
||||
defer ns.mutex.Unlock()
|
||||
ns.ListID = id
|
||||
}
|
||||
|
||||
func (ns *NPCSpell) SetSpellID(id int32) {
|
||||
ns.mutex.Lock()
|
||||
defer ns.mutex.Unlock()
|
||||
ns.SpellID = id
|
||||
}
|
||||
|
||||
func (ns *NPCSpell) SetTier(tier int8) {
|
||||
ns.mutex.Lock()
|
||||
defer ns.mutex.Unlock()
|
||||
ns.Tier = tier
|
||||
}
|
||||
|
||||
func (ns *NPCSpell) SetCastOnSpawn(cast bool) {
|
||||
ns.mutex.Lock()
|
||||
defer ns.mutex.Unlock()
|
||||
ns.CastOnSpawn = cast
|
||||
}
|
||||
|
||||
func (ns *NPCSpell) SetCastOnInitialAggro(cast bool) {
|
||||
ns.mutex.Lock()
|
||||
defer ns.mutex.Unlock()
|
||||
ns.CastOnInitialAggro = cast
|
||||
}
|
||||
|
||||
func (ns *NPCSpell) SetRequiredHPRatio(ratio int8) {
|
||||
ns.mutex.Lock()
|
||||
defer ns.mutex.Unlock()
|
||||
ns.RequiredHPRatio = ratio
|
||||
}
|
||||
|
||||
// SkillBonus represents a skill bonus from spells
|
||||
type SkillBonus struct {
|
||||
SpellID int32 // Spell providing the bonus
|
||||
Skills map[int32]*SkillBonusValue // Map of skill ID to bonus value
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// SkillBonusValue represents the actual bonus value for a skill
|
||||
type SkillBonusValue struct {
|
||||
SkillID int32 // Skill receiving the bonus
|
||||
Value float32 // Bonus amount
|
||||
}
|
||||
|
||||
// NewSkillBonus creates a new SkillBonus
|
||||
func NewSkillBonus(spellID int32) *SkillBonus {
|
||||
return &SkillBonus{
|
||||
SpellID: spellID,
|
||||
Skills: make(map[int32]*SkillBonusValue),
|
||||
}
|
||||
}
|
||||
|
||||
// AddSkill adds a skill bonus
|
||||
func (sb *SkillBonus) AddSkill(skillID int32, value float32) {
|
||||
sb.mutex.Lock()
|
||||
defer sb.mutex.Unlock()
|
||||
|
||||
sb.Skills[skillID] = &SkillBonusValue{
|
||||
SkillID: skillID,
|
||||
Value: value,
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveSkill removes a skill bonus
|
||||
func (sb *SkillBonus) RemoveSkill(skillID int32) bool {
|
||||
sb.mutex.Lock()
|
||||
defer sb.mutex.Unlock()
|
||||
|
||||
if _, exists := sb.Skills[skillID]; exists {
|
||||
delete(sb.Skills, skillID)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetSkills returns a copy of all skill bonuses
|
||||
func (sb *SkillBonus) GetSkills() map[int32]*SkillBonusValue {
|
||||
sb.mutex.RLock()
|
||||
defer sb.mutex.RUnlock()
|
||||
|
||||
result := make(map[int32]*SkillBonusValue)
|
||||
for id, bonus := range sb.Skills {
|
||||
result[id] = &SkillBonusValue{
|
||||
SkillID: bonus.SkillID,
|
||||
Value: bonus.Value,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MovementLocation represents a movement destination for runback
|
||||
type MovementLocation struct {
|
||||
X float32 // X coordinate
|
||||
Y float32 // Y coordinate
|
||||
Z float32 // Z coordinate
|
||||
GridID int32 // Grid location ID
|
||||
Stage int32 // Movement stage
|
||||
ResetHPOnRunback bool // Whether to reset HP when reaching location
|
||||
UseNavPath bool // Whether to use navigation pathfinding
|
||||
Mapped bool // Whether location is mapped
|
||||
}
|
||||
|
||||
// NewMovementLocation creates a new MovementLocation
|
||||
func NewMovementLocation(x, y, z float32, gridID int32) *MovementLocation {
|
||||
return &MovementLocation{
|
||||
X: x,
|
||||
Y: y,
|
||||
Z: z,
|
||||
GridID: gridID,
|
||||
Stage: 0,
|
||||
ResetHPOnRunback: false,
|
||||
UseNavPath: false,
|
||||
Mapped: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of the MovementLocation
|
||||
func (ml *MovementLocation) Copy() *MovementLocation {
|
||||
return &MovementLocation{
|
||||
X: ml.X,
|
||||
Y: ml.Y,
|
||||
Z: ml.Z,
|
||||
GridID: ml.GridID,
|
||||
Stage: ml.Stage,
|
||||
ResetHPOnRunback: ml.ResetHPOnRunback,
|
||||
UseNavPath: ml.UseNavPath,
|
||||
Mapped: ml.Mapped,
|
||||
}
|
||||
}
|
||||
|
||||
// NPC represents a non-player character extending Entity
|
||||
type NPC struct {
|
||||
*entity.Entity // Embedded entity for combat capabilities
|
||||
|
||||
// Core NPC properties
|
||||
appearanceID int32 // Appearance ID for client display
|
||||
npcID int32 // NPC database ID
|
||||
aiStrategy int8 // AI strategy (balanced/offensive/defensive)
|
||||
attackType int8 // Attack type preference
|
||||
castPercentage int8 // Percentage chance to cast spells
|
||||
maxPetLevel int8 // Maximum pet level
|
||||
|
||||
// Combat and movement
|
||||
aggroRadius float32 // Aggro detection radius
|
||||
baseAggroRadius float32 // Base aggro radius (for resets)
|
||||
runback *MovementLocation // Runback location when leaving combat
|
||||
runningBack bool // Currently running back to spawn point
|
||||
runbackHeadingDir1 int16 // Original heading direction 1
|
||||
runbackHeadingDir2 int16 // Original heading direction 2
|
||||
pauseTimer *Timer // Movement pause timer
|
||||
|
||||
// Spell and skill management
|
||||
primarySpellList int32 // Primary spell list ID
|
||||
secondarySpellList int32 // Secondary spell list ID
|
||||
primarySkillList int32 // Primary skill list ID
|
||||
secondarySkillList int32 // Secondary skill list ID
|
||||
equipmentListID int32 // Equipment list ID
|
||||
skills map[string]*Skill // NPC skills by name
|
||||
spells []*NPCSpell // Available spells
|
||||
castOnSpells map[int8][]*NPCSpell // Spells to cast by trigger type
|
||||
skillBonuses map[int32]*SkillBonus // Skill bonuses from spells
|
||||
hasSpells bool // Whether NPC has any spells
|
||||
castOnAggroCompleted bool // Whether cast-on-aggro spells are done
|
||||
|
||||
// Brain/AI system (placeholder for now)
|
||||
brain Brain // AI brain for decision making
|
||||
|
||||
// Shard system (for cross-server functionality)
|
||||
shardID int32 // Shard identifier
|
||||
shardCharID int32 // Character ID on shard
|
||||
shardCreatedTimestamp int64 // Timestamp when created on shard
|
||||
|
||||
// Thread safety
|
||||
mutex sync.RWMutex // Main NPC mutex
|
||||
brainMutex sync.RWMutex // Brain-specific mutex
|
||||
|
||||
// Atomic flags for thread-safe state management
|
||||
callRunback bool // Flag to trigger runback
|
||||
}
|
||||
|
||||
// Timer represents a simple timer for NPC operations
|
||||
type Timer struct {
|
||||
duration time.Duration
|
||||
startTime time.Time
|
||||
enabled bool
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewTimer creates a new timer
|
||||
func NewTimer() *Timer {
|
||||
return &Timer{
|
||||
enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the timer with the given duration
|
||||
func (t *Timer) Start(durationMS int32, reset bool) {
|
||||
t.mutex.Lock()
|
||||
defer t.mutex.Unlock()
|
||||
|
||||
if reset || !t.enabled {
|
||||
t.duration = time.Duration(durationMS) * time.Millisecond
|
||||
t.startTime = time.Now()
|
||||
t.enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check checks if the timer has expired
|
||||
func (t *Timer) Check() bool {
|
||||
t.mutex.RLock()
|
||||
defer t.mutex.RUnlock()
|
||||
|
||||
if !t.enabled {
|
||||
return false
|
||||
}
|
||||
|
||||
return time.Since(t.startTime) >= t.duration
|
||||
}
|
||||
|
||||
// Enabled returns whether the timer is currently enabled
|
||||
func (t *Timer) Enabled() bool {
|
||||
t.mutex.RLock()
|
||||
defer t.mutex.RUnlock()
|
||||
return t.enabled
|
||||
}
|
||||
|
||||
// Disable disables the timer
|
||||
func (t *Timer) Disable() {
|
||||
t.mutex.Lock()
|
||||
defer t.mutex.Unlock()
|
||||
t.enabled = false
|
||||
}
|
||||
|
||||
// Skill represents an NPC skill (simplified from C++ version)
|
||||
type Skill struct {
|
||||
SkillID int32 // Skill identifier
|
||||
Name string // Skill name
|
||||
CurrentVal int16 // Current skill value
|
||||
MaxVal int16 // Maximum skill value
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewSkill creates a new skill
|
||||
func NewSkill(id int32, name string, current, max int16) *Skill {
|
||||
return &Skill{
|
||||
SkillID: id,
|
||||
Name: name,
|
||||
CurrentVal: current,
|
||||
MaxVal: max,
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrentVal returns the current skill value
|
||||
func (s *Skill) GetCurrentVal() int16 {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
return s.CurrentVal
|
||||
}
|
||||
|
||||
// SetCurrentVal sets the current skill value
|
||||
func (s *Skill) SetCurrentVal(val int16) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
s.CurrentVal = val
|
||||
}
|
||||
|
||||
// IncreaseSkill increases the skill value (with random chance)
|
||||
func (s *Skill) IncreaseSkill() bool {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
if s.CurrentVal < s.MaxVal {
|
||||
s.CurrentVal++
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Brain interface represents the AI brain system (placeholder)
|
||||
type Brain interface {
|
||||
Think() error
|
||||
GetBody() *NPC
|
||||
SetBody(*NPC)
|
||||
IsActive() bool
|
||||
SetActive(bool)
|
||||
}
|
||||
|
||||
// DefaultBrain provides a simple brain implementation
|
||||
type DefaultBrain struct {
|
||||
npc *NPC
|
||||
active bool
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewDefaultBrain creates a new default brain
|
||||
func NewDefaultBrain(npc *NPC) *DefaultBrain {
|
||||
return &DefaultBrain{
|
||||
npc: npc,
|
||||
active: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Think processes AI logic (placeholder implementation)
|
||||
func (b *DefaultBrain) Think() error {
|
||||
// TODO: Implement AI thinking logic
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBody returns the NPC this brain controls
|
||||
func (b *DefaultBrain) GetBody() *NPC {
|
||||
b.mutex.RLock()
|
||||
defer b.mutex.RUnlock()
|
||||
return b.npc
|
||||
}
|
||||
|
||||
// SetBody sets the NPC this brain controls
|
||||
func (b *DefaultBrain) SetBody(npc *NPC) {
|
||||
b.mutex.Lock()
|
||||
defer b.mutex.Unlock()
|
||||
b.npc = npc
|
||||
}
|
||||
|
||||
// IsActive returns whether the brain is active
|
||||
func (b *DefaultBrain) IsActive() bool {
|
||||
b.mutex.RLock()
|
||||
defer b.mutex.RUnlock()
|
||||
return b.active
|
||||
}
|
||||
|
||||
// SetActive sets the brain's active state
|
||||
func (b *DefaultBrain) SetActive(active bool) {
|
||||
b.mutex.Lock()
|
||||
defer b.mutex.Unlock()
|
||||
b.active = active
|
||||
}
|
||||
|
||||
// NPCStatistics contains NPC system statistics
|
||||
type NPCStatistics struct {
|
||||
TotalNPCs int `json:"total_npcs"`
|
||||
NPCsInCombat int `json:"npcs_in_combat"`
|
||||
NPCsWithSpells int `json:"npcs_with_spells"`
|
||||
NPCsWithSkills int `json:"npcs_with_skills"`
|
||||
AIStrategyCounts map[string]int `json:"ai_strategy_counts"`
|
||||
SpellCastCount int64 `json:"spell_cast_count"`
|
||||
SkillUsageCount int64 `json:"skill_usage_count"`
|
||||
RunbackCount int64 `json:"runback_count"`
|
||||
AverageAggroRadius float32 `json:"average_aggro_radius"`
|
||||
}
|
426
internal/quests/actions.go
Normal file
426
internal/quests/actions.go
Normal file
@ -0,0 +1,426 @@
|
||||
package quests
|
||||
|
||||
// Action management methods for Quest
|
||||
|
||||
// AddCompleteAction adds a completion action for a step
|
||||
func (q *Quest) AddCompleteAction(stepID int32, action string) {
|
||||
q.completeActionsMutex.Lock()
|
||||
defer q.completeActionsMutex.Unlock()
|
||||
q.CompleteActions[stepID] = action
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// AddProgressAction adds a progress action for a step
|
||||
func (q *Quest) AddProgressAction(stepID int32, action string) {
|
||||
q.progressActionsMutex.Lock()
|
||||
defer q.progressActionsMutex.Unlock()
|
||||
q.ProgressActions[stepID] = action
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// AddFailedAction adds a failure action for a step
|
||||
func (q *Quest) AddFailedAction(stepID int32, action string) {
|
||||
q.failedActionsMutex.Lock()
|
||||
defer q.failedActionsMutex.Unlock()
|
||||
q.FailedActions[stepID] = action
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// RemoveCompleteAction removes a completion action for a step
|
||||
func (q *Quest) RemoveCompleteAction(stepID int32) bool {
|
||||
q.completeActionsMutex.Lock()
|
||||
defer q.completeActionsMutex.Unlock()
|
||||
|
||||
if _, exists := q.CompleteActions[stepID]; exists {
|
||||
delete(q.CompleteActions, stepID)
|
||||
q.SetSaveNeeded(true)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RemoveProgressAction removes a progress action for a step
|
||||
func (q *Quest) RemoveProgressAction(stepID int32) bool {
|
||||
q.progressActionsMutex.Lock()
|
||||
defer q.progressActionsMutex.Unlock()
|
||||
|
||||
if _, exists := q.ProgressActions[stepID]; exists {
|
||||
delete(q.ProgressActions, stepID)
|
||||
q.SetSaveNeeded(true)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RemoveFailedAction removes a failure action for a step
|
||||
func (q *Quest) RemoveFailedAction(stepID int32) bool {
|
||||
q.failedActionsMutex.Lock()
|
||||
defer q.failedActionsMutex.Unlock()
|
||||
|
||||
if _, exists := q.FailedActions[stepID]; exists {
|
||||
delete(q.FailedActions, stepID)
|
||||
q.SetSaveNeeded(true)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetCompleteAction returns the completion action for a step
|
||||
func (q *Quest) GetCompleteAction(stepID int32) (string, bool) {
|
||||
q.completeActionsMutex.RLock()
|
||||
defer q.completeActionsMutex.RUnlock()
|
||||
|
||||
action, exists := q.CompleteActions[stepID]
|
||||
return action, exists
|
||||
}
|
||||
|
||||
// GetProgressAction returns the progress action for a step
|
||||
func (q *Quest) GetProgressAction(stepID int32) (string, bool) {
|
||||
q.progressActionsMutex.RLock()
|
||||
defer q.progressActionsMutex.RUnlock()
|
||||
|
||||
action, exists := q.ProgressActions[stepID]
|
||||
return action, exists
|
||||
}
|
||||
|
||||
// GetFailedAction returns the failure action for a step
|
||||
func (q *Quest) GetFailedAction(stepID int32) (string, bool) {
|
||||
q.failedActionsMutex.RLock()
|
||||
defer q.failedActionsMutex.RUnlock()
|
||||
|
||||
action, exists := q.FailedActions[stepID]
|
||||
return action, exists
|
||||
}
|
||||
|
||||
// HasCompleteAction checks if a step has a completion action
|
||||
func (q *Quest) HasCompleteAction(stepID int32) bool {
|
||||
q.completeActionsMutex.RLock()
|
||||
defer q.completeActionsMutex.RUnlock()
|
||||
|
||||
_, exists := q.CompleteActions[stepID]
|
||||
return exists
|
||||
}
|
||||
|
||||
// HasProgressAction checks if a step has a progress action
|
||||
func (q *Quest) HasProgressAction(stepID int32) bool {
|
||||
q.progressActionsMutex.RLock()
|
||||
defer q.progressActionsMutex.RUnlock()
|
||||
|
||||
_, exists := q.ProgressActions[stepID]
|
||||
return exists
|
||||
}
|
||||
|
||||
// HasFailedAction checks if a step has a failure action
|
||||
func (q *Quest) HasFailedAction(stepID int32) bool {
|
||||
q.failedActionsMutex.RLock()
|
||||
defer q.failedActionsMutex.RUnlock()
|
||||
|
||||
_, exists := q.FailedActions[stepID]
|
||||
return exists
|
||||
}
|
||||
|
||||
// SetCompleteAction sets the general quest completion action
|
||||
func (q *Quest) SetCompleteAction(action string) {
|
||||
q.CompleteAction = action
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// GetQuestCompleteAction returns the general quest completion action
|
||||
func (q *Quest) GetQuestCompleteAction() string {
|
||||
return q.CompleteAction
|
||||
}
|
||||
|
||||
// ClearAllActions removes all actions for all steps
|
||||
func (q *Quest) ClearAllActions() {
|
||||
q.completeActionsMutex.Lock()
|
||||
q.CompleteActions = make(map[int32]string)
|
||||
q.completeActionsMutex.Unlock()
|
||||
|
||||
q.progressActionsMutex.Lock()
|
||||
q.ProgressActions = make(map[int32]string)
|
||||
q.progressActionsMutex.Unlock()
|
||||
|
||||
q.failedActionsMutex.Lock()
|
||||
q.FailedActions = make(map[int32]string)
|
||||
q.failedActionsMutex.Unlock()
|
||||
|
||||
q.CompleteAction = ""
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// ClearStepActions removes all actions for a specific step
|
||||
func (q *Quest) ClearStepActions(stepID int32) {
|
||||
q.RemoveCompleteAction(stepID)
|
||||
q.RemoveProgressAction(stepID)
|
||||
q.RemoveFailedAction(stepID)
|
||||
}
|
||||
|
||||
// GetAllCompleteActions returns a copy of all completion actions
|
||||
func (q *Quest) GetAllCompleteActions() map[int32]string {
|
||||
q.completeActionsMutex.RLock()
|
||||
defer q.completeActionsMutex.RUnlock()
|
||||
|
||||
actions := make(map[int32]string)
|
||||
for stepID, action := range q.CompleteActions {
|
||||
actions[stepID] = action
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
// GetAllProgressActions returns a copy of all progress actions
|
||||
func (q *Quest) GetAllProgressActions() map[int32]string {
|
||||
q.progressActionsMutex.RLock()
|
||||
defer q.progressActionsMutex.RUnlock()
|
||||
|
||||
actions := make(map[int32]string)
|
||||
for stepID, action := range q.ProgressActions {
|
||||
actions[stepID] = action
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
// GetAllFailedActions returns a copy of all failure actions
|
||||
func (q *Quest) GetAllFailedActions() map[int32]string {
|
||||
q.failedActionsMutex.RLock()
|
||||
defer q.failedActionsMutex.RUnlock()
|
||||
|
||||
actions := make(map[int32]string)
|
||||
for stepID, action := range q.FailedActions {
|
||||
actions[stepID] = action
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
// ExecuteCompleteAction executes a completion action for a step
|
||||
func (q *Quest) ExecuteCompleteAction(stepID int32) error {
|
||||
action, exists := q.GetCompleteAction(stepID)
|
||||
if !exists || action == "" {
|
||||
return fmt.Errorf("no completion action for step %d", stepID)
|
||||
}
|
||||
|
||||
// TODO: Execute Lua script with action
|
||||
// This would integrate with the Lua interface when available
|
||||
return q.executeLuaAction("complete", stepID, action)
|
||||
}
|
||||
|
||||
// ExecuteProgressAction executes a progress action for a step
|
||||
func (q *Quest) ExecuteProgressAction(stepID int32, progressAmount int32) error {
|
||||
action, exists := q.GetProgressAction(stepID)
|
||||
if !exists || action == "" {
|
||||
return fmt.Errorf("no progress action for step %d", stepID)
|
||||
}
|
||||
|
||||
// TODO: Execute Lua script with action and progress amount
|
||||
return q.executeLuaActionWithProgress("progress", stepID, action, progressAmount)
|
||||
}
|
||||
|
||||
// ExecuteFailedAction executes a failure action for a step
|
||||
func (q *Quest) ExecuteFailedAction(stepID int32) error {
|
||||
action, exists := q.GetFailedAction(stepID)
|
||||
if !exists || action == "" {
|
||||
return fmt.Errorf("no failure action for step %d", stepID)
|
||||
}
|
||||
|
||||
// TODO: Execute Lua script with action
|
||||
return q.executeLuaAction("failed", stepID, action)
|
||||
}
|
||||
|
||||
// ExecuteQuestCompleteAction executes the general quest completion action
|
||||
func (q *Quest) ExecuteQuestCompleteAction() error {
|
||||
if q.CompleteAction == "" {
|
||||
return nil // No action to execute
|
||||
}
|
||||
|
||||
// TODO: Execute Lua script with quest completion action
|
||||
return q.executeLuaAction("quest_complete", 0, q.CompleteAction)
|
||||
}
|
||||
|
||||
// Placeholder methods for Lua integration (to be implemented when Lua interface is available)
|
||||
|
||||
// executeLuaAction executes a Lua action script
|
||||
func (q *Quest) executeLuaAction(actionType string, stepID int32, action string) error {
|
||||
// TODO: Implement Lua script execution
|
||||
// This is a placeholder that would integrate with the Lua interface
|
||||
// when it becomes available in the Go codebase
|
||||
|
||||
// For now, just log the action that would be executed
|
||||
fmt.Printf("Quest %d: Would execute %s action for step %d: %s\n", q.ID, actionType, stepID, action)
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeLuaActionWithProgress executes a Lua action script with progress information
|
||||
func (q *Quest) executeLuaActionWithProgress(actionType string, stepID int32, action string, progress int32) error {
|
||||
// TODO: Implement Lua script execution with progress parameter
|
||||
// This is a placeholder that would integrate with the Lua interface
|
||||
|
||||
// For now, just log the action that would be executed
|
||||
fmt.Printf("Quest %d: Would execute %s action for step %d with progress %d: %s\n", q.ID, actionType, stepID, progress, action)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Action validation methods
|
||||
|
||||
// ValidateActions validates all quest actions
|
||||
func (q *Quest) ValidateActions() error {
|
||||
// Validate completion actions
|
||||
q.completeActionsMutex.RLock()
|
||||
for stepID, action := range q.CompleteActions {
|
||||
if err := q.validateAction(stepID, action, "completion"); err != nil {
|
||||
q.completeActionsMutex.RUnlock()
|
||||
return err
|
||||
}
|
||||
}
|
||||
q.completeActionsMutex.RUnlock()
|
||||
|
||||
// Validate progress actions
|
||||
q.progressActionsMutex.RLock()
|
||||
for stepID, action := range q.ProgressActions {
|
||||
if err := q.validateAction(stepID, action, "progress"); err != nil {
|
||||
q.progressActionsMutex.RUnlock()
|
||||
return err
|
||||
}
|
||||
}
|
||||
q.progressActionsMutex.RUnlock()
|
||||
|
||||
// Validate failure actions
|
||||
q.failedActionsMutex.RLock()
|
||||
for stepID, action := range q.FailedActions {
|
||||
if err := q.validateAction(stepID, action, "failure"); err != nil {
|
||||
q.failedActionsMutex.RUnlock()
|
||||
return err
|
||||
}
|
||||
}
|
||||
q.failedActionsMutex.RUnlock()
|
||||
|
||||
// Validate quest completion action
|
||||
if q.CompleteAction != "" {
|
||||
if err := q.validateAction(0, q.CompleteAction, "quest completion"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAction validates a single action
|
||||
func (q *Quest) validateAction(stepID int32, action, actionType string) error {
|
||||
if action == "" {
|
||||
return fmt.Errorf("empty %s action for step %d", actionType, stepID)
|
||||
}
|
||||
|
||||
if len(action) > MaxCompleteActionLength {
|
||||
return fmt.Errorf("%s action too long for step %d (max %d)", actionType, stepID, MaxCompleteActionLength)
|
||||
}
|
||||
|
||||
// Validate that the step exists (except for quest completion action)
|
||||
if stepID > 0 {
|
||||
if step := q.GetQuestStep(stepID); step == nil {
|
||||
return fmt.Errorf("%s action references non-existent step %d", actionType, stepID)
|
||||
}
|
||||
}
|
||||
|
||||
// Basic Lua syntax validation (very basic check)
|
||||
if err := q.validateLuaSyntax(action); err != nil {
|
||||
return fmt.Errorf("invalid Lua syntax in %s action for step %d: %w", actionType, stepID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateLuaSyntax performs basic Lua syntax validation
|
||||
func (q *Quest) validateLuaSyntax(luaCode string) error {
|
||||
// TODO: Implement proper Lua syntax validation
|
||||
// This is a placeholder that does very basic checks
|
||||
|
||||
// Check for balanced parentheses, brackets, and braces
|
||||
if err := q.checkBalancedDelimiters(luaCode); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check for obviously invalid syntax patterns
|
||||
invalidPatterns := []string{
|
||||
"--[[", // Unfinished multi-line comments
|
||||
"function(", // Function without closing
|
||||
}
|
||||
|
||||
for _, pattern := range invalidPatterns {
|
||||
if strings.Contains(luaCode, pattern) {
|
||||
// Do more thorough checking for these patterns
|
||||
// This is just a basic check - real validation would be more sophisticated
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkBalancedDelimiters checks if delimiters are balanced in Lua code
|
||||
func (q *Quest) checkBalancedDelimiters(code string) error {
|
||||
stack := make([]rune, 0)
|
||||
pairs := map[rune]rune{
|
||||
')': '(',
|
||||
']': '[',
|
||||
'}': '{',
|
||||
}
|
||||
|
||||
inString := false
|
||||
inComment := false
|
||||
var stringChar rune
|
||||
|
||||
for i, char := range code {
|
||||
// Handle string literals
|
||||
if (char == '"' || char == '\'') && !inComment {
|
||||
if !inString {
|
||||
inString = true
|
||||
stringChar = char
|
||||
} else if char == stringChar {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip everything inside strings
|
||||
if inString {
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle line comments
|
||||
if i > 0 && code[i-1] == '-' && char == '-' && !inComment {
|
||||
inComment = true
|
||||
continue
|
||||
}
|
||||
|
||||
// End line comment at newline
|
||||
if inComment && char == '\n' {
|
||||
inComment = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip everything in comments
|
||||
if inComment {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check delimiters
|
||||
switch char {
|
||||
case '(', '[', '{':
|
||||
stack = append(stack, char)
|
||||
case ')', ']', '}':
|
||||
if len(stack) == 0 {
|
||||
return fmt.Errorf("unmatched closing delimiter '%c'", char)
|
||||
}
|
||||
|
||||
expected := pairs[char]
|
||||
if stack[len(stack)-1] != expected {
|
||||
return fmt.Errorf("mismatched delimiter: expected '%c', got '%c'", expected, char)
|
||||
}
|
||||
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
}
|
||||
|
||||
if len(stack) > 0 {
|
||||
return fmt.Errorf("unclosed delimiter '%c'", stack[len(stack)-1])
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
77
internal/quests/constants.go
Normal file
77
internal/quests/constants.go
Normal file
@ -0,0 +1,77 @@
|
||||
package quests
|
||||
|
||||
// Quest step type constants
|
||||
const (
|
||||
StepTypeKill int8 = 1
|
||||
StepTypeChat int8 = 2
|
||||
StepTypeObtainItem int8 = 3
|
||||
StepTypeLocation int8 = 4
|
||||
StepTypeSpell int8 = 5
|
||||
StepTypeNormal int8 = 6
|
||||
StepTypeCraft int8 = 7
|
||||
StepTypeHarvest int8 = 8
|
||||
StepTypeKillRaceReq int8 = 9 // kill using race type requirement instead of npc db id
|
||||
)
|
||||
|
||||
// Quest display status constants
|
||||
const (
|
||||
DisplayStatusHidden int32 = 0
|
||||
DisplayStatusNoCheck int32 = 1
|
||||
DisplayStatusYellow int32 = 2
|
||||
DisplayStatusCompleted int32 = 4
|
||||
DisplayStatusRepeatable int32 = 8
|
||||
DisplayStatusCanShare int32 = 16
|
||||
DisplayStatusCompleteFlag int32 = 32
|
||||
DisplayStatusShow int32 = 64
|
||||
DisplayStatusCheck int32 = 128
|
||||
)
|
||||
|
||||
// Quest shareable flags
|
||||
const (
|
||||
ShareableNone int32 = 0
|
||||
ShareableActive int32 = 1
|
||||
ShareableDuring int32 = 2
|
||||
ShareableCompleted int32 = 4
|
||||
)
|
||||
|
||||
// Default quest values
|
||||
const (
|
||||
DefaultIcon int16 = 11
|
||||
DefaultPrereqLevel int8 = 1
|
||||
DefaultVisible int8 = 1
|
||||
DefaultTaskGroupNum int16 = 1
|
||||
)
|
||||
|
||||
// Quest validation limits
|
||||
const (
|
||||
MaxQuestNameLength int = 255
|
||||
MaxQuestDescriptionLength int = 2048
|
||||
MaxStepDescriptionLength int = 1024
|
||||
MaxTaskGroupNameLength int = 255
|
||||
MaxCompleteActionLength int = 512
|
||||
)
|
||||
|
||||
// Location-based constants
|
||||
const (
|
||||
MinLocationVariation float32 = 0.1
|
||||
MaxLocationVariation float32 = 1000.0
|
||||
)
|
||||
|
||||
// Percentage constants
|
||||
const (
|
||||
MinPercentage float32 = 0.0
|
||||
MaxPercentage float32 = 100.0
|
||||
)
|
||||
|
||||
// Status constants
|
||||
const (
|
||||
StatusInactive int32 = 0
|
||||
StatusActive int32 = 1
|
||||
StatusComplete int32 = 2
|
||||
StatusFailed int32 = 3
|
||||
)
|
||||
|
||||
// Random constants
|
||||
const (
|
||||
DefaultRandomRange float32 = 100.0
|
||||
)
|
476
internal/quests/interfaces.go
Normal file
476
internal/quests/interfaces.go
Normal file
@ -0,0 +1,476 @@
|
||||
package quests
|
||||
|
||||
// Player interface defines the required player functionality for quest system
|
||||
type Player interface {
|
||||
// Basic player information
|
||||
GetID() int32
|
||||
GetName() string
|
||||
GetLevel() int8
|
||||
GetTSLevel() int8
|
||||
GetClass() int8
|
||||
GetTSClass() int8
|
||||
GetRace() int8
|
||||
|
||||
// Client interface for packet sending
|
||||
GetClient() Client
|
||||
|
||||
// Quest-related player methods
|
||||
GetQuest(questID int32) *Quest
|
||||
HasQuest(questID int32) bool
|
||||
HasQuestBeenCompleted(questID int32) bool
|
||||
AddQuest(quest *Quest) error
|
||||
RemoveQuest(questID int32) bool
|
||||
|
||||
// Position and zone information
|
||||
GetX() float32
|
||||
GetY() float32
|
||||
GetZ() float32
|
||||
GetZoneID() int32
|
||||
|
||||
// Faction information
|
||||
GetFactionValue(factionID int32) int32
|
||||
|
||||
// Experience and rewards
|
||||
AddCoins(amount int64)
|
||||
AddExp(amount int32)
|
||||
AddTSExp(amount int32)
|
||||
AddStatusPoints(amount int32)
|
||||
|
||||
// Inventory management
|
||||
GetPlayerItemList() ItemList
|
||||
|
||||
// Arrow color calculation (difficulty indication)
|
||||
GetArrowColor(level int8) int8
|
||||
GetTSArrowColor(level int8) int8
|
||||
}
|
||||
|
||||
// Client interface defines client functionality needed for quest system
|
||||
type Client interface {
|
||||
// Basic client information
|
||||
GetVersion() int16
|
||||
GetNameCRC() int32
|
||||
GetPlayer() Player
|
||||
|
||||
// Packet sending
|
||||
QueuePacket(packet Packet)
|
||||
SimpleMessage(color int32, message string)
|
||||
|
||||
// Quest-specific client methods
|
||||
SendQuestJournalUpdate(quest *Quest, forceUpdate bool)
|
||||
PopulateQuestRewardItems(items *[]Item, packet PacketStruct, arrayName ...string)
|
||||
}
|
||||
|
||||
// Spawn interface defines spawn functionality needed for quest system
|
||||
type Spawn interface {
|
||||
// Basic spawn information
|
||||
GetID() int32
|
||||
GetDatabaseID() int32
|
||||
GetName() string
|
||||
GetRace() int8
|
||||
GetModelType() int16
|
||||
|
||||
// Position information
|
||||
GetX() float32
|
||||
GetY() float32
|
||||
GetZ() float32
|
||||
GetZoneID() int32
|
||||
}
|
||||
|
||||
// Spell interface defines spell functionality needed for quest system
|
||||
type Spell interface {
|
||||
GetSpellID() int32
|
||||
GetName() string
|
||||
}
|
||||
|
||||
// Item interface defines item functionality needed for quest system
|
||||
type Item interface {
|
||||
GetItemID() int32
|
||||
GetName() string
|
||||
GetIcon(version int16) int32
|
||||
GetDetails() ItemDetails
|
||||
GetStackCount() int32
|
||||
}
|
||||
|
||||
// ItemDetails interface defines item detail functionality
|
||||
type ItemDetails interface {
|
||||
GetItemID() int32
|
||||
GetUniqueID() int32
|
||||
GetCount() int32
|
||||
}
|
||||
|
||||
// ItemList interface defines item list functionality
|
||||
type ItemList interface {
|
||||
GetItemFromID(itemID int32, count int32) Item
|
||||
}
|
||||
|
||||
// Packet interface defines packet functionality
|
||||
type Packet interface {
|
||||
// Packet data methods (placeholder)
|
||||
Serialize() []byte
|
||||
}
|
||||
|
||||
// PacketStruct interface defines packet structure functionality
|
||||
type PacketStruct interface {
|
||||
// Packet building methods
|
||||
SetDataByName(name string, value interface{}, index ...int)
|
||||
SetArrayLengthByName(name string, length int)
|
||||
SetArrayDataByName(name string, value interface{}, index int)
|
||||
SetSubArrayLengthByName(name string, length int, index int)
|
||||
SetSubArrayDataByName(name string, value interface{}, index1, index2 int)
|
||||
SetSubstructArrayDataByName(substruct, name string, value interface{}, index int)
|
||||
SetItemArrayDataByName(name string, item Item, player Player, index int, flag ...int)
|
||||
Serialize() Packet
|
||||
GetVersion() int16
|
||||
}
|
||||
|
||||
// QuestOfferer interface defines NPCs that can offer quests
|
||||
type QuestOfferer interface {
|
||||
Spawn
|
||||
GetOfferedQuests() []int32
|
||||
CanOfferQuest(questID int32, player Player) bool
|
||||
OfferQuest(questID int32, player Player) error
|
||||
}
|
||||
|
||||
// QuestReceiver interface defines NPCs that can receive completed quests
|
||||
type QuestReceiver interface {
|
||||
Spawn
|
||||
GetAcceptedQuests() []int32
|
||||
CanAcceptQuest(questID int32, player Player) bool
|
||||
AcceptCompletedQuest(questID int32, player Player) error
|
||||
}
|
||||
|
||||
// QuestValidator interface defines quest validation functionality
|
||||
type QuestValidator interface {
|
||||
ValidatePrerequisites(quest *Quest, player Player) error
|
||||
ValidateQuestCompletion(quest *Quest, player Player) error
|
||||
ValidateQuestSharing(quest *Quest, sharer, receiver Player) error
|
||||
}
|
||||
|
||||
// QuestRewardProcessor interface defines quest reward processing
|
||||
type QuestRewardProcessor interface {
|
||||
ProcessQuestRewards(quest *Quest, player Player) error
|
||||
CalculateRewards(quest *Quest, player Player) *QuestRewards
|
||||
GiveRewards(rewards *QuestRewards, player Player) error
|
||||
}
|
||||
|
||||
// QuestRewards contains calculated quest rewards
|
||||
type QuestRewards struct {
|
||||
Coins int64 `json:"coins"`
|
||||
Experience int32 `json:"experience"`
|
||||
TSExperience int32 `json:"ts_experience"`
|
||||
StatusPoints int32 `json:"status_points"`
|
||||
Items []Item `json:"items"`
|
||||
FactionRewards map[int32]int32 `json:"faction_rewards"`
|
||||
}
|
||||
|
||||
// QuestEventHandler interface defines quest event handling
|
||||
type QuestEventHandler interface {
|
||||
OnQuestStarted(quest *Quest, player Player)
|
||||
OnQuestStepCompleted(quest *Quest, step *QuestStep, player Player)
|
||||
OnQuestCompleted(quest *Quest, player Player)
|
||||
OnQuestFailed(quest *Quest, step *QuestStep, player Player)
|
||||
OnQuestAbandoned(quest *Quest, player Player)
|
||||
OnQuestShared(quest *Quest, sharer, receiver Player)
|
||||
}
|
||||
|
||||
// Database interface defines database operations for quest system
|
||||
type Database interface {
|
||||
// Quest CRUD operations
|
||||
LoadQuest(questID int32) (*Quest, error)
|
||||
SaveQuest(quest *Quest) error
|
||||
DeleteQuest(questID int32) error
|
||||
LoadAllQuests() ([]*Quest, error)
|
||||
|
||||
// Player quest operations
|
||||
LoadPlayerQuests(playerID int32) ([]*Quest, error)
|
||||
SavePlayerQuest(playerID int32, quest *Quest) error
|
||||
DeletePlayerQuest(playerID, questID int32) error
|
||||
MarkQuestCompleted(playerID, questID int32) error
|
||||
IsQuestCompleted(playerID, questID int32) bool
|
||||
|
||||
// Quest step operations
|
||||
SaveQuestStepProgress(playerID, questID, stepID int32, progress int32) error
|
||||
LoadQuestStepProgress(playerID, questID, stepID int32) (int32, error)
|
||||
}
|
||||
|
||||
// Logger interface defines logging functionality for quest system
|
||||
type Logger interface {
|
||||
LogInfo(message string, args ...interface{})
|
||||
LogError(message string, args ...interface{})
|
||||
LogDebug(message string, args ...interface{})
|
||||
LogWarning(message string, args ...interface{})
|
||||
}
|
||||
|
||||
// Configuration interface defines quest system configuration
|
||||
type Configuration interface {
|
||||
GetMaxPlayerQuests() int
|
||||
GetQuestSharingEnabled() bool
|
||||
GetQuestFailureEnabled() bool
|
||||
GetQuestTimersEnabled() bool
|
||||
GetXPBonusMultiplier() float64
|
||||
GetCoinBonusMultiplier() float64
|
||||
GetStatusBonusMultiplier() float64
|
||||
}
|
||||
|
||||
// QuestSystemAdapter provides integration with other game systems
|
||||
type QuestSystemAdapter struct {
|
||||
questManager *QuestManager
|
||||
validator QuestValidator
|
||||
rewardProcessor QuestRewardProcessor
|
||||
eventHandler QuestEventHandler
|
||||
database Database
|
||||
logger Logger
|
||||
config Configuration
|
||||
}
|
||||
|
||||
// NewQuestSystemAdapter creates a new quest system adapter
|
||||
func NewQuestSystemAdapter(questManager *QuestManager, validator QuestValidator, rewardProcessor QuestRewardProcessor, eventHandler QuestEventHandler, database Database, logger Logger, config Configuration) *QuestSystemAdapter {
|
||||
return &QuestSystemAdapter{
|
||||
questManager: questManager,
|
||||
validator: validator,
|
||||
rewardProcessor: rewardProcessor,
|
||||
eventHandler: eventHandler,
|
||||
database: database,
|
||||
logger: logger,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// StartQuest handles starting a new quest for a player
|
||||
func (qsa *QuestSystemAdapter) StartQuest(questID int32, player Player) error {
|
||||
// Get quest from master list
|
||||
quest := qsa.questManager.GetMasterList().GetQuest(questID, true)
|
||||
if quest == nil {
|
||||
return fmt.Errorf("quest %d not found", questID)
|
||||
}
|
||||
|
||||
// Validate prerequisites
|
||||
if qsa.validator != nil {
|
||||
if err := qsa.validator.ValidatePrerequisites(quest, player); err != nil {
|
||||
return fmt.Errorf("quest prerequisites not met: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if player can accept more quests
|
||||
if qsa.config != nil {
|
||||
maxQuests := qsa.config.GetMaxPlayerQuests()
|
||||
if maxQuests > 0 && qsa.questManager.GetPlayerQuestCount(player.GetID()) >= maxQuests {
|
||||
return fmt.Errorf("player has too many active quests")
|
||||
}
|
||||
}
|
||||
|
||||
// Give quest to player
|
||||
_, err := qsa.questManager.GiveQuestToPlayer(player.GetID(), questID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Save to database
|
||||
if qsa.database != nil {
|
||||
if err := qsa.database.SavePlayerQuest(player.GetID(), quest); err != nil {
|
||||
qsa.logger.LogError("Failed to save player quest to database: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger event
|
||||
if qsa.eventHandler != nil {
|
||||
qsa.eventHandler.OnQuestStarted(quest, player)
|
||||
}
|
||||
|
||||
// Log
|
||||
if qsa.logger != nil {
|
||||
qsa.logger.LogInfo("Player %s (%d) started quest %s (%d)", player.GetName(), player.GetID(), quest.Name, questID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CompleteQuest handles completing a quest for a player
|
||||
func (qsa *QuestSystemAdapter) CompleteQuest(questID int32, player Player) error {
|
||||
// Get player's quest
|
||||
quest := qsa.questManager.GetPlayerQuest(player.GetID(), questID)
|
||||
if quest == nil {
|
||||
return fmt.Errorf("player does not have quest %d", questID)
|
||||
}
|
||||
|
||||
// Validate completion
|
||||
if qsa.validator != nil {
|
||||
if err := qsa.validator.ValidateQuestCompletion(quest, player); err != nil {
|
||||
return fmt.Errorf("quest completion validation failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if quest is actually complete
|
||||
if !quest.GetCompleted() {
|
||||
return fmt.Errorf("quest is not complete")
|
||||
}
|
||||
|
||||
// Process rewards
|
||||
if qsa.rewardProcessor != nil {
|
||||
if err := qsa.rewardProcessor.ProcessQuestRewards(quest, player); err != nil {
|
||||
qsa.logger.LogError("Failed to process quest rewards: %v", err)
|
||||
return fmt.Errorf("failed to process quest rewards: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as completed in database
|
||||
if qsa.database != nil {
|
||||
if err := qsa.database.MarkQuestCompleted(player.GetID(), questID); err != nil {
|
||||
qsa.logger.LogError("Failed to mark quest as completed in database: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from active quests if not repeatable
|
||||
if !quest.IsRepeatable() {
|
||||
qsa.questManager.RemoveQuestFromPlayer(player.GetID(), questID)
|
||||
}
|
||||
|
||||
// Trigger event
|
||||
if qsa.eventHandler != nil {
|
||||
qsa.eventHandler.OnQuestCompleted(quest, player)
|
||||
}
|
||||
|
||||
// Log
|
||||
if qsa.logger != nil {
|
||||
qsa.logger.LogInfo("Player %s (%d) completed quest %s (%d)", player.GetName(), player.GetID(), quest.Name, questID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateQuestProgress handles updating quest step progress
|
||||
func (qsa *QuestSystemAdapter) UpdateQuestProgress(questID, stepID, progress int32, player Player) error {
|
||||
// Get player's quest
|
||||
quest := qsa.questManager.GetPlayerQuest(player.GetID(), questID)
|
||||
if quest == nil {
|
||||
return fmt.Errorf("player does not have quest %d", questID)
|
||||
}
|
||||
|
||||
// Update progress
|
||||
if !quest.AddStepProgress(stepID, progress) {
|
||||
return fmt.Errorf("failed to update quest step progress")
|
||||
}
|
||||
|
||||
// Check if step is now complete
|
||||
step := quest.GetQuestStep(stepID)
|
||||
if step != nil && step.Complete() {
|
||||
// Execute complete action if exists
|
||||
if quest.HasCompleteAction(stepID) {
|
||||
if err := quest.ExecuteCompleteAction(stepID); err != nil {
|
||||
qsa.logger.LogError("Failed to execute quest step complete action: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger event
|
||||
if qsa.eventHandler != nil {
|
||||
qsa.eventHandler.OnQuestStepCompleted(quest, step, player)
|
||||
}
|
||||
|
||||
// Log
|
||||
if qsa.logger != nil {
|
||||
qsa.logger.LogInfo("Player %s (%d) completed step %d of quest %s (%d)", player.GetName(), player.GetID(), stepID, quest.Name, questID)
|
||||
}
|
||||
}
|
||||
|
||||
// Save progress to database
|
||||
if qsa.database != nil {
|
||||
if err := qsa.database.SaveQuestStepProgress(player.GetID(), questID, stepID, step.GetStepProgress()); err != nil {
|
||||
qsa.logger.LogError("Failed to save quest step progress to database: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AbandonQuest handles abandoning a quest
|
||||
func (qsa *QuestSystemAdapter) AbandonQuest(questID int32, player Player) error {
|
||||
// Get player's quest
|
||||
quest := qsa.questManager.GetPlayerQuest(player.GetID(), questID)
|
||||
if quest == nil {
|
||||
return fmt.Errorf("player does not have quest %d", questID)
|
||||
}
|
||||
|
||||
// Check if quest can be abandoned
|
||||
if !quest.CanDeleteQuest {
|
||||
return fmt.Errorf("quest cannot be abandoned")
|
||||
}
|
||||
|
||||
// Remove from player
|
||||
if !qsa.questManager.RemoveQuestFromPlayer(player.GetID(), questID) {
|
||||
return fmt.Errorf("failed to remove quest from player")
|
||||
}
|
||||
|
||||
// Remove from database
|
||||
if qsa.database != nil {
|
||||
if err := qsa.database.DeletePlayerQuest(player.GetID(), questID); err != nil {
|
||||
qsa.logger.LogError("Failed to delete player quest from database: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger event
|
||||
if qsa.eventHandler != nil {
|
||||
qsa.eventHandler.OnQuestAbandoned(quest, player)
|
||||
}
|
||||
|
||||
// Log
|
||||
if qsa.logger != nil {
|
||||
qsa.logger.LogInfo("Player %s (%d) abandoned quest %s (%d)", player.GetName(), player.GetID(), quest.Name, questID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShareQuest handles sharing a quest between players
|
||||
func (qsa *QuestSystemAdapter) ShareQuest(questID int32, sharer, receiver Player) error {
|
||||
if !qsa.config.GetQuestSharingEnabled() {
|
||||
return fmt.Errorf("quest sharing is disabled")
|
||||
}
|
||||
|
||||
// Get sharer's quest
|
||||
quest := qsa.questManager.GetPlayerQuest(sharer.GetID(), questID)
|
||||
if quest == nil {
|
||||
return fmt.Errorf("sharer does not have quest %d", questID)
|
||||
}
|
||||
|
||||
// Validate sharing
|
||||
if qsa.validator != nil {
|
||||
if err := qsa.validator.ValidateQuestSharing(quest, sharer, receiver); err != nil {
|
||||
return fmt.Errorf("quest sharing validation failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if receiver can accept more quests
|
||||
if qsa.config != nil {
|
||||
maxQuests := qsa.config.GetMaxPlayerQuests()
|
||||
if maxQuests > 0 && qsa.questManager.GetPlayerQuestCount(receiver.GetID()) >= maxQuests {
|
||||
return fmt.Errorf("receiver has too many active quests")
|
||||
}
|
||||
}
|
||||
|
||||
// Give quest to receiver
|
||||
_, err := qsa.questManager.GiveQuestToPlayer(receiver.GetID(), questID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to give quest to receiver: %w", err)
|
||||
}
|
||||
|
||||
// Save to database
|
||||
if qsa.database != nil {
|
||||
receiverQuest := qsa.questManager.GetPlayerQuest(receiver.GetID(), questID)
|
||||
if err := qsa.database.SavePlayerQuest(receiver.GetID(), receiverQuest); err != nil {
|
||||
qsa.logger.LogError("Failed to save shared quest to database: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger event
|
||||
if qsa.eventHandler != nil {
|
||||
qsa.eventHandler.OnQuestShared(quest, sharer, receiver)
|
||||
}
|
||||
|
||||
// Log
|
||||
if qsa.logger != nil {
|
||||
qsa.logger.LogInfo("Player %s (%d) shared quest %s (%d) with %s (%d)",
|
||||
sharer.GetName(), sharer.GetID(), quest.Name, questID, receiver.GetName(), receiver.GetID())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
450
internal/quests/manager.go
Normal file
450
internal/quests/manager.go
Normal file
@ -0,0 +1,450 @@
|
||||
package quests
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// MasterQuestList manages all quests in the system
|
||||
type MasterQuestList struct {
|
||||
quests map[int32]*Quest
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewMasterQuestList creates a new master quest list
|
||||
func NewMasterQuestList() *MasterQuestList {
|
||||
return &MasterQuestList{
|
||||
quests: make(map[int32]*Quest),
|
||||
}
|
||||
}
|
||||
|
||||
// AddQuest adds a quest to the master list
|
||||
func (mql *MasterQuestList) AddQuest(questID int32, quest *Quest) error {
|
||||
if quest == nil {
|
||||
return fmt.Errorf("quest cannot be nil")
|
||||
}
|
||||
|
||||
if questID != quest.ID {
|
||||
return fmt.Errorf("quest ID mismatch: provided %d, quest has %d", questID, quest.ID)
|
||||
}
|
||||
|
||||
mql.mutex.Lock()
|
||||
defer mql.mutex.Unlock()
|
||||
|
||||
if _, exists := mql.quests[questID]; exists {
|
||||
return fmt.Errorf("quest %d already exists", questID)
|
||||
}
|
||||
|
||||
mql.quests[questID] = quest
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetQuest returns a quest by ID, optionally creating a copy
|
||||
func (mql *MasterQuestList) GetQuest(questID int32, copyQuest bool) *Quest {
|
||||
mql.mutex.RLock()
|
||||
defer mql.mutex.RUnlock()
|
||||
|
||||
quest, exists := mql.quests[questID]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
if copyQuest {
|
||||
return quest.Copy()
|
||||
}
|
||||
|
||||
return quest
|
||||
}
|
||||
|
||||
// RemoveQuest removes a quest from the master list
|
||||
func (mql *MasterQuestList) RemoveQuest(questID int32) bool {
|
||||
mql.mutex.Lock()
|
||||
defer mql.mutex.Unlock()
|
||||
|
||||
if _, exists := mql.quests[questID]; exists {
|
||||
delete(mql.quests, questID)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// HasQuest checks if a quest exists
|
||||
func (mql *MasterQuestList) HasQuest(questID int32) bool {
|
||||
mql.mutex.RLock()
|
||||
defer mql.mutex.RUnlock()
|
||||
|
||||
_, exists := mql.quests[questID]
|
||||
return exists
|
||||
}
|
||||
|
||||
// GetAllQuests returns a copy of all quests map
|
||||
func (mql *MasterQuestList) GetAllQuests() map[int32]*Quest {
|
||||
mql.mutex.RLock()
|
||||
defer mql.mutex.RUnlock()
|
||||
|
||||
quests := make(map[int32]*Quest)
|
||||
for id, quest := range mql.quests {
|
||||
quests[id] = quest
|
||||
}
|
||||
|
||||
return quests
|
||||
}
|
||||
|
||||
// GetQuestsByLevel returns quests within a level range
|
||||
func (mql *MasterQuestList) GetQuestsByLevel(minLevel, maxLevel int8) []*Quest {
|
||||
mql.mutex.RLock()
|
||||
defer mql.mutex.RUnlock()
|
||||
|
||||
var quests []*Quest
|
||||
for _, quest := range mql.quests {
|
||||
if quest.Level >= minLevel && quest.Level <= maxLevel {
|
||||
quests = append(quests, quest)
|
||||
}
|
||||
}
|
||||
|
||||
return quests
|
||||
}
|
||||
|
||||
// GetQuestsByType returns quests of a specific type
|
||||
func (mql *MasterQuestList) GetQuestsByType(questType string) []*Quest {
|
||||
mql.mutex.RLock()
|
||||
defer mql.mutex.RUnlock()
|
||||
|
||||
var quests []*Quest
|
||||
for _, quest := range mql.quests {
|
||||
if quest.Type == questType {
|
||||
quests = append(quests, quest)
|
||||
}
|
||||
}
|
||||
|
||||
return quests
|
||||
}
|
||||
|
||||
// GetQuestsByZone returns quests in a specific zone
|
||||
func (mql *MasterQuestList) GetQuestsByZone(zone string) []*Quest {
|
||||
mql.mutex.RLock()
|
||||
defer mql.mutex.RUnlock()
|
||||
|
||||
var quests []*Quest
|
||||
for _, quest := range mql.quests {
|
||||
if quest.Zone == zone {
|
||||
quests = append(quests, quest)
|
||||
}
|
||||
}
|
||||
|
||||
return quests
|
||||
}
|
||||
|
||||
// GetQuestsByGiver returns quests given by a specific NPC
|
||||
func (mql *MasterQuestList) GetQuestsByGiver(giverID int32) []*Quest {
|
||||
mql.mutex.RLock()
|
||||
defer mql.mutex.RUnlock()
|
||||
|
||||
var quests []*Quest
|
||||
for _, quest := range mql.quests {
|
||||
if quest.QuestGiver == giverID {
|
||||
quests = append(quests, quest)
|
||||
}
|
||||
}
|
||||
|
||||
return quests
|
||||
}
|
||||
|
||||
// GetRepeatableQuests returns all repeatable quests
|
||||
func (mql *MasterQuestList) GetRepeatableQuests() []*Quest {
|
||||
mql.mutex.RLock()
|
||||
defer mql.mutex.RUnlock()
|
||||
|
||||
var quests []*Quest
|
||||
for _, quest := range mql.quests {
|
||||
if quest.Repeatable {
|
||||
quests = append(quests, quest)
|
||||
}
|
||||
}
|
||||
|
||||
return quests
|
||||
}
|
||||
|
||||
// GetQuestCount returns the total number of quests
|
||||
func (mql *MasterQuestList) GetQuestCount() int {
|
||||
mql.mutex.RLock()
|
||||
defer mql.mutex.RUnlock()
|
||||
|
||||
return len(mql.quests)
|
||||
}
|
||||
|
||||
// Clear removes all quests
|
||||
func (mql *MasterQuestList) Clear() {
|
||||
mql.mutex.Lock()
|
||||
defer mql.mutex.Unlock()
|
||||
|
||||
mql.quests = make(map[int32]*Quest)
|
||||
}
|
||||
|
||||
// Reload clears and reloads all quests
|
||||
func (mql *MasterQuestList) Reload() {
|
||||
mql.Clear()
|
||||
|
||||
// TODO: Implement quest reloading from database or files
|
||||
// This would typically involve:
|
||||
// 1. Loading quest data from database
|
||||
// 2. Creating Quest objects
|
||||
// 3. Adding them to the master list
|
||||
|
||||
// For now, this is a placeholder
|
||||
fmt.Println("Quest reload requested - implementation pending")
|
||||
}
|
||||
|
||||
// ValidateAllQuests validates all quests in the master list
|
||||
func (mql *MasterQuestList) ValidateAllQuests() []error {
|
||||
mql.mutex.RLock()
|
||||
defer mql.mutex.RUnlock()
|
||||
|
||||
var errors []error
|
||||
|
||||
for questID, quest := range mql.quests {
|
||||
if err := quest.ValidateQuest(); err != nil {
|
||||
errors = append(errors, fmt.Errorf("quest %d validation failed: %w", questID, err))
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// GetQuestStatistics returns basic statistics about the quest system
|
||||
func (mql *MasterQuestList) GetQuestStatistics() *QuestStatistics {
|
||||
mql.mutex.RLock()
|
||||
defer mql.mutex.RUnlock()
|
||||
|
||||
stats := &QuestStatistics{
|
||||
TotalQuests: len(mql.quests),
|
||||
QuestsByType: make(map[string]int),
|
||||
QuestsByLevel: make(map[int8]int),
|
||||
RepeatableCount: 0,
|
||||
HiddenCount: 0,
|
||||
}
|
||||
|
||||
for _, quest := range mql.quests {
|
||||
// Count by type
|
||||
stats.QuestsByType[quest.Type]++
|
||||
|
||||
// Count by level
|
||||
stats.QuestsByLevel[quest.Level]++
|
||||
|
||||
// Count special flags
|
||||
if quest.Repeatable {
|
||||
stats.RepeatableCount++
|
||||
}
|
||||
if quest.Hidden {
|
||||
stats.HiddenCount++
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// QuestStatistics contains statistical information about quests
|
||||
type QuestStatistics struct {
|
||||
TotalQuests int `json:"total_quests"`
|
||||
QuestsByType map[string]int `json:"quests_by_type"`
|
||||
QuestsByLevel map[int8]int `json:"quests_by_level"`
|
||||
RepeatableCount int `json:"repeatable_count"`
|
||||
HiddenCount int `json:"hidden_count"`
|
||||
}
|
||||
|
||||
// QuestManager provides high-level quest management functionality
|
||||
type QuestManager struct {
|
||||
masterList *MasterQuestList
|
||||
playerQuests map[int32]map[int32]*Quest // playerID -> questID -> quest
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewQuestManager creates a new quest manager
|
||||
func NewQuestManager() *QuestManager {
|
||||
return &QuestManager{
|
||||
masterList: NewMasterQuestList(),
|
||||
playerQuests: make(map[int32]map[int32]*Quest),
|
||||
}
|
||||
}
|
||||
|
||||
// GetMasterList returns the master quest list
|
||||
func (qm *QuestManager) GetMasterList() *MasterQuestList {
|
||||
return qm.masterList
|
||||
}
|
||||
|
||||
// GiveQuestToPlayer assigns a quest to a player
|
||||
func (qm *QuestManager) GiveQuestToPlayer(playerID, questID int32) (*Quest, error) {
|
||||
// Get quest from master list (copy it)
|
||||
quest := qm.masterList.GetQuest(questID, true)
|
||||
if quest == nil {
|
||||
return nil, fmt.Errorf("quest %d not found", questID)
|
||||
}
|
||||
|
||||
qm.mutex.Lock()
|
||||
defer qm.mutex.Unlock()
|
||||
|
||||
// Initialize player quest map if needed
|
||||
if qm.playerQuests[playerID] == nil {
|
||||
qm.playerQuests[playerID] = make(map[int32]*Quest)
|
||||
}
|
||||
|
||||
// Check if player already has this quest
|
||||
if _, exists := qm.playerQuests[playerID][questID]; exists {
|
||||
return nil, fmt.Errorf("player %d already has quest %d", playerID, questID)
|
||||
}
|
||||
|
||||
// Assign quest to player
|
||||
qm.playerQuests[playerID][questID] = quest
|
||||
|
||||
return quest, nil
|
||||
}
|
||||
|
||||
// RemoveQuestFromPlayer removes a quest from a player
|
||||
func (qm *QuestManager) RemoveQuestFromPlayer(playerID, questID int32) bool {
|
||||
qm.mutex.Lock()
|
||||
defer qm.mutex.Unlock()
|
||||
|
||||
if playerQuests, exists := qm.playerQuests[playerID]; exists {
|
||||
if _, questExists := playerQuests[questID]; questExists {
|
||||
delete(playerQuests, questID)
|
||||
|
||||
// Clean up empty player quest map
|
||||
if len(playerQuests) == 0 {
|
||||
delete(qm.playerQuests, playerID)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GetPlayerQuest returns a specific quest for a player
|
||||
func (qm *QuestManager) GetPlayerQuest(playerID, questID int32) *Quest {
|
||||
qm.mutex.RLock()
|
||||
defer qm.mutex.RUnlock()
|
||||
|
||||
if playerQuests, exists := qm.playerQuests[playerID]; exists {
|
||||
return playerQuests[questID]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPlayerQuests returns all quests for a player
|
||||
func (qm *QuestManager) GetPlayerQuests(playerID int32) map[int32]*Quest {
|
||||
qm.mutex.RLock()
|
||||
defer qm.mutex.RUnlock()
|
||||
|
||||
if playerQuests, exists := qm.playerQuests[playerID]; exists {
|
||||
// Return a copy
|
||||
quests := make(map[int32]*Quest)
|
||||
for questID, quest := range playerQuests {
|
||||
quests[questID] = quest
|
||||
}
|
||||
return quests
|
||||
}
|
||||
|
||||
return make(map[int32]*Quest)
|
||||
}
|
||||
|
||||
// PlayerHasQuest checks if a player has a specific quest
|
||||
func (qm *QuestManager) PlayerHasQuest(playerID, questID int32) bool {
|
||||
return qm.GetPlayerQuest(playerID, questID) != nil
|
||||
}
|
||||
|
||||
// GetPlayerQuestCount returns the number of quests a player has
|
||||
func (qm *QuestManager) GetPlayerQuestCount(playerID int32) int {
|
||||
qm.mutex.RLock()
|
||||
defer qm.mutex.RUnlock()
|
||||
|
||||
if playerQuests, exists := qm.playerQuests[playerID]; exists {
|
||||
return len(playerQuests)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// ClearPlayerQuests removes all quests from a player
|
||||
func (qm *QuestManager) ClearPlayerQuests(playerID int32) {
|
||||
qm.mutex.Lock()
|
||||
defer qm.mutex.Unlock()
|
||||
|
||||
delete(qm.playerQuests, playerID)
|
||||
}
|
||||
|
||||
// GetAllPlayerIDs returns all player IDs that have quests
|
||||
func (qm *QuestManager) GetAllPlayerIDs() []int32 {
|
||||
qm.mutex.RLock()
|
||||
defer qm.mutex.RUnlock()
|
||||
|
||||
var playerIDs []int32
|
||||
for playerID := range qm.playerQuests {
|
||||
playerIDs = append(playerIDs, playerID)
|
||||
}
|
||||
|
||||
return playerIDs
|
||||
}
|
||||
|
||||
// UpdatePlayerQuestProgress updates progress for a player's quest step
|
||||
func (qm *QuestManager) UpdatePlayerQuestProgress(playerID, questID, stepID, progress int32) bool {
|
||||
quest := qm.GetPlayerQuest(playerID, questID)
|
||||
if quest == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return quest.AddStepProgress(stepID, progress)
|
||||
}
|
||||
|
||||
// CompletePlayerQuestStep marks a quest step as complete for a player
|
||||
func (qm *QuestManager) CompletePlayerQuestStep(playerID, questID, stepID int32) bool {
|
||||
quest := qm.GetPlayerQuest(playerID, questID)
|
||||
if quest == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return quest.SetStepComplete(stepID)
|
||||
}
|
||||
|
||||
// IsPlayerQuestComplete checks if a player's quest is complete
|
||||
func (qm *QuestManager) IsPlayerQuestComplete(playerID, questID int32) bool {
|
||||
quest := qm.GetPlayerQuest(playerID, questID)
|
||||
if quest == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return quest.GetCompleted()
|
||||
}
|
||||
|
||||
// GetPlayerQuestStatistics returns statistics for a player's quests
|
||||
func (qm *QuestManager) GetPlayerQuestStatistics(playerID int32) *PlayerQuestStatistics {
|
||||
quests := qm.GetPlayerQuests(playerID)
|
||||
|
||||
stats := &PlayerQuestStatistics{
|
||||
TotalQuests: len(quests),
|
||||
CompletedQuests: 0,
|
||||
QuestsByType: make(map[string]int),
|
||||
QuestsByLevel: make(map[int8]int),
|
||||
}
|
||||
|
||||
for _, quest := range quests {
|
||||
if quest.GetCompleted() {
|
||||
stats.CompletedQuests++
|
||||
}
|
||||
|
||||
stats.QuestsByType[quest.Type]++
|
||||
stats.QuestsByLevel[quest.Level]++
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// PlayerQuestStatistics contains statistical information about a player's quests
|
||||
type PlayerQuestStatistics struct {
|
||||
TotalQuests int `json:"total_quests"`
|
||||
CompletedQuests int `json:"completed_quests"`
|
||||
QuestsByType map[string]int `json:"quests_by_type"`
|
||||
QuestsByLevel map[int8]int `json:"quests_by_level"`
|
||||
}
|
378
internal/quests/prerequisites.go
Normal file
378
internal/quests/prerequisites.go
Normal file
@ -0,0 +1,378 @@
|
||||
package quests
|
||||
|
||||
// Prerequisite management methods for Quest
|
||||
|
||||
// SetPrereqLevel sets the minimum level requirement
|
||||
func (q *Quest) SetPrereqLevel(level int8) {
|
||||
q.PrereqLevel = level
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// SetPrereqTSLevel sets the minimum tradeskill level requirement
|
||||
func (q *Quest) SetPrereqTSLevel(level int8) {
|
||||
q.PrereqTSLevel = level
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// SetPrereqMaxLevel sets the maximum level requirement
|
||||
func (q *Quest) SetPrereqMaxLevel(level int8) {
|
||||
q.PrereqMaxLevel = level
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// SetPrereqMaxTSLevel sets the maximum tradeskill level requirement
|
||||
func (q *Quest) SetPrereqMaxTSLevel(level int8) {
|
||||
q.PrereqMaxTSLevel = level
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// AddPrereqClass adds a class prerequisite
|
||||
func (q *Quest) AddPrereqClass(classID int8) {
|
||||
q.PrereqClasses = append(q.PrereqClasses, classID)
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// AddPrereqTradeskillClass adds a tradeskill class prerequisite
|
||||
func (q *Quest) AddPrereqTradeskillClass(classID int8) {
|
||||
q.PrereqTSClasses = append(q.PrereqTSClasses, classID)
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// AddPrereqModelType adds a model type prerequisite
|
||||
func (q *Quest) AddPrereqModelType(modelType int16) {
|
||||
q.PrereqModelTypes = append(q.PrereqModelTypes, modelType)
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// AddPrereqRace adds a race prerequisite
|
||||
func (q *Quest) AddPrereqRace(race int8) {
|
||||
q.PrereqRaces = append(q.PrereqRaces, race)
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// AddPrereqQuest adds a quest prerequisite
|
||||
func (q *Quest) AddPrereqQuest(questID int32) {
|
||||
q.PrereqQuests = append(q.PrereqQuests, questID)
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// AddPrereqFaction adds a faction prerequisite
|
||||
func (q *Quest) AddPrereqFaction(factionID int32, min, max int32) {
|
||||
faction := NewQuestFactionPrereq(factionID, min, max)
|
||||
q.PrereqFactions = append(q.PrereqFactions, faction)
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// RemovePrereqClass removes a class prerequisite
|
||||
func (q *Quest) RemovePrereqClass(classID int8) bool {
|
||||
for i, class := range q.PrereqClasses {
|
||||
if class == classID {
|
||||
q.PrereqClasses = append(q.PrereqClasses[:i], q.PrereqClasses[i+1:]...)
|
||||
q.SetSaveNeeded(true)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RemovePrereqTradeskillClass removes a tradeskill class prerequisite
|
||||
func (q *Quest) RemovePrereqTradeskillClass(classID int8) bool {
|
||||
for i, class := range q.PrereqTSClasses {
|
||||
if class == classID {
|
||||
q.PrereqTSClasses = append(q.PrereqTSClasses[:i], q.PrereqTSClasses[i+1:]...)
|
||||
q.SetSaveNeeded(true)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RemovePrereqModelType removes a model type prerequisite
|
||||
func (q *Quest) RemovePrereqModelType(modelType int16) bool {
|
||||
for i, model := range q.PrereqModelTypes {
|
||||
if model == modelType {
|
||||
q.PrereqModelTypes = append(q.PrereqModelTypes[:i], q.PrereqModelTypes[i+1:]...)
|
||||
q.SetSaveNeeded(true)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RemovePrereqRace removes a race prerequisite
|
||||
func (q *Quest) RemovePrereqRace(race int8) bool {
|
||||
for i, r := range q.PrereqRaces {
|
||||
if r == race {
|
||||
q.PrereqRaces = append(q.PrereqRaces[:i], q.PrereqRaces[i+1:]...)
|
||||
q.SetSaveNeeded(true)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RemovePrereqQuest removes a quest prerequisite
|
||||
func (q *Quest) RemovePrereqQuest(questID int32) bool {
|
||||
for i, quest := range q.PrereqQuests {
|
||||
if quest == questID {
|
||||
q.PrereqQuests = append(q.PrereqQuests[:i], q.PrereqQuests[i+1:]...)
|
||||
q.SetSaveNeeded(true)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RemovePrereqFaction removes a faction prerequisite
|
||||
func (q *Quest) RemovePrereqFaction(factionID int32) bool {
|
||||
for i, faction := range q.PrereqFactions {
|
||||
if faction.FactionID == factionID {
|
||||
q.PrereqFactions = append(q.PrereqFactions[:i], q.PrereqFactions[i+1:]...)
|
||||
q.SetSaveNeeded(true)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasPrereqClass checks if a class is a prerequisite
|
||||
func (q *Quest) HasPrereqClass(classID int8) bool {
|
||||
for _, class := range q.PrereqClasses {
|
||||
if class == classID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasPrereqTradeskillClass checks if a tradeskill class is a prerequisite
|
||||
func (q *Quest) HasPrereqTradeskillClass(classID int8) bool {
|
||||
for _, class := range q.PrereqTSClasses {
|
||||
if class == classID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasPrereqModelType checks if a model type is a prerequisite
|
||||
func (q *Quest) HasPrereqModelType(modelType int16) bool {
|
||||
for _, model := range q.PrereqModelTypes {
|
||||
if model == modelType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasPrereqRace checks if a race is a prerequisite
|
||||
func (q *Quest) HasPrereqRace(race int8) bool {
|
||||
for _, r := range q.PrereqRaces {
|
||||
if r == race {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasPrereqQuest checks if a quest is a prerequisite
|
||||
func (q *Quest) HasPrereqQuest(questID int32) bool {
|
||||
for _, quest := range q.PrereqQuests {
|
||||
if quest == questID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasPrereqFaction checks if a faction is a prerequisite
|
||||
func (q *Quest) HasPrereqFaction(factionID int32) bool {
|
||||
for _, faction := range q.PrereqFactions {
|
||||
if faction.FactionID == factionID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetPrereqFaction returns the faction prerequisite for a given faction ID
|
||||
func (q *Quest) GetPrereqFaction(factionID int32) *QuestFactionPrereq {
|
||||
for _, faction := range q.PrereqFactions {
|
||||
if faction.FactionID == factionID {
|
||||
return faction
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearAllPrerequisites removes all prerequisites
|
||||
func (q *Quest) ClearAllPrerequisites() {
|
||||
q.PrereqLevel = DefaultPrereqLevel
|
||||
q.PrereqTSLevel = 0
|
||||
q.PrereqMaxLevel = 0
|
||||
q.PrereqMaxTSLevel = 0
|
||||
q.PrereqFactions = q.PrereqFactions[:0]
|
||||
q.PrereqRaces = q.PrereqRaces[:0]
|
||||
q.PrereqModelTypes = q.PrereqModelTypes[:0]
|
||||
q.PrereqClasses = q.PrereqClasses[:0]
|
||||
q.PrereqTSClasses = q.PrereqTSClasses[:0]
|
||||
q.PrereqQuests = q.PrereqQuests[:0]
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// Prerequisite validation methods
|
||||
|
||||
// ValidatePrerequisites validates all quest prerequisites
|
||||
func (q *Quest) ValidatePrerequisites() error {
|
||||
// Validate level requirements
|
||||
if q.PrereqLevel < 1 || q.PrereqLevel > 100 {
|
||||
return fmt.Errorf("prerequisite level must be between 1 and 100")
|
||||
}
|
||||
|
||||
if q.PrereqTSLevel < 0 || q.PrereqTSLevel > 100 {
|
||||
return fmt.Errorf("prerequisite tradeskill level must be between 0 and 100")
|
||||
}
|
||||
|
||||
if q.PrereqMaxLevel > 0 && q.PrereqMaxLevel < q.PrereqLevel {
|
||||
return fmt.Errorf("prerequisite max level cannot be less than min level")
|
||||
}
|
||||
|
||||
if q.PrereqMaxTSLevel > 0 && q.PrereqMaxTSLevel < q.PrereqTSLevel {
|
||||
return fmt.Errorf("prerequisite max tradeskill level cannot be less than min tradeskill level")
|
||||
}
|
||||
|
||||
// Validate class prerequisites
|
||||
if err := q.validateClassPrerequisites(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate race prerequisites
|
||||
if err := q.validateRacePrerequisites(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate faction prerequisites
|
||||
if err := q.validateFactionPrerequisites(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateClassPrerequisites validates class prerequisites
|
||||
func (q *Quest) validateClassPrerequisites() error {
|
||||
// Check for duplicate classes
|
||||
classMap := make(map[int8]bool)
|
||||
for _, classID := range q.PrereqClasses {
|
||||
if classMap[classID] {
|
||||
return fmt.Errorf("duplicate class prerequisite: %d", classID)
|
||||
}
|
||||
classMap[classID] = true
|
||||
|
||||
// Validate class ID range (basic validation)
|
||||
if classID < 1 || classID > 100 {
|
||||
return fmt.Errorf("invalid class ID in prerequisites: %d", classID)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate tradeskill classes
|
||||
tsClassMap := make(map[int8]bool)
|
||||
for _, classID := range q.PrereqTSClasses {
|
||||
if tsClassMap[classID] {
|
||||
return fmt.Errorf("duplicate tradeskill class prerequisite: %d", classID)
|
||||
}
|
||||
tsClassMap[classID] = true
|
||||
|
||||
// Validate tradeskill class ID range
|
||||
if classID < 1 || classID > 100 {
|
||||
return fmt.Errorf("invalid tradeskill class ID in prerequisites: %d", classID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRacePrerequisites validates race prerequisites
|
||||
func (q *Quest) validateRacePrerequisites() error {
|
||||
// Check for duplicate races
|
||||
raceMap := make(map[int8]bool)
|
||||
for _, race := range q.PrereqRaces {
|
||||
if raceMap[race] {
|
||||
return fmt.Errorf("duplicate race prerequisite: %d", race)
|
||||
}
|
||||
raceMap[race] = true
|
||||
|
||||
// Validate race ID range
|
||||
if race < 1 || race > 50 {
|
||||
return fmt.Errorf("invalid race ID in prerequisites: %d", race)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate model types
|
||||
modelMap := make(map[int16]bool)
|
||||
for _, modelType := range q.PrereqModelTypes {
|
||||
if modelMap[modelType] {
|
||||
return fmt.Errorf("duplicate model type prerequisite: %d", modelType)
|
||||
}
|
||||
modelMap[modelType] = true
|
||||
|
||||
// Validate model type range
|
||||
if modelType < 1 {
|
||||
return fmt.Errorf("invalid model type in prerequisites: %d", modelType)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFactionPrerequisites validates faction prerequisites
|
||||
func (q *Quest) validateFactionPrerequisites() error {
|
||||
// Check for duplicate factions and validate ranges
|
||||
factionMap := make(map[int32]bool)
|
||||
for _, faction := range q.PrereqFactions {
|
||||
if factionMap[faction.FactionID] {
|
||||
return fmt.Errorf("duplicate faction prerequisite: %d", faction.FactionID)
|
||||
}
|
||||
factionMap[faction.FactionID] = true
|
||||
|
||||
// Validate faction ID
|
||||
if faction.FactionID <= 0 {
|
||||
return fmt.Errorf("invalid faction ID in prerequisites: %d", faction.FactionID)
|
||||
}
|
||||
|
||||
// Validate faction value ranges
|
||||
if faction.Min < -50000 || faction.Min > 50000 {
|
||||
return fmt.Errorf("faction %d min value out of range: %d", faction.FactionID, faction.Min)
|
||||
}
|
||||
|
||||
if faction.Max < -50000 || faction.Max > 50000 {
|
||||
return fmt.Errorf("faction %d max value out of range: %d", faction.FactionID, faction.Max)
|
||||
}
|
||||
|
||||
// Check min/max relationship
|
||||
if faction.Max != 0 && faction.Max < faction.Min {
|
||||
return fmt.Errorf("faction %d max value cannot be less than min value", faction.FactionID)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate quest prerequisites
|
||||
questMap := make(map[int32]bool)
|
||||
for _, questID := range q.PrereqQuests {
|
||||
if questMap[questID] {
|
||||
return fmt.Errorf("duplicate quest prerequisite: %d", questID)
|
||||
}
|
||||
questMap[questID] = true
|
||||
|
||||
// Don't allow self-reference
|
||||
if questID == q.ID {
|
||||
return fmt.Errorf("quest cannot be a prerequisite of itself")
|
||||
}
|
||||
|
||||
// Validate quest ID
|
||||
if questID <= 0 {
|
||||
return fmt.Errorf("invalid quest ID in prerequisites: %d", questID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
737
internal/quests/quest.go
Normal file
737
internal/quests/quest.go
Normal file
@ -0,0 +1,737 @@
|
||||
package quests
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RegisterQuest sets the basic quest information
|
||||
func (q *Quest) RegisterQuest(name, questType, zone string, level int8, description string) {
|
||||
q.Name = name
|
||||
q.Type = questType
|
||||
q.Zone = zone
|
||||
q.Level = level
|
||||
q.Description = description
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// AddQuestStep adds a step to the quest
|
||||
func (q *Quest) AddQuestStep(step *QuestStep) bool {
|
||||
q.stepsMutex.Lock()
|
||||
defer q.stepsMutex.Unlock()
|
||||
|
||||
// Check if step ID already exists
|
||||
if _, exists := q.QuestStepMap[step.ID]; exists {
|
||||
return false
|
||||
}
|
||||
|
||||
// Add to all tracking structures
|
||||
q.QuestSteps = append(q.QuestSteps, step)
|
||||
q.QuestStepMap[step.ID] = step
|
||||
q.QuestStepReverseMap[step] = step.ID
|
||||
|
||||
// Handle task groups
|
||||
taskGroup := step.TaskGroup
|
||||
if taskGroup == "" {
|
||||
taskGroup = step.Description
|
||||
}
|
||||
|
||||
if taskGroup != "" {
|
||||
// Add to task group order if new
|
||||
if _, exists := q.getTaskGroupByName(taskGroup); !exists {
|
||||
q.TaskGroupOrder[q.TaskGroupNum] = taskGroup
|
||||
q.TaskGroupNum++
|
||||
}
|
||||
|
||||
// Add step to task group
|
||||
q.TaskGroup[taskGroup] = append(q.TaskGroup[taskGroup], step)
|
||||
}
|
||||
|
||||
q.SetSaveNeeded(true)
|
||||
return true
|
||||
}
|
||||
|
||||
// CreateQuestStep creates and adds a new quest step
|
||||
func (q *Quest) CreateQuestStep(id int32, stepType int8, description string, ids []int32, quantity int32, taskGroup string, locations []*Location, maxVariation, percentage float32, usableItemID int32) *QuestStep {
|
||||
step := NewQuestStep(id, stepType, description, ids, quantity, taskGroup, locations, maxVariation, percentage, usableItemID)
|
||||
if q.AddQuestStep(step) {
|
||||
return step
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveQuestStep removes a step from the quest
|
||||
func (q *Quest) RemoveQuestStep(stepID int32) bool {
|
||||
q.stepsMutex.Lock()
|
||||
defer q.stepsMutex.Unlock()
|
||||
|
||||
step, exists := q.QuestStepMap[stepID]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
// Remove from maps
|
||||
delete(q.QuestStepMap, stepID)
|
||||
delete(q.QuestStepReverseMap, step)
|
||||
|
||||
// Remove from slice
|
||||
for i, questStep := range q.QuestSteps {
|
||||
if questStep == step {
|
||||
q.QuestSteps = append(q.QuestSteps[:i], q.QuestSteps[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from task groups
|
||||
taskGroup := step.TaskGroup
|
||||
if taskGroup != "" {
|
||||
if steps, exists := q.TaskGroup[taskGroup]; exists {
|
||||
for i, taskStep := range steps {
|
||||
if taskStep == step {
|
||||
q.TaskGroup[taskGroup] = append(steps[:i], steps[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If task group is now empty, remove it
|
||||
if len(q.TaskGroup[taskGroup]) == 0 {
|
||||
delete(q.TaskGroup, taskGroup)
|
||||
// Find and remove from task group order
|
||||
for orderNum, groupName := range q.TaskGroupOrder {
|
||||
if groupName == taskGroup {
|
||||
delete(q.TaskGroupOrder, orderNum)
|
||||
q.TaskGroupNum--
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from actions
|
||||
q.completeActionsMutex.Lock()
|
||||
delete(q.CompleteActions, stepID)
|
||||
q.completeActionsMutex.Unlock()
|
||||
|
||||
q.progressActionsMutex.Lock()
|
||||
delete(q.ProgressActions, stepID)
|
||||
q.progressActionsMutex.Unlock()
|
||||
|
||||
q.failedActionsMutex.Lock()
|
||||
delete(q.FailedActions, stepID)
|
||||
q.failedActionsMutex.Unlock()
|
||||
|
||||
q.SetSaveNeeded(true)
|
||||
return true
|
||||
}
|
||||
|
||||
// GetQuestStep returns a quest step by ID
|
||||
func (q *Quest) GetQuestStep(stepID int32) *QuestStep {
|
||||
q.stepsMutex.RLock()
|
||||
defer q.stepsMutex.RUnlock()
|
||||
return q.QuestStepMap[stepID]
|
||||
}
|
||||
|
||||
// SetStepComplete marks a step as complete
|
||||
func (q *Quest) SetStepComplete(stepID int32) bool {
|
||||
q.stepsMutex.Lock()
|
||||
defer q.stepsMutex.Unlock()
|
||||
|
||||
step, exists := q.QuestStepMap[stepID]
|
||||
if !exists || step.Complete() {
|
||||
return false
|
||||
}
|
||||
|
||||
step.SetComplete()
|
||||
q.StepUpdates = append(q.StepUpdates, step)
|
||||
q.SetSaveNeeded(true)
|
||||
return true
|
||||
}
|
||||
|
||||
// AddStepProgress adds progress to a step
|
||||
func (q *Quest) AddStepProgress(stepID int32, progress int32) bool {
|
||||
q.stepsMutex.Lock()
|
||||
defer q.stepsMutex.Unlock()
|
||||
|
||||
step, exists := q.QuestStepMap[stepID]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check percentage chance for success
|
||||
if step.Percentage < MaxPercentage && step.Percentage > 0 {
|
||||
if step.Percentage <= rand.Float32()*MaxPercentage {
|
||||
q.StepFailures = append(q.StepFailures, step)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
actualProgress := step.AddStepProgress(progress)
|
||||
if actualProgress > 0 {
|
||||
q.StepUpdates = append(q.StepUpdates, step)
|
||||
q.SetSaveNeeded(true)
|
||||
|
||||
// TODO: Call progress action if exists
|
||||
q.progressActionsMutex.RLock()
|
||||
if action, exists := q.ProgressActions[stepID]; exists && action != "" {
|
||||
// TODO: Execute Lua script with action
|
||||
_ = action // Placeholder for Lua execution
|
||||
}
|
||||
q.progressActionsMutex.RUnlock()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GetStepProgress returns the current progress of a step
|
||||
func (q *Quest) GetStepProgress(stepID int32) int32 {
|
||||
step := q.GetQuestStep(stepID)
|
||||
if step != nil {
|
||||
return step.GetStepProgress()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetQuestStepCompleted checks if a step is completed
|
||||
func (q *Quest) GetQuestStepCompleted(stepID int32) bool {
|
||||
step := q.GetQuestStep(stepID)
|
||||
return step != nil && step.Complete()
|
||||
}
|
||||
|
||||
// GetQuestStep returns the first incomplete step ID
|
||||
func (q *Quest) GetCurrentQuestStep() int16 {
|
||||
q.stepsMutex.RLock()
|
||||
defer q.stepsMutex.RUnlock()
|
||||
|
||||
for _, step := range q.QuestSteps {
|
||||
if !step.Complete() {
|
||||
return int16(step.ID)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// QuestStepIsActive checks if a step is active (not completed)
|
||||
func (q *Quest) QuestStepIsActive(stepID int16) bool {
|
||||
step := q.GetQuestStep(int32(stepID))
|
||||
return step != nil && !step.Complete()
|
||||
}
|
||||
|
||||
// GetTaskGroupStep returns the current task group step
|
||||
func (q *Quest) GetTaskGroupStep() int16 {
|
||||
q.stepsMutex.RLock()
|
||||
defer q.stepsMutex.RUnlock()
|
||||
|
||||
ret := int16(len(q.TaskGroupOrder))
|
||||
|
||||
for orderNum, taskGroupName := range q.TaskGroupOrder {
|
||||
if steps, exists := q.TaskGroup[taskGroupName]; exists {
|
||||
complete := true
|
||||
for _, step := range steps {
|
||||
if !step.Complete() {
|
||||
complete = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !complete && orderNum < ret {
|
||||
ret = orderNum
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// CheckQuestReferencedSpawns checks if a spawn is referenced by any quest step
|
||||
func (q *Quest) CheckQuestReferencedSpawns(spawnID int32) bool {
|
||||
q.stepsMutex.RLock()
|
||||
defer q.stepsMutex.RUnlock()
|
||||
|
||||
for _, step := range q.QuestSteps {
|
||||
if step.Complete() {
|
||||
continue
|
||||
}
|
||||
|
||||
switch step.Type {
|
||||
case StepTypeKill, StepTypeNormal:
|
||||
if step.CheckStepReferencedID(spawnID) {
|
||||
return true
|
||||
}
|
||||
case StepTypeKillRaceReq:
|
||||
// TODO: Implement race requirement checking
|
||||
// This would require spawn race information
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// CheckQuestKillUpdate checks and updates kill quest steps
|
||||
func (q *Quest) CheckQuestKillUpdate(spawnID int32, update bool) bool {
|
||||
q.stepsMutex.Lock()
|
||||
defer q.stepsMutex.Unlock()
|
||||
|
||||
hasUpdate := false
|
||||
|
||||
for _, step := range q.QuestSteps {
|
||||
if step.Complete() {
|
||||
continue
|
||||
}
|
||||
|
||||
shouldUpdate := false
|
||||
switch step.Type {
|
||||
case StepTypeKill:
|
||||
shouldUpdate = step.CheckStepReferencedID(spawnID)
|
||||
case StepTypeKillRaceReq:
|
||||
// TODO: Implement race requirement checking
|
||||
// shouldUpdate = step.CheckStepKillRaceReqUpdate(spawn)
|
||||
}
|
||||
|
||||
if shouldUpdate {
|
||||
if update {
|
||||
// Check percentage chance
|
||||
passed := true
|
||||
if step.Percentage < MaxPercentage {
|
||||
passed = step.Percentage > rand.Float32()*MaxPercentage
|
||||
}
|
||||
|
||||
if passed {
|
||||
actualProgress := step.AddStepProgress(1)
|
||||
if actualProgress > 0 {
|
||||
q.StepUpdates = append(q.StepUpdates, step)
|
||||
|
||||
// TODO: Call progress action
|
||||
q.progressActionsMutex.RLock()
|
||||
if action, exists := q.ProgressActions[step.ID]; exists && action != "" {
|
||||
// TODO: Execute Lua script
|
||||
_ = action
|
||||
}
|
||||
q.progressActionsMutex.RUnlock()
|
||||
|
||||
hasUpdate = true
|
||||
}
|
||||
} else {
|
||||
q.StepFailures = append(q.StepFailures, step)
|
||||
}
|
||||
} else {
|
||||
hasUpdate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasUpdate && update {
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
return hasUpdate
|
||||
}
|
||||
|
||||
// CheckQuestChatUpdate checks and updates chat quest steps
|
||||
func (q *Quest) CheckQuestChatUpdate(npcID int32, update bool) bool {
|
||||
q.stepsMutex.Lock()
|
||||
defer q.stepsMutex.Unlock()
|
||||
|
||||
hasUpdate := false
|
||||
|
||||
for _, step := range q.QuestSteps {
|
||||
if step.Complete() || step.Type != StepTypeChat {
|
||||
continue
|
||||
}
|
||||
|
||||
if step.CheckStepReferencedID(npcID) {
|
||||
if update {
|
||||
actualProgress := step.AddStepProgress(1)
|
||||
if actualProgress > 0 {
|
||||
q.StepUpdates = append(q.StepUpdates, step)
|
||||
|
||||
// TODO: Call progress action
|
||||
q.progressActionsMutex.RLock()
|
||||
if action, exists := q.ProgressActions[step.ID]; exists && action != "" {
|
||||
// TODO: Execute Lua script
|
||||
_ = action
|
||||
}
|
||||
q.progressActionsMutex.RUnlock()
|
||||
}
|
||||
}
|
||||
hasUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
if hasUpdate && update {
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
return hasUpdate
|
||||
}
|
||||
|
||||
// CheckQuestItemUpdate checks and updates item quest steps
|
||||
func (q *Quest) CheckQuestItemUpdate(itemID int32, quantity int8) bool {
|
||||
q.stepsMutex.Lock()
|
||||
defer q.stepsMutex.Unlock()
|
||||
|
||||
hasUpdate := false
|
||||
|
||||
for _, step := range q.QuestSteps {
|
||||
if step.Complete() || step.Type != StepTypeObtainItem {
|
||||
continue
|
||||
}
|
||||
|
||||
if step.CheckStepReferencedID(itemID) {
|
||||
// Check percentage chance
|
||||
passed := true
|
||||
if step.Percentage < MaxPercentage {
|
||||
passed = step.Percentage > rand.Float32()*MaxPercentage
|
||||
}
|
||||
|
||||
if passed {
|
||||
actualProgress := step.AddStepProgress(int32(quantity))
|
||||
if actualProgress > 0 {
|
||||
q.StepUpdates = append(q.StepUpdates, step)
|
||||
|
||||
// TODO: Call progress action
|
||||
q.progressActionsMutex.RLock()
|
||||
if action, exists := q.ProgressActions[step.ID]; exists && action != "" {
|
||||
// TODO: Execute Lua script
|
||||
_ = action
|
||||
}
|
||||
q.progressActionsMutex.RUnlock()
|
||||
}
|
||||
hasUpdate = true
|
||||
} else {
|
||||
q.StepFailures = append(q.StepFailures, step)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasUpdate {
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
return hasUpdate
|
||||
}
|
||||
|
||||
// CheckQuestLocationUpdate checks and updates location quest steps
|
||||
func (q *Quest) CheckQuestLocationUpdate(charX, charY, charZ float32, zoneID int32) bool {
|
||||
q.stepsMutex.Lock()
|
||||
defer q.stepsMutex.Unlock()
|
||||
|
||||
hasUpdate := false
|
||||
|
||||
for _, step := range q.QuestSteps {
|
||||
if step.Complete() || step.Type != StepTypeLocation {
|
||||
continue
|
||||
}
|
||||
|
||||
if step.CheckStepLocationUpdate(charX, charY, charZ, zoneID) {
|
||||
actualProgress := step.AddStepProgress(1)
|
||||
if actualProgress > 0 {
|
||||
q.StepUpdates = append(q.StepUpdates, step)
|
||||
|
||||
// TODO: Call progress action
|
||||
q.progressActionsMutex.RLock()
|
||||
if action, exists := q.ProgressActions[step.ID]; exists && action != "" {
|
||||
// TODO: Execute Lua script
|
||||
_ = action
|
||||
}
|
||||
q.progressActionsMutex.RUnlock()
|
||||
}
|
||||
hasUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
if hasUpdate {
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
return hasUpdate
|
||||
}
|
||||
|
||||
// CheckQuestSpellUpdate checks and updates spell quest steps
|
||||
func (q *Quest) CheckQuestSpellUpdate(spellID int32) bool {
|
||||
q.stepsMutex.Lock()
|
||||
defer q.stepsMutex.Unlock()
|
||||
|
||||
hasUpdate := false
|
||||
|
||||
for _, step := range q.QuestSteps {
|
||||
if step.Complete() || step.Type != StepTypeSpell {
|
||||
continue
|
||||
}
|
||||
|
||||
if step.CheckStepReferencedID(spellID) {
|
||||
// Check percentage chance
|
||||
passed := true
|
||||
if step.Percentage < MaxPercentage {
|
||||
passed = step.Percentage > rand.Float32()*MaxPercentage
|
||||
}
|
||||
|
||||
if passed {
|
||||
actualProgress := step.AddStepProgress(1)
|
||||
if actualProgress > 0 {
|
||||
q.StepUpdates = append(q.StepUpdates, step)
|
||||
|
||||
// TODO: Call progress action
|
||||
q.progressActionsMutex.RLock()
|
||||
if action, exists := q.ProgressActions[step.ID]; exists && action != "" {
|
||||
// TODO: Execute Lua script
|
||||
_ = action
|
||||
}
|
||||
q.progressActionsMutex.RUnlock()
|
||||
}
|
||||
hasUpdate = true
|
||||
} else {
|
||||
q.StepFailures = append(q.StepFailures, step)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasUpdate {
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
return hasUpdate
|
||||
}
|
||||
|
||||
// CheckQuestRefIDUpdate checks and updates reference ID quest steps (craft/harvest)
|
||||
func (q *Quest) CheckQuestRefIDUpdate(refID int32, quantity int32) bool {
|
||||
q.stepsMutex.Lock()
|
||||
defer q.stepsMutex.Unlock()
|
||||
|
||||
hasUpdate := false
|
||||
|
||||
for _, step := range q.QuestSteps {
|
||||
if step.Complete() {
|
||||
continue
|
||||
}
|
||||
|
||||
if step.Type == StepTypeHarvest || step.Type == StepTypeCraft {
|
||||
if step.CheckStepReferencedID(refID) {
|
||||
// Check percentage chance
|
||||
passed := true
|
||||
if step.Percentage < MaxPercentage {
|
||||
passed = step.Percentage > rand.Float32()*MaxPercentage
|
||||
}
|
||||
|
||||
if passed {
|
||||
actualProgress := step.AddStepProgress(quantity)
|
||||
if actualProgress > 0 {
|
||||
q.StepUpdates = append(q.StepUpdates, step)
|
||||
|
||||
// TODO: Call progress action
|
||||
q.progressActionsMutex.RLock()
|
||||
if action, exists := q.ProgressActions[step.ID]; exists && action != "" {
|
||||
// TODO: Execute Lua script
|
||||
_ = action
|
||||
}
|
||||
q.progressActionsMutex.RUnlock()
|
||||
}
|
||||
hasUpdate = true
|
||||
} else {
|
||||
q.StepFailures = append(q.StepFailures, step)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasUpdate {
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
return hasUpdate
|
||||
}
|
||||
|
||||
// GetCompleted checks if the quest is complete
|
||||
func (q *Quest) GetCompleted() bool {
|
||||
q.stepsMutex.RLock()
|
||||
defer q.stepsMutex.RUnlock()
|
||||
|
||||
for _, step := range q.QuestSteps {
|
||||
if !step.Complete() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// CheckCategoryYellow checks if the quest category should be displayed in yellow
|
||||
func (q *Quest) CheckCategoryYellow() bool {
|
||||
category := q.Type
|
||||
yellowCategories := []string{
|
||||
"Signature", "Heritage", "Hallmark", "Deity", "Miscellaneous",
|
||||
"Language", "Lore and Legend", "World Event", "Tradeskill",
|
||||
}
|
||||
|
||||
for _, yellowCat := range yellowCategories {
|
||||
if strings.EqualFold(category, yellowCat) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SetStepTimer sets a timer for a quest step
|
||||
func (q *Quest) SetStepTimer(duration int32) {
|
||||
if duration == 0 {
|
||||
q.Timestamp = 0
|
||||
} else {
|
||||
q.Timestamp = int32(time.Now().Unix()) + duration
|
||||
}
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// StepFailed handles when a step fails
|
||||
func (q *Quest) StepFailed(stepID int32) {
|
||||
q.failedActionsMutex.RLock()
|
||||
action, exists := q.FailedActions[stepID]
|
||||
q.failedActionsMutex.RUnlock()
|
||||
|
||||
if exists && action != "" {
|
||||
// TODO: Execute Lua script for failed action
|
||||
_ = action
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
// getTaskGroupByName finds a task group by name
|
||||
func (q *Quest) getTaskGroupByName(name string) ([]*QuestStep, bool) {
|
||||
steps, exists := q.TaskGroup[name]
|
||||
return steps, exists
|
||||
}
|
||||
|
||||
// SetSaveNeeded sets the save needed flag
|
||||
func (q *Quest) SetSaveNeeded(needed bool) {
|
||||
q.NeedsSave = needed
|
||||
}
|
||||
|
||||
// SetQuestTemporaryState sets the quest temporary state
|
||||
func (q *Quest) SetQuestTemporaryState(tempState bool, customDescription string) {
|
||||
if !tempState {
|
||||
q.TmpRewardCoins = 0
|
||||
q.TmpRewardStatus = 0
|
||||
// TODO: Clear temporary reward items
|
||||
}
|
||||
|
||||
q.QuestStateTemporary = tempState
|
||||
q.QuestTempDescription = customDescription
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// CanShareQuestCriteria checks if the quest meets sharing criteria
|
||||
func (q *Quest) CanShareQuestCriteria(hasQuest, hasCompleted bool, currentStep int16) bool {
|
||||
shareableFlag := q.QuestShareableFlag
|
||||
|
||||
// Check if quest can be shared at all
|
||||
if shareableFlag == ShareableNone {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check completed sharing
|
||||
if (shareableFlag&ShareableCompleted) == 0 && hasCompleted {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if can only share when completed
|
||||
if shareableFlag == ShareableCompleted && !hasCompleted {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check during quest sharing
|
||||
if (shareableFlag&ShareableDuring) == 0 && hasQuest && currentStep > 1 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check active quest sharing
|
||||
if (shareableFlag&ShareableActive) == 0 && hasQuest {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if has quest for sharing
|
||||
if (shareableFlag&ShareableCompleted) == 0 && !hasQuest {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Validation methods
|
||||
|
||||
// ValidateQuest performs basic quest validation
|
||||
func (q *Quest) ValidateQuest() error {
|
||||
if q.ID <= 0 {
|
||||
return fmt.Errorf("quest ID must be positive")
|
||||
}
|
||||
|
||||
if q.Name == "" {
|
||||
return fmt.Errorf("quest name cannot be empty")
|
||||
}
|
||||
|
||||
if len(q.Name) > MaxQuestNameLength {
|
||||
return fmt.Errorf("quest name too long (max %d)", MaxQuestNameLength)
|
||||
}
|
||||
|
||||
if len(q.Description) > MaxQuestDescriptionLength {
|
||||
return fmt.Errorf("quest description too long (max %d)", MaxQuestDescriptionLength)
|
||||
}
|
||||
|
||||
if q.Level < 1 || q.Level > 100 {
|
||||
return fmt.Errorf("quest level must be between 1 and 100")
|
||||
}
|
||||
|
||||
if len(q.QuestSteps) == 0 {
|
||||
return fmt.Errorf("quest must have at least one step")
|
||||
}
|
||||
|
||||
// Validate steps
|
||||
for _, step := range q.QuestSteps {
|
||||
if err := q.validateStep(step); err != nil {
|
||||
return fmt.Errorf("step %d validation failed: %w", step.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateStep validates a single quest step
|
||||
func (q *Quest) validateStep(step *QuestStep) error {
|
||||
if step.ID <= 0 {
|
||||
return fmt.Errorf("step ID must be positive")
|
||||
}
|
||||
|
||||
if step.Type < StepTypeKill || step.Type > StepTypeKillRaceReq {
|
||||
return fmt.Errorf("invalid step type: %d", step.Type)
|
||||
}
|
||||
|
||||
if len(step.Description) > MaxStepDescriptionLength {
|
||||
return fmt.Errorf("step description too long (max %d)", MaxStepDescriptionLength)
|
||||
}
|
||||
|
||||
if step.Quantity <= 0 {
|
||||
return fmt.Errorf("step quantity must be positive")
|
||||
}
|
||||
|
||||
if step.Percentage < MinPercentage || step.Percentage > MaxPercentage {
|
||||
return fmt.Errorf("step percentage must be between %.1f and %.1f", MinPercentage, MaxPercentage)
|
||||
}
|
||||
|
||||
// Type-specific validation
|
||||
switch step.Type {
|
||||
case StepTypeLocation:
|
||||
if len(step.Locations) == 0 {
|
||||
return fmt.Errorf("location step must have at least one location")
|
||||
}
|
||||
if step.MaxVariation < MinLocationVariation || step.MaxVariation > MaxLocationVariation {
|
||||
return fmt.Errorf("location max variation must be between %.1f and %.1f", MinLocationVariation, MaxLocationVariation)
|
||||
}
|
||||
default:
|
||||
if len(step.IDs) == 0 {
|
||||
return fmt.Errorf("non-location step must have at least one referenced ID")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
427
internal/quests/rewards.go
Normal file
427
internal/quests/rewards.go
Normal file
@ -0,0 +1,427 @@
|
||||
package quests
|
||||
|
||||
// Reward management methods for Quest
|
||||
|
||||
// AddRewardCoins adds coin rewards
|
||||
func (q *Quest) AddRewardCoins(copper, silver, gold, platinum int32) {
|
||||
q.RewardCoins = int64(copper) + int64(silver)*100 + int64(gold)*10000 + int64(platinum)*1000000
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// AddRewardCoinsMax sets the maximum coin reward
|
||||
func (q *Quest) AddRewardCoinsMax(coins int64) {
|
||||
q.RewardCoinsMax = coins
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// AddRewardFaction adds a faction reward
|
||||
func (q *Quest) AddRewardFaction(factionID int32, amount int32) {
|
||||
q.RewardFactions[factionID] = amount
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// RemoveRewardFaction removes a faction reward
|
||||
func (q *Quest) RemoveRewardFaction(factionID int32) bool {
|
||||
if _, exists := q.RewardFactions[factionID]; exists {
|
||||
delete(q.RewardFactions, factionID)
|
||||
q.SetSaveNeeded(true)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SetRewardStatus sets the status point reward
|
||||
func (q *Quest) SetRewardStatus(amount int32) {
|
||||
q.RewardStatus = amount
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// SetRewardComment sets the reward comment text
|
||||
func (q *Quest) SetRewardComment(comment string) {
|
||||
q.RewardComment = comment
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// SetRewardXP sets the experience point reward
|
||||
func (q *Quest) SetRewardXP(xp int32) {
|
||||
q.RewardExp = xp
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// SetRewardTSXP sets the tradeskill experience point reward
|
||||
func (q *Quest) SetRewardTSXP(xp int32) {
|
||||
q.RewardTSExp = xp
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// SetGeneratedCoin sets the generated coin amount
|
||||
func (q *Quest) SetGeneratedCoin(coin int64) {
|
||||
q.GeneratedCoin = coin
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// GetCoinsReward returns the base coin reward
|
||||
func (q *Quest) GetCoinsReward() int64 {
|
||||
return q.RewardCoins
|
||||
}
|
||||
|
||||
// GetCoinsRewardMax returns the maximum coin reward
|
||||
func (q *Quest) GetCoinsRewardMax() int64 {
|
||||
return q.RewardCoinsMax
|
||||
}
|
||||
|
||||
// GetGeneratedCoin returns the generated coin amount
|
||||
func (q *Quest) GetGeneratedCoin() int64 {
|
||||
return q.GeneratedCoin
|
||||
}
|
||||
|
||||
// GetExpReward returns the experience point reward
|
||||
func (q *Quest) GetExpReward() int32 {
|
||||
return q.RewardExp
|
||||
}
|
||||
|
||||
// GetTSExpReward returns the tradeskill experience point reward
|
||||
func (q *Quest) GetTSExpReward() int32 {
|
||||
return q.RewardTSExp
|
||||
}
|
||||
|
||||
// GetStatusPoints returns the status point reward
|
||||
func (q *Quest) GetStatusPoints() int32 {
|
||||
return q.RewardStatus
|
||||
}
|
||||
|
||||
// GetRewardFactions returns the faction rewards map
|
||||
func (q *Quest) GetRewardFactions() map[int32]int32 {
|
||||
return q.RewardFactions
|
||||
}
|
||||
|
||||
// GetRewardFaction returns the faction reward amount for a specific faction
|
||||
func (q *Quest) GetRewardFaction(factionID int32) (int32, bool) {
|
||||
amount, exists := q.RewardFactions[factionID]
|
||||
return amount, exists
|
||||
}
|
||||
|
||||
// HasRewardFaction checks if a faction has a reward
|
||||
func (q *Quest) HasRewardFaction(factionID int32) bool {
|
||||
_, exists := q.RewardFactions[factionID]
|
||||
return exists
|
||||
}
|
||||
|
||||
// ClearAllRewards removes all rewards
|
||||
func (q *Quest) ClearAllRewards() {
|
||||
q.RewardCoins = 0
|
||||
q.RewardCoinsMax = 0
|
||||
q.RewardStatus = 0
|
||||
q.RewardComment = ""
|
||||
q.RewardExp = 0
|
||||
q.RewardTSExp = 0
|
||||
q.GeneratedCoin = 0
|
||||
q.RewardFactions = make(map[int32]int32)
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// Temporary reward methods
|
||||
|
||||
// SetStatusTmpReward sets the temporary status reward
|
||||
func (q *Quest) SetStatusTmpReward(status int32) {
|
||||
q.TmpRewardStatus = status
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// GetStatusTmpReward returns the temporary status reward
|
||||
func (q *Quest) GetStatusTmpReward() int32 {
|
||||
return q.TmpRewardStatus
|
||||
}
|
||||
|
||||
// SetCoinTmpReward sets the temporary coin reward
|
||||
func (q *Quest) SetCoinTmpReward(coins int64) {
|
||||
q.TmpRewardCoins = coins
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// GetCoinTmpReward returns the temporary coin reward
|
||||
func (q *Quest) GetCoinTmpReward() int64 {
|
||||
return q.TmpRewardCoins
|
||||
}
|
||||
|
||||
// ClearTmpRewards clears temporary rewards
|
||||
func (q *Quest) ClearTmpRewards() {
|
||||
q.TmpRewardStatus = 0
|
||||
q.TmpRewardCoins = 0
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// Status earning methods
|
||||
|
||||
// SetStatusToEarnMin sets the minimum status to earn
|
||||
func (q *Quest) SetStatusToEarnMin(value int32) {
|
||||
q.StatusToEarnMin = value
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// GetStatusToEarnMin returns the minimum status to earn
|
||||
func (q *Quest) GetStatusToEarnMin() int32 {
|
||||
return q.StatusToEarnMin
|
||||
}
|
||||
|
||||
// SetStatusToEarnMax sets the maximum status to earn
|
||||
func (q *Quest) SetStatusToEarnMax(value int32) {
|
||||
q.StatusToEarnMax = value
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// GetStatusToEarnMax returns the maximum status to earn
|
||||
func (q *Quest) GetStatusToEarnMax() int32 {
|
||||
return q.StatusToEarnMax
|
||||
}
|
||||
|
||||
// SetStatusEarned sets the quest status earned
|
||||
func (q *Quest) SetStatusEarned(status int32) {
|
||||
q.Status = status
|
||||
q.SetSaveNeeded(true)
|
||||
}
|
||||
|
||||
// GetStatusEarned returns the quest status earned
|
||||
func (q *Quest) GetStatusEarned() int32 {
|
||||
return q.Status
|
||||
}
|
||||
|
||||
// Reward calculation methods
|
||||
|
||||
// CalculateCoinsReward calculates the actual coin reward based on level and other factors
|
||||
func (q *Quest) CalculateCoinsReward(playerLevel int8) int64 {
|
||||
baseReward := q.RewardCoins
|
||||
if baseReward <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Use generated coin if set
|
||||
if q.GeneratedCoin > 0 {
|
||||
return q.GeneratedCoin
|
||||
}
|
||||
|
||||
// Level-based coin scaling (simplified version)
|
||||
levelMultiplier := float64(playerLevel) / float64(q.Level)
|
||||
if levelMultiplier > 1.5 {
|
||||
levelMultiplier = 1.5 // Cap the multiplier
|
||||
} else if levelMultiplier < 0.5 {
|
||||
levelMultiplier = 0.5 // Floor the multiplier
|
||||
}
|
||||
|
||||
calculatedReward := int64(float64(baseReward) * levelMultiplier)
|
||||
|
||||
// Apply max reward cap if set
|
||||
if q.RewardCoinsMax > 0 && calculatedReward > q.RewardCoinsMax {
|
||||
calculatedReward = q.RewardCoinsMax
|
||||
}
|
||||
|
||||
return calculatedReward
|
||||
}
|
||||
|
||||
// CalculateExpReward calculates the actual experience reward based on level and other factors
|
||||
func (q *Quest) CalculateExpReward(playerLevel int8) int32 {
|
||||
baseReward := q.RewardExp
|
||||
if baseReward <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Level-based experience scaling
|
||||
if playerLevel > q.Level {
|
||||
// Reduced XP for overleveled players
|
||||
levelDiff := playerLevel - q.Level
|
||||
reduction := float64(levelDiff) * 0.1
|
||||
if reduction > 0.8 {
|
||||
reduction = 0.8 // Max 80% reduction
|
||||
}
|
||||
return int32(float64(baseReward) * (1.0 - reduction))
|
||||
} else if playerLevel < q.Level {
|
||||
// Bonus XP for underleveled players (small bonus)
|
||||
levelDiff := q.Level - playerLevel
|
||||
bonus := float64(levelDiff) * 0.05
|
||||
if bonus > 0.25 {
|
||||
bonus = 0.25 // Max 25% bonus
|
||||
}
|
||||
return int32(float64(baseReward) * (1.0 + bonus))
|
||||
}
|
||||
|
||||
return baseReward
|
||||
}
|
||||
|
||||
// CalculateTSExpReward calculates the actual tradeskill experience reward
|
||||
func (q *Quest) CalculateTSExpReward(playerTSLevel int8) int32 {
|
||||
baseReward := q.RewardTSExp
|
||||
if baseReward <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Similar scaling as regular XP but for tradeskill level
|
||||
if playerTSLevel > q.Level {
|
||||
levelDiff := playerTSLevel - q.Level
|
||||
reduction := float64(levelDiff) * 0.1
|
||||
if reduction > 0.8 {
|
||||
reduction = 0.8
|
||||
}
|
||||
return int32(float64(baseReward) * (1.0 - reduction))
|
||||
} else if playerTSLevel < q.Level {
|
||||
levelDiff := q.Level - playerTSLevel
|
||||
bonus := float64(levelDiff) * 0.05
|
||||
if bonus > 0.25 {
|
||||
bonus = 0.25
|
||||
}
|
||||
return int32(float64(baseReward) * (1.0 + bonus))
|
||||
}
|
||||
|
||||
return baseReward
|
||||
}
|
||||
|
||||
// CalculateStatusReward calculates the actual status reward
|
||||
func (q *Quest) CalculateStatusReward() int32 {
|
||||
// Use status earned if set, otherwise use base reward status
|
||||
if q.Status > 0 {
|
||||
return q.Status
|
||||
}
|
||||
return q.RewardStatus
|
||||
}
|
||||
|
||||
// Reward validation methods
|
||||
|
||||
// ValidateRewards validates all quest rewards
|
||||
func (q *Quest) ValidateRewards() error {
|
||||
// Validate coin rewards
|
||||
if q.RewardCoins < 0 {
|
||||
return fmt.Errorf("reward coins cannot be negative")
|
||||
}
|
||||
|
||||
if q.RewardCoinsMax < 0 {
|
||||
return fmt.Errorf("reward coins max cannot be negative")
|
||||
}
|
||||
|
||||
if q.RewardCoinsMax > 0 && q.RewardCoinsMax < q.RewardCoins {
|
||||
return fmt.Errorf("reward coins max cannot be less than base reward coins")
|
||||
}
|
||||
|
||||
// Validate experience rewards
|
||||
if q.RewardExp < 0 {
|
||||
return fmt.Errorf("reward experience cannot be negative")
|
||||
}
|
||||
|
||||
if q.RewardTSExp < 0 {
|
||||
return fmt.Errorf("reward tradeskill experience cannot be negative")
|
||||
}
|
||||
|
||||
// Validate status rewards
|
||||
if q.RewardStatus < 0 {
|
||||
return fmt.Errorf("reward status cannot be negative")
|
||||
}
|
||||
|
||||
if q.Status < 0 {
|
||||
return fmt.Errorf("status earned cannot be negative")
|
||||
}
|
||||
|
||||
// Validate temporary rewards
|
||||
if q.TmpRewardCoins < 0 {
|
||||
return fmt.Errorf("temporary reward coins cannot be negative")
|
||||
}
|
||||
|
||||
if q.TmpRewardStatus < 0 {
|
||||
return fmt.Errorf("temporary reward status cannot be negative")
|
||||
}
|
||||
|
||||
// Validate status to earn ranges
|
||||
if q.StatusToEarnMin < 0 {
|
||||
return fmt.Errorf("status to earn min cannot be negative")
|
||||
}
|
||||
|
||||
if q.StatusToEarnMax < 0 {
|
||||
return fmt.Errorf("status to earn max cannot be negative")
|
||||
}
|
||||
|
||||
if q.StatusToEarnMax > 0 && q.StatusToEarnMax < q.StatusToEarnMin {
|
||||
return fmt.Errorf("status to earn max cannot be less than min")
|
||||
}
|
||||
|
||||
// Validate faction rewards
|
||||
for factionID, amount := range q.RewardFactions {
|
||||
if factionID <= 0 {
|
||||
return fmt.Errorf("invalid faction ID in rewards: %d", factionID)
|
||||
}
|
||||
|
||||
// Faction amounts can be negative (reputation loss)
|
||||
if amount < -50000 || amount > 50000 {
|
||||
return fmt.Errorf("faction reward amount out of range for faction %d: %d", factionID, amount)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate reward comment length
|
||||
if len(q.RewardComment) > MaxCompleteActionLength {
|
||||
return fmt.Errorf("reward comment too long (max %d)", MaxCompleteActionLength)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper methods for coin conversion
|
||||
|
||||
// ConvertCoinsToComponents breaks down coins into copper, silver, gold, platinum
|
||||
func ConvertCoinsToComponents(totalCoins int64) (copper, silver, gold, platinum int32) {
|
||||
platinum = int32(totalCoins / 1000000)
|
||||
remaining := totalCoins % 1000000
|
||||
|
||||
gold = int32(remaining / 10000)
|
||||
remaining = remaining % 10000
|
||||
|
||||
silver = int32(remaining / 100)
|
||||
copper = int32(remaining % 100)
|
||||
|
||||
return copper, silver, gold, platinum
|
||||
}
|
||||
|
||||
// ConvertComponentsToCoins converts individual coin components to total coins
|
||||
func ConvertComponentsToCoins(copper, silver, gold, platinum int32) int64 {
|
||||
return int64(copper) + int64(silver)*100 + int64(gold)*10000 + int64(platinum)*1000000
|
||||
}
|
||||
|
||||
// FormatCoinsString formats coins as a readable string
|
||||
func FormatCoinsString(totalCoins int64) string {
|
||||
if totalCoins == 0 {
|
||||
return "0 copper"
|
||||
}
|
||||
|
||||
copper, silver, gold, platinum := ConvertCoinsToComponents(totalCoins)
|
||||
|
||||
var parts []string
|
||||
if platinum > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d platinum", platinum))
|
||||
}
|
||||
if gold > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d gold", gold))
|
||||
}
|
||||
if silver > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d silver", silver))
|
||||
}
|
||||
if copper > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d copper", copper))
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return "0 copper"
|
||||
}
|
||||
|
||||
if len(parts) == 1 {
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
// Join with commas and "and" for the last item
|
||||
result := ""
|
||||
for i, part := range parts {
|
||||
if i == 0 {
|
||||
result = part
|
||||
} else if i == len(parts)-1 {
|
||||
result += " and " + part
|
||||
} else {
|
||||
result += ", " + part
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
602
internal/quests/types.go
Normal file
602
internal/quests/types.go
Normal file
@ -0,0 +1,602 @@
|
||||
package quests
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
"eq2emu/internal/common"
|
||||
)
|
||||
|
||||
// Location represents a 3D location in a zone for quest steps
|
||||
type Location struct {
|
||||
ID int32 `json:"id"`
|
||||
X float32 `json:"x"`
|
||||
Y float32 `json:"y"`
|
||||
Z float32 `json:"z"`
|
||||
ZoneID int32 `json:"zone_id"`
|
||||
}
|
||||
|
||||
// NewLocation creates a new location
|
||||
func NewLocation(id int32, x, y, z float32, zoneID int32) *Location {
|
||||
return &Location{
|
||||
ID: id,
|
||||
X: x,
|
||||
Y: y,
|
||||
Z: z,
|
||||
ZoneID: zoneID,
|
||||
}
|
||||
}
|
||||
|
||||
// QuestFactionPrereq represents faction requirements for a quest
|
||||
type QuestFactionPrereq struct {
|
||||
FactionID int32 `json:"faction_id"`
|
||||
Min int32 `json:"min"`
|
||||
Max int32 `json:"max"`
|
||||
}
|
||||
|
||||
// NewQuestFactionPrereq creates a new faction prerequisite
|
||||
func NewQuestFactionPrereq(factionID, min, max int32) *QuestFactionPrereq {
|
||||
return &QuestFactionPrereq{
|
||||
FactionID: factionID,
|
||||
Min: min,
|
||||
Max: max,
|
||||
}
|
||||
}
|
||||
|
||||
// QuestStep represents a single step in a quest
|
||||
type QuestStep struct {
|
||||
// Basic step data
|
||||
ID int32 `json:"step_id"`
|
||||
Type int8 `json:"type"`
|
||||
Description string `json:"description"`
|
||||
TaskGroup string `json:"task_group"`
|
||||
Quantity int32 `json:"quantity"`
|
||||
StepProgress int32 `json:"step_progress"`
|
||||
Icon int16 `json:"icon"`
|
||||
MaxVariation float32 `json:"max_variation"`
|
||||
Percentage float32 `json:"percentage"`
|
||||
UsableItemID int32 `json:"usable_item_id"`
|
||||
|
||||
// Tracking data
|
||||
UpdateName string `json:"update_name"`
|
||||
UpdateTargetName string `json:"update_target_name"`
|
||||
Updated bool `json:"updated"`
|
||||
|
||||
// Step data (one of these will be populated based on type)
|
||||
IDs map[int32]bool `json:"ids,omitempty"` // For kill, chat, obtain item, etc.
|
||||
Locations []*Location `json:"locations,omitempty"` // For location steps
|
||||
|
||||
// Thread safety
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewQuestStep creates a new quest step
|
||||
func NewQuestStep(id int32, stepType int8, description string, ids []int32, quantity int32, taskGroup string, locations []*Location, maxVariation, percentage float32, usableItemID int32) *QuestStep {
|
||||
step := &QuestStep{
|
||||
ID: id,
|
||||
Type: stepType,
|
||||
Description: description,
|
||||
TaskGroup: taskGroup,
|
||||
Quantity: quantity,
|
||||
StepProgress: 0,
|
||||
Icon: DefaultIcon,
|
||||
MaxVariation: maxVariation,
|
||||
Percentage: percentage,
|
||||
UsableItemID: usableItemID,
|
||||
Updated: false,
|
||||
}
|
||||
|
||||
// Initialize IDs map for non-location steps
|
||||
if stepType != StepTypeLocation && len(ids) > 0 {
|
||||
step.IDs = make(map[int32]bool)
|
||||
for _, id := range ids {
|
||||
step.IDs[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize locations for location steps
|
||||
if stepType == StepTypeLocation && len(locations) > 0 {
|
||||
step.Locations = make([]*Location, len(locations))
|
||||
copy(step.Locations, locations)
|
||||
}
|
||||
|
||||
return step
|
||||
}
|
||||
|
||||
// Copy creates a copy of a quest step
|
||||
func (qs *QuestStep) Copy() *QuestStep {
|
||||
qs.mutex.RLock()
|
||||
defer qs.mutex.RUnlock()
|
||||
|
||||
newStep := &QuestStep{
|
||||
ID: qs.ID,
|
||||
Type: qs.Type,
|
||||
Description: qs.Description,
|
||||
TaskGroup: qs.TaskGroup,
|
||||
Quantity: qs.Quantity,
|
||||
StepProgress: 0, // Reset progress for new quest copy
|
||||
Icon: qs.Icon,
|
||||
MaxVariation: qs.MaxVariation,
|
||||
Percentage: qs.Percentage,
|
||||
UsableItemID: qs.UsableItemID,
|
||||
UpdateName: qs.UpdateName,
|
||||
UpdateTargetName: qs.UpdateTargetName,
|
||||
Updated: false,
|
||||
}
|
||||
|
||||
// Copy IDs map
|
||||
if qs.IDs != nil {
|
||||
newStep.IDs = make(map[int32]bool)
|
||||
for id, value := range qs.IDs {
|
||||
newStep.IDs[id] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Copy locations
|
||||
if qs.Locations != nil {
|
||||
newStep.Locations = make([]*Location, len(qs.Locations))
|
||||
for i, loc := range qs.Locations {
|
||||
newStep.Locations[i] = &Location{
|
||||
ID: loc.ID,
|
||||
X: loc.X,
|
||||
Y: loc.Y,
|
||||
Z: loc.Z,
|
||||
ZoneID: loc.ZoneID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newStep
|
||||
}
|
||||
|
||||
// Complete checks if the step is complete
|
||||
func (qs *QuestStep) Complete() bool {
|
||||
qs.mutex.RLock()
|
||||
defer qs.mutex.RUnlock()
|
||||
return qs.StepProgress >= qs.Quantity
|
||||
}
|
||||
|
||||
// SetComplete marks the step as complete
|
||||
func (qs *QuestStep) SetComplete() {
|
||||
qs.mutex.Lock()
|
||||
defer qs.mutex.Unlock()
|
||||
qs.StepProgress = qs.Quantity
|
||||
qs.Updated = true
|
||||
}
|
||||
|
||||
// AddStepProgress adds progress to the step and returns the actual amount added
|
||||
func (qs *QuestStep) AddStepProgress(val int32) int32 {
|
||||
qs.mutex.Lock()
|
||||
defer qs.mutex.Unlock()
|
||||
|
||||
qs.Updated = true
|
||||
remaining := qs.Quantity - qs.StepProgress
|
||||
if val > remaining {
|
||||
qs.StepProgress = qs.Quantity
|
||||
return remaining
|
||||
}
|
||||
|
||||
qs.StepProgress += val
|
||||
return val
|
||||
}
|
||||
|
||||
// SetStepProgress sets the progress directly
|
||||
func (qs *QuestStep) SetStepProgress(val int32) {
|
||||
qs.mutex.Lock()
|
||||
defer qs.mutex.Unlock()
|
||||
qs.StepProgress = val
|
||||
}
|
||||
|
||||
// GetStepProgress returns current progress
|
||||
func (qs *QuestStep) GetStepProgress() int32 {
|
||||
qs.mutex.RLock()
|
||||
defer qs.mutex.RUnlock()
|
||||
return qs.StepProgress
|
||||
}
|
||||
|
||||
// CheckStepReferencedID checks if an ID is referenced by this step
|
||||
func (qs *QuestStep) CheckStepReferencedID(id int32) bool {
|
||||
qs.mutex.RLock()
|
||||
defer qs.mutex.RUnlock()
|
||||
|
||||
if qs.IDs != nil {
|
||||
_, exists := qs.IDs[id]
|
||||
return exists
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CheckStepLocationUpdate checks if character location matches step requirements
|
||||
func (qs *QuestStep) CheckStepLocationUpdate(charX, charY, charZ float32, zoneID int32) bool {
|
||||
qs.mutex.RLock()
|
||||
defer qs.mutex.RUnlock()
|
||||
|
||||
if qs.Locations == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, loc := range qs.Locations {
|
||||
if loc.ZoneID > 0 && loc.ZoneID != zoneID {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate distance within max variation
|
||||
diffX := loc.X - charX
|
||||
if diffX < 0 {
|
||||
diffX = -diffX
|
||||
}
|
||||
if diffX <= qs.MaxVariation {
|
||||
diffZ := loc.Z - charZ
|
||||
if diffZ < 0 {
|
||||
diffZ = -diffZ
|
||||
}
|
||||
if diffZ <= qs.MaxVariation {
|
||||
totalDiff := diffX + diffZ
|
||||
if totalDiff <= qs.MaxVariation {
|
||||
diffY := loc.Y - charY
|
||||
if diffY < 0 {
|
||||
diffY = -diffY
|
||||
}
|
||||
if diffY <= qs.MaxVariation {
|
||||
totalDiff += diffY
|
||||
if totalDiff <= qs.MaxVariation {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// WasUpdated returns if step was updated
|
||||
func (qs *QuestStep) WasUpdated() bool {
|
||||
qs.mutex.RLock()
|
||||
defer qs.mutex.RUnlock()
|
||||
return qs.Updated
|
||||
}
|
||||
|
||||
// SetWasUpdated sets the updated flag
|
||||
func (qs *QuestStep) SetWasUpdated(val bool) {
|
||||
qs.mutex.Lock()
|
||||
defer qs.mutex.Unlock()
|
||||
qs.Updated = val
|
||||
}
|
||||
|
||||
// GetCurrentQuantity returns current progress as int16
|
||||
func (qs *QuestStep) GetCurrentQuantity() int16 {
|
||||
qs.mutex.RLock()
|
||||
defer qs.mutex.RUnlock()
|
||||
return int16(qs.StepProgress)
|
||||
}
|
||||
|
||||
// GetNeededQuantity returns required quantity as int16
|
||||
func (qs *QuestStep) GetNeededQuantity() int16 {
|
||||
qs.mutex.RLock()
|
||||
defer qs.mutex.RUnlock()
|
||||
return int16(qs.Quantity)
|
||||
}
|
||||
|
||||
// ResetTaskGroup clears the task group
|
||||
func (qs *QuestStep) ResetTaskGroup() {
|
||||
qs.mutex.Lock()
|
||||
defer qs.mutex.Unlock()
|
||||
qs.TaskGroup = ""
|
||||
}
|
||||
|
||||
// SetTaskGroup sets the task group
|
||||
func (qs *QuestStep) SetTaskGroup(taskGroup string) {
|
||||
qs.mutex.Lock()
|
||||
defer qs.mutex.Unlock()
|
||||
qs.TaskGroup = taskGroup
|
||||
}
|
||||
|
||||
// SetDescription sets the step description
|
||||
func (qs *QuestStep) SetDescription(description string) {
|
||||
qs.mutex.Lock()
|
||||
defer qs.mutex.Unlock()
|
||||
qs.Description = description
|
||||
}
|
||||
|
||||
// SetUpdateName sets the update name
|
||||
func (qs *QuestStep) SetUpdateName(name string) {
|
||||
qs.mutex.Lock()
|
||||
defer qs.mutex.Unlock()
|
||||
qs.UpdateName = name
|
||||
}
|
||||
|
||||
// SetUpdateTargetName sets the update target name
|
||||
func (qs *QuestStep) SetUpdateTargetName(name string) {
|
||||
qs.mutex.Lock()
|
||||
defer qs.mutex.Unlock()
|
||||
qs.UpdateTargetName = name
|
||||
}
|
||||
|
||||
// SetIcon sets the step icon
|
||||
func (qs *QuestStep) SetIcon(icon int16) {
|
||||
qs.mutex.Lock()
|
||||
defer qs.mutex.Unlock()
|
||||
qs.Icon = icon
|
||||
}
|
||||
|
||||
// Quest represents a complete quest with all its steps and requirements
|
||||
type Quest struct {
|
||||
// Basic quest information
|
||||
ID int32 `json:"quest_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Zone string `json:"zone"`
|
||||
Level int8 `json:"level"`
|
||||
EncounterLevel int8 `json:"encounter_level"`
|
||||
Description string `json:"description"`
|
||||
CompletedDesc string `json:"completed_description"`
|
||||
|
||||
// Quest giver and return NPC
|
||||
QuestGiver int32 `json:"quest_giver"`
|
||||
ReturnID int32 `json:"return_id"`
|
||||
|
||||
// Prerequisites
|
||||
PrereqLevel int8 `json:"prereq_level"`
|
||||
PrereqTSLevel int8 `json:"prereq_ts_level"`
|
||||
PrereqMaxLevel int8 `json:"prereq_max_level"`
|
||||
PrereqMaxTSLevel int8 `json:"prereq_max_ts_level"`
|
||||
PrereqFactions []*QuestFactionPrereq `json:"prereq_factions"`
|
||||
PrereqRaces []int8 `json:"prereq_races"`
|
||||
PrereqModelTypes []int16 `json:"prereq_model_types"`
|
||||
PrereqClasses []int8 `json:"prereq_classes"`
|
||||
PrereqTSClasses []int8 `json:"prereq_ts_classes"`
|
||||
PrereqQuests []int32 `json:"prereq_quests"`
|
||||
|
||||
// Rewards
|
||||
RewardCoins int64 `json:"reward_coins"`
|
||||
RewardCoinsMax int64 `json:"reward_coins_max"`
|
||||
RewardFactions map[int32]int32 `json:"reward_factions"`
|
||||
RewardStatus int32 `json:"reward_status"`
|
||||
RewardComment string `json:"reward_comment"`
|
||||
RewardExp int32 `json:"reward_exp"`
|
||||
RewardTSExp int32 `json:"reward_ts_exp"`
|
||||
GeneratedCoin int64 `json:"generated_coin"`
|
||||
|
||||
// Temporary rewards
|
||||
TmpRewardStatus int32 `json:"tmp_reward_status"`
|
||||
TmpRewardCoins int64 `json:"tmp_reward_coins"`
|
||||
|
||||
// Steps and task groups
|
||||
QuestSteps []*QuestStep `json:"quest_steps"`
|
||||
QuestStepMap map[int32]*QuestStep `json:"-"` // For quick lookup
|
||||
QuestStepReverseMap map[*QuestStep]int32 `json:"-"` // Reverse lookup
|
||||
StepUpdates []*QuestStep `json:"-"` // Steps that were updated
|
||||
StepFailures []*QuestStep `json:"-"` // Steps that failed
|
||||
TaskGroupOrder map[int16]string `json:"task_group_order"`
|
||||
TaskGroup map[string][]*QuestStep `json:"-"` // Grouped steps
|
||||
TaskGroupNum int16 `json:"task_group_num"`
|
||||
|
||||
// Actions
|
||||
CompleteActions map[int32]string `json:"complete_actions"`
|
||||
ProgressActions map[int32]string `json:"progress_actions"`
|
||||
FailedActions map[int32]string `json:"failed_actions"`
|
||||
CompleteAction string `json:"complete_action"`
|
||||
|
||||
// State tracking
|
||||
Deleted bool `json:"deleted"`
|
||||
TurnedIn bool `json:"turned_in"`
|
||||
UpdateNeeded bool `json:"update_needed"`
|
||||
HasSentLastUpdate bool `json:"has_sent_last_update"`
|
||||
NeedsSave bool `json:"needs_save"`
|
||||
Visible int8 `json:"visible"`
|
||||
|
||||
// Date tracking
|
||||
Day int8 `json:"day"`
|
||||
Month int8 `json:"month"`
|
||||
Year int8 `json:"year"`
|
||||
|
||||
// Quest flags and settings
|
||||
FeatherColor int8 `json:"feather_color"`
|
||||
Repeatable bool `json:"repeatable"`
|
||||
Tracked bool `json:"tracked"`
|
||||
CompletedFlag bool `json:"completed_flag"`
|
||||
YellowName bool `json:"yellow_name"`
|
||||
QuestFlags int32 `json:"quest_flags"`
|
||||
Hidden bool `json:"hidden"`
|
||||
Status int32 `json:"status"`
|
||||
|
||||
// Timer and completion tracking
|
||||
Timestamp int32 `json:"timestamp"`
|
||||
TimerStep int32 `json:"timer_step"`
|
||||
CompleteCount int16 `json:"complete_count"`
|
||||
|
||||
// Temporary state
|
||||
QuestStateTemporary bool `json:"quest_state_temporary"`
|
||||
QuestTempDescription string `json:"quest_temp_description"`
|
||||
QuestShareableFlag int32 `json:"quest_shareable_flag"`
|
||||
CanDeleteQuest bool `json:"can_delete_quest"`
|
||||
StatusToEarnMin int32 `json:"status_to_earn_min"`
|
||||
StatusToEarnMax int32 `json:"status_to_earn_max"`
|
||||
HideReward bool `json:"hide_reward"`
|
||||
|
||||
// Thread safety
|
||||
stepsMutex sync.RWMutex
|
||||
completeActionsMutex sync.RWMutex
|
||||
progressActionsMutex sync.RWMutex
|
||||
failedActionsMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewQuest creates a new quest with the given ID
|
||||
func NewQuest(id int32) *Quest {
|
||||
now := time.Now()
|
||||
|
||||
quest := &Quest{
|
||||
ID: id,
|
||||
PrereqLevel: DefaultPrereqLevel,
|
||||
PrereqTSLevel: 0,
|
||||
PrereqMaxLevel: 0,
|
||||
PrereqMaxTSLevel: 0,
|
||||
RewardCoins: 0,
|
||||
RewardCoinsMax: 0,
|
||||
CompletedFlag: false,
|
||||
HasSentLastUpdate: false,
|
||||
EncounterLevel: 0,
|
||||
RewardExp: 0,
|
||||
RewardTSExp: 0,
|
||||
FeatherColor: 0,
|
||||
Repeatable: false,
|
||||
YellowName: false,
|
||||
Hidden: false,
|
||||
GeneratedCoin: 0,
|
||||
QuestFlags: 0,
|
||||
Timestamp: 0,
|
||||
CompleteCount: 0,
|
||||
QuestStateTemporary: false,
|
||||
TmpRewardStatus: 0,
|
||||
TmpRewardCoins: 0,
|
||||
CompletedDesc: "",
|
||||
QuestTempDescription: "",
|
||||
QuestShareableFlag: 0,
|
||||
CanDeleteQuest: false,
|
||||
Status: 0,
|
||||
StatusToEarnMin: 0,
|
||||
StatusToEarnMax: 0,
|
||||
HideReward: false,
|
||||
Deleted: false,
|
||||
TurnedIn: false,
|
||||
UpdateNeeded: true,
|
||||
NeedsSave: false,
|
||||
TaskGroupNum: DefaultTaskGroupNum,
|
||||
Visible: DefaultVisible,
|
||||
Day: int8(now.Day()),
|
||||
Month: int8(now.Month()),
|
||||
Year: int8(now.Year() - 2000), // EQ2 uses 2-digit years
|
||||
|
||||
// Initialize maps and slices
|
||||
QuestStepMap: make(map[int32]*QuestStep),
|
||||
QuestStepReverseMap: make(map[*QuestStep]int32),
|
||||
TaskGroupOrder: make(map[int16]string),
|
||||
TaskGroup: make(map[string][]*QuestStep),
|
||||
CompleteActions: make(map[int32]string),
|
||||
ProgressActions: make(map[int32]string),
|
||||
FailedActions: make(map[int32]string),
|
||||
RewardFactions: make(map[int32]int32),
|
||||
}
|
||||
|
||||
return quest
|
||||
}
|
||||
|
||||
// Copy creates a complete copy of a quest (used when giving quest to player)
|
||||
func (q *Quest) Copy() *Quest {
|
||||
q.stepsMutex.RLock()
|
||||
defer q.stepsMutex.RUnlock()
|
||||
|
||||
newQuest := NewQuest(q.ID)
|
||||
|
||||
// Copy basic information
|
||||
newQuest.Name = q.Name
|
||||
newQuest.Type = q.Type
|
||||
newQuest.Zone = q.Zone
|
||||
newQuest.Level = q.Level
|
||||
newQuest.EncounterLevel = q.EncounterLevel
|
||||
newQuest.Description = q.Description
|
||||
newQuest.CompletedDesc = q.CompletedDesc
|
||||
newQuest.QuestGiver = q.QuestGiver
|
||||
newQuest.ReturnID = q.ReturnID
|
||||
|
||||
// Copy prerequisites
|
||||
newQuest.PrereqLevel = q.PrereqLevel
|
||||
newQuest.PrereqTSLevel = q.PrereqTSLevel
|
||||
newQuest.PrereqMaxLevel = q.PrereqMaxLevel
|
||||
newQuest.PrereqMaxTSLevel = q.PrereqMaxTSLevel
|
||||
|
||||
// Copy prerequisite slices - create new slices
|
||||
newQuest.PrereqRaces = make([]int8, len(q.PrereqRaces))
|
||||
copy(newQuest.PrereqRaces, q.PrereqRaces)
|
||||
|
||||
newQuest.PrereqModelTypes = make([]int16, len(q.PrereqModelTypes))
|
||||
copy(newQuest.PrereqModelTypes, q.PrereqModelTypes)
|
||||
|
||||
newQuest.PrereqClasses = make([]int8, len(q.PrereqClasses))
|
||||
copy(newQuest.PrereqClasses, q.PrereqClasses)
|
||||
|
||||
newQuest.PrereqTSClasses = make([]int8, len(q.PrereqTSClasses))
|
||||
copy(newQuest.PrereqTSClasses, q.PrereqTSClasses)
|
||||
|
||||
newQuest.PrereqQuests = make([]int32, len(q.PrereqQuests))
|
||||
copy(newQuest.PrereqQuests, q.PrereqQuests)
|
||||
|
||||
// Copy faction prerequisites
|
||||
newQuest.PrereqFactions = make([]*QuestFactionPrereq, len(q.PrereqFactions))
|
||||
for i, faction := range q.PrereqFactions {
|
||||
newQuest.PrereqFactions[i] = &QuestFactionPrereq{
|
||||
FactionID: faction.FactionID,
|
||||
Min: faction.Min,
|
||||
Max: faction.Max,
|
||||
}
|
||||
}
|
||||
|
||||
// Copy rewards
|
||||
newQuest.RewardCoins = q.RewardCoins
|
||||
newQuest.RewardCoinsMax = q.RewardCoinsMax
|
||||
newQuest.RewardStatus = q.RewardStatus
|
||||
newQuest.RewardComment = q.RewardComment
|
||||
newQuest.RewardExp = q.RewardExp
|
||||
newQuest.RewardTSExp = q.RewardTSExp
|
||||
newQuest.GeneratedCoin = q.GeneratedCoin
|
||||
|
||||
// Copy reward factions map
|
||||
for factionID, amount := range q.RewardFactions {
|
||||
newQuest.RewardFactions[factionID] = amount
|
||||
}
|
||||
|
||||
// Copy quest steps
|
||||
for _, step := range q.QuestSteps {
|
||||
newQuest.AddQuestStep(step.Copy())
|
||||
}
|
||||
|
||||
// Copy actions maps
|
||||
q.completeActionsMutex.RLock()
|
||||
for stepID, action := range q.CompleteActions {
|
||||
newQuest.CompleteActions[stepID] = action
|
||||
}
|
||||
q.completeActionsMutex.RUnlock()
|
||||
|
||||
q.progressActionsMutex.RLock()
|
||||
for stepID, action := range q.ProgressActions {
|
||||
newQuest.ProgressActions[stepID] = action
|
||||
}
|
||||
q.progressActionsMutex.RUnlock()
|
||||
|
||||
q.failedActionsMutex.RLock()
|
||||
for stepID, action := range q.FailedActions {
|
||||
newQuest.FailedActions[stepID] = action
|
||||
}
|
||||
q.failedActionsMutex.RUnlock()
|
||||
|
||||
// Copy other properties
|
||||
newQuest.CompleteAction = q.CompleteAction
|
||||
newQuest.FeatherColor = q.FeatherColor
|
||||
newQuest.Repeatable = q.Repeatable
|
||||
newQuest.Hidden = q.Hidden
|
||||
newQuest.QuestFlags = q.QuestFlags
|
||||
newQuest.Status = q.Status
|
||||
newQuest.CompleteCount = q.CompleteCount
|
||||
newQuest.QuestShareableFlag = q.QuestShareableFlag
|
||||
newQuest.CanDeleteQuest = q.CanDeleteQuest
|
||||
newQuest.StatusToEarnMin = q.StatusToEarnMin
|
||||
newQuest.StatusToEarnMax = q.StatusToEarnMax
|
||||
newQuest.HideReward = q.HideReward
|
||||
newQuest.CompletedFlag = q.CompletedFlag
|
||||
newQuest.HasSentLastUpdate = q.HasSentLastUpdate
|
||||
newQuest.YellowName = q.YellowName
|
||||
|
||||
// Reset state for new quest copy
|
||||
newQuest.StepUpdates = make([]*QuestStep, 0)
|
||||
newQuest.StepFailures = make([]*QuestStep, 0)
|
||||
newQuest.Deleted = false
|
||||
newQuest.UpdateNeeded = true
|
||||
newQuest.TurnedIn = false
|
||||
newQuest.QuestStateTemporary = false
|
||||
newQuest.TmpRewardStatus = 0
|
||||
newQuest.TmpRewardCoins = 0
|
||||
newQuest.QuestTempDescription = ""
|
||||
|
||||
return newQuest
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user