diff --git a/internal/HousingDB.cpp b/internal/HousingDB.cpp new file mode 100644 index 0000000..edc6a49 --- /dev/null +++ b/internal/HousingDB.cpp @@ -0,0 +1,131 @@ +#include "../Worlddatabase.hpp" +#include "../World.h" + +extern World world; + +void WorldDatabase::LoadHouseZones() { + Query query; + MYSQL_ROW row; + MYSQL_RES* result = query.RunQuery2(Q_SELECT, "SELECT * FROM `houses`"); + + if (result && mysql_num_rows(result) > 0) { + while ((row = mysql_fetch_row(result))) { + world.AddHouseZone(atoul(row[0]), row[1], atoi64(row[2]), atoul(row[3]), atoi64(row[4]), atoul(row[5]), atoi(row[6]), atoi(row[7]), atoi(row[8]), atoul(row[9]), atoul(row[10]), atof(row[11]), atof(row[12]), atof(row[13]), atof(row[14])); + } + } +} + +int64 WorldDatabase::AddPlayerHouse(int32 char_id, int32 house_id, int32 instance_id, int32 upkeep_due) { + Query query; + string insert = string("INSERT INTO character_houses (char_id, house_id, instance_id, upkeep_due) VALUES (%u, %u, %u, %u) "); + query.RunQuery2(Q_INSERT, insert.c_str(), char_id, house_id, instance_id, upkeep_due); + + int64 unique_id = query.GetLastInsertedID(); + return unique_id; +} + +void WorldDatabase::SetHouseUpkeepDue(int32 char_id, int32 house_id, int32 instance_id, int32 upkeep_due) { + Query query; + string update = string("UPDATE character_houses set upkeep_due=%u where char_id = %u and house_id = %u and instance_id = %u"); + query.RunQuery2(Q_UPDATE, update.c_str(), upkeep_due, char_id, house_id, instance_id); +} + + +void WorldDatabase::UpdateHouseEscrow(int32 house_id, int32 instance_id, int64 amount_coins, int32 amount_status) { + Query query; + string update = string("UPDATE character_houses set escrow_coins = %llu, escrow_status = %u where house_id = %u and instance_id = %u"); + query.RunQuery2(Q_UPDATE, update.c_str(), amount_coins, amount_status, house_id, instance_id); +} + + +void WorldDatabase::RemovePlayerHouse(int32 char_id, int32 house_id) { +} + +void WorldDatabase::LoadPlayerHouses() { + Query query; + MYSQL_ROW row; + MYSQL_RES* result = query.RunQuery2(Q_SELECT, "SELECT h.id, h.char_id, h.house_id, h.instance_id, h.upkeep_due, h.escrow_coins, h.escrow_status, c.name FROM character_houses h, characters c WHERE h.char_id = c.id"); + + if (result && mysql_num_rows(result) > 0) { + while ((row = mysql_fetch_row(result))) { + world.AddPlayerHouse(atoul(row[1]), atoul(row[2]), atoi64(row[0]), atoul(row[3]), atoul(row[4]), atoi64(row[5]), atoul(row[6]), row[7]); + } + } +} + +void WorldDatabase::LoadDeposits(PlayerHouse* ph) +{ + if (!ph) + return; + ph->deposits.clear(); + ph->depositsMap.clear(); + + Query query; + MYSQL_ROW row; + MYSQL_RES* result = query.RunQuery2(Q_SELECT, "select timestamp, amount, last_amount, status, last_status, name from character_house_deposits where house_id = %u and instance_id = %u order by timestamp asc limit 255", ph->house_id, ph->instance_id); + + if (result && mysql_num_rows(result) > 0) { + while ((row = mysql_fetch_row(result))) { + Deposit d; + d.timestamp = atoul(row[0]); + + int64 outVal = strtoull(row[1], NULL, 0); + d.amount = outVal; + + outVal = strtoull(row[2], NULL, 0); + d.last_amount = outVal; + + d.status = atoul(row[3]); + d.last_status = atoul(row[4]); + + d.name = string(row[5]); + + ph->deposits.push_back(d); + ph->depositsMap.insert(make_pair(d.name, d)); + } + } +} + +void WorldDatabase::LoadHistory(PlayerHouse* ph) +{ + if (!ph) + return; + ph->history.clear(); + + Query query; + MYSQL_ROW row; + MYSQL_RES* result = query.RunQuery2(Q_SELECT, "select timestamp, amount, status, reason, name, pos_flag from character_house_history where house_id = %u and instance_id = %u order by timestamp asc limit 255", ph->house_id, ph->instance_id); + + if (result && mysql_num_rows(result) > 0) { + while ((row = mysql_fetch_row(result))) { + HouseHistory h; + h.timestamp = atoul(row[0]); + + int64 outVal = strtoull(row[1], NULL, 0); + h.amount = outVal; + + h.status = atoul(row[2]); + + h.reason = string(row[3]); + h.name = string(row[4]); + + h.pos_flag = atoul(row[5]); + + ph->history.push_back(h); + } + } +} + + +void WorldDatabase::AddHistory(PlayerHouse* house, char* name, char* reason, int32 timestamp, int64 amount, int32 status, int8 pos_flag) +{ + if (!house) + return; + + HouseHistory h(Timer::GetUnixTimeStamp(), amount, string(name), string(reason), status, pos_flag); + house->history.push_back(h); + + Query query; + string insert = string("INSERT INTO character_house_history (timestamp, house_id, instance_id, name, amount, status, reason, pos_flag) VALUES (%u, %u, %u, '%s', %llu, %u, '%s', %u) "); + query.RunQuery2(Q_INSERT, insert.c_str(), timestamp, house->house_id, house->instance_id, name, amount, status, reason, pos_flag); +} \ No newline at end of file diff --git a/internal/HousingPackets.cpp b/internal/HousingPackets.cpp new file mode 100644 index 0000000..b889868 --- /dev/null +++ b/internal/HousingPackets.cpp @@ -0,0 +1,454 @@ +#include "../ClientPacketFunctions.h" +#include "../World.h" +#include "../client.h" +#include "../Worlddatabase.hpp" +#include "../Rules/Rules.h" + +extern ConfigReader configReader; +extern World world; +extern WorldDatabase database; +extern RuleManager rule_manager; + +void ClientPacketFunctions::SendHousePurchase(Client* client, HouseZone* hz, int32 spawnID) { + PacketStruct* packet = configReader.getStruct("WS_PlayerHousePurchase", client->GetVersion()); + if (packet) { + int8 disable_alignment_req = rule_manager.GetZoneRule(client->GetCurrentZoneID(), R_Player, DisableHouseAlignmentRequirement)->GetInt8(); + packet->setDataByName("house_name", hz->name.c_str()); + packet->setDataByName("house_id", hz->id); + packet->setDataByName("spawn_id", spawnID); + packet->setDataByName("purchase_coins", hz->cost_coin); + packet->setDataByName("purchase_status", hz->cost_status); + packet->setDataByName("upkeep_coins", hz->upkeep_coin); + packet->setDataByName("upkeep_status", hz->upkeep_status); + packet->setDataByName("vendor_vault_slots", hz->vault_slots); + string req; + if (hz->alignment > 0 && !disable_alignment_req) { + req = "You must be of "; + if (hz->alignment == 1) + req.append("Good"); + else + req.append("Evil"); + req.append(" alignment"); + } + if (hz->guild_level > 0) { + if (req.length() > 0) { + req.append(", and a guild level of "); + char temp[5]; + sprintf(temp, "%i", hz->guild_level); + req.append(temp); + //req.append(std::to_string(static_cast(hz->guild_level))); + } + else { + req.append("Requires a guild of level "); + char temp[5]; + sprintf(temp, "%i", hz->guild_level); + req.append(temp); + //req.append(std::to_string(static_cast(hz->guild_level))) + req.append(" or above"); + } + } + if (req.length() > 0) { + req.append(" in order to purchase a home within the "); + req.append(hz->name); + req.append("."); + } + + packet->setDataByName("additional_reqs", req.c_str()); + + bool enable_buy = true; + if (hz->alignment > 0 && client->GetPlayer()->GetAlignment() != hz->alignment && !disable_alignment_req) + enable_buy = false; + if (hz->guild_level > 0 && (!client->GetPlayer()->GetGuild() || (client->GetPlayer()->GetGuild() && client->GetPlayer()->GetGuild()->GetLevel() < hz->guild_level))) + enable_buy = false; + + packet->setDataByName("enable_buy", enable_buy ? 1 : 0); + //packet->PrintPacket(); + client->QueuePacket(packet->serialize()); + } + + safe_delete(packet); +} + +void ClientPacketFunctions::SendHousingList(Client* client) { + if(client->GetVersion() <= 561) { + return; // not supported + } + + std::vector houses = world.GetAllPlayerHouses(client->GetCharacterID()); + // this packet must be sent first otherwise it blocks out the enter house option after paying upkeep + PacketStruct* packet = configReader.getStruct("WS_CharacterHousingList", client->GetVersion()); + if(!packet) { + return; + } + packet->setArrayLengthByName("num_houses", houses.size()); + for (int i = 0; i < houses.size(); i++) + { + PlayerHouse* ph = (PlayerHouse*)houses[i]; + HouseZone* hz = world.GetHouseZone(ph->house_id); + string name; + name = ph->player_name; + name.append("'s "); + name.append(hz->name); + packet->setArrayDataByName("house_id", ph->unique_id, i); + string zone_name = database.GetZoneName(hz->zone_id); + if(zone_name.length() > 0) + packet->setArrayDataByName("zone", zone_name.c_str(), i); + packet->setArrayDataByName("house_city", hz->name.c_str(), i); + packet->setArrayDataByName("house_address", "", i); // need this pulled from live + packet->setArrayDataByName("house_description", name.c_str(), i); + packet->setArrayDataByName("index", i, i); // they send 2, 4, 6, 8 as the index ID's on the client.. + + // this seems to be some kind of timestamp, if we keep updating then in conjunction with upkeep_due + // in SendBaseHouseWindow/WS_PlayerHouseBaseScreen being a >0 number we can access 'enter house' + + int32 upkeep_due = 0; + + if (((sint64)ph->upkeep_due - (sint64)Timer::GetUnixTimeStamp()) > 0) + upkeep_due = ph->upkeep_due - Timer::GetUnixTimeStamp(); + + if ( client->GetVersion() >= 63119 ) + packet->setArrayDataByName("unknown2a", 0xFFFFFFFF, i); + else + packet->setArrayDataByName("unknown2", 0xFFFFFFFF, i); + } + client->QueuePacket(packet->serialize()); + safe_delete(packet); +} + +void ClientPacketFunctions::SendBaseHouseWindow(Client* client, HouseZone* hz, PlayerHouse* ph, int32 spawnID) { + // if we don't send this then the enter house option won't be available if upkeep is paid + if (!hz || !ph) + { + client->SimpleMessage(CHANNEL_COLOR_RED, "HouseZone or PlayerHouse missing and cannot send SendBaseHouseWindow"); + return; + } + + string name; + name = ph->player_name; + name.append("'s "); + name.append(hz->name); + + if (spawnID) + SendHousingList(client); + + int32 upkeep_due = 0; + + if (((sint64)ph->upkeep_due - (sint64)Timer::GetUnixTimeStamp()) > 0) + upkeep_due = ph->upkeep_due - Timer::GetUnixTimeStamp(); + + // need this to enable the "enter house" button + PacketStruct* packet = nullptr; + + + if(client->GetVersion() > 561 && client->GetCurrentZone()->GetInstanceType() != PERSONAL_HOUSE_INSTANCE + && client->GetCurrentZone()->GetInstanceType() != GUILD_HOUSE_INSTANCE) { + packet = configReader.getStruct("WS_UpdateHouseAccessDataMsg", client->GetVersion()); + + if(!packet) { + return; // we need this for these clients or enter house will not work properly + } + if (packet) { + packet->setDataByName("house_id", 0xFFFFFFFFFFFFFFFF); + packet->setDataByName("success", (upkeep_due > 0) ? 0xFFFFFFFF : 0); + packet->setDataByName("unknown2", 0xFFFFFFFF); + packet->setDataByName("unknown3", 0xFFFFFFFF); + } + client->QueuePacket(packet->serialize()); + } + safe_delete(packet); + + packet = configReader.getStruct("WS_PlayerHouseBaseScreen", client->GetVersion()); + if (packet) { + packet->setDataByName("house_id", ph->unique_id); + packet->setDataByName("spawn_id", spawnID); + packet->setDataByName("character_id", client->GetPlayer()->GetCharacterID()); + packet->setDataByName("house_name", name.c_str()); + packet->setDataByName("zone_name", hz->name.c_str()); + packet->setDataByName("upkeep_cost_coins", hz->upkeep_coin); + packet->setDataByName("upkeep_cost_status", hz->upkeep_status); + + packet->setDataByName("upkeep_due", upkeep_due); + + packet->setDataByName("escrow_balance_coins", ph->escrow_coins); + packet->setDataByName("escrow_balance_status", ph->escrow_status); + // temp - set priv level to owner for now + packet->setDataByName("privlage_level", 4); + // temp - set house type to personal house for now + packet->setDataByName("house_type", 0); + + if(client->GetCurrentZone()->GetInstanceType() == PERSONAL_HOUSE_INSTANCE + || client->GetCurrentZone()->GetInstanceType() == GUILD_HOUSE_INSTANCE) { + packet->setDataByName("inside_house", 1); + packet->setDataByName("public_access_level", 1); + } + packet->setDataByName("num_access", 0); + packet->setDataByName("num_history", 0); + + // allows deposits/history to be seen -- at this point seems plausible supposed to be 'inside_house'..? + packet->setDataByName("unknown3", (ph->deposits.size() || ph->history.size()) ? 1 : 0); + + packet->setArrayLengthByName("num_deposit", ph->deposits.size()); + list::iterator itr; + int d = 0; + for (itr = ph->deposits.begin(); itr != ph->deposits.end(); itr++) + { + packet->setArrayDataByName("deposit_name", itr->name.c_str(), d); + packet->setArrayDataByName("deposit_total_coin", itr->amount, d); + packet->setArrayDataByName("deposit_time_stamp", itr->timestamp, d); + packet->setArrayDataByName("deposit_last_coin", itr->last_amount, d); + packet->setArrayDataByName("deposit_total_status", itr->status, d); + packet->setArrayDataByName("deposit_last_status", itr->last_status, d); + d++; + } + + + packet->setArrayLengthByName("num_history", ph->history.size()); + list::iterator hitr; + d = 0; + for (hitr = ph->history.begin(); hitr != ph->history.end(); hitr++) + { + packet->setArrayDataByName("history_name", hitr->name.c_str(), d); + packet->setArrayDataByName("history_coins", hitr->amount, d); + packet->setArrayDataByName("history_status", hitr->status, d); + packet->setArrayDataByName("history_time_stamp", hitr->timestamp, d); + packet->setArrayDataByName("history_reason", hitr->reason.c_str(), d); + packet->setArrayDataByName("history_add_flag", hitr->pos_flag, d); + d++; + } + + EQ2Packet* pack = packet->serialize(); + //DumpPacket(pack); + client->QueuePacket(pack); + } + safe_delete(packet); +} + +void ClientPacketFunctions::SendHouseVisitWindow(Client* client, vector houses) { + PacketStruct* packet = configReader.getStruct("WS_DisplayVisitScreen", client->GetVersion()); + if (packet) { + vector::iterator itr; + packet->setArrayLengthByName("num_houses", houses.size()); + int16 i = 0; + for (itr = houses.begin(); itr != houses.end(); itr++) { + PlayerHouse* ph = *itr; + if (ph) { + HouseZone* hz = world.GetHouseZone(ph->house_id); + if (hz) { + packet->setArrayDataByName("house_id", ph->unique_id, i); + packet->setArrayDataByName("house_owner", ph->player_name.c_str(), i); + packet->setArrayDataByName("house_location", hz->name.c_str(), i); + packet->setArrayDataByName("house_zone", client->GetCurrentZone()->GetZoneName(), i); + + if ( string(client->GetPlayer()->GetName()).compare(ph->player_name) == 0 ) + packet->setArrayDataByName("access_level", 4, i); + else + packet->setArrayDataByName("access_level", 1, i); + packet->setArrayDataByName("visit_flag", 0, i); // 0 = allowed to visit, 1 = owner hasn't paid upkeep + i++; + } + } + } + client->QueuePacket(packet->serialize()); + } + safe_delete(packet); +} + +/* + + + + + + + + + + + + + + +*/ + + +void ClientPacketFunctions::SendLocalizedTextMessage(Client* client) +{ + /*** + -- OP_ReloadLocalizedTxtMsg -- +5/26/2020 19:08:41 +69.174.200.100 -> 192.168.1.1 +0000: 01 FF 63 01 62 00 00 00 1C 00 49 72 6F 6E 74 6F ..c.b.....Ironto +0010: 65 73 20 45 61 73 74 20 4C 61 72 67 65 20 49 6E es East Large In +0020: 6E 20 52 6F 6F 6D 07 01 00 00 00 1C 00 49 72 6F n Room.......Iro +0030: 6E 74 6F 65 73 20 45 61 73 74 20 4C 61 72 67 65 ntoes East Large +0040: 20 49 6E 6E 20 52 6F 6F 6D 07 02 00 00 00 1C 00 Inn Room....... +0050: 49 72 6F 6E 74 6F 65 73 20 45 61 73 74 20 4C 61 Irontoes East La +0060: 72 67 65 20 49 6E 6E 20 52 6F 6F 6D 07 03 00 00 rge Inn Room.... +0070: 00 1C 00 49 72 6F 6E 74 6F 65 73 20 45 61 73 74 ...Irontoes East +0080: 20 4C 61 72 67 65 20 49 6E 6E 20 52 6F 6F 6D 07 Large Inn Room. +0090: 04 00 00 00 1C 00 49 72 6F 6E 74 6F 65 73 20 45 ......Irontoes E +00A0: 61 73 74 20 4C 61 72 67 65 20 49 6E 6E 20 52 6F ast Large Inn Ro +00B0: 6F 6D 07 05 00 00 00 1C 00 49 72 6F 6E 74 6F 65 om.......Irontoe +00C0: 73 20 45 61 73 74 20 4C 61 72 67 65 20 49 6E 6E s East Large Inn +00D0: 20 52 6F 6F 6D 07 06 00 00 00 1C 00 49 72 6F 6E Room.......Iron +00E0: 74 6F 65 73 20 45 61 73 74 20 4C 61 72 67 65 20 toes East Large +00F0: 49 6E 6E 20 52 6F 6F 6D 07 07 00 00 00 19 00 51 Inn Room.......Q +0100: 65 79 6E 6F 73 20 47 75 69 6C 64 20 48 61 6C 6C eynos Guild Hall +0110: 2C 20 54 69 65 72 20 31 07 08 00 00 00 16 00 4C , Tier 1.......L +0120: 69 6F 6E 27 73 20 4D 61 6E 65 20 53 75 69 74 65 ion's Mane Suite +0130: 20 52 6F 6F 6D 07 09 00 00 00 16 00 4C 69 6F 6E Room.......Lion +0140: 27 73 20 4D 61 6E 65 20 53 75 69 74 65 20 52 6F 's Mane Suite Ro +0150: 6F 6D 07 0A 00 00 00 16 00 4C 69 6F 6E 27 73 20 om.......Lion's +0160: 4D 61 6E 65 20 53 75 69 74 65 20 52 6F 6F 6D 07 Mane Suite Room. +0170: 0B 00 00 00 16 00 4C 69 6F 6E 27 73 20 4D 61 6E ......Lion's Man +0180: 65 20 53 75 69 74 65 20 52 6F 6F 6D 07 0C 00 00 e Suite Room.... +0190: 00 0E 00 32 20 4C 75 63 69 65 20 53 74 72 65 65 ...2 Lucie Stree +01A0: 74 07 0D 00 00 00 0F 00 31 37 20 54 72 61 6E 71 t.......17 Tranq +01B0: 75 69 6C 20 57 61 79 07 0E 00 00 00 0E 00 38 20 uil Way.......8 +01C0: 4C 75 63 69 65 20 53 74 72 65 65 74 07 0F 00 00 Lucie Street.... +01D0: 00 0F 00 31 32 20 4C 75 63 69 65 20 53 74 72 65 ...12 Lucie Stre +01E0: 65 74 07 10 00 00 00 0F 00 31 38 20 4C 75 63 69 et.......18 Luci +01F0: 65 20 53 74 72 65 65 74 07 11 00 00 00 0F 00 32 e Street.......2 +0200: 30 20 4C 75 63 69 65 20 53 74 72 65 65 74 07 12 0 Lucie Street.. +0210: 00 00 00 0E 00 33 20 54 72 61 6E 71 75 69 6C 20 .....3 Tranquil +0220: 57 61 79 07 13 00 00 00 0E 00 37 20 54 72 61 6E Way.......7 Tran +0230: 71 75 69 6C 20 57 61 79 07 14 00 00 00 0F 00 31 quil Way.......1 +0240: 33 20 54 72 61 6E 71 75 69 6C 20 57 61 79 07 15 3 Tranquil Way.. +0250: 00 00 00 0F 00 31 35 20 54 72 61 6E 71 75 69 6C .....15 Tranquil +0260: 20 57 61 79 07 16 00 00 00 19 00 51 65 79 6E 6F Way.......Qeyno +0270: 73 20 47 75 69 6C 64 20 48 61 6C 6C 2C 20 54 69 s Guild Hall, Ti +0280: 65 72 20 32 07 17 00 00 00 0F 00 38 20 45 72 6F er 2.......8 Ero +0290: 6C 6C 69 73 69 20 4C 61 6E 65 07 18 00 00 00 0F llisi Lane...... +02A0: 00 35 20 45 72 6F 6C 6C 69 73 69 20 4C 61 6E 65 .5 Erollisi Lane +02B0: 07 19 00 00 00 0E 00 35 20 4B 61 72 61 6E 61 20 .......5 Karana +02C0: 43 6F 75 72 74 07 1A 00 00 00 0D 00 32 20 42 61 Court.......2 Ba +02D0: 79 6C 65 20 43 6F 75 72 74 07 1B 00 00 00 0D 00 yle Court....... +02E0: 34 20 42 61 79 6C 65 20 43 6F 75 72 74 07 1C 00 4 Bayle Court... +02F0: 00 00 16 00 4C 69 6F 6E 27 73 20 4D 61 6E 65 20 ....Lion's Mane +0300: 53 75 69 74 65 20 52 6F 6F 6D 07 1D 00 00 00 16 Suite Room...... +0310: 00 4C 69 6F 6E 27 73 20 4D 61 6E 65 20 53 75 69 .Lion's Mane Sui +0320: 74 65 20 52 6F 6F 6D 07 1E 00 00 00 16 00 4C 69 te Room.......Li +0330: 6F 6E 27 73 20 4D 61 6E 65 20 53 75 69 74 65 20 on's Mane Suite +0340: 52 6F 6F 6D 07 1F 00 00 00 16 00 4C 69 6F 6E 27 Room.......Lion' +0350: 73 20 4D 61 6E 65 20 53 75 69 74 65 20 52 6F 6F s Mane Suite Roo +0360: 6D 07 20 00 00 00 0E 00 35 20 4C 75 63 69 65 20 m. .....5 Lucie +0370: 53 74 72 65 65 74 07 21 00 00 00 0F 00 32 30 20 Street.!.....20 +0380: 4B 61 72 61 6E 61 20 43 6F 75 72 74 07 22 00 00 Karana Court.".. +0390: 00 0E 00 39 20 4C 75 63 69 65 20 53 74 72 65 65 ...9 Lucie Stree +03A0: 74 07 23 00 00 00 0F 00 31 35 20 4C 75 63 69 65 t.#.....15 Lucie +03B0: 20 53 74 72 65 65 74 07 24 00 00 00 0F 00 31 37 Street.$.....17 +03C0: 20 4C 75 63 69 65 20 53 74 72 65 65 74 07 25 00 Lucie Street.%. +03D0: 00 00 0F 00 32 31 20 4C 75 63 69 65 20 53 74 72 ....21 Lucie Str +03E0: 65 65 74 07 26 00 00 00 0E 00 36 20 4B 61 72 61 eet.&.....6 Kara +03F0: 6E 61 20 43 6F 75 72 74 07 27 00 00 00 0F 00 31 na Court.'.....1 +0400: 32 20 4B 61 72 61 6E 61 20 43 6F 75 72 74 07 28 2 Karana Court.( +0410: 00 00 00 0F 00 31 34 20 4B 61 72 61 6E 61 20 43 .....14 Karana C +0420: 6F 75 72 74 07 29 00 00 00 0F 00 31 38 20 4B 61 ourt.).....18 Ka +0430: 72 61 6E 61 20 43 6F 75 72 74 07 2A 00 00 00 1E rana Court.*.... +0440: 00 43 6F 6E 63 6F 72 64 69 75 6D 20 54 6F 77 65 .Concordium Towe +0450: 72 20 4D 61 67 69 63 61 6C 20 4D 61 6E 6F 72 07 r Magical Manor. +0460: 2B 00 00 00 15 00 41 72 63 61 6E 65 20 41 63 61 +.....Arcane Aca +0470: 64 65 6D 79 20 50 6F 72 74 61 6C 07 2C 00 00 00 demy Portal.,... +0480: 13 00 43 6F 75 72 74 20 6F 66 20 74 68 65 20 4D ..Court of the M +0490: 61 73 74 65 72 07 2D 00 00 00 13 00 43 69 74 79 aster.-.....City +04A0: 20 6F 66 20 4D 69 73 74 20 45 73 74 61 74 65 07 of Mist Estate. +04B0: 2E 00 00 00 10 00 44 61 72 6B 6C 69 67 68 74 20 ......Darklight +04C0: 50 61 6C 61 63 65 07 2F 00 00 00 11 00 44 65 65 Palace./.....Dee +04D0: 70 77 61 74 65 72 20 52 65 74 72 65 61 74 07 30 pwater Retreat.0 +04E0: 00 00 00 24 00 44 68 61 6C 67 61 72 20 50 72 65 ...$.Dhalgar Pre +04F0: 63 69 70 69 63 65 20 6F 66 20 74 68 65 20 44 65 cipice of the De +0500: 65 70 20 50 6F 72 74 61 6C 07 31 00 00 00 12 00 ep Portal.1..... +0510: 44 69 6D 65 6E 73 69 6F 6E 61 6C 20 50 6F 63 6B Dimensional Pock +0520: 65 74 07 32 00 00 00 0B 00 44 6F 6A 6F 20 50 6F et.2.....Dojo Po +0530: 72 74 61 6C 07 33 00 00 00 21 00 45 6C 61 62 6F rtal.3...!.Elabo +0540: 72 61 74 65 20 45 73 74 61 74 65 20 6F 66 20 55 rate Estate of U +0550: 6E 72 65 73 74 20 50 6F 72 74 61 6C 07 34 00 00 nrest Portal.4.. +0560: 00 11 00 45 74 68 65 72 6E 65 72 65 20 45 6E 63 ...Ethernere Enc +0570: 6C 61 76 65 07 35 00 00 00 10 00 45 76 65 72 66 lave.5.....Everf +0580: 72 6F 73 74 20 50 6F 72 74 61 6C 07 36 00 00 00 rost Portal.6... +0590: 16 00 46 65 61 72 66 75 6C 20 52 65 74 72 65 61 ..Fearful Retrea +05A0: 74 20 50 6F 72 74 61 6C 07 37 00 00 00 0F 00 46 t Portal.7.....F +05B0: 65 6C 77 69 74 68 65 20 50 6F 72 74 61 6C 07 38 elwithe Portal.8 +05C0: 00 00 00 10 00 46 72 65 65 62 6C 6F 6F 64 20 50 .....Freeblood P +05D0: 6F 72 74 61 6C 07 39 00 00 00 0C 00 46 72 69 67 ortal.9.....Frig +05E0: 68 74 20 4D 61 6E 6F 72 07 3A 00 00 00 11 00 47 ht Manor.:.....G +05F0: 61 6C 6C 65 6F 6E 20 6F 66 20 44 72 65 61 6D 73 alleon of Dreams +0600: 07 3B 00 00 00 14 00 48 61 6C 6C 20 6F 66 20 74 .;.....Hall of t +0610: 68 65 20 43 68 61 6D 70 69 6F 6E 07 3C 00 00 00 he Champion.<... +0620: 10 00 48 75 61 20 4D 65 69 6E 20 52 65 74 72 65 ..Hua Mein Retre +0630: 61 74 07 3D 00 00 00 1C 00 49 73 6C 65 20 6F 66 at.=.....Isle of +0640: 20 52 65 66 75 67 65 20 50 72 65 73 74 69 67 65 Refuge Prestige +0650: 20 48 6F 6D 65 07 3E 00 00 00 0F 00 4B 65 72 61 Home.>.....Kera +0660: 66 79 72 6D 27 73 20 4C 61 69 72 07 3F 00 00 00 fyrm's Lair.?... +0670: 0E 00 4B 72 6F 6D 7A 65 6B 20 50 6F 72 74 61 6C ..Kromzek Portal +0680: 07 40 00 00 00 10 00 4C 61 76 61 73 74 6F 72 6D .@.....Lavastorm +0690: 20 50 6F 72 74 61 6C 07 41 00 00 00 0E 00 4C 69 Portal.A.....Li +06A0: 62 72 61 72 79 20 50 6F 72 74 61 6C 07 42 00 00 brary Portal.B.. +06B0: 00 0B 00 4D 61 72 61 20 45 73 74 61 74 65 07 43 ...Mara Estate.C +06C0: 00 00 00 21 00 4D 61 6A 27 44 75 6C 20 41 73 74 ...!.Maj'Dul Ast +06D0: 72 6F 6E 6F 6D 65 72 27 73 20 54 6F 77 65 72 20 ronomer's Tower +06E0: 50 6F 72 74 61 6C 07 44 00 00 00 14 00 4D 61 6A Portal.D.....Maj +06F0: 27 44 75 6C 20 53 75 69 74 65 20 50 6F 72 74 61 'Dul Suite Porta +0700: 6C 07 45 00 00 00 17 00 4D 69 73 74 6D 6F 6F 72 l.E.....Mistmoor +0710: 65 20 43 72 61 67 73 20 45 73 74 61 74 65 73 07 e Crags Estates. +0720: 46 00 00 00 0D 00 4F 61 6B 6D 79 73 74 20 47 6C F.....Oakmyst Gl +0730: 61 64 65 07 47 00 00 00 12 00 4F 70 65 72 61 20 ade.G.....Opera +0740: 48 6F 75 73 65 20 50 6F 72 74 61 6C 07 48 00 00 House Portal.H.. +0750: 00 16 00 50 65 72 73 6F 6E 61 6C 20 47 72 6F 74 ...Personal Grot +0760: 74 6F 20 50 6F 72 74 61 6C 07 49 00 00 00 17 00 to Portal.I..... +0770: 52 75 6D 20 52 75 6E 6E 65 72 73 20 43 6F 76 65 Rum Runners Cove +0780: 20 50 6F 72 74 61 6C 07 4A 00 00 00 12 00 50 6C Portal.J.....Pl +0790: 61 6E 65 74 61 72 69 75 6D 20 50 6F 72 74 61 6C anetarium Portal +07A0: 07 4B 00 00 00 14 00 52 65 73 65 61 72 63 68 65 .K.....Researche +07B0: 72 27 73 20 53 61 6E 63 74 75 6D 07 4C 00 00 00 r's Sanctum.L... +07C0: 1E 00 52 65 73 69 64 65 6E 63 65 20 6F 66 20 74 ..Residence of t +07D0: 68 65 20 42 6C 61 64 65 73 20 50 6F 72 74 61 6C he Blades Portal +07E0: 07 4D 00 00 00 16 00 53 61 6E 63 74 75 73 20 53 .M.....Sanctus S +07F0: 65 72 75 20 50 72 6F 6D 65 6E 61 64 65 07 4E 00 eru Promenade.N. +0800: 00 00 22 00 53 61 6E 74 61 20 47 6C 75 67 27 73 ..".Santa Glug's +0810: 20 43 68 65 65 72 66 75 6C 20 48 6F 6C 69 64 61 Cheerful Holida +0820: 79 20 48 6F 6D 65 07 4F 00 00 00 17 00 53 65 63 y Home.O.....Sec +0830: 6C 75 64 65 64 20 53 61 6E 63 74 75 6D 20 50 6F luded Sanctum Po +0840: 72 74 61 6C 07 50 00 00 00 18 00 53 6B 79 62 6C rtal.P.....Skybl +0850: 61 64 65 20 53 6B 69 66 66 20 4C 61 75 6E 63 68 ade Skiff Launch +0860: 70 61 64 07 51 00 00 00 0E 00 53 6E 6F 77 79 20 pad.Q.....Snowy +0870: 44 77 65 6C 6C 69 6E 67 07 52 00 00 00 1D 00 53 Dwelling.R.....S +0880: 70 72 6F 63 6B 65 74 27 73 20 49 6E 74 65 72 6C procket's Interl +0890: 6F 63 6B 69 6E 67 20 50 6C 61 6E 65 07 53 00 00 ocking Plane.S.. +08A0: 00 17 00 53 74 6F 72 6D 20 54 6F 77 65 72 20 49 ...Storm Tower I +08B0: 73 6C 65 20 50 6F 72 74 61 6C 07 54 00 00 00 21 sle Portal.T...! +08C0: 00 52 65 6C 69 63 20 54 69 6E 6B 65 72 20 50 72 .Relic Tinker Pr +08D0: 65 73 74 69 67 65 20 48 6F 6D 65 20 50 6F 72 74 estige Home Port +08E0: 61 6C 07 55 00 00 00 10 00 54 65 6E 65 62 72 6F al.U.....Tenebro +08F0: 75 73 20 50 6F 72 74 61 6C 07 56 00 00 00 10 00 us Portal.V..... +0900: 54 68 65 20 42 61 75 62 62 6C 65 73 68 69 72 65 The Baubbleshire +0910: 07 57 00 00 00 0F 00 54 69 6E 6B 65 72 65 72 27 .W.....Tinkerer' +0920: 73 20 49 73 6C 65 07 58 00 00 00 12 00 54 6F 77 s Isle.X.....Tow +0930: 65 72 20 6F 66 20 4B 6E 6F 77 6C 65 64 67 65 07 er of Knowledge. +0940: 59 00 00 00 15 00 55 6E 63 61 6E 6E 79 20 45 73 Y.....Uncanny Es +0950: 74 61 74 65 20 50 6F 72 74 61 6C 07 5A 00 00 00 tate Portal.Z... +0960: 1E 00 56 61 63 61 6E 74 20 45 73 74 61 74 65 20 ..Vacant Estate +0970: 6F 66 20 55 6E 72 65 73 74 20 50 6F 72 74 61 6C of Unrest Portal +0980: 07 5B 00 00 00 18 00 56 61 6C 65 20 6F 66 20 48 .[.....Vale of H +0990: 61 6C 66 70 69 6E 74 20 44 65 6C 69 67 68 74 07 alfpint Delight. +09A0: 5C 00 00 00 26 00 4C 69 6F 6E 27 73 20 4D 61 6E \...&.Lion's Man +09B0: 65 20 56 65 73 74 69 67 65 20 52 6F 6F 6D 20 2D e Vestige Room - +09C0: 20 4E 65 74 74 6C 65 76 69 6C 6C 65 07 5D 00 00 Nettleville.].. +09D0: 00 2C 00 4C 69 6F 6E 27 73 20 4D 61 6E 65 20 56 .,.Lion's Mane V +09E0: 65 73 74 69 67 65 20 52 6F 6F 6D 20 2D 20 53 74 estige Room - St +09F0: 61 72 63 72 65 73 74 20 43 6F 6D 6D 75 6E 65 07 arcrest Commune. +0A00: 5E 00 00 00 29 00 4C 69 6F 6E 27 73 20 4D 61 6E ^...).Lion's Man +0A10: 65 20 56 65 73 74 69 67 65 20 52 6F 6F 6D 20 2D e Vestige Room - +0A20: 20 47 72 61 79 73 74 6F 6E 65 20 59 61 72 64 07 Graystone Yard. +0A30: 5F 00 00 00 2C 00 4C 69 6F 6E 27 73 20 4D 61 6E _...,.Lion's Man +0A40: 65 20 56 65 73 74 69 67 65 20 52 6F 6F 6D 20 2D e Vestige Room - +0A50: 20 43 61 73 74 6C 65 76 69 65 77 20 48 61 6D 6C Castleview Haml +0A60: 65 74 07 60 00 00 00 2A 00 4C 69 6F 6E 27 73 20 et.`...*.Lion's +0A70: 4D 61 6E 65 20 56 65 73 74 69 67 65 20 52 6F 6F Mane Vestige Roo +0A80: 6D 20 2D 20 54 68 65 20 57 69 6C 6C 6F 77 20 57 m - The Willow W +0A90: 6F 6F 64 07 61 00 00 00 2B 00 4C 69 6F 6E 27 73 ood.a...+.Lion's +0AA0: 20 4D 61 6E 65 20 56 65 73 74 69 67 65 20 52 6F Mane Vestige Ro +0AB0: 6F 6D 20 2D 20 54 68 65 20 42 61 75 62 62 6C 65 om - The Baubble +0AC0: 73 68 69 72 65 07 62 00 00 00 FF FF FF FF shire.b....... +*/ +} \ No newline at end of file diff --git a/internal/chat/README.md b/internal/chat/README.md new file mode 100644 index 0000000..96d52c8 --- /dev/null +++ b/internal/chat/README.md @@ -0,0 +1,226 @@ +# Chat System + +The chat system provides comprehensive channel-based communication for EverQuest II server emulation, converted from the original C++ EQ2EMu implementation. + +## Overview + +The chat system manages multiple chat channels with membership, access control, and message routing capabilities. It supports both persistent world channels (loaded from database) and temporary custom channels (created by players). + +## Architecture + +### Core Components + +**ChatManager** - Main chat system coordinator managing multiple channels +**Channel** - Individual channel implementation with membership and messaging +**ChatService** - High-level service interface for chat operations +**DatabaseChannelManager** - Database persistence layer for world channels + +### Key Features + +- **Channel Types**: World (persistent) and Custom (temporary) channels +- **Access Control**: Level, race, and class restrictions with bitmask filtering +- **Password Protection**: Optional password protection for channels +- **Language Integration**: Multilingual chat processing with language comprehension +- **Discord Integration**: Optional Discord webhook bridge for specific channels +- **Thread Safety**: All operations use proper Go concurrency patterns + +## Channel Types + +### World Channels +- Persistent channels loaded from database at server startup +- Cannot be deleted by players +- Configured with access restrictions (level, race, class) +- Examples: "Auction", "Level_1-9", "Trade" + +### Custom Channels +- Created dynamically by players +- Automatically deleted when empty for 5+ minutes +- Support optional password protection +- Player-controlled membership + +## Database Schema + +```sql +CREATE TABLE channels ( + name TEXT PRIMARY KEY, + password TEXT, + level_restriction INTEGER NOT NULL DEFAULT 0, + classes INTEGER NOT NULL DEFAULT 0, + races INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +``` + +## Access Control + +### Level Restrictions +- Minimum level required to join channel +- 0 = no level restriction + +### Race Restrictions (Bitmask) +- Bit position corresponds to race ID +- 0 = all races allowed +- Example: `(1 << raceID) & raceMask` checks if race is allowed + +### Class Restrictions (Bitmask) +- Bit position corresponds to class ID +- 0 = all classes allowed +- Example: `(1 << classID) & classMask` checks if class is allowed + +## Usage Examples + +### Basic Operations + +```go +// Initialize chat service +service := NewChatService(database, clientManager, playerManager, languageProcessor) +err := service.Initialize(ctx) + +// Create custom channel +err := service.CreateChannel("MyChannel", "password123") + +// Join channel +err := service.JoinChannel(characterID, "MyChannel", "password123") + +// Send message +err := service.SendChannelMessage(characterID, "MyChannel", "Hello everyone!") + +// Leave channel +err := service.LeaveChannel(characterID, "MyChannel") +``` + +### Channel Commands + +```go +// Process channel command from client +err := service.ProcessChannelCommand(characterID, "join", "Auction") +err := service.ProcessChannelCommand(characterID, "tell", "Auction", "WTS", "Epic", "Sword") +err := service.ProcessChannelCommand(characterID, "who", "Auction") +err := service.ProcessChannelCommand(characterID, "leave", "Auction") +``` + +### Admin Operations + +```go +// Get channel statistics +stats := service.GetStatistics() +fmt.Printf("Total channels: %d, Active: %d\n", stats.TotalChannels, stats.ActiveChannels) + +// Broadcast system message +err := service.BroadcastSystemMessage("Auction", "Server restart in 10 minutes", "System") + +// Cleanup empty channels +removed := service.CleanupEmptyChannels() +``` + +## Integration Interfaces + +### ClientManager +Handles client communication for channel lists, messages, and updates. + +### PlayerManager +Provides player information for access control and message routing. + +### LanguageProcessor +Processes multilingual messages with language comprehension checks. + +### ChannelDatabase +Manages persistent storage of world channels and configuration. + +## Protocol Integration + +### Packet Types +- `WS_AvailWorldChannels` - Channel list for client +- `WS_ChatChannelUpdate` - Join/leave notifications +- `WS_HearChat` - Channel message delivery +- `WS_WhoChannelQueryReply` - User list responses + +### Channel Actions +- `CHAT_CHANNEL_JOIN` (0) - Player joins channel +- `CHAT_CHANNEL_LEAVE` (1) - Player leaves channel +- `CHAT_CHANNEL_OTHER_JOIN` (2) - Another player joins +- `CHAT_CHANNEL_OTHER_LEAVE` (3) - Another player leaves + +## Language Support + +The chat system integrates with the language system for multilingual communication: + +- Messages are processed based on sender's default language +- Recipients receive scrambled text for unknown languages +- Language comprehension is checked per message +- Proper language ID tracking for all communications + +## Discord Integration (Optional) + +Channels can be configured for Discord webhook integration: + +- Bidirectional chat bridge (EQ2 ↔ Discord) +- Configurable per channel via rules system +- Webhook-based implementation for simplicity +- Server name and character name formatting + +## Thread Safety + +All operations are thread-safe using: +- `sync.RWMutex` for read/write operations +- Atomic operations where appropriate +- Proper locking hierarchies to prevent deadlocks +- Channel-level and manager-level synchronization + +## Performance Considerations + +- Channel lookups use case-insensitive maps +- Member lists use slices with efficient removal +- Read operations use read locks for concurrency +- Database operations are context-aware with timeouts + +## Error Handling + +The system provides comprehensive error handling: +- Channel not found errors +- Access denied errors (level/race/class restrictions) +- Password validation errors +- Database connection errors +- Language processing errors + +## Future Enhancements + +Areas marked for future implementation: +- Advanced Discord bot integration +- Channel moderation features +- Message history and logging +- Channel-specific emote support +- Advanced filtering and search +- Cross-server channel support + +## File Structure + +``` +internal/chat/ +├── README.md # This documentation +├── constants.go # Channel constants and limits +├── types.go # Core data structures +├── interfaces.go # Integration interfaces +├── chat.go # Main ChatManager implementation +├── channel.go # Channel implementation +├── database.go # Database operations +├── manager.go # High-level ChatService +└── channel/ + └── channel.go # Standalone channel package +``` + +## Dependencies + +- `eq2emu/internal/database` - Database wrapper +- `eq2emu/internal/languages` - Language processing +- Standard library: `context`, `sync`, `strings`, `time` + +## Testing + +The chat system is designed for comprehensive testing: +- Mock interfaces for all dependencies +- Isolated channel testing +- Concurrent operation testing +- Database integration testing +- Error condition testing \ No newline at end of file diff --git a/internal/chat/channel.go b/internal/chat/channel.go new file mode 100644 index 0000000..6897354 --- /dev/null +++ b/internal/chat/channel.go @@ -0,0 +1,249 @@ +package chat + +import ( + "fmt" + "slices" +) + +// NewChannel creates a new channel instance +func NewChannel(name string) *Channel { + return &Channel{ + name: name, + members: make([]int32, 0), + } +} + +// SetName sets the channel name +func (c *Channel) SetName(name string) { + c.mu.Lock() + defer c.mu.Unlock() + c.name = name +} + +// SetPassword sets the channel password +func (c *Channel) SetPassword(password string) { + c.mu.Lock() + defer c.mu.Unlock() + c.password = password +} + +// SetType sets the channel type +func (c *Channel) SetType(channelType int) { + c.mu.Lock() + defer c.mu.Unlock() + c.channelType = channelType +} + +// SetLevelRestriction sets the minimum level required to join +func (c *Channel) SetLevelRestriction(level int32) { + c.mu.Lock() + defer c.mu.Unlock() + c.levelRestriction = level +} + +// SetRacesAllowed sets the race bitmask for allowed races +func (c *Channel) SetRacesAllowed(races int32) { + c.mu.Lock() + defer c.mu.Unlock() + c.raceRestriction = races +} + +// SetClassesAllowed sets the class bitmask for allowed classes +func (c *Channel) SetClassesAllowed(classes int32) { + c.mu.Lock() + defer c.mu.Unlock() + c.classRestriction = classes +} + +// GetName returns the channel name +func (c *Channel) GetName() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.name +} + +// GetType returns the channel type +func (c *Channel) GetType() int { + c.mu.RLock() + defer c.mu.RUnlock() + return c.channelType +} + +// GetNumClients returns the number of clients in the channel +func (c *Channel) GetNumClients() int { + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.members) +} + +// HasPassword returns true if the channel has a password +func (c *Channel) HasPassword() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.password != "" +} + +// PasswordMatches checks if the provided password matches the channel password +func (c *Channel) PasswordMatches(password string) bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.password == password +} + +// CanJoinChannelByLevel checks if a player's level meets the channel requirements +func (c *Channel) CanJoinChannelByLevel(level int32) bool { + c.mu.RLock() + defer c.mu.RUnlock() + return level >= c.levelRestriction +} + +// CanJoinChannelByRace checks if a player's race is allowed in the channel +func (c *Channel) CanJoinChannelByRace(raceID int32) bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.raceRestriction == NoRaceRestriction || (c.raceRestriction&(1<= c.levelRestriction +} + +// CanJoinChannelByRace checks if a player's race is allowed in the channel +func (c *Channel) CanJoinChannelByRace(raceID int32) bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.raceRestriction == NoRaceRestriction || (c.raceRestriction&(1< MaxChannelNameLength { + return fmt.Errorf("channel name too long: %d > %d", len(channelName), MaxChannelNameLength) + } + + cm.mu.Lock() + defer cm.mu.Unlock() + + // Check if channel already exists + if _, exists := cm.channels[strings.ToLower(channelName)]; exists { + return fmt.Errorf("channel %s already exists", channelName) + } + + // Create new custom channel + channel := &Channel{ + name: channelName, + channelType: ChannelTypeCustom, + members: make([]int32, 0), + created: time.Now(), + } + + // Set password if provided + if len(password) > 0 && password[0] != "" { + if len(password[0]) > MaxChannelPasswordLength { + return fmt.Errorf("channel password too long: %d > %d", len(password[0]), MaxChannelPasswordLength) + } + channel.password = password[0] + } + + cm.channels[strings.ToLower(channelName)] = channel + return nil +} + +// IsInChannel checks if a character is in the specified channel +func (cm *ChatManager) IsInChannel(characterID int32, channelName string) bool { + cm.mu.RLock() + defer cm.mu.RUnlock() + + if channel, exists := cm.channels[strings.ToLower(channelName)]; exists { + return channel.isInChannel(characterID) + } + return false +} + +// JoinChannel adds a character to a channel +func (cm *ChatManager) JoinChannel(characterID int32, channelName string, password ...string) error { + cm.mu.Lock() + defer cm.mu.Unlock() + + channel, exists := cm.channels[strings.ToLower(channelName)] + if !exists { + return fmt.Errorf("channel %s does not exist", channelName) + } + + // Check password if channel has one + if channel.password != "" { + if len(password) == 0 || password[0] != channel.password { + return fmt.Errorf("invalid password for channel %s", channelName) + } + } + + // Get player info for validation + playerInfo, err := cm.playerManager.GetPlayerInfo(characterID) + if err != nil { + return fmt.Errorf("failed to get player info: %w", err) + } + + // Validate restrictions + if !cm.canJoinChannel(playerInfo.Level, playerInfo.Race, playerInfo.Class, + channel.levelRestriction, channel.raceRestriction, channel.classRestriction) { + return fmt.Errorf("player does not meet channel requirements") + } + + // Add to channel + if err := channel.joinChannel(characterID); err != nil { + return err + } + + // Notify all channel members of the join + cm.notifyChannelUpdate(channelName, ChatChannelOtherJoin, playerInfo.CharacterName, characterID) + + return nil +} + +// LeaveChannel removes a character from a channel +func (cm *ChatManager) LeaveChannel(characterID int32, channelName string) error { + cm.mu.Lock() + defer cm.mu.Unlock() + + channel, exists := cm.channels[strings.ToLower(channelName)] + if !exists { + return fmt.Errorf("channel %s does not exist", channelName) + } + + // Get player info for notification + playerInfo, err := cm.playerManager.GetPlayerInfo(characterID) + if err != nil { + return fmt.Errorf("failed to get player info: %w", err) + } + + // Remove from channel + if err := channel.leaveChannel(characterID); err != nil { + return err + } + + // Delete custom channels with no members + if channel.channelType == ChannelTypeCustom && len(channel.members) == 0 { + delete(cm.channels, strings.ToLower(channelName)) + } + + // Notify all remaining channel members of the leave + cm.notifyChannelUpdate(channelName, ChatChannelOtherLeave, playerInfo.CharacterName, characterID) + + return nil +} + +// LeaveAllChannels removes a character from all channels they're in +func (cm *ChatManager) LeaveAllChannels(characterID int32) error { + cm.mu.Lock() + defer cm.mu.Unlock() + + playerInfo, err := cm.playerManager.GetPlayerInfo(characterID) + if err != nil { + return fmt.Errorf("failed to get player info: %w", err) + } + + // Find all channels the player is in and remove them + var channelsToDelete []string + for channelName, channel := range cm.channels { + if channel.isInChannel(characterID) { + channel.leaveChannel(characterID) + + // Mark custom channels with no members for deletion + if channel.channelType == ChannelTypeCustom && len(channel.members) == 0 { + channelsToDelete = append(channelsToDelete, channelName) + } else { + // Notify remaining members + cm.notifyChannelUpdate(channel.name, ChatChannelOtherLeave, playerInfo.CharacterName, characterID) + } + } + } + + // Delete empty custom channels + for _, channelName := range channelsToDelete { + delete(cm.channels, channelName) + } + + return nil +} + +// TellChannel sends a message to all members of a channel +func (cm *ChatManager) TellChannel(senderID int32, channelName, message string, customName ...string) error { + cm.mu.RLock() + defer cm.mu.RUnlock() + + channel, exists := cm.channels[strings.ToLower(channelName)] + if !exists { + return fmt.Errorf("channel %s does not exist", channelName) + } + + // Check if sender is in channel (unless it's a system message) + if senderID != 0 && !channel.isInChannel(senderID) { + return fmt.Errorf("sender is not in channel %s", channelName) + } + + // Get sender info + var senderName string + var languageID int32 + + if senderID != 0 { + playerInfo, err := cm.playerManager.GetPlayerInfo(senderID) + if err != nil { + return fmt.Errorf("failed to get sender info: %w", err) + } + senderName = playerInfo.CharacterName + + // Get sender's default language + if cm.languageProcessor != nil { + languageID = cm.languageProcessor.GetDefaultLanguage(senderID) + } + } + + // Use custom name if provided (for system messages) + if len(customName) > 0 && customName[0] != "" { + senderName = customName[0] + } + + // Create message + chatMessage := ChannelMessage{ + SenderID: senderID, + SenderName: senderName, + Message: message, + LanguageID: languageID, + ChannelName: channelName, + Timestamp: time.Now(), + } + + // Send to all channel members + return cm.deliverChannelMessage(channel, chatMessage) +} + +// SendChannelUserList sends the list of users in a channel to a client +func (cm *ChatManager) SendChannelUserList(requesterID int32, channelName string) error { + cm.mu.RLock() + defer cm.mu.RUnlock() + + channel, exists := cm.channels[strings.ToLower(channelName)] + if !exists { + return fmt.Errorf("channel %s does not exist", channelName) + } + + // Check if requester is in channel + if !channel.isInChannel(requesterID) { + return fmt.Errorf("requester is not in channel %s", channelName) + } + + // Build member list + var members []ChannelMember + for _, memberID := range channel.members { + if playerInfo, err := cm.playerManager.GetPlayerInfo(memberID); err == nil { + member := ChannelMember{ + CharacterID: memberID, + CharacterName: playerInfo.CharacterName, + Level: playerInfo.Level, + Race: playerInfo.Race, + Class: playerInfo.Class, + JoinedAt: time.Now(), // TODO: Track actual join time + } + members = append(members, member) + } + } + + // Send user list to requester + return cm.clientManager.SendChannelUserList(requesterID, channelName, members) +} + +// GetChannel returns a channel by name (for internal use) +func (cm *ChatManager) GetChannel(channelName string) *Channel { + cm.mu.RLock() + defer cm.mu.RUnlock() + + return cm.channels[strings.ToLower(channelName)] +} + +// GetStatistics returns chat system statistics +func (cm *ChatManager) GetStatistics() ChatStatistics { + cm.mu.RLock() + defer cm.mu.RUnlock() + + stats := ChatStatistics{ + TotalChannels: len(cm.channels), + } + + for _, channel := range cm.channels { + switch channel.channelType { + case ChannelTypeWorld: + stats.WorldChannels++ + case ChannelTypeCustom: + stats.CustomChannels++ + } + + stats.TotalMembers += len(channel.members) + if len(channel.members) > 0 { + stats.ActiveChannels++ + } + } + + return stats +} + +// Helper methods + +// canJoinChannel checks if a player meets channel requirements +func (cm *ChatManager) canJoinChannel(playerLevel, playerRace, playerClass, levelReq, raceReq, classReq int32) bool { + // Check level restriction + if levelReq > NoLevelRestriction && playerLevel < levelReq { + return false + } + + // Check race restriction (bitmask) + if raceReq > NoRaceRestriction && (raceReq&(1< NoClassRestriction && (classReq&(1< 0 { + password = args[0] + } + return cs.manager.JoinChannel(characterID, channelName, password) + + case "leave": + return cs.manager.LeaveChannel(characterID, channelName) + + case "create": + password := "" + if len(args) > 0 { + password = args[0] + } + return cs.manager.CreateChannel(channelName, password) + + case "tell", "say": + if len(args) == 0 { + return fmt.Errorf("no message provided") + } + message := strings.Join(args, " ") + return cs.manager.TellChannel(characterID, channelName, message) + + case "who", "list": + return cs.manager.SendChannelUserList(characterID, channelName) + + default: + return fmt.Errorf("unknown channel command: %s", command) + } +} + +// SendChannelMessage sends a message to a channel +func (cs *ChatService) SendChannelMessage(senderID int32, channelName string, message string, customName ...string) error { + cs.mu.RLock() + defer cs.mu.RUnlock() + + return cs.manager.TellChannel(senderID, channelName, message, customName...) +} + +// JoinChannel adds a character to a channel +func (cs *ChatService) JoinChannel(characterID int32, channelName string, password ...string) error { + cs.mu.RLock() + defer cs.mu.RUnlock() + + return cs.manager.JoinChannel(characterID, channelName, password...) +} + +// LeaveChannel removes a character from a channel +func (cs *ChatService) LeaveChannel(characterID int32, channelName string) error { + cs.mu.RLock() + defer cs.mu.RUnlock() + + return cs.manager.LeaveChannel(characterID, channelName) +} + +// LeaveAllChannels removes a character from all channels +func (cs *ChatService) LeaveAllChannels(characterID int32) error { + cs.mu.RLock() + defer cs.mu.RUnlock() + + return cs.manager.LeaveAllChannels(characterID) +} + +// CreateChannel creates a new custom channel +func (cs *ChatService) CreateChannel(channelName string, password ...string) error { + cs.mu.RLock() + defer cs.mu.RUnlock() + + return cs.manager.CreateChannel(channelName, password...) +} + +// GetWorldChannelList returns available world channels for a character +func (cs *ChatService) GetWorldChannelList(characterID int32) ([]ChannelInfo, error) { + cs.mu.RLock() + defer cs.mu.RUnlock() + + return cs.manager.GetWorldChannelList(characterID) +} + +// ChannelExists checks if a channel exists +func (cs *ChatService) ChannelExists(channelName string) bool { + cs.mu.RLock() + defer cs.mu.RUnlock() + + return cs.manager.ChannelExists(channelName) +} + +// IsInChannel checks if a character is in a channel +func (cs *ChatService) IsInChannel(characterID int32, channelName string) bool { + cs.mu.RLock() + defer cs.mu.RUnlock() + + return cs.manager.IsInChannel(characterID, channelName) +} + +// GetChannelInfo returns information about a specific channel +func (cs *ChatService) GetChannelInfo(channelName string) (*ChannelInfo, error) { + cs.mu.RLock() + defer cs.mu.RUnlock() + + channel := cs.manager.GetChannel(channelName) + if channel == nil { + return nil, fmt.Errorf("channel %s not found", channelName) + } + + info := channel.GetChannelInfo() + return &info, nil +} + +// GetStatistics returns chat system statistics +func (cs *ChatService) GetStatistics() ChatStatistics { + cs.mu.RLock() + defer cs.mu.RUnlock() + + return cs.manager.GetStatistics() +} + +// SendChannelUserList sends the user list for a channel to a character +func (cs *ChatService) SendChannelUserList(requesterID int32, channelName string) error { + cs.mu.RLock() + defer cs.mu.RUnlock() + + return cs.manager.SendChannelUserList(requesterID, channelName) +} + +// ValidateChannelName checks if a channel name is valid +func (cs *ChatService) ValidateChannelName(channelName string) error { + if len(channelName) == 0 { + return fmt.Errorf("channel name cannot be empty") + } + + if len(channelName) > MaxChannelNameLength { + return fmt.Errorf("channel name too long: %d > %d", len(channelName), MaxChannelNameLength) + } + + // Check for invalid characters + if strings.ContainsAny(channelName, " \t\n\r") { + return fmt.Errorf("channel name cannot contain whitespace") + } + + return nil +} + +// ValidateChannelPassword checks if a channel password is valid +func (cs *ChatService) ValidateChannelPassword(password string) error { + if len(password) > MaxChannelPasswordLength { + return fmt.Errorf("channel password too long: %d > %d", len(password), MaxChannelPasswordLength) + } + + return nil +} + +// GetChannelMembers returns the list of members in a channel +func (cs *ChatService) GetChannelMembers(channelName string) ([]int32, error) { + cs.mu.RLock() + defer cs.mu.RUnlock() + + channel := cs.manager.GetChannel(channelName) + if channel == nil { + return nil, fmt.Errorf("channel %s not found", channelName) + } + + return channel.GetMembers(), nil +} + +// CleanupEmptyChannels removes empty custom channels (called periodically) +func (cs *ChatService) CleanupEmptyChannels() int { + cs.mu.Lock() + defer cs.mu.Unlock() + + removed := 0 + for name, channel := range cs.manager.channels { + if channel.channelType == ChannelTypeCustom && channel.IsEmpty() { + // Check if channel has been empty for a reasonable time + if time.Since(channel.created) > 5*time.Minute { + delete(cs.manager.channels, name) + removed++ + } + } + } + + return removed +} + +// BroadcastSystemMessage sends a system message to all members of a channel +func (cs *ChatService) BroadcastSystemMessage(channelName string, message string, systemName string) error { + cs.mu.RLock() + defer cs.mu.RUnlock() + + return cs.manager.TellChannel(0, channelName, message, systemName) +} + +// GetActiveChannels returns a list of channels that have active members +func (cs *ChatService) GetActiveChannels() []string { + cs.mu.RLock() + defer cs.mu.RUnlock() + + var activeChannels []string + for name, channel := range cs.manager.channels { + if !channel.IsEmpty() { + activeChannels = append(activeChannels, name) + } + } + + return activeChannels +} + +// GetChannelsByType returns channels of a specific type +func (cs *ChatService) GetChannelsByType(channelType int) []string { + cs.mu.RLock() + defer cs.mu.RUnlock() + + var channels []string + for name, channel := range cs.manager.channels { + if channel.channelType == channelType { + channels = append(channels, name) + } + } + + return channels +} + +// ProcessChannelFilter applies filtering to channel lists based on player criteria +func (cs *ChatService) ProcessChannelFilter(characterID int32, filter ChannelFilter) ([]ChannelInfo, error) { + cs.mu.RLock() + defer cs.mu.RUnlock() + + playerInfo, err := cs.manager.playerManager.GetPlayerInfo(characterID) + if err != nil { + return nil, fmt.Errorf("failed to get player info: %w", err) + } + + var filteredChannels []ChannelInfo + for _, channel := range cs.manager.channels { + // Apply type filters + if !filter.IncludeWorld && channel.channelType == ChannelTypeWorld { + continue + } + if !filter.IncludeCustom && channel.channelType == ChannelTypeCustom { + continue + } + + // Apply level range filters + if filter.MinLevel > 0 && playerInfo.Level < filter.MinLevel { + continue + } + if filter.MaxLevel > 0 && playerInfo.Level > filter.MaxLevel { + continue + } + + // Apply race filter + if filter.Race > 0 && playerInfo.Race != filter.Race { + continue + } + + // Apply class filter + if filter.Class > 0 && playerInfo.Class != filter.Class { + continue + } + + // Check if player can actually join the channel + if cs.manager.canJoinChannel(playerInfo.Level, playerInfo.Race, playerInfo.Class, + channel.levelRestriction, channel.raceRestriction, channel.classRestriction) { + filteredChannels = append(filteredChannels, channel.GetChannelInfo()) + } + } + + return filteredChannels, nil +} \ No newline at end of file diff --git a/internal/chat/types.go b/internal/chat/types.go new file mode 100644 index 0000000..4adb6ba --- /dev/null +++ b/internal/chat/types.go @@ -0,0 +1,94 @@ +package chat + +import ( + "sync" + "time" +) + +// Channel represents a chat channel with membership and message routing capabilities +type Channel struct { + mu sync.RWMutex + name string + password string + channelType int + levelRestriction int32 + raceRestriction int32 + classRestriction int32 + members []int32 // Character IDs + discordEnabled bool + created time.Time +} + +// ChannelMessage represents a message sent to a channel +type ChannelMessage struct { + SenderID int32 + SenderName string + Message string + LanguageID int32 + ChannelName string + Timestamp time.Time + IsEmote bool + IsOOC bool +} + +// ChannelMember represents a member in a channel +type ChannelMember struct { + CharacterID int32 + CharacterName string + Level int32 + Race int32 + Class int32 + JoinedAt time.Time +} + +// ChannelInfo provides basic channel information for client lists +type ChannelInfo struct { + Name string + HasPassword bool + MemberCount int + LevelRestriction int32 + RaceRestriction int32 + ClassRestriction int32 + ChannelType int +} + +// ChatChannelData represents persistent channel data from database +type ChatChannelData struct { + Name string + Password string + LevelRestriction int32 + ClassRestriction int32 + RaceRestriction int32 +} + +// ChatManager manages all chat channels and operations +type ChatManager struct { + mu sync.RWMutex + channels map[string]*Channel + database ChannelDatabase + + // Integration interfaces + clientManager ClientManager + playerManager PlayerManager + languageProcessor LanguageProcessor +} + +// ChatStatistics provides statistics about chat system usage +type ChatStatistics struct { + TotalChannels int + WorldChannels int + CustomChannels int + TotalMembers int + MessagesPerHour int + ActiveChannels int +} + +// ChannelFilter provides filtering options for channel lists +type ChannelFilter struct { + MinLevel int32 + MaxLevel int32 + Race int32 + Class int32 + IncludeCustom bool + IncludeWorld bool +} \ No newline at end of file diff --git a/internal/collections/README.md b/internal/collections/README.md new file mode 100644 index 0000000..326c612 --- /dev/null +++ b/internal/collections/README.md @@ -0,0 +1,315 @@ +# Collections System + +The collections system provides comprehensive achievement-based item collection functionality for EverQuest II server emulation, converted from the original C++ EQ2EMu implementation. + +## Overview + +The collections system allows players to find specific items scattered throughout the game world and combine them into collections for rewards. When players complete a collection by finding all required items, they receive rewards such as experience, coins, items, or a choice of selectable items. + +## Architecture + +### Core Components + +**Collection** - Individual collection with required items, rewards, and completion tracking +**MasterCollectionList** - Registry of all available collections in the game +**PlayerCollectionList** - Per-player collection progress and completion tracking +**CollectionManager** - High-level collection system coordinator +**CollectionService** - Service layer for game integration and client communication + +### Key Features + +- **Item-Based Collections**: Players find specific items to complete collections +- **Multiple Reward Types**: Coins, experience points, fixed items, and selectable items +- **Progress Tracking**: Real-time tracking of collection completion progress +- **Category Organization**: Collections organized by categories for easy browsing +- **Level Restrictions**: Collections appropriate for different player levels +- **Thread Safety**: All operations use proper Go concurrency patterns +- **Database Persistence**: Player progress saved automatically + +## Collection Structure + +### Collection Data +- **ID**: Unique collection identifier +- **Name**: Display name for the collection +- **Category**: Organizational category (e.g., "Artifacts", "Shinies") +- **Level**: Recommended level for the collection +- **Items**: List of required items with index positions +- **Rewards**: Coins, XP, items, and selectable items + +### Item States +- **Not Found** (0): Player hasn't found this item yet +- **Found** (1): Player has found and added this item to the collection + +### Collection States +- **Incomplete**: Not all required items have been found +- **Ready to Turn In**: All items found but not yet completed +- **Completed**: Collection has been turned in and rewards claimed + +## Database Schema + +### Collections Table +```sql +CREATE TABLE collections ( + id INTEGER PRIMARY KEY, + collection_name TEXT NOT NULL, + collection_category TEXT NOT NULL DEFAULT '', + level INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +``` + +### Collection Details Table +```sql +CREATE TABLE collection_details ( + collection_id INTEGER NOT NULL, + item_id INTEGER NOT NULL, + item_index INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (collection_id, item_id), + FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE +); +``` + +### Collection Rewards Table +```sql +CREATE TABLE collection_rewards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL, + reward_type TEXT NOT NULL, -- 'Item', 'Selectable', 'Coin', 'XP' + reward_value TEXT NOT NULL, + reward_quantity INTEGER NOT NULL DEFAULT 1, + FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE +); +``` + +### Player Collections Table +```sql +CREATE TABLE character_collections ( + char_id INTEGER NOT NULL, + collection_id INTEGER NOT NULL, + completed INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (char_id, collection_id), + FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE +); +``` + +### Player Collection Items Table +```sql +CREATE TABLE character_collection_items ( + char_id INTEGER NOT NULL, + collection_id INTEGER NOT NULL, + collection_item_id INTEGER NOT NULL, + found_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (char_id, collection_id, collection_item_id), + FOREIGN KEY (char_id, collection_id) REFERENCES character_collections(char_id, collection_id) ON DELETE CASCADE +); +``` + +## Usage Examples + +### System Initialization + +```go +// Initialize collection service +database := NewDatabaseCollectionManager(db) +itemLookup := NewItemLookupService() +clientManager := NewClientManager() + +service := NewCollectionService(database, itemLookup, clientManager) +err := service.Initialize(ctx) +``` + +### Player Operations + +```go +// Load player collections when they log in +err := service.LoadPlayerCollections(ctx, characterID) + +// Process when player finds an item +err := service.ProcessItemFound(characterID, itemID) + +// Complete a collection +rewardProvider := NewRewardProvider() +err := service.CompleteCollection(characterID, collectionID, rewardProvider) + +// Get player's collection progress +progress, err := service.GetPlayerCollectionProgress(characterID) + +// Unload when player logs out +err := service.UnloadPlayerCollections(ctx, characterID) +``` + +### Collection Management + +```go +// Get all collections in a category +collections := manager.GetCollectionsByCategory("Artifacts") + +// Search collections by name +collections := manager.SearchCollections("Ancient") + +// Get collections appropriate for player level +collections := manager.GetAvailableCollections(playerLevel) + +// Check if item is needed by any collection +needed := masterList.NeedsItem(itemID) +``` + +## Reward System + +### Reward Types + +**Coin Rewards** +```go +collection.SetRewardCoin(50000) // 5 gold +``` + +**Experience Rewards** +```go +collection.SetRewardXP(10000) // 10,000 XP +``` + +**Item Rewards** (automatically given) +```go +collection.AddRewardItem(CollectionRewardItem{ + ItemID: 12345, + Quantity: 1, +}) +``` + +**Selectable Rewards** (player chooses one) +```go +collection.AddSelectableRewardItem(CollectionRewardItem{ + ItemID: 12346, + Quantity: 1, +}) +``` + +## Integration Interfaces + +### ItemLookup +Provides item information and validation for collections. + +### ClientManager +Handles client communication for collection updates and lists. + +### RewardProvider +Manages distribution of collection rewards to players. + +### CollectionEventHandler +Handles collection-related events for logging and notifications. + +## Thread Safety + +All operations are thread-safe using: +- `sync.RWMutex` for collection and list operations +- Atomic updates for collection progress +- Database transactions for consistency +- Proper locking hierarchies to prevent deadlocks + +## Performance Features + +- **Efficient Lookups**: Hash-based collection and item lookups +- **Lazy Loading**: Player collections loaded only when needed +- **Batch Operations**: Multiple items and collections processed together +- **Connection Pooling**: Efficient database connection management +- **Caching**: Master collections cached in memory + +## Event System + +The collections system provides comprehensive event handling: + +```go +// Item found event +OnItemFound(characterID, collectionID, itemID int32) + +// Collection completed event +OnCollectionCompleted(characterID, collectionID int32) + +// Rewards claimed event +OnRewardClaimed(characterID, collectionID int32, rewards []CollectionRewardItem, coin, xp int64) +``` + +## Statistics and Monitoring + +### System Statistics +- Total collections available +- Collections per category +- Total items across all collections +- Reward distribution statistics + +### Player Statistics +- Collections completed +- Collections in progress +- Items found +- Progress percentages + +## Error Handling + +Comprehensive error handling covers: +- Database connection failures +- Invalid collection or item IDs +- Reward distribution failures +- Concurrent access issues +- Data validation errors + +## Future Enhancements + +Areas marked for future implementation: +- Collection discovery mechanics +- Rare item collection bonuses +- Collection sharing and trading +- Achievement integration +- Collection leaderboards +- Seasonal collections + +## File Structure + +``` +internal/collections/ +├── README.md # This documentation +├── constants.go # Collection constants and limits +├── types.go # Core data structures +├── interfaces.go # Integration interfaces +├── collections.go # Collection implementation +├── master_list.go # Master collection registry +├── player_list.go # Player collection tracking +├── database.go # Database operations +└── manager.go # High-level collection services +``` + +## Dependencies + +- `eq2emu/internal/database` - Database wrapper +- Standard library: `context`, `sync`, `fmt`, `strings`, `time` + +## Testing + +The collections system is designed for comprehensive testing: +- Mock interfaces for all dependencies +- Unit tests for collection logic +- Integration tests with database +- Concurrent operation testing +- Performance benchmarking + +## Migration from C++ + +Key changes from the C++ implementation: +- Go interfaces for better modularity +- Context-based operations for cancellation +- Proper error handling with wrapped errors +- Thread-safe operations using sync primitives +- Database connection pooling +- Event-driven architecture for notifications + +## Integration Notes + +When integrating with the game server: +1. Initialize the collection service at server startup +2. Load player collections on character login +3. Process item finds during gameplay +4. Handle collection completion through UI interactions +5. Save collections on logout or periodic intervals +6. Clean up resources during server shutdown \ No newline at end of file diff --git a/internal/collections/collections.go b/internal/collections/collections.go new file mode 100644 index 0000000..4b2387f --- /dev/null +++ b/internal/collections/collections.go @@ -0,0 +1,499 @@ +package collections + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// NewCollection creates a new collection instance +func NewCollection() *Collection { + return &Collection{ + collectionItems: make([]CollectionItem, 0), + rewardItems: make([]CollectionRewardItem, 0), + selectableRewardItems: make([]CollectionRewardItem, 0), + lastModified: time.Now(), + } +} + +// NewCollectionFromData creates a collection from another collection (copy constructor) +func NewCollectionFromData(source *Collection) *Collection { + if source == nil { + return nil + } + + source.mu.RLock() + defer source.mu.RUnlock() + + collection := &Collection{ + id: source.id, + name: source.name, + category: source.category, + level: source.level, + rewardCoin: source.rewardCoin, + rewardXP: source.rewardXP, + completed: source.completed, + saveNeeded: source.saveNeeded, + collectionItems: make([]CollectionItem, len(source.collectionItems)), + rewardItems: make([]CollectionRewardItem, len(source.rewardItems)), + selectableRewardItems: make([]CollectionRewardItem, len(source.selectableRewardItems)), + lastModified: time.Now(), + } + + // Deep copy collection items + copy(collection.collectionItems, source.collectionItems) + + // Deep copy reward items + copy(collection.rewardItems, source.rewardItems) + + // Deep copy selectable reward items + copy(collection.selectableRewardItems, source.selectableRewardItems) + + return collection +} + +// SetID sets the collection ID +func (c *Collection) SetID(id int32) { + c.mu.Lock() + defer c.mu.Unlock() + c.id = id +} + +// SetName sets the collection name +func (c *Collection) SetName(name string) { + c.mu.Lock() + defer c.mu.Unlock() + if len(name) > MaxCollectionNameLength { + name = name[:MaxCollectionNameLength] + } + c.name = name +} + +// SetCategory sets the collection category +func (c *Collection) SetCategory(category string) { + c.mu.Lock() + defer c.mu.Unlock() + if len(category) > MaxCollectionCategoryLength { + category = category[:MaxCollectionCategoryLength] + } + c.category = category +} + +// SetLevel sets the collection level +func (c *Collection) SetLevel(level int8) { + c.mu.Lock() + defer c.mu.Unlock() + c.level = level +} + +// SetCompleted sets the collection completion status +func (c *Collection) SetCompleted(completed bool) { + c.mu.Lock() + defer c.mu.Unlock() + c.completed = completed + c.lastModified = time.Now() +} + +// SetSaveNeeded sets whether the collection needs to be saved +func (c *Collection) SetSaveNeeded(saveNeeded bool) { + c.mu.Lock() + defer c.mu.Unlock() + c.saveNeeded = saveNeeded +} + +// SetRewardCoin sets the coin reward amount +func (c *Collection) SetRewardCoin(coin int64) { + c.mu.Lock() + defer c.mu.Unlock() + c.rewardCoin = coin +} + +// SetRewardXP sets the XP reward amount +func (c *Collection) SetRewardXP(xp int64) { + c.mu.Lock() + defer c.mu.Unlock() + c.rewardXP = xp +} + +// AddCollectionItem adds a required item to the collection +func (c *Collection) AddCollectionItem(item CollectionItem) { + c.mu.Lock() + defer c.mu.Unlock() + c.collectionItems = append(c.collectionItems, item) +} + +// AddRewardItem adds a reward item to the collection +func (c *Collection) AddRewardItem(item CollectionRewardItem) { + c.mu.Lock() + defer c.mu.Unlock() + c.rewardItems = append(c.rewardItems, item) +} + +// AddSelectableRewardItem adds a selectable reward item to the collection +func (c *Collection) AddSelectableRewardItem(item CollectionRewardItem) { + c.mu.Lock() + defer c.mu.Unlock() + c.selectableRewardItems = append(c.selectableRewardItems, item) +} + +// GetID returns the collection ID +func (c *Collection) GetID() int32 { + c.mu.RLock() + defer c.mu.RUnlock() + return c.id +} + +// GetName returns the collection name +func (c *Collection) GetName() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.name +} + +// GetCategory returns the collection category +func (c *Collection) GetCategory() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.category +} + +// GetLevel returns the collection level +func (c *Collection) GetLevel() int8 { + c.mu.RLock() + defer c.mu.RUnlock() + return c.level +} + +// GetCompleted returns whether the collection is completed +func (c *Collection) GetCompleted() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.completed +} + +// GetSaveNeeded returns whether the collection needs to be saved +func (c *Collection) GetSaveNeeded() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.saveNeeded +} + +// GetRewardCoin returns the coin reward amount +func (c *Collection) GetRewardCoin() int64 { + c.mu.RLock() + defer c.mu.RUnlock() + return c.rewardCoin +} + +// GetRewardXP returns the XP reward amount +func (c *Collection) GetRewardXP() int64 { + c.mu.RLock() + defer c.mu.RUnlock() + return c.rewardXP +} + +// GetCollectionItems returns a copy of the collection items +func (c *Collection) GetCollectionItems() []CollectionItem { + c.mu.RLock() + defer c.mu.RUnlock() + items := make([]CollectionItem, len(c.collectionItems)) + copy(items, c.collectionItems) + return items +} + +// GetRewardItems returns a copy of the reward items +func (c *Collection) GetRewardItems() []CollectionRewardItem { + c.mu.RLock() + defer c.mu.RUnlock() + items := make([]CollectionRewardItem, len(c.rewardItems)) + copy(items, c.rewardItems) + return items +} + +// GetSelectableRewardItems returns a copy of the selectable reward items +func (c *Collection) GetSelectableRewardItems() []CollectionRewardItem { + c.mu.RLock() + defer c.mu.RUnlock() + items := make([]CollectionRewardItem, len(c.selectableRewardItems)) + copy(items, c.selectableRewardItems) + return items +} + +// NeedsItem checks if the collection needs a specific item +func (c *Collection) NeedsItem(itemID int32) bool { + c.mu.RLock() + defer c.mu.RUnlock() + + if c.completed { + return false + } + + for _, item := range c.collectionItems { + if item.ItemID == itemID { + return item.Found == ItemNotFound + } + } + + return false +} + +// GetCollectionItemByItemID returns the collection item for a specific item ID +func (c *Collection) GetCollectionItemByItemID(itemID int32) *CollectionItem { + c.mu.RLock() + defer c.mu.RUnlock() + + for i := range c.collectionItems { + if c.collectionItems[i].ItemID == itemID { + return &c.collectionItems[i] + } + } + + return nil +} + +// GetIsReadyToTurnIn checks if all required items have been found +func (c *Collection) GetIsReadyToTurnIn() bool { + c.mu.RLock() + defer c.mu.RUnlock() + + if c.completed { + return false + } + + for _, item := range c.collectionItems { + if item.Found == ItemNotFound { + return false + } + } + + return true +} + +// MarkItemFound marks an item as found in the collection +func (c *Collection) MarkItemFound(itemID int32) bool { + c.mu.Lock() + defer c.mu.Unlock() + + if c.completed { + return false + } + + for i := range c.collectionItems { + if c.collectionItems[i].ItemID == itemID && c.collectionItems[i].Found == ItemNotFound { + c.collectionItems[i].Found = ItemFound + c.saveNeeded = true + c.lastModified = time.Now() + return true + } + } + + return false +} + +// GetProgress returns the completion progress as a percentage +func (c *Collection) GetProgress() float64 { + c.mu.RLock() + defer c.mu.RUnlock() + + if len(c.collectionItems) == 0 { + return 0.0 + } + + foundCount := 0 + for _, item := range c.collectionItems { + if item.Found == ItemFound { + foundCount++ + } + } + + return float64(foundCount) / float64(len(c.collectionItems)) * 100.0 +} + +// GetFoundItemsCount returns the number of found items +func (c *Collection) GetFoundItemsCount() int { + c.mu.RLock() + defer c.mu.RUnlock() + + count := 0 + for _, item := range c.collectionItems { + if item.Found == ItemFound { + count++ + } + } + return count +} + +// GetTotalItemsCount returns the total number of required items +func (c *Collection) GetTotalItemsCount() int { + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.collectionItems) +} + +// GetCollectionInfo returns detailed collection information +func (c *Collection) GetCollectionInfo() CollectionInfo { + c.mu.RLock() + defer c.mu.RUnlock() + + return CollectionInfo{ + ID: c.id, + Name: c.name, + Category: c.category, + Level: c.level, + Completed: c.completed, + ReadyToTurnIn: c.getIsReadyToTurnInNoLock(), + ItemsFound: c.getFoundItemsCountNoLock(), + ItemsTotal: len(c.collectionItems), + RewardCoin: c.rewardCoin, + RewardXP: c.rewardXP, + RewardItems: append([]CollectionRewardItem(nil), c.rewardItems...), + SelectableRewards: append([]CollectionRewardItem(nil), c.selectableRewardItems...), + RequiredItems: append([]CollectionItem(nil), c.collectionItems...), + } +} + +// GetCollectionProgress returns detailed progress information +func (c *Collection) GetCollectionProgress() CollectionProgress { + c.mu.RLock() + defer c.mu.RUnlock() + + var foundItems, neededItems []CollectionItem + for _, item := range c.collectionItems { + if item.Found == ItemFound { + foundItems = append(foundItems, item) + } else { + neededItems = append(neededItems, item) + } + } + + return CollectionProgress{ + CollectionID: c.id, + Name: c.name, + Category: c.category, + Level: c.level, + Completed: c.completed, + ReadyToTurnIn: c.getIsReadyToTurnInNoLock(), + Progress: c.getProgressNoLock(), + ItemsFound: foundItems, + ItemsNeeded: neededItems, + LastUpdated: c.lastModified, + } +} + +// LoadFromRewardData loads reward data into the collection +func (c *Collection) LoadFromRewardData(rewards []CollectionRewardData) error { + c.mu.Lock() + defer c.mu.Unlock() + + for _, reward := range rewards { + switch strings.ToLower(reward.RewardType) { + case strings.ToLower(RewardTypeItem): + itemID, err := strconv.ParseInt(reward.RewardValue, 10, 32) + if err != nil { + return fmt.Errorf("invalid item ID in reward: %s", reward.RewardValue) + } + c.rewardItems = append(c.rewardItems, CollectionRewardItem{ + ItemID: int32(itemID), + Quantity: reward.Quantity, + }) + + case strings.ToLower(RewardTypeSelectable): + itemID, err := strconv.ParseInt(reward.RewardValue, 10, 32) + if err != nil { + return fmt.Errorf("invalid item ID in selectable reward: %s", reward.RewardValue) + } + c.selectableRewardItems = append(c.selectableRewardItems, CollectionRewardItem{ + ItemID: int32(itemID), + Quantity: reward.Quantity, + }) + + case strings.ToLower(RewardTypeCoin): + coin, err := strconv.ParseInt(reward.RewardValue, 10, 64) + if err != nil { + return fmt.Errorf("invalid coin amount in reward: %s", reward.RewardValue) + } + c.rewardCoin = coin + + case strings.ToLower(RewardTypeXP): + xp, err := strconv.ParseInt(reward.RewardValue, 10, 64) + if err != nil { + return fmt.Errorf("invalid XP amount in reward: %s", reward.RewardValue) + } + c.rewardXP = xp + + default: + return fmt.Errorf("unknown reward type: %s", reward.RewardType) + } + } + + return nil +} + +// Validate checks if the collection data is valid +func (c *Collection) Validate() error { + c.mu.RLock() + defer c.mu.RUnlock() + + if c.id <= 0 { + return fmt.Errorf("collection ID must be positive") + } + + if strings.TrimSpace(c.name) == "" { + return fmt.Errorf("collection name cannot be empty") + } + + if len(c.collectionItems) == 0 { + return fmt.Errorf("collection must have at least one required item") + } + + // Check for duplicate item IDs + itemIDs := make(map[int32]bool) + for _, item := range c.collectionItems { + if itemIDs[item.ItemID] { + return fmt.Errorf("duplicate item ID in collection: %d", item.ItemID) + } + itemIDs[item.ItemID] = true + + if item.ItemID <= 0 { + return fmt.Errorf("collection item ID must be positive: %d", item.ItemID) + } + } + + return nil +} + +// Helper methods (no lock versions for internal use) + +func (c *Collection) getIsReadyToTurnInNoLock() bool { + if c.completed { + return false + } + + for _, item := range c.collectionItems { + if item.Found == ItemNotFound { + return false + } + } + + return true +} + +func (c *Collection) getFoundItemsCountNoLock() int { + count := 0 + for _, item := range c.collectionItems { + if item.Found == ItemFound { + count++ + } + } + return count +} + +func (c *Collection) getProgressNoLock() float64 { + if len(c.collectionItems) == 0 { + return 0.0 + } + + foundCount := c.getFoundItemsCountNoLock() + return float64(foundCount) / float64(len(c.collectionItems)) * 100.0 +} \ No newline at end of file diff --git a/internal/collections/constants.go b/internal/collections/constants.go new file mode 100644 index 0000000..bf82a37 --- /dev/null +++ b/internal/collections/constants.go @@ -0,0 +1,36 @@ +package collections + +// Collection reward types +const ( + RewardTypeItem = "Item" + RewardTypeSelectable = "Selectable" + RewardTypeCoin = "Coin" + RewardTypeXP = "XP" +) + +// Collection item states +const ( + ItemNotFound = 0 + ItemFound = 1 +) + +// Collection states +const ( + CollectionIncomplete = false + CollectionCompleted = true +) + +// String length limits +const ( + MaxCollectionNameLength = 512 + MaxCollectionCategoryLength = 512 +) + +// Database table names +const ( + TableCollections = "collections" + TableCollectionDetails = "collection_details" + TableCollectionRewards = "collection_rewards" + TableCharacterCollections = "character_collections" + TableCharacterCollectionItems = "character_collection_items" +) \ No newline at end of file diff --git a/internal/collections/database.go b/internal/collections/database.go new file mode 100644 index 0000000..4722eed --- /dev/null +++ b/internal/collections/database.go @@ -0,0 +1,491 @@ +package collections + +import ( + "context" + "fmt" + + "eq2emu/internal/database" +) + +// DatabaseCollectionManager implements CollectionDatabase interface using the existing database wrapper +type DatabaseCollectionManager struct { + db *database.DB +} + +// NewDatabaseCollectionManager creates a new database collection manager +func NewDatabaseCollectionManager(db *database.DB) *DatabaseCollectionManager { + return &DatabaseCollectionManager{ + db: db, + } +} + +// LoadCollections retrieves all collections from database +func (dcm *DatabaseCollectionManager) LoadCollections(ctx context.Context) ([]CollectionData, error) { + query := "SELECT `id`, `collection_name`, `collection_category`, `level` FROM `collections`" + + rows, err := dcm.db.QueryContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("failed to query collections: %w", err) + } + defer rows.Close() + + var collections []CollectionData + for rows.Next() { + var collection CollectionData + err := rows.Scan( + &collection.ID, + &collection.Name, + &collection.Category, + &collection.Level, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan collection row: %w", err) + } + + collections = append(collections, collection) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating collection rows: %w", err) + } + + return collections, nil +} + +// LoadCollectionItems retrieves items for a specific collection +func (dcm *DatabaseCollectionManager) LoadCollectionItems(ctx context.Context, collectionID int32) ([]CollectionItem, error) { + query := `SELECT item_id, item_index + FROM collection_details + WHERE collection_id = ? + ORDER BY item_index ASC` + + rows, err := dcm.db.QueryContext(ctx, query, collectionID) + if err != nil { + return nil, fmt.Errorf("failed to query collection items for collection %d: %w", collectionID, err) + } + defer rows.Close() + + var items []CollectionItem + for rows.Next() { + var item CollectionItem + err := rows.Scan( + &item.ItemID, + &item.Index, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan collection item row: %w", err) + } + + // Items start as not found + item.Found = ItemNotFound + items = append(items, item) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating collection item rows: %w", err) + } + + return items, nil +} + +// LoadCollectionRewards retrieves rewards for a specific collection +func (dcm *DatabaseCollectionManager) LoadCollectionRewards(ctx context.Context, collectionID int32) ([]CollectionRewardData, error) { + query := `SELECT collection_id, reward_type, reward_value, reward_quantity + FROM collection_rewards + WHERE collection_id = ?` + + rows, err := dcm.db.QueryContext(ctx, query, collectionID) + if err != nil { + return nil, fmt.Errorf("failed to query collection rewards for collection %d: %w", collectionID, err) + } + defer rows.Close() + + var rewards []CollectionRewardData + for rows.Next() { + var reward CollectionRewardData + err := rows.Scan( + &reward.CollectionID, + &reward.RewardType, + &reward.RewardValue, + &reward.Quantity, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan collection reward row: %w", err) + } + + rewards = append(rewards, reward) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating collection reward rows: %w", err) + } + + return rewards, nil +} + +// LoadPlayerCollections retrieves player's collection progress +func (dcm *DatabaseCollectionManager) LoadPlayerCollections(ctx context.Context, characterID int32) ([]PlayerCollectionData, error) { + query := `SELECT char_id, collection_id, completed + FROM character_collections + WHERE char_id = ?` + + rows, err := dcm.db.QueryContext(ctx, query, characterID) + if err != nil { + return nil, fmt.Errorf("failed to query player collections for character %d: %w", characterID, err) + } + defer rows.Close() + + var collections []PlayerCollectionData + for rows.Next() { + var collection PlayerCollectionData + var completed int + err := rows.Scan( + &collection.CharacterID, + &collection.CollectionID, + &completed, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan player collection row: %w", err) + } + + collection.Completed = completed == 1 + collections = append(collections, collection) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating player collection rows: %w", err) + } + + return collections, nil +} + +// LoadPlayerCollectionItems retrieves player's found collection items +func (dcm *DatabaseCollectionManager) LoadPlayerCollectionItems(ctx context.Context, characterID, collectionID int32) ([]int32, error) { + query := `SELECT collection_item_id + FROM character_collection_items + WHERE char_id = ? AND collection_id = ?` + + rows, err := dcm.db.QueryContext(ctx, query, characterID, collectionID) + if err != nil { + return nil, fmt.Errorf("failed to query player collection items for character %d, collection %d: %w", characterID, collectionID, err) + } + defer rows.Close() + + var itemIDs []int32 + for rows.Next() { + var itemID int32 + err := rows.Scan(&itemID) + if err != nil { + return nil, fmt.Errorf("failed to scan player collection item row: %w", err) + } + + itemIDs = append(itemIDs, itemID) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating player collection item rows: %w", err) + } + + return itemIDs, nil +} + +// SavePlayerCollection saves player collection completion status +func (dcm *DatabaseCollectionManager) SavePlayerCollection(ctx context.Context, characterID, collectionID int32, completed bool) error { + completedInt := 0 + if completed { + completedInt = 1 + } + + query := `INSERT INTO character_collections (char_id, collection_id, completed) + VALUES (?, ?, ?) + ON CONFLICT(char_id, collection_id) + DO UPDATE SET completed = ?` + + _, err := dcm.db.ExecContext(ctx, query, characterID, collectionID, completedInt, completedInt) + if err != nil { + return fmt.Errorf("failed to save player collection for character %d, collection %d: %w", characterID, collectionID, err) + } + + return nil +} + +// SavePlayerCollectionItem saves a found collection item +func (dcm *DatabaseCollectionManager) SavePlayerCollectionItem(ctx context.Context, characterID, collectionID, itemID int32) error { + query := `INSERT OR IGNORE INTO character_collection_items (char_id, collection_id, collection_item_id) + VALUES (?, ?, ?)` + + _, err := dcm.db.ExecContext(ctx, query, characterID, collectionID, itemID) + if err != nil { + return fmt.Errorf("failed to save player collection item for character %d, collection %d, item %d: %w", characterID, collectionID, itemID, err) + } + + return nil +} + +// SavePlayerCollections saves all modified player collections +func (dcm *DatabaseCollectionManager) SavePlayerCollections(ctx context.Context, characterID int32, collections []*Collection) error { + if len(collections) == 0 { + return nil + } + + // Use a transaction for atomic updates + tx, err := dcm.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + for _, collection := range collections { + if !collection.GetSaveNeeded() { + continue + } + + // Save collection completion status + if err := dcm.savePlayerCollectionTx(ctx, tx, characterID, collection); err != nil { + return fmt.Errorf("failed to save collection %d: %w", collection.GetID(), err) + } + + // Save found items + if err := dcm.savePlayerCollectionItemsTx(ctx, tx, characterID, collection); err != nil { + return fmt.Errorf("failed to save collection items for collection %d: %w", collection.GetID(), err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} + +// savePlayerCollectionTx saves a single collection within a transaction +func (dcm *DatabaseCollectionManager) savePlayerCollectionTx(ctx context.Context, tx database.Tx, characterID int32, collection *Collection) error { + completedInt := 0 + if collection.GetCompleted() { + completedInt = 1 + } + + query := `INSERT INTO character_collections (char_id, collection_id, completed) + VALUES (?, ?, ?) + ON CONFLICT(char_id, collection_id) + DO UPDATE SET completed = ?` + + _, err := tx.ExecContext(ctx, query, characterID, collection.GetID(), completedInt, completedInt) + return err +} + +// savePlayerCollectionItemsTx saves collection items within a transaction +func (dcm *DatabaseCollectionManager) savePlayerCollectionItemsTx(ctx context.Context, tx database.Tx, characterID int32, collection *Collection) error { + items := collection.GetCollectionItems() + + for _, item := range items { + if item.Found == ItemFound { + query := `INSERT OR IGNORE INTO character_collection_items (char_id, collection_id, collection_item_id) + VALUES (?, ?, ?)` + + _, err := tx.ExecContext(ctx, query, characterID, collection.GetID(), item.ItemID) + if err != nil { + return fmt.Errorf("failed to save item %d: %w", item.ItemID, err) + } + } + } + + return nil +} + +// EnsureCollectionTables creates the collection tables if they don't exist +func (dcm *DatabaseCollectionManager) EnsureCollectionTables(ctx context.Context) error { + queries := []string{ + `CREATE TABLE IF NOT EXISTS collections ( + id INTEGER PRIMARY KEY, + collection_name TEXT NOT NULL, + collection_category TEXT NOT NULL DEFAULT '', + level INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS collection_details ( + collection_id INTEGER NOT NULL, + item_id INTEGER NOT NULL, + item_index INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (collection_id, item_id), + FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS collection_rewards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL, + reward_type TEXT NOT NULL, + reward_value TEXT NOT NULL, + reward_quantity INTEGER NOT NULL DEFAULT 1, + FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS character_collections ( + char_id INTEGER NOT NULL, + collection_id INTEGER NOT NULL, + completed INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (char_id, collection_id), + FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS character_collection_items ( + char_id INTEGER NOT NULL, + collection_id INTEGER NOT NULL, + collection_item_id INTEGER NOT NULL, + found_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (char_id, collection_id, collection_item_id), + FOREIGN KEY (char_id, collection_id) REFERENCES character_collections(char_id, collection_id) ON DELETE CASCADE + )`, + } + + for i, query := range queries { + _, err := dcm.db.ExecContext(ctx, query) + if err != nil { + return fmt.Errorf("failed to create collection table %d: %w", i+1, err) + } + } + + // Create indexes for better performance + indexes := []string{ + `CREATE INDEX IF NOT EXISTS idx_collection_details_collection_id ON collection_details(collection_id)`, + `CREATE INDEX IF NOT EXISTS idx_collection_rewards_collection_id ON collection_rewards(collection_id)`, + `CREATE INDEX IF NOT EXISTS idx_character_collections_char_id ON character_collections(char_id)`, + `CREATE INDEX IF NOT EXISTS idx_character_collection_items_char_id ON character_collection_items(char_id)`, + `CREATE INDEX IF NOT EXISTS idx_character_collection_items_collection_id ON character_collection_items(collection_id)`, + `CREATE INDEX IF NOT EXISTS idx_collections_category ON collections(collection_category)`, + `CREATE INDEX IF NOT EXISTS idx_collections_level ON collections(level)`, + } + + for i, query := range indexes { + _, err := dcm.db.ExecContext(ctx, query) + if err != nil { + return fmt.Errorf("failed to create collection index %d: %w", i+1, err) + } + } + + return nil +} + +// GetCollectionCount returns the total number of collections in the database +func (dcm *DatabaseCollectionManager) GetCollectionCount(ctx context.Context) (int, error) { + query := "SELECT COUNT(*) FROM collections" + + var count int + err := dcm.db.QueryRowContext(ctx, query).Scan(&count) + if err != nil { + return 0, fmt.Errorf("failed to get collection count: %w", err) + } + + return count, nil +} + +// GetPlayerCollectionCount returns the number of collections a player has +func (dcm *DatabaseCollectionManager) GetPlayerCollectionCount(ctx context.Context, characterID int32) (int, error) { + query := "SELECT COUNT(*) FROM character_collections WHERE char_id = ?" + + var count int + err := dcm.db.QueryRowContext(ctx, query, characterID).Scan(&count) + if err != nil { + return 0, fmt.Errorf("failed to get player collection count for character %d: %w", characterID, err) + } + + return count, nil +} + +// GetCompletedCollectionCount returns the number of completed collections for a player +func (dcm *DatabaseCollectionManager) GetCompletedCollectionCount(ctx context.Context, characterID int32) (int, error) { + query := "SELECT COUNT(*) FROM character_collections WHERE char_id = ? AND completed = 1" + + var count int + err := dcm.db.QueryRowContext(ctx, query, characterID).Scan(&count) + if err != nil { + return 0, fmt.Errorf("failed to get completed collection count for character %d: %w", characterID, err) + } + + return count, nil +} + +// DeletePlayerCollection removes a player's collection progress +func (dcm *DatabaseCollectionManager) DeletePlayerCollection(ctx context.Context, characterID, collectionID int32) error { + // Use a transaction to ensure both tables are updated atomically + tx, err := dcm.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Delete collection items first due to foreign key constraint + _, err = tx.ExecContext(ctx, + "DELETE FROM character_collection_items WHERE char_id = ? AND collection_id = ?", + characterID, collectionID) + if err != nil { + return fmt.Errorf("failed to delete player collection items: %w", err) + } + + // Delete collection + _, err = tx.ExecContext(ctx, + "DELETE FROM character_collections WHERE char_id = ? AND collection_id = ?", + characterID, collectionID) + if err != nil { + return fmt.Errorf("failed to delete player collection: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} + +// GetCollectionStatistics returns database-level collection statistics +func (dcm *DatabaseCollectionManager) GetCollectionStatistics(ctx context.Context) (CollectionStatistics, error) { + var stats CollectionStatistics + + // Total collections + err := dcm.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM collections").Scan(&stats.TotalCollections) + if err != nil { + return stats, fmt.Errorf("failed to get total collections: %w", err) + } + + // Total collection items + err = dcm.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM collection_details").Scan(&stats.TotalItems) + if err != nil { + return stats, fmt.Errorf("failed to get total items: %w", err) + } + + // Players with collections + err = dcm.db.QueryRowContext(ctx, "SELECT COUNT(DISTINCT char_id) FROM character_collections").Scan(&stats.PlayersWithCollections) + if err != nil { + return stats, fmt.Errorf("failed to get players with collections: %w", err) + } + + // Completed collections across all players + err = dcm.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM character_collections WHERE completed = 1").Scan(&stats.CompletedCollections) + if err != nil { + return stats, fmt.Errorf("failed to get completed collections: %w", err) + } + + // Active collections (incomplete with at least one item found) across all players + query := `SELECT COUNT(DISTINCT cc.char_id, cc.collection_id) + FROM character_collections cc + JOIN character_collection_items cci ON cc.char_id = cci.char_id AND cc.collection_id = cci.collection_id + WHERE cc.completed = 0` + err = dcm.db.QueryRowContext(ctx, query).Scan(&stats.ActiveCollections) + if err != nil { + return stats, fmt.Errorf("failed to get active collections: %w", err) + } + + // Found items across all players + err = dcm.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM character_collection_items").Scan(&stats.FoundItems) + if err != nil { + return stats, fmt.Errorf("failed to get found items: %w", err) + } + + // Total rewards + err = dcm.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM collection_rewards").Scan(&stats.TotalRewards) + if err != nil { + return stats, fmt.Errorf("failed to get total rewards: %w", err) + } + + return stats, nil +} \ No newline at end of file diff --git a/internal/collections/interfaces.go b/internal/collections/interfaces.go new file mode 100644 index 0000000..5c1690d --- /dev/null +++ b/internal/collections/interfaces.go @@ -0,0 +1,176 @@ +package collections + +import "context" + +// CollectionDatabase defines database operations for collections +type CollectionDatabase interface { + // LoadCollections retrieves all collections from database + LoadCollections(ctx context.Context) ([]CollectionData, error) + + // LoadCollectionItems retrieves items for a specific collection + LoadCollectionItems(ctx context.Context, collectionID int32) ([]CollectionItem, error) + + // LoadCollectionRewards retrieves rewards for a specific collection + LoadCollectionRewards(ctx context.Context, collectionID int32) ([]CollectionRewardData, error) + + // LoadPlayerCollections retrieves player's collection progress + LoadPlayerCollections(ctx context.Context, characterID int32) ([]PlayerCollectionData, error) + + // LoadPlayerCollectionItems retrieves player's found collection items + LoadPlayerCollectionItems(ctx context.Context, characterID, collectionID int32) ([]int32, error) + + // SavePlayerCollection saves player collection completion status + SavePlayerCollection(ctx context.Context, characterID, collectionID int32, completed bool) error + + // SavePlayerCollectionItem saves a found collection item + SavePlayerCollectionItem(ctx context.Context, characterID, collectionID, itemID int32) error + + // SavePlayerCollections saves all modified player collections + SavePlayerCollections(ctx context.Context, characterID int32, collections []*Collection) error +} + +// ItemLookup provides item information for collections +type ItemLookup interface { + // GetItem retrieves an item by ID + GetItem(itemID int32) (ItemInfo, error) + + // ItemExists checks if an item exists + ItemExists(itemID int32) bool + + // GetItemName returns the name of an item + GetItemName(itemID int32) string +} + +// PlayerManager provides player information for collections +type PlayerManager interface { + // GetPlayerInfo retrieves basic player information + GetPlayerInfo(characterID int32) (PlayerInfo, error) + + // IsPlayerOnline checks if a player is currently online + IsPlayerOnline(characterID int32) bool + + // GetPlayerLevel returns player's current level + GetPlayerLevel(characterID int32) int8 +} + +// ClientManager handles client communication for collections +type ClientManager interface { + // SendCollectionUpdate notifies client of collection changes + SendCollectionUpdate(characterID int32, collection *Collection) error + + // SendCollectionComplete notifies client of collection completion + SendCollectionComplete(characterID int32, collection *Collection) error + + // SendCollectionList sends available collections to client + SendCollectionList(characterID int32, collections []CollectionInfo) error + + // SendCollectionProgress sends collection progress to client + SendCollectionProgress(characterID int32, progress []CollectionProgress) error +} + +// ItemInfo contains item information needed for collections +type ItemInfo struct { + ID int32 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Icon int32 `json:"icon"` + Level int8 `json:"level"` + Rarity int8 `json:"rarity"` +} + +// PlayerInfo contains basic player information +type PlayerInfo struct { + CharacterID int32 `json:"character_id"` + CharacterName string `json:"character_name"` + Level int8 `json:"level"` + Race int32 `json:"race"` + Class int32 `json:"class"` + IsOnline bool `json:"is_online"` +} + +// CollectionAware interface for entities that can participate in collections +type CollectionAware interface { + GetCharacterID() int32 + GetLevel() int8 + HasItem(itemID int32) bool + GetCollectionList() *PlayerCollectionList +} + +// EntityCollectionAdapter adapts entities to work with collection system +type EntityCollectionAdapter struct { + entity interface { + GetID() int32 + // Add other entity methods as needed + } + playerManager PlayerManager +} + +// GetCharacterID returns the character ID from the adapted entity +func (a *EntityCollectionAdapter) GetCharacterID() int32 { + return a.entity.GetID() +} + +// GetLevel returns the character level from player manager +func (a *EntityCollectionAdapter) GetLevel() int8 { + if info, err := a.playerManager.GetPlayerInfo(a.entity.GetID()); err == nil { + return info.Level + } + return 0 +} + +// HasItem checks if the character has a specific item (placeholder) +func (a *EntityCollectionAdapter) HasItem(itemID int32) bool { + // TODO: Implement item checking through entity system + return false +} + +// GetCollectionList placeholder for getting player collection list +func (a *EntityCollectionAdapter) GetCollectionList() *PlayerCollectionList { + // TODO: Implement collection list retrieval + return nil +} + +// RewardProvider handles collection reward distribution +type RewardProvider interface { + // GiveItem gives an item to a player + GiveItem(characterID int32, itemID int32, quantity int8) error + + // GiveCoin gives coins to a player + GiveCoin(characterID int32, amount int64) error + + // GiveXP gives experience points to a player + GiveXP(characterID int32, amount int64) error + + // ValidateRewards checks if rewards can be given + ValidateRewards(characterID int32, rewards []CollectionRewardItem, coin, xp int64) error +} + +// CollectionEventHandler handles collection-related events +type CollectionEventHandler interface { + // OnCollectionStarted called when player starts a collection + OnCollectionStarted(characterID, collectionID int32) + + // OnItemFound called when player finds a collection item + OnItemFound(characterID, collectionID, itemID int32) + + // OnCollectionCompleted called when player completes a collection + OnCollectionCompleted(characterID, collectionID int32) + + // OnRewardClaimed called when player claims collection rewards + OnRewardClaimed(characterID, collectionID int32, rewards []CollectionRewardItem, coin, xp int64) +} + +// LogHandler provides logging functionality +type LogHandler interface { + // LogDebug logs debug messages + LogDebug(category, message string, args ...interface{}) + + // LogInfo logs informational messages + LogInfo(category, message string, args ...interface{}) + + // LogError logs error messages + LogError(category, message string, args ...interface{}) + + // LogWarning logs warning messages + LogWarning(category, message string, args ...interface{}) +} \ No newline at end of file diff --git a/internal/collections/manager.go b/internal/collections/manager.go new file mode 100644 index 0000000..4721e1c --- /dev/null +++ b/internal/collections/manager.go @@ -0,0 +1,380 @@ +package collections + +import ( + "context" + "fmt" + "sync" +) + +// NewCollectionManager creates a new collection manager instance +func NewCollectionManager(database CollectionDatabase, itemLookup ItemLookup) *CollectionManager { + return &CollectionManager{ + masterList: NewMasterCollectionList(database), + database: database, + itemLookup: itemLookup, + } +} + +// Initialize initializes the collection system by loading all collections +func (cm *CollectionManager) Initialize(ctx context.Context) error { + return cm.masterList.Initialize(ctx, cm.itemLookup) +} + +// GetMasterList returns the master collection list +func (cm *CollectionManager) GetMasterList() *MasterCollectionList { + return cm.masterList +} + +// CreatePlayerCollectionList creates a new player collection list +func (cm *CollectionManager) CreatePlayerCollectionList(characterID int32) *PlayerCollectionList { + return NewPlayerCollectionList(characterID, cm.database) +} + +// GetCollection returns a collection by ID from the master list +func (cm *CollectionManager) GetCollection(collectionID int32) *Collection { + return cm.masterList.GetCollection(collectionID) +} + +// GetCollectionCopy returns a copy of a collection by ID +func (cm *CollectionManager) GetCollectionCopy(collectionID int32) *Collection { + return cm.masterList.GetCollectionCopy(collectionID) +} + +// ProcessItemFound processes when a player finds an item across all collections +func (cm *CollectionManager) ProcessItemFound(playerList *PlayerCollectionList, itemID int32) ([]*Collection, error) { + if playerList == nil { + return nil, fmt.Errorf("player collection list is nil") + } + + return playerList.ProcessItemFound(itemID, cm.masterList) +} + +// CompleteCollection processes collection completion for a player +func (cm *CollectionManager) CompleteCollection(playerList *PlayerCollectionList, collectionID int32, rewardProvider RewardProvider) error { + if playerList == nil { + return fmt.Errorf("player collection list is nil") + } + + collection := playerList.GetCollection(collectionID) + if collection == nil { + return fmt.Errorf("collection %d not found in player list", collectionID) + } + + if !collection.GetIsReadyToTurnIn() { + return fmt.Errorf("collection %d is not ready to complete", collectionID) + } + + // Give rewards if provider is available + if rewardProvider != nil { + characterID := playerList.GetCharacterID() + + // Give coin reward + if coin := collection.GetRewardCoin(); coin > 0 { + if err := rewardProvider.GiveCoin(characterID, coin); err != nil { + return fmt.Errorf("failed to give coin reward: %w", err) + } + } + + // Give XP reward + if xp := collection.GetRewardXP(); xp > 0 { + if err := rewardProvider.GiveXP(characterID, xp); err != nil { + return fmt.Errorf("failed to give XP reward: %w", err) + } + } + + // Give item rewards + for _, reward := range collection.GetRewardItems() { + if err := rewardProvider.GiveItem(characterID, reward.ItemID, reward.Quantity); err != nil { + return fmt.Errorf("failed to give item reward %d: %w", reward.ItemID, err) + } + } + } + + // Mark collection as completed + return playerList.CompleteCollection(collectionID) +} + +// GetAvailableCollections returns collections available to a player based on level +func (cm *CollectionManager) GetAvailableCollections(playerLevel int8) []*Collection { + return cm.masterList.GetCollectionsByLevel(0, playerLevel) +} + +// GetCollectionsByCategory returns collections in a specific category +func (cm *CollectionManager) GetCollectionsByCategory(category string) []*Collection { + return cm.masterList.GetCollectionsByCategory(category) +} + +// GetAllCategories returns all collection categories +func (cm *CollectionManager) GetAllCategories() []string { + return cm.masterList.GetCategories() +} + +// SearchCollections searches for collections by name +func (cm *CollectionManager) SearchCollections(searchTerm string) []*Collection { + return cm.masterList.FindCollectionsByName(searchTerm) +} + +// ValidateSystemIntegrity validates the integrity of the collection system +func (cm *CollectionManager) ValidateSystemIntegrity() []error { + return cm.masterList.ValidateIntegrity(cm.itemLookup) +} + +// GetSystemStatistics returns overall collection system statistics +func (cm *CollectionManager) GetSystemStatistics() CollectionStatistics { + return cm.masterList.GetStatistics() +} + +// CollectionService provides high-level collection system services +type CollectionService struct { + manager *CollectionManager + playerLists map[int32]*PlayerCollectionList + mu sync.RWMutex + clientManager ClientManager + eventHandler CollectionEventHandler + logger LogHandler +} + +// NewCollectionService creates a new collection service +func NewCollectionService(database CollectionDatabase, itemLookup ItemLookup, clientManager ClientManager) *CollectionService { + return &CollectionService{ + manager: NewCollectionManager(database, itemLookup), + playerLists: make(map[int32]*PlayerCollectionList), + clientManager: clientManager, + } +} + +// SetEventHandler sets the collection event handler +func (cs *CollectionService) SetEventHandler(handler CollectionEventHandler) { + cs.eventHandler = handler +} + +// SetLogger sets the logger for the service +func (cs *CollectionService) SetLogger(logger LogHandler) { + cs.logger = logger +} + +// Initialize initializes the collection service +func (cs *CollectionService) Initialize(ctx context.Context) error { + return cs.manager.Initialize(ctx) +} + +// LoadPlayerCollections loads collections for a specific player +func (cs *CollectionService) LoadPlayerCollections(ctx context.Context, characterID int32) error { + cs.mu.Lock() + defer cs.mu.Unlock() + + playerList := cs.manager.CreatePlayerCollectionList(characterID) + if err := playerList.Initialize(ctx, cs.manager.GetMasterList()); err != nil { + return fmt.Errorf("failed to initialize player collections: %w", err) + } + + cs.playerLists[characterID] = playerList + + if cs.logger != nil { + cs.logger.LogDebug("collections", "Loaded %d collections for character %d", + playerList.Size(), characterID) + } + + return nil +} + +// UnloadPlayerCollections unloads collections for a player (when they log out) +func (cs *CollectionService) UnloadPlayerCollections(ctx context.Context, characterID int32) error { + cs.mu.Lock() + defer cs.mu.Unlock() + + playerList, exists := cs.playerLists[characterID] + if !exists { + return nil // Already unloaded + } + + // Save any pending changes + if err := playerList.SaveCollections(ctx); err != nil { + if cs.logger != nil { + cs.logger.LogError("collections", "Failed to save collections for character %d: %v", characterID, err) + } + return fmt.Errorf("failed to save collections: %w", err) + } + + delete(cs.playerLists, characterID) + + if cs.logger != nil { + cs.logger.LogDebug("collections", "Unloaded collections for character %d", characterID) + } + + return nil +} + +// ProcessItemFound processes when a player finds an item +func (cs *CollectionService) ProcessItemFound(characterID, itemID int32) error { + cs.mu.RLock() + playerList, exists := cs.playerLists[characterID] + cs.mu.RUnlock() + + if !exists { + return fmt.Errorf("player collections not loaded for character %d", characterID) + } + + updatedCollections, err := cs.manager.ProcessItemFound(playerList, itemID) + if err != nil { + return fmt.Errorf("failed to process found item: %w", err) + } + + // Notify client and event handler of updates + for _, collection := range updatedCollections { + if cs.clientManager != nil { + cs.clientManager.SendCollectionUpdate(characterID, collection) + } + + if cs.eventHandler != nil { + cs.eventHandler.OnItemFound(characterID, collection.GetID(), itemID) + } + + if cs.logger != nil { + cs.logger.LogDebug("collections", "Character %d found item %d for collection %d (%s)", + characterID, itemID, collection.GetID(), collection.GetName()) + } + } + + return nil +} + +// CompleteCollection processes collection completion +func (cs *CollectionService) CompleteCollection(characterID, collectionID int32, rewardProvider RewardProvider) error { + cs.mu.RLock() + playerList, exists := cs.playerLists[characterID] + cs.mu.RUnlock() + + if !exists { + return fmt.Errorf("player collections not loaded for character %d", characterID) + } + + collection := playerList.GetCollection(collectionID) + if collection == nil { + return fmt.Errorf("collection %d not found for character %d", collectionID, characterID) + } + + // Complete the collection + if err := cs.manager.CompleteCollection(playerList, collectionID, rewardProvider); err != nil { + return fmt.Errorf("failed to complete collection: %w", err) + } + + // Notify client and event handler + if cs.clientManager != nil { + cs.clientManager.SendCollectionComplete(characterID, collection) + } + + if cs.eventHandler != nil { + rewards := collection.GetRewardItems() + selectableRewards := collection.GetSelectableRewardItems() + allRewards := append(rewards, selectableRewards...) + cs.eventHandler.OnCollectionCompleted(characterID, collectionID) + cs.eventHandler.OnRewardClaimed(characterID, collectionID, allRewards, + collection.GetRewardCoin(), collection.GetRewardXP()) + } + + if cs.logger != nil { + cs.logger.LogInfo("collections", "Character %d completed collection %d (%s)", + characterID, collectionID, collection.GetName()) + } + + return nil +} + +// GetPlayerCollections returns all collections for a player +func (cs *CollectionService) GetPlayerCollections(characterID int32) ([]*Collection, error) { + cs.mu.RLock() + playerList, exists := cs.playerLists[characterID] + cs.mu.RUnlock() + + if !exists { + return nil, fmt.Errorf("player collections not loaded for character %d", characterID) + } + + return playerList.GetAllCollections(), nil +} + +// GetPlayerCollectionProgress returns detailed progress for all player collections +func (cs *CollectionService) GetPlayerCollectionProgress(characterID int32) ([]CollectionProgress, error) { + cs.mu.RLock() + playerList, exists := cs.playerLists[characterID] + cs.mu.RUnlock() + + if !exists { + return nil, fmt.Errorf("player collections not loaded for character %d", characterID) + } + + return playerList.GetCollectionProgress(), nil +} + +// SendCollectionList sends available collections to a player +func (cs *CollectionService) SendCollectionList(characterID int32, playerLevel int8) error { + if cs.clientManager == nil { + return fmt.Errorf("client manager not available") + } + + collections := cs.manager.GetAvailableCollections(playerLevel) + collectionInfos := make([]CollectionInfo, len(collections)) + + for i, collection := range collections { + collectionInfos[i] = collection.GetCollectionInfo() + } + + return cs.clientManager.SendCollectionList(characterID, collectionInfos) +} + +// SaveAllPlayerCollections saves all loaded player collections +func (cs *CollectionService) SaveAllPlayerCollections(ctx context.Context) error { + cs.mu.RLock() + playerLists := make(map[int32]*PlayerCollectionList) + for characterID, playerList := range cs.playerLists { + playerLists[characterID] = playerList + } + cs.mu.RUnlock() + + var saveErrors []error + for characterID, playerList := range playerLists { + if err := playerList.SaveCollections(ctx); err != nil { + saveErrors = append(saveErrors, fmt.Errorf("character %d: %w", characterID, err)) + if cs.logger != nil { + cs.logger.LogError("collections", "Failed to save collections for character %d: %v", characterID, err) + } + } + } + + if len(saveErrors) > 0 { + return fmt.Errorf("failed to save some player collections: %v", saveErrors) + } + + return nil +} + +// GetLoadedPlayerCount returns the number of players with loaded collections +func (cs *CollectionService) GetLoadedPlayerCount() int { + cs.mu.RLock() + defer cs.mu.RUnlock() + return len(cs.playerLists) +} + +// IsPlayerLoaded checks if a player's collections are loaded +func (cs *CollectionService) IsPlayerLoaded(characterID int32) bool { + cs.mu.RLock() + defer cs.mu.RUnlock() + _, exists := cs.playerLists[characterID] + return exists +} + +// GetMasterCollection returns a collection from the master list +func (cs *CollectionService) GetMasterCollection(collectionID int32) *Collection { + return cs.manager.GetCollection(collectionID) +} + +// GetAllCategories returns all collection categories +func (cs *CollectionService) GetAllCategories() []string { + return cs.manager.GetAllCategories() +} + +// SearchCollections searches for collections by name +func (cs *CollectionService) SearchCollections(searchTerm string) []*Collection { + return cs.manager.SearchCollections(searchTerm) +} \ No newline at end of file diff --git a/internal/collections/master_list.go b/internal/collections/master_list.go new file mode 100644 index 0000000..11443d0 --- /dev/null +++ b/internal/collections/master_list.go @@ -0,0 +1,335 @@ +package collections + +import ( + "context" + "fmt" + "sort" +) + +// NewMasterCollectionList creates a new master collection list +func NewMasterCollectionList(database CollectionDatabase) *MasterCollectionList { + return &MasterCollectionList{ + collections: make(map[int32]*Collection), + database: database, + } +} + +// Initialize loads all collections from the database +func (mcl *MasterCollectionList) Initialize(ctx context.Context, itemLookup ItemLookup) error { + mcl.mu.Lock() + defer mcl.mu.Unlock() + + // Load collection data + collectionData, err := mcl.database.LoadCollections(ctx) + if err != nil { + return fmt.Errorf("failed to load collections: %w", err) + } + + totalItems := 0 + totalRewards := 0 + + for _, data := range collectionData { + collection := NewCollection() + collection.SetID(data.ID) + collection.SetName(data.Name) + collection.SetCategory(data.Category) + collection.SetLevel(data.Level) + + // Load collection items + items, err := mcl.database.LoadCollectionItems(ctx, data.ID) + if err != nil { + return fmt.Errorf("failed to load items for collection %d: %w", data.ID, err) + } + + for _, item := range items { + // Validate item exists + if itemLookup != nil && !itemLookup.ItemExists(item.ItemID) { + continue // Skip non-existent items + } + collection.AddCollectionItem(item) + totalItems++ + } + + // Load collection rewards + rewards, err := mcl.database.LoadCollectionRewards(ctx, data.ID) + if err != nil { + return fmt.Errorf("failed to load rewards for collection %d: %w", data.ID, err) + } + + if err := collection.LoadFromRewardData(rewards); err != nil { + return fmt.Errorf("failed to load reward data for collection %d: %w", data.ID, err) + } + totalRewards += len(rewards) + + // Validate collection before adding + if err := collection.Validate(); err != nil { + return fmt.Errorf("invalid collection %d (%s): %w", data.ID, data.Name, err) + } + + if !mcl.addCollectionNoLock(collection) { + return fmt.Errorf("duplicate collection ID: %d", data.ID) + } + } + + return nil +} + +// AddCollection adds a collection to the master list +func (mcl *MasterCollectionList) AddCollection(collection *Collection) bool { + mcl.mu.Lock() + defer mcl.mu.Unlock() + return mcl.addCollectionNoLock(collection) +} + +// addCollectionNoLock adds a collection without acquiring the lock +func (mcl *MasterCollectionList) addCollectionNoLock(collection *Collection) bool { + if collection == nil { + return false + } + + id := collection.GetID() + if _, exists := mcl.collections[id]; exists { + return false + } + + mcl.collections[id] = collection + return true +} + +// GetCollection retrieves a collection by ID +func (mcl *MasterCollectionList) GetCollection(collectionID int32) *Collection { + mcl.mu.RLock() + defer mcl.mu.RUnlock() + return mcl.collections[collectionID] +} + +// GetCollectionCopy retrieves a copy of a collection by ID +func (mcl *MasterCollectionList) GetCollectionCopy(collectionID int32) *Collection { + mcl.mu.RLock() + defer mcl.mu.RUnlock() + + if collection, exists := mcl.collections[collectionID]; exists { + return NewCollectionFromData(collection) + } + return nil +} + +// ClearCollections removes all collections +func (mcl *MasterCollectionList) ClearCollections() { + mcl.mu.Lock() + defer mcl.mu.Unlock() + mcl.collections = make(map[int32]*Collection) +} + +// Size returns the number of collections +func (mcl *MasterCollectionList) Size() int { + mcl.mu.RLock() + defer mcl.mu.RUnlock() + return len(mcl.collections) +} + +// NeedsItem checks if any collection needs a specific item +func (mcl *MasterCollectionList) NeedsItem(itemID int32) bool { + mcl.mu.RLock() + defer mcl.mu.RUnlock() + + for _, collection := range mcl.collections { + if collection.NeedsItem(itemID) { + return true + } + } + + return false +} + +// GetCollectionsByCategory returns collections in a specific category +func (mcl *MasterCollectionList) GetCollectionsByCategory(category string) []*Collection { + mcl.mu.RLock() + defer mcl.mu.RUnlock() + + var result []*Collection + for _, collection := range mcl.collections { + if collection.GetCategory() == category { + result = append(result, collection) + } + } + + return result +} + +// GetCollectionsByLevel returns collections for a specific level range +func (mcl *MasterCollectionList) GetCollectionsByLevel(minLevel, maxLevel int8) []*Collection { + mcl.mu.RLock() + defer mcl.mu.RUnlock() + + var result []*Collection + for _, collection := range mcl.collections { + level := collection.GetLevel() + if level >= minLevel && level <= maxLevel { + result = append(result, collection) + } + } + + return result +} + +// GetAllCollections returns all collections +func (mcl *MasterCollectionList) GetAllCollections() []*Collection { + mcl.mu.RLock() + defer mcl.mu.RUnlock() + + result := make([]*Collection, 0, len(mcl.collections)) + for _, collection := range mcl.collections { + result = append(result, collection) + } + + return result +} + +// GetCollectionIDs returns all collection IDs +func (mcl *MasterCollectionList) GetCollectionIDs() []int32 { + mcl.mu.RLock() + defer mcl.mu.RUnlock() + + ids := make([]int32, 0, len(mcl.collections)) + for id := range mcl.collections { + ids = append(ids, id) + } + + sort.Slice(ids, func(i, j int) bool { + return ids[i] < ids[j] + }) + + return ids +} + +// GetCategories returns all unique categories +func (mcl *MasterCollectionList) GetCategories() []string { + mcl.mu.RLock() + defer mcl.mu.RUnlock() + + categoryMap := make(map[string]bool) + for _, collection := range mcl.collections { + category := collection.GetCategory() + if category != "" { + categoryMap[category] = true + } + } + + categories := make([]string, 0, len(categoryMap)) + for category := range categoryMap { + categories = append(categories, category) + } + + sort.Strings(categories) + return categories +} + +// GetCollectionsRequiringItem returns collections that need a specific item +func (mcl *MasterCollectionList) GetCollectionsRequiringItem(itemID int32) []*Collection { + mcl.mu.RLock() + defer mcl.mu.RUnlock() + + var result []*Collection + for _, collection := range mcl.collections { + if collection.NeedsItem(itemID) { + result = append(result, collection) + } + } + + return result +} + +// GetStatistics returns master collection list statistics +func (mcl *MasterCollectionList) GetStatistics() CollectionStatistics { + mcl.mu.RLock() + defer mcl.mu.RUnlock() + + stats := CollectionStatistics{ + TotalCollections: len(mcl.collections), + } + + for _, collection := range mcl.collections { + if collection.GetCompleted() { + stats.CompletedCollections++ + } + if collection.GetTotalItemsCount() > 0 { + stats.ActiveCollections++ + } + stats.TotalItems += collection.GetTotalItemsCount() + stats.FoundItems += collection.GetFoundItemsCount() + stats.TotalRewards += len(collection.GetRewardItems()) + len(collection.GetSelectableRewardItems()) + if collection.GetRewardCoin() > 0 { + stats.TotalRewards++ + } + if collection.GetRewardXP() > 0 { + stats.TotalRewards++ + } + } + + return stats +} + +// ValidateIntegrity checks the integrity of all collections +func (mcl *MasterCollectionList) ValidateIntegrity(itemLookup ItemLookup) []error { + mcl.mu.RLock() + defer mcl.mu.RUnlock() + + var errors []error + + for _, collection := range mcl.collections { + if err := collection.Validate(); err != nil { + errors = append(errors, fmt.Errorf("collection %d (%s): %w", + collection.GetID(), collection.GetName(), err)) + } + + // Check if all required items exist + if itemLookup != nil { + for _, item := range collection.GetCollectionItems() { + if !itemLookup.ItemExists(item.ItemID) { + errors = append(errors, fmt.Errorf("collection %d (%s) references non-existent item %d", + collection.GetID(), collection.GetName(), item.ItemID)) + } + } + + // Check reward items + for _, item := range collection.GetRewardItems() { + if !itemLookup.ItemExists(item.ItemID) { + errors = append(errors, fmt.Errorf("collection %d (%s) has non-existent reward item %d", + collection.GetID(), collection.GetName(), item.ItemID)) + } + } + + for _, item := range collection.GetSelectableRewardItems() { + if !itemLookup.ItemExists(item.ItemID) { + errors = append(errors, fmt.Errorf("collection %d (%s) has non-existent selectable reward item %d", + collection.GetID(), collection.GetName(), item.ItemID)) + } + } + } + } + + return errors +} + +// FindCollectionsByName searches for collections by name (case-insensitive) +func (mcl *MasterCollectionList) FindCollectionsByName(searchTerm string) []*Collection { + mcl.mu.RLock() + defer mcl.mu.RUnlock() + + var result []*Collection + searchLower := strings.ToLower(searchTerm) + + for _, collection := range mcl.collections { + if strings.Contains(strings.ToLower(collection.GetName()), searchLower) { + result = append(result, collection) + } + } + + // Sort by name + sort.Slice(result, func(i, j int) bool { + return result[i].GetName() < result[j].GetName() + }) + + return result +} \ No newline at end of file diff --git a/internal/collections/player_list.go b/internal/collections/player_list.go new file mode 100644 index 0000000..0f16a8d --- /dev/null +++ b/internal/collections/player_list.go @@ -0,0 +1,397 @@ +package collections + +import ( + "context" + "fmt" + "sort" +) + +// NewPlayerCollectionList creates a new player collection list +func NewPlayerCollectionList(characterID int32, database CollectionDatabase) *PlayerCollectionList { + return &PlayerCollectionList{ + characterID: characterID, + collections: make(map[int32]*Collection), + database: database, + } +} + +// Initialize loads player's collection progress from database +func (pcl *PlayerCollectionList) Initialize(ctx context.Context, masterList *MasterCollectionList) error { + pcl.mu.Lock() + defer pcl.mu.Unlock() + + // Load player collection data + playerCollections, err := pcl.database.LoadPlayerCollections(ctx, pcl.characterID) + if err != nil { + return fmt.Errorf("failed to load player collections: %w", err) + } + + for _, playerCollection := range playerCollections { + // Get the master collection template + masterCollection := masterList.GetCollection(playerCollection.CollectionID) + if masterCollection == nil { + continue // Skip collections that no longer exist + } + + // Create a copy for the player + collection := NewCollectionFromData(masterCollection) + if collection == nil { + continue + } + + collection.SetCompleted(playerCollection.Completed) + + // Load player's found items + foundItems, err := pcl.database.LoadPlayerCollectionItems(ctx, pcl.characterID, playerCollection.CollectionID) + if err != nil { + return fmt.Errorf("failed to load player collection items for collection %d: %w", playerCollection.CollectionID, err) + } + + // Mark found items + for _, itemID := range foundItems { + collection.MarkItemFound(itemID) + } + + // Reset save needed flag after loading + collection.SetSaveNeeded(false) + + pcl.collections[playerCollection.CollectionID] = collection + } + + return nil +} + +// AddCollection adds a collection to the player's list +func (pcl *PlayerCollectionList) AddCollection(collection *Collection) bool { + pcl.mu.Lock() + defer pcl.mu.Unlock() + + if collection == nil { + return false + } + + id := collection.GetID() + if _, exists := pcl.collections[id]; exists { + return false + } + + pcl.collections[id] = collection + return true +} + +// GetCollection retrieves a collection by ID +func (pcl *PlayerCollectionList) GetCollection(collectionID int32) *Collection { + pcl.mu.RLock() + defer pcl.mu.RUnlock() + return pcl.collections[collectionID] +} + +// ClearCollections removes all collections +func (pcl *PlayerCollectionList) ClearCollections() { + pcl.mu.Lock() + defer pcl.mu.Unlock() + pcl.collections = make(map[int32]*Collection) +} + +// Size returns the number of collections +func (pcl *PlayerCollectionList) Size() int { + pcl.mu.RLock() + defer pcl.mu.RUnlock() + return len(pcl.collections) +} + +// NeedsItem checks if any player collection or potential collection needs an item +func (pcl *PlayerCollectionList) NeedsItem(itemID int32, masterList *MasterCollectionList) bool { + pcl.mu.RLock() + defer pcl.mu.RUnlock() + + // Check player's active collections first + for _, collection := range pcl.collections { + if collection.NeedsItem(itemID) { + return true + } + } + + // Check if any master collection the player doesn't have needs this item + if masterList != nil { + for _, masterCollection := range masterList.GetAllCollections() { + if masterCollection.NeedsItem(itemID) { + // Player doesn't have this collection yet + if _, hasCollection := pcl.collections[masterCollection.GetID()]; !hasCollection { + return true + } + } + } + } + + return false +} + +// HasCollectionsToHandIn checks if any collections are ready to turn in +func (pcl *PlayerCollectionList) HasCollectionsToHandIn() bool { + pcl.mu.RLock() + defer pcl.mu.RUnlock() + + for _, collection := range pcl.collections { + if collection.GetIsReadyToTurnIn() { + return true + } + } + + return false +} + +// GetCollectionsReadyToTurnIn returns collections that are ready to complete +func (pcl *PlayerCollectionList) GetCollectionsReadyToTurnIn() []*Collection { + pcl.mu.RLock() + defer pcl.mu.RUnlock() + + var result []*Collection + for _, collection := range pcl.collections { + if collection.GetIsReadyToTurnIn() { + result = append(result, collection) + } + } + + return result +} + +// GetCompletedCollections returns all completed collections +func (pcl *PlayerCollectionList) GetCompletedCollections() []*Collection { + pcl.mu.RLock() + defer pcl.mu.RUnlock() + + var result []*Collection + for _, collection := range pcl.collections { + if collection.GetCompleted() { + result = append(result, collection) + } + } + + return result +} + +// GetActiveCollections returns all active (incomplete) collections +func (pcl *PlayerCollectionList) GetActiveCollections() []*Collection { + pcl.mu.RLock() + defer pcl.mu.RUnlock() + + var result []*Collection + for _, collection := range pcl.collections { + if !collection.GetCompleted() { + result = append(result, collection) + } + } + + return result +} + +// GetAllCollections returns all player collections +func (pcl *PlayerCollectionList) GetAllCollections() []*Collection { + pcl.mu.RLock() + defer pcl.mu.RUnlock() + + result := make([]*Collection, 0, len(pcl.collections)) + for _, collection := range pcl.collections { + result = append(result, collection) + } + + return result +} + +// GetCollectionsByCategory returns collections in a specific category +func (pcl *PlayerCollectionList) GetCollectionsByCategory(category string) []*Collection { + pcl.mu.RLock() + defer pcl.mu.RUnlock() + + var result []*Collection + for _, collection := range pcl.collections { + if collection.GetCategory() == category { + result = append(result, collection) + } + } + + return result +} + +// ProcessItemFound processes when a player finds an item that may belong to collections +func (pcl *PlayerCollectionList) ProcessItemFound(itemID int32, masterList *MasterCollectionList) ([]*Collection, error) { + pcl.mu.Lock() + defer pcl.mu.Unlock() + + var updatedCollections []*Collection + + // Check existing player collections + for _, collection := range pcl.collections { + if collection.NeedsItem(itemID) { + if collection.MarkItemFound(itemID) { + updatedCollections = append(updatedCollections, collection) + } + } + } + + // Check if player should start new collections + if masterList != nil { + for _, masterCollection := range masterList.GetAllCollections() { + // Skip if player already has this collection + if _, hasCollection := pcl.collections[masterCollection.GetID()]; hasCollection { + continue + } + + // Check if master collection needs this item + if masterCollection.NeedsItem(itemID) { + // Create new collection for player + newCollection := NewCollectionFromData(masterCollection) + if newCollection != nil { + newCollection.MarkItemFound(itemID) + pcl.collections[masterCollection.GetID()] = newCollection + updatedCollections = append(updatedCollections, newCollection) + } + } + } + } + + return updatedCollections, nil +} + +// CompleteCollection marks a collection as completed +func (pcl *PlayerCollectionList) CompleteCollection(collectionID int32) error { + pcl.mu.Lock() + defer pcl.mu.Unlock() + + collection, exists := pcl.collections[collectionID] + if !exists { + return fmt.Errorf("collection %d not found", collectionID) + } + + if collection.GetCompleted() { + return fmt.Errorf("collection %d is already completed", collectionID) + } + + if !collection.GetIsReadyToTurnIn() { + return fmt.Errorf("collection %d is not ready to complete", collectionID) + } + + collection.SetCompleted(true) + collection.SetSaveNeeded(true) + + return nil +} + +// GetCollectionsNeedingSave returns collections that need to be saved +func (pcl *PlayerCollectionList) GetCollectionsNeedingSave() []*Collection { + pcl.mu.RLock() + defer pcl.mu.RUnlock() + + var result []*Collection + for _, collection := range pcl.collections { + if collection.GetSaveNeeded() { + result = append(result, collection) + } + } + + return result +} + +// SaveCollections saves all collections that need saving +func (pcl *PlayerCollectionList) SaveCollections(ctx context.Context) error { + collectionsToSave := pcl.GetCollectionsNeedingSave() + if len(collectionsToSave) == 0 { + return nil + } + + if err := pcl.database.SavePlayerCollections(ctx, pcl.characterID, collectionsToSave); err != nil { + return fmt.Errorf("failed to save player collections: %w", err) + } + + // Mark collections as saved + for _, collection := range collectionsToSave { + collection.SetSaveNeeded(false) + } + + return nil +} + +// GetStatistics returns player collection statistics +func (pcl *PlayerCollectionList) GetStatistics() CollectionStatistics { + pcl.mu.RLock() + defer pcl.mu.RUnlock() + + stats := CollectionStatistics{ + TotalCollections: len(pcl.collections), + PlayersWithCollections: 1, // This player + } + + for _, collection := range pcl.collections { + if collection.GetCompleted() { + stats.CompletedCollections++ + } + if !collection.GetCompleted() && collection.GetFoundItemsCount() > 0 { + stats.ActiveCollections++ + } + stats.TotalItems += collection.GetTotalItemsCount() + stats.FoundItems += collection.GetFoundItemsCount() + stats.TotalRewards += len(collection.GetRewardItems()) + len(collection.GetSelectableRewardItems()) + if collection.GetRewardCoin() > 0 { + stats.TotalRewards++ + } + if collection.GetRewardXP() > 0 { + stats.TotalRewards++ + } + } + + return stats +} + +// GetCollectionProgress returns detailed progress for all collections +func (pcl *PlayerCollectionList) GetCollectionProgress() []CollectionProgress { + pcl.mu.RLock() + defer pcl.mu.RUnlock() + + progress := make([]CollectionProgress, 0, len(pcl.collections)) + for _, collection := range pcl.collections { + progress = append(progress, collection.GetCollectionProgress()) + } + + // Sort by name + sort.Slice(progress, func(i, j int) bool { + return progress[i].Name < progress[j].Name + }) + + return progress +} + +// GetCharacterID returns the character ID for this collection list +func (pcl *PlayerCollectionList) GetCharacterID() int32 { + return pcl.characterID +} + +// RemoveCollection removes a collection from the player's list +func (pcl *PlayerCollectionList) RemoveCollection(collectionID int32) bool { + pcl.mu.Lock() + defer pcl.mu.Unlock() + + if _, exists := pcl.collections[collectionID]; exists { + delete(pcl.collections, collectionID) + return true + } + + return false +} + +// GetCollectionIDs returns all collection IDs the player has +func (pcl *PlayerCollectionList) GetCollectionIDs() []int32 { + pcl.mu.RLock() + defer pcl.mu.RUnlock() + + ids := make([]int32, 0, len(pcl.collections)) + for id := range pcl.collections { + ids = append(ids, id) + } + + sort.Slice(ids, func(i, j int) bool { + return ids[i] < ids[j] + }) + + return ids +} \ No newline at end of file diff --git a/internal/collections/types.go b/internal/collections/types.go new file mode 100644 index 0000000..6e94664 --- /dev/null +++ b/internal/collections/types.go @@ -0,0 +1,130 @@ +package collections + +import ( + "sync" + "time" +) + +// CollectionItem represents an item required for a collection +type CollectionItem struct { + ItemID int32 `json:"item_id" db:"item_id"` + Index int8 `json:"index" db:"item_index"` + Found int8 `json:"found" db:"found"` +} + +// CollectionRewardItem represents a reward item for completing a collection +type CollectionRewardItem struct { + ItemID int32 `json:"item_id" db:"item_id"` + Quantity int8 `json:"quantity" db:"quantity"` +} + +// Collection represents a collection that players can complete +type Collection struct { + mu sync.RWMutex + id int32 + name string + category string + level int8 + rewardCoin int64 + rewardXP int64 + completed bool + saveNeeded bool + collectionItems []CollectionItem + rewardItems []CollectionRewardItem + selectableRewardItems []CollectionRewardItem + lastModified time.Time +} + +// CollectionData represents collection data for database operations +type CollectionData struct { + ID int32 `json:"id" db:"id"` + Name string `json:"collection_name" db:"collection_name"` + Category string `json:"collection_category" db:"collection_category"` + Level int8 `json:"level" db:"level"` +} + +// CollectionRewardData represents reward data from database +type CollectionRewardData struct { + CollectionID int32 `json:"collection_id" db:"collection_id"` + RewardType string `json:"reward_type" db:"reward_type"` + RewardValue string `json:"reward_value" db:"reward_value"` + Quantity int8 `json:"reward_quantity" db:"reward_quantity"` +} + +// PlayerCollectionData represents player collection progress +type PlayerCollectionData struct { + CharacterID int32 `json:"char_id" db:"char_id"` + CollectionID int32 `json:"collection_id" db:"collection_id"` + Completed bool `json:"completed" db:"completed"` +} + +// PlayerCollectionItemData represents player found collection items +type PlayerCollectionItemData struct { + CharacterID int32 `json:"char_id" db:"char_id"` + CollectionID int32 `json:"collection_id" db:"collection_id"` + CollectionItemID int32 `json:"collection_item_id" db:"collection_item_id"` +} + +// MasterCollectionList manages all available collections in the game +type MasterCollectionList struct { + mu sync.RWMutex + collections map[int32]*Collection + database CollectionDatabase +} + +// PlayerCollectionList manages collections for a specific player +type PlayerCollectionList struct { + mu sync.RWMutex + characterID int32 + collections map[int32]*Collection + database CollectionDatabase +} + +// CollectionManager provides high-level collection management +type CollectionManager struct { + masterList *MasterCollectionList + database CollectionDatabase + itemLookup ItemLookup +} + +// CollectionStatistics provides collection system usage statistics +type CollectionStatistics struct { + TotalCollections int + CompletedCollections int + ActiveCollections int + TotalItems int + FoundItems int + TotalRewards int + PlayersWithCollections int +} + +// CollectionInfo provides basic collection information +type CollectionInfo struct { + ID int32 `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + Level int8 `json:"level"` + Completed bool `json:"completed"` + ReadyToTurnIn bool `json:"ready_to_turn_in"` + ItemsFound int `json:"items_found"` + ItemsTotal int `json:"items_total"` + RewardCoin int64 `json:"reward_coin"` + RewardXP int64 `json:"reward_xp"` + RewardItems []CollectionRewardItem `json:"reward_items"` + SelectableRewards []CollectionRewardItem `json:"selectable_rewards"` + RequiredItems []CollectionItem `json:"required_items"` +} + +// CollectionProgress represents player progress on a collection +type CollectionProgress struct { + CollectionID int32 `json:"collection_id"` + Name string `json:"name"` + Category string `json:"category"` + Level int8 `json:"level"` + Completed bool `json:"completed"` + ReadyToTurnIn bool `json:"ready_to_turn_in"` + Progress float64 `json:"progress_percentage"` + ItemsFound []CollectionItem `json:"items_found"` + ItemsNeeded []CollectionItem `json:"items_needed"` + LastUpdated time.Time `json:"last_updated"` +} \ No newline at end of file diff --git a/internal/guilds/constants.go b/internal/guilds/constants.go new file mode 100644 index 0000000..460bcc5 --- /dev/null +++ b/internal/guilds/constants.go @@ -0,0 +1,230 @@ +package guilds + +// Guild rank constants +const ( + RankLeader = 0 + RankSeniorOfficer = 1 + RankOfficer = 2 + RankSeniorMember = 3 + RankMember = 4 + RankJuniorMember = 5 + RankInitiate = 6 + RankRecruit = 7 +) + +// Guild permission constants +const ( + PermissionInvite = 0 + PermissionRemoveMember = 1 + PermissionPromoteMember = 2 + PermissionDemoteMember = 3 + PermissionChangeMOTD = 6 + PermissionChangePermissions = 7 + PermissionChangeRankNames = 8 + PermissionSeeOfficerNotes = 9 + PermissionEditOfficerNotes = 10 + PermissionSeeOfficerChat = 11 + PermissionSpeakInOfficerChat = 12 + PermissionSeeGuildChat = 13 + PermissionSpeakInGuildChat = 14 + PermissionEditPersonalNotes = 15 + PermissionEditPersonalNotesOthers = 16 + PermissionEditEventFilters = 17 + PermissionEditEvents = 18 + PermissionPurchaseStatusItems = 19 + PermissionDisplayGuildName = 20 + PermissionSendEmailToGuild = 21 + PermissionBank1SeeContents = 22 + PermissionBank2SeeContents = 23 + PermissionBank3SeeContents = 24 + PermissionBank4SeeContents = 25 + PermissionBank1Deposit = 26 + PermissionBank2Deposit = 27 + PermissionBank3Deposit = 28 + PermissionBank4Deposit = 29 + PermissionBank1Withdrawal = 30 + PermissionBank2Withdrawal = 31 + PermissionBank3Withdrawal = 32 + PermissionBank4Withdrawal = 33 + PermissionEditRecruitingSettings = 35 + PermissionMakeOthersRecruiters = 36 + PermissionSeeRecruitingSettings = 37 + PermissionAssignPoints = 43 + PermissionReceivePoints = 44 +) + +// Event filter categories +const ( + EventFilterCategoryRetainHistory = 0 + EventFilterCategoryBroadcast = 1 +) + +// Guild event types +const ( + EventGuildLevelUp = 0 + EventGuildLevelDown = 1 + EventDiscoversItem = 2 + EventGainsAdvLevel1To10 = 3 + EventGainsAdvLevel11To20 = 4 + EventGainsAdvLevel21To30 = 5 + EventGainsAdvLevel31To40 = 6 + EventGainsAdvLevel41To50 = 7 + EventGainsTSLevel1To10 = 8 + EventGainsTSLevel11To20 = 9 + EventGainsTSLevel21To30 = 10 + EventGainsTSLevel31To40 = 11 + EventGainsTSLevel41To50 = 12 + EventMemberJoins = 13 + EventMemberLeaves = 14 + EventMemberPromoted = 15 + EventMemberDemoted = 16 + EventCompletesHeritageQuest = 19 + EventKillsEpicMonster = 20 + EventLootsArtifact = 21 + EventLootsFabeledItem = 22 + EventLootsLegendaryItem = 23 + EventCompletesWrit = 24 + EventLootsMythicalItem = 25 + EventGainsAdvLevel10 = 26 + EventGainsAdvLevel20 = 27 + EventGainsAdvLevel30 = 28 + EventGainsAdvLevel40 = 29 + EventGainsAdvLevel50 = 30 + EventGainsTSLevel10 = 31 + EventGainsTSLevel20 = 32 + EventGainsTSLevel30 = 33 + EventGainsTSLevel40 = 34 + EventGainsTSLevel50 = 35 + EventGainsAdvLevel51To60 = 37 + EventGainsTSLevel51To60 = 38 + EventGainsAdvLevel60 = 39 + EventGainsTSLevel60 = 40 + EventGainsAdvLevel61To70 = 41 + EventGainsTSLevel61To70 = 42 + EventGainsAdvLevel70 = 43 + EventGainsTSLevel70 = 44 + EventGainsAA10 = 45 + EventGainsAA20 = 46 + EventGainsAA30 = 47 + EventGainsAA40 = 48 + EventGainsAA50 = 49 + EventGainsAA1To10 = 50 + EventGainsAA11To20 = 51 + EventGainsAA21To30 = 52 + EventGainsAA31To40 = 53 + EventGainsAA41To50 = 54 + EventBecomesRecruiter = 55 + EventNoLongerRecruiter = 56 + EventHeraldyChange = 57 + EventGainsAA60 = 58 + EventGainsAA70 = 59 + EventGainsAA80 = 60 + EventGainsAA90 = 61 + EventGainsAA100 = 62 + EventGainsAA51To60 = 63 + EventGainsAA61To70 = 64 + EventGainsAA71To80 = 65 + EventGainsAA81To90 = 66 + EventGainsAA91To100 = 67 + EventGainsAdvLevel80 = 68 + EventGainsTSLevel80 = 69 + EventGainsAdvLevel71To80 = 70 + EventGainsTSLevel71To80 = 71 + EventGainsAA110 = 72 + EventGainsAA120 = 73 + EventGainsAA130 = 74 + EventGainsAA140 = 75 + EventGainsAA101To110 = 76 + EventGainsAA111To120 = 77 + EventGainsAA121To130 = 78 + EventGainsAA131To140 = 79 + EventGainsAA150 = 80 + EventGainsAA141To150 = 81 + EventGainsAA160 = 82 + EventGainsAA170 = 83 + EventGainsAA180 = 84 + EventGainsAA190 = 85 + EventGainsAA200 = 86 + EventGainsAA151To160 = 87 + EventGainsAA161To170 = 88 + EventGainsAA171To180 = 89 + EventGainsAA181To190 = 90 + EventGainsAA191To200 = 91 + EventEarnsAchievement = 92 +) + +// Recruiting flags +const ( + RecruitingFlagTraining = 0 + RecruitingFlagFighters = 1 + RecruitingFlagPriests = 2 + RecruitingFlagScouts = 3 + RecruitingFlagMages = 4 + RecruitingFlagTradeskillers = 5 +) + +// Recruiting play styles +const ( + RecruitingPlayStyleNone = 0 + RecruitingPlayStyleCasual = 1 + RecruitingPlayStyleHardcore = 2 +) + +// Recruiting description tags +const ( + RecruitingDescTagNone = 0 + RecruitingDescTagGood = 1 + RecruitingDescTagEvil = 2 + RecruitingDescTagChatty = 3 + RecruitingDescTagOrganized = 4 + RecruitingDescTagRoleplay = 5 + RecruitingDescTagEnjoyQuests = 6 + RecruitingDescTagEnjoyRaids = 7 + RecruitingDescTagOddHours = 8 + RecruitingDescTagCrafterOriented = 9 + RecruitingDescTagFamilyFriendly = 10 + RecruitingDescTagMatureHumor = 11 + RecruitingDescTagInmatesRun = 12 + RecruitingDescTagVeryFunny = 13 + RecruitingDescTagHumorCausesPain = 14 + RecruitingDescTagSerious = 15 +) + +// Member flags +const ( + MemberFlagRecruitingForGuild = 1 + MemberFlagNotifyLogins = 2 + MemberFlagDontGenerateEvents = 4 +) + +// Event actions +const ( + EventActionLock = 0 + EventActionUnlock = 1 + EventActionDelete = 2 +) + +// System limits +const ( + MaxGuildLevel = 80 + MaxPointHistory = 50 + MaxEvents = 500 + MaxLockedEvents = 200 + MaxGuildNameLength = 64 + MaxMOTDLength = 256 + MaxMemberNameLength = 64 + MaxBankNameLength = 64 + MaxRecruitingDescLength = 512 +) + +// Default rank names +var DefaultRankNames = map[int8]string{ + RankLeader: "Leader", + RankSeniorOfficer: "Senior Officer", + RankOfficer: "Officer", + RankSeniorMember: "Senior Member", + RankMember: "Member", + RankJuniorMember: "Junior Member", + RankInitiate: "Initiate", + RankRecruit: "Recruit", +} \ No newline at end of file diff --git a/internal/guilds/database.go b/internal/guilds/database.go new file mode 100644 index 0000000..af7a54a --- /dev/null +++ b/internal/guilds/database.go @@ -0,0 +1,936 @@ +package guilds + +import ( + "context" + "fmt" + "time" + + "eq2emu/internal/database" +) + +// DatabaseGuildManager implements GuildDatabase interface using the existing database wrapper +type DatabaseGuildManager struct { + db *database.DB +} + +// NewDatabaseGuildManager creates a new database guild manager +func NewDatabaseGuildManager(db *database.DB) *DatabaseGuildManager { + return &DatabaseGuildManager{ + db: db, + } +} + +// LoadGuilds retrieves all guilds from database +func (dgm *DatabaseGuildManager) LoadGuilds(ctx context.Context) ([]GuildData, error) { + query := "SELECT `id`, `name`, `motd`, `level`, `xp`, `xp_needed`, `formed_on` FROM `guilds`" + + rows, err := dgm.db.QueryContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("failed to query guilds: %w", err) + } + defer rows.Close() + + var guilds []GuildData + for rows.Next() { + var guild GuildData + var motd *string + var formedOnTimestamp int64 + + err := rows.Scan( + &guild.ID, + &guild.Name, + &motd, + &guild.Level, + &guild.EXPCurrent, + &guild.EXPToNextLevel, + &formedOnTimestamp, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan guild row: %w", err) + } + + // Handle nullable MOTD field + if motd != nil { + guild.MOTD = *motd + } + + // Convert timestamp to time + guild.FormedDate = time.Unix(formedOnTimestamp, 0) + + guilds = append(guilds, guild) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating guild rows: %w", err) + } + + return guilds, nil +} + +// LoadGuild retrieves a specific guild from database +func (dgm *DatabaseGuildManager) LoadGuild(ctx context.Context, guildID int32) (*GuildData, error) { + query := "SELECT `id`, `name`, `motd`, `level`, `xp`, `xp_needed`, `formed_on` FROM `guilds` WHERE `id` = ?" + + var guild GuildData + var motd *string + var formedOnTimestamp int64 + + err := dgm.db.QueryRowContext(ctx, query, guildID).Scan( + &guild.ID, + &guild.Name, + &motd, + &guild.Level, + &guild.EXPCurrent, + &guild.EXPToNextLevel, + &formedOnTimestamp, + ) + if err != nil { + return nil, fmt.Errorf("failed to load guild %d: %w", guildID, err) + } + + // Handle nullable MOTD field + if motd != nil { + guild.MOTD = *motd + } + + // Convert timestamp to time + guild.FormedDate = time.Unix(formedOnTimestamp, 0) + + return &guild, nil +} + +// LoadGuildMembers retrieves all members for a guild +func (dgm *DatabaseGuildManager) LoadGuildMembers(ctx context.Context, guildID int32) ([]GuildMemberData, error) { + query := `SELECT char_id, guild_id, account_id, recruiter_id, name, guild_status, points, + adventure_class, adventure_level, tradeskill_class, tradeskill_level, rank, + member_flags, zone, join_date, last_login_date, note, officer_note, + recruiter_description, recruiter_picture_data, recruiting_show_adventure_class + FROM guild_members WHERE guild_id = ?` + + rows, err := dgm.db.QueryContext(ctx, query, guildID) + if err != nil { + return nil, fmt.Errorf("failed to query guild members for guild %d: %w", guildID, err) + } + defer rows.Close() + + var members []GuildMemberData + for rows.Next() { + var member GuildMemberData + var joinDateTimestamp int64 + var lastLoginTimestamp int64 + var note, officerNote, recruiterDesc *string + var pictureData []byte + + err := rows.Scan( + &member.CharacterID, + &member.GuildID, + &member.AccountID, + &member.RecruiterID, + &member.Name, + &member.GuildStatus, + &member.Points, + &member.AdventureClass, + &member.AdventureLevel, + &member.TradeskillClass, + &member.TradeskillLevel, + &member.Rank, + &member.MemberFlags, + &member.Zone, + &joinDateTimestamp, + &lastLoginTimestamp, + ¬e, + &officerNote, + &recruiterDesc, + &pictureData, + &member.RecruitingShowAdventureClass, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan guild member row: %w", err) + } + + // Handle nullable fields + if note != nil { + member.Note = *note + } + if officerNote != nil { + member.OfficerNote = *officerNote + } + if recruiterDesc != nil { + member.RecruiterDescription = *recruiterDesc + } + member.RecruiterPictureData = pictureData + + // Convert timestamps to time + member.JoinDate = time.Unix(joinDateTimestamp, 0) + member.LastLoginDate = time.Unix(lastLoginTimestamp, 0) + + members = append(members, member) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating guild member rows: %w", err) + } + + return members, nil +} + +// LoadGuildEvents retrieves events for a guild +func (dgm *DatabaseGuildManager) LoadGuildEvents(ctx context.Context, guildID int32) ([]GuildEventData, error) { + query := `SELECT event_id, guild_id, date, type, description, locked + FROM guild_events WHERE guild_id = ? + ORDER BY event_id DESC LIMIT ?` + + rows, err := dgm.db.QueryContext(ctx, query, guildID, MaxEvents) + if err != nil { + return nil, fmt.Errorf("failed to query guild events for guild %d: %w", guildID, err) + } + defer rows.Close() + + var events []GuildEventData + for rows.Next() { + var event GuildEventData + var dateTimestamp int64 + + err := rows.Scan( + &event.EventID, + &event.GuildID, + &dateTimestamp, + &event.Type, + &event.Description, + &event.Locked, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan guild event row: %w", err) + } + + // Convert timestamp to time + event.Date = time.Unix(dateTimestamp, 0) + + events = append(events, event) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating guild event rows: %w", err) + } + + return events, nil +} + +// LoadGuildRanks retrieves custom rank names for a guild +func (dgm *DatabaseGuildManager) LoadGuildRanks(ctx context.Context, guildID int32) ([]GuildRankData, error) { + query := "SELECT guild_id, rank, name FROM guild_ranks WHERE guild_id = ?" + + rows, err := dgm.db.QueryContext(ctx, query, guildID) + if err != nil { + return nil, fmt.Errorf("failed to query guild ranks for guild %d: %w", guildID, err) + } + defer rows.Close() + + var ranks []GuildRankData + for rows.Next() { + var rank GuildRankData + + err := rows.Scan( + &rank.GuildID, + &rank.Rank, + &rank.Name, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan guild rank row: %w", err) + } + + ranks = append(ranks, rank) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating guild rank rows: %w", err) + } + + return ranks, nil +} + +// LoadGuildPermissions retrieves permissions for a guild +func (dgm *DatabaseGuildManager) LoadGuildPermissions(ctx context.Context, guildID int32) ([]GuildPermissionData, error) { + query := "SELECT guild_id, rank, permission, value FROM guild_permissions WHERE guild_id = ?" + + rows, err := dgm.db.QueryContext(ctx, query, guildID) + if err != nil { + return nil, fmt.Errorf("failed to query guild permissions for guild %d: %w", guildID, err) + } + defer rows.Close() + + var permissions []GuildPermissionData + for rows.Next() { + var permission GuildPermissionData + + err := rows.Scan( + &permission.GuildID, + &permission.Rank, + &permission.Permission, + &permission.Value, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan guild permission row: %w", err) + } + + permissions = append(permissions, permission) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating guild permission rows: %w", err) + } + + return permissions, nil +} + +// LoadGuildEventFilters retrieves event filters for a guild +func (dgm *DatabaseGuildManager) LoadGuildEventFilters(ctx context.Context, guildID int32) ([]GuildEventFilterData, error) { + query := "SELECT guild_id, event_id, category, value FROM guild_event_filters WHERE guild_id = ?" + + rows, err := dgm.db.QueryContext(ctx, query, guildID) + if err != nil { + return nil, fmt.Errorf("failed to query guild event filters for guild %d: %w", guildID, err) + } + defer rows.Close() + + var filters []GuildEventFilterData + for rows.Next() { + var filter GuildEventFilterData + + err := rows.Scan( + &filter.GuildID, + &filter.EventID, + &filter.Category, + &filter.Value, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan guild event filter row: %w", err) + } + + filters = append(filters, filter) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating guild event filter rows: %w", err) + } + + return filters, nil +} + +// LoadGuildRecruiting retrieves recruiting settings for a guild +func (dgm *DatabaseGuildManager) LoadGuildRecruiting(ctx context.Context, guildID int32) ([]GuildRecruitingData, error) { + query := "SELECT guild_id, flag, value FROM guild_recruiting WHERE guild_id = ?" + + rows, err := dgm.db.QueryContext(ctx, query, guildID) + if err != nil { + return nil, fmt.Errorf("failed to query guild recruiting for guild %d: %w", guildID, err) + } + defer rows.Close() + + var recruiting []GuildRecruitingData + for rows.Next() { + var recruit GuildRecruitingData + + err := rows.Scan( + &recruit.GuildID, + &recruit.Flag, + &recruit.Value, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan guild recruiting row: %w", err) + } + + recruiting = append(recruiting, recruit) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating guild recruiting rows: %w", err) + } + + return recruiting, nil +} + +// LoadPointHistory retrieves point history for a member +func (dgm *DatabaseGuildManager) LoadPointHistory(ctx context.Context, characterID int32) ([]PointHistoryData, error) { + query := `SELECT char_id, date, modified_by, comment, points + FROM guild_point_history WHERE char_id = ? + ORDER BY date DESC LIMIT ?` + + rows, err := dgm.db.QueryContext(ctx, query, characterID, MaxPointHistory) + if err != nil { + return nil, fmt.Errorf("failed to query point history for character %d: %w", characterID, err) + } + defer rows.Close() + + var history []PointHistoryData + for rows.Next() { + var entry PointHistoryData + var dateTimestamp int64 + + err := rows.Scan( + &entry.CharacterID, + &dateTimestamp, + &entry.ModifiedBy, + &entry.Comment, + &entry.Points, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan point history row: %w", err) + } + + // Convert timestamp to time + entry.Date = time.Unix(dateTimestamp, 0) + + history = append(history, entry) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating point history rows: %w", err) + } + + return history, nil +} + +// SaveGuild saves guild basic data +func (dgm *DatabaseGuildManager) SaveGuild(ctx context.Context, guild *Guild) error { + query := `INSERT OR REPLACE INTO guilds + (id, name, motd, level, xp, xp_needed, formed_on) + VALUES (?, ?, ?, ?, ?, ?, ?)` + + guildInfo := guild.GetGuildInfo() + formedTimestamp := guild.GetFormedDate().Unix() + + _, err := dgm.db.ExecContext(ctx, query, + guild.GetID(), + guild.GetName(), + guild.GetMOTD(), + guild.GetLevel(), + guild.GetEXPCurrent(), + guild.GetEXPToNextLevel(), + formedTimestamp, + ) + if err != nil { + return fmt.Errorf("failed to save guild %d: %w", guild.GetID(), err) + } + + return nil +} + +// SaveGuildMembers saves all guild members +func (dgm *DatabaseGuildManager) SaveGuildMembers(ctx context.Context, guildID int32, members []*GuildMember) error { + if len(members) == 0 { + return nil + } + + // Use a transaction for atomic updates + tx, err := dgm.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Delete existing members for this guild + _, err = tx.ExecContext(ctx, "DELETE FROM guild_members WHERE guild_id = ?", guildID) + if err != nil { + return fmt.Errorf("failed to delete existing guild members: %w", err) + } + + // Insert all members + insertQuery := `INSERT INTO guild_members + (char_id, guild_id, account_id, recruiter_id, name, guild_status, points, + adventure_class, adventure_level, tradeskill_class, tradeskill_level, rank, + member_flags, zone, join_date, last_login_date, note, officer_note, + recruiter_description, recruiter_picture_data, recruiting_show_adventure_class) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + + for _, member := range members { + joinTimestamp := member.GetJoinDate().Unix() + lastLoginTimestamp := member.GetLastLoginDate().Unix() + + _, err = tx.ExecContext(ctx, insertQuery, + member.GetCharacterID(), + guildID, + member.AccountID, + member.GetRecruiterID(), + member.GetName(), + member.GuildStatus, + member.GetPoints(), + member.GetAdventureClass(), + member.GetAdventureLevel(), + member.GetTradeskillClass(), + member.GetTradeskillLevel(), + member.GetRank(), + member.GetMemberFlags(), + member.GetZone(), + joinTimestamp, + lastLoginTimestamp, + member.GetNote(), + member.GetOfficerNote(), + member.GetRecruiterDescription(), + member.GetRecruiterPictureData(), + member.RecruitingShowAdventureClass, + ) + if err != nil { + return fmt.Errorf("failed to insert guild member %d: %w", member.GetCharacterID(), err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} + +// SaveGuildEvents saves guild events +func (dgm *DatabaseGuildManager) SaveGuildEvents(ctx context.Context, guildID int32, events []GuildEvent) error { + if len(events) == 0 { + return nil + } + + query := `INSERT OR REPLACE INTO guild_events + (event_id, guild_id, date, type, description, locked) + VALUES (?, ?, ?, ?, ?, ?)` + + for _, event := range events { + if !event.SaveNeeded { + continue + } + + dateTimestamp := event.Date.Unix() + + _, err := dgm.db.ExecContext(ctx, query, + event.EventID, + guildID, + dateTimestamp, + event.Type, + event.Description, + event.Locked, + ) + if err != nil { + return fmt.Errorf("failed to save guild event %d: %w", event.EventID, err) + } + } + + return nil +} + +// SaveGuildRanks saves guild rank names +func (dgm *DatabaseGuildManager) SaveGuildRanks(ctx context.Context, guildID int32, ranks map[int8]string) error { + // Use a transaction for atomic updates + tx, err := dgm.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Delete existing ranks for this guild + _, err = tx.ExecContext(ctx, "DELETE FROM guild_ranks WHERE guild_id = ?", guildID) + if err != nil { + return fmt.Errorf("failed to delete existing guild ranks: %w", err) + } + + // Insert all ranks + insertQuery := "INSERT INTO guild_ranks (guild_id, rank, name) VALUES (?, ?, ?)" + + for rank, name := range ranks { + // Only save non-default rank names + if defaultName, exists := DefaultRankNames[rank]; !exists || name != defaultName { + _, err = tx.ExecContext(ctx, insertQuery, guildID, rank, name) + if err != nil { + return fmt.Errorf("failed to insert guild rank %d: %w", rank, err) + } + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} + +// SaveGuildPermissions saves guild permissions +func (dgm *DatabaseGuildManager) SaveGuildPermissions(ctx context.Context, guildID int32, permissions map[int8]map[int8]int8) error { + // Use a transaction for atomic updates + tx, err := dgm.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Delete existing permissions for this guild + _, err = tx.ExecContext(ctx, "DELETE FROM guild_permissions WHERE guild_id = ?", guildID) + if err != nil { + return fmt.Errorf("failed to delete existing guild permissions: %w", err) + } + + // Insert all permissions + insertQuery := "INSERT INTO guild_permissions (guild_id, rank, permission, value) VALUES (?, ?, ?, ?)" + + for rank, rankPermissions := range permissions { + for permission, value := range rankPermissions { + _, err = tx.ExecContext(ctx, insertQuery, guildID, rank, permission, value) + if err != nil { + return fmt.Errorf("failed to insert guild permission %d/%d: %w", rank, permission, err) + } + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} + +// SaveGuildEventFilters saves guild event filters +func (dgm *DatabaseGuildManager) SaveGuildEventFilters(ctx context.Context, guildID int32, filters map[int8]map[int8]int8) error { + // Use a transaction for atomic updates + tx, err := dgm.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Delete existing filters for this guild + _, err = tx.ExecContext(ctx, "DELETE FROM guild_event_filters WHERE guild_id = ?", guildID) + if err != nil { + return fmt.Errorf("failed to delete existing guild event filters: %w", err) + } + + // Insert all filters + insertQuery := "INSERT INTO guild_event_filters (guild_id, event_id, category, value) VALUES (?, ?, ?, ?)" + + for eventID, eventFilters := range filters { + for category, value := range eventFilters { + _, err = tx.ExecContext(ctx, insertQuery, guildID, eventID, category, value) + if err != nil { + return fmt.Errorf("failed to insert guild event filter %d/%d: %w", eventID, category, err) + } + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} + +// SaveGuildRecruiting saves guild recruiting settings +func (dgm *DatabaseGuildManager) SaveGuildRecruiting(ctx context.Context, guildID int32, flags, descTags map[int8]int8) error { + // Use a transaction for atomic updates + tx, err := dgm.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Delete existing recruiting settings for this guild + _, err = tx.ExecContext(ctx, "DELETE FROM guild_recruiting WHERE guild_id = ?", guildID) + if err != nil { + return fmt.Errorf("failed to delete existing guild recruiting: %w", err) + } + + // Insert recruiting flags + insertQuery := "INSERT INTO guild_recruiting (guild_id, flag, value) VALUES (?, ?, ?)" + + for flag, value := range flags { + _, err = tx.ExecContext(ctx, insertQuery, guildID, flag, value) + if err != nil { + return fmt.Errorf("failed to insert guild recruiting flag %d: %w", flag, err) + } + } + + // Insert description tags (with negative flag values to distinguish) + for tag, value := range descTags { + _, err = tx.ExecContext(ctx, insertQuery, guildID, -tag-1, value) // Negative to distinguish from flags + if err != nil { + return fmt.Errorf("failed to insert guild recruiting desc tag %d: %w", tag, err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} + +// SavePointHistory saves point history for a member +func (dgm *DatabaseGuildManager) SavePointHistory(ctx context.Context, characterID int32, history []PointHistory) error { + if len(history) == 0 { + return nil + } + + // Use a transaction for atomic updates + tx, err := dgm.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Delete existing history for this character + _, err = tx.ExecContext(ctx, "DELETE FROM guild_point_history WHERE char_id = ?", characterID) + if err != nil { + return fmt.Errorf("failed to delete existing point history: %w", err) + } + + // Insert all history entries + insertQuery := "INSERT INTO guild_point_history (char_id, date, modified_by, comment, points) VALUES (?, ?, ?, ?, ?)" + + for _, entry := range history { + dateTimestamp := entry.Date.Unix() + + _, err = tx.ExecContext(ctx, insertQuery, + characterID, + dateTimestamp, + entry.ModifiedBy, + entry.Comment, + entry.Points, + ) + if err != nil { + return fmt.Errorf("failed to insert point history entry: %w", err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} + +// GetGuildIDByCharacterID returns guild ID for a character +func (dgm *DatabaseGuildManager) GetGuildIDByCharacterID(ctx context.Context, characterID int32) (int32, error) { + query := "SELECT guild_id FROM guild_members WHERE char_id = ?" + + var guildID int32 + err := dgm.db.QueryRowContext(ctx, query, characterID).Scan(&guildID) + if err != nil { + return 0, fmt.Errorf("failed to get guild ID for character %d: %w", characterID, err) + } + + return guildID, nil +} + +// CreateGuild creates a new guild +func (dgm *DatabaseGuildManager) CreateGuild(ctx context.Context, guildData GuildData) (int32, error) { + query := `INSERT INTO guilds (name, motd, level, xp, xp_needed, formed_on) + VALUES (?, ?, ?, ?, ?, ?)` + + formedTimestamp := guildData.FormedDate.Unix() + + result, err := dgm.db.ExecContext(ctx, query, + guildData.Name, + guildData.MOTD, + guildData.Level, + guildData.EXPCurrent, + guildData.EXPToNextLevel, + formedTimestamp, + ) + if err != nil { + return 0, fmt.Errorf("failed to create guild: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return 0, fmt.Errorf("failed to get new guild ID: %w", err) + } + + return int32(id), nil +} + +// DeleteGuild removes a guild and all related data +func (dgm *DatabaseGuildManager) DeleteGuild(ctx context.Context, guildID int32) error { + // Use a transaction for atomic deletion + tx, err := dgm.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Delete related data first (foreign key constraints) + tables := []string{ + "guild_point_history", + "guild_members", + "guild_events", + "guild_ranks", + "guild_permissions", + "guild_event_filters", + "guild_recruiting", + } + + for _, table := range tables { + var query string + if table == "guild_point_history" { + // Special case: need to join with guild_members to get char_ids + query = fmt.Sprintf("DELETE ph FROM %s ph JOIN guild_members gm ON ph.char_id = gm.char_id WHERE gm.guild_id = ?", table) + } else { + query = fmt.Sprintf("DELETE FROM %s WHERE guild_id = ?", table) + } + + _, err = tx.ExecContext(ctx, query, guildID) + if err != nil { + return fmt.Errorf("failed to delete from %s: %w", table, err) + } + } + + // Finally delete the guild itself + _, err = tx.ExecContext(ctx, "DELETE FROM guilds WHERE id = ?", guildID) + if err != nil { + return fmt.Errorf("failed to delete guild: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} + +// GetNextGuildID returns the next available guild ID +func (dgm *DatabaseGuildManager) GetNextGuildID(ctx context.Context) (int32, error) { + query := "SELECT COALESCE(MAX(id), 0) + 1 FROM guilds" + + var nextID int32 + err := dgm.db.QueryRowContext(ctx, query).Scan(&nextID) + if err != nil { + return 0, fmt.Errorf("failed to get next guild ID: %w", err) + } + + return nextID, nil +} + +// GetNextEventID returns the next available event ID for a guild +func (dgm *DatabaseGuildManager) GetNextEventID(ctx context.Context, guildID int32) (int64, error) { + query := "SELECT COALESCE(MAX(event_id), 0) + 1 FROM guild_events WHERE guild_id = ?" + + var nextID int64 + err := dgm.db.QueryRowContext(ctx, query, guildID).Scan(&nextID) + if err != nil { + return 0, fmt.Errorf("failed to get next event ID for guild %d: %w", guildID, err) + } + + return nextID, nil +} + +// EnsureGuildTables creates the guild tables if they don't exist +func (dgm *DatabaseGuildManager) EnsureGuildTables(ctx context.Context) error { + queries := []string{ + `CREATE TABLE IF NOT EXISTS guilds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + motd TEXT, + level INTEGER NOT NULL DEFAULT 1, + xp INTEGER NOT NULL DEFAULT 111, + xp_needed INTEGER NOT NULL DEFAULT 2521, + formed_on INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS guild_members ( + char_id INTEGER NOT NULL, + guild_id INTEGER NOT NULL, + account_id INTEGER NOT NULL DEFAULT 0, + recruiter_id INTEGER NOT NULL DEFAULT 0, + name TEXT NOT NULL, + guild_status INTEGER NOT NULL DEFAULT 0, + points REAL NOT NULL DEFAULT 0.0, + adventure_class INTEGER NOT NULL DEFAULT 0, + adventure_level INTEGER NOT NULL DEFAULT 1, + tradeskill_class INTEGER NOT NULL DEFAULT 0, + tradeskill_level INTEGER NOT NULL DEFAULT 1, + rank INTEGER NOT NULL DEFAULT 7, + member_flags INTEGER NOT NULL DEFAULT 0, + zone TEXT NOT NULL DEFAULT '', + join_date INTEGER NOT NULL, + last_login_date INTEGER NOT NULL, + note TEXT NOT NULL DEFAULT '', + officer_note TEXT NOT NULL DEFAULT '', + recruiter_description TEXT NOT NULL DEFAULT '', + recruiter_picture_data BLOB, + recruiting_show_adventure_class INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (char_id), + FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS guild_events ( + event_id INTEGER NOT NULL, + guild_id INTEGER NOT NULL, + date INTEGER NOT NULL, + type INTEGER NOT NULL, + description TEXT NOT NULL, + locked INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (event_id, guild_id), + FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS guild_ranks ( + guild_id INTEGER NOT NULL, + rank INTEGER NOT NULL, + name TEXT NOT NULL, + PRIMARY KEY (guild_id, rank), + FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS guild_permissions ( + guild_id INTEGER NOT NULL, + rank INTEGER NOT NULL, + permission INTEGER NOT NULL, + value INTEGER NOT NULL, + PRIMARY KEY (guild_id, rank, permission), + FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS guild_event_filters ( + guild_id INTEGER NOT NULL, + event_id INTEGER NOT NULL, + category INTEGER NOT NULL, + value INTEGER NOT NULL, + PRIMARY KEY (guild_id, event_id, category), + FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS guild_recruiting ( + guild_id INTEGER NOT NULL, + flag INTEGER NOT NULL, + value INTEGER NOT NULL, + PRIMARY KEY (guild_id, flag), + FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS guild_point_history ( + char_id INTEGER NOT NULL, + date INTEGER NOT NULL, + modified_by TEXT NOT NULL, + comment TEXT NOT NULL, + points REAL NOT NULL, + PRIMARY KEY (char_id, date), + FOREIGN KEY (char_id) REFERENCES guild_members(char_id) ON DELETE CASCADE + )`, + } + + for i, query := range queries { + _, err := dgm.db.ExecContext(ctx, query) + if err != nil { + return fmt.Errorf("failed to create guild table %d: %w", i+1, err) + } + } + + // Create indexes for better performance + indexes := []string{ + `CREATE INDEX IF NOT EXISTS idx_guild_members_guild_id ON guild_members(guild_id)`, + `CREATE INDEX IF NOT EXISTS idx_guild_members_char_id ON guild_members(char_id)`, + `CREATE INDEX IF NOT EXISTS idx_guild_events_guild_id ON guild_events(guild_id)`, + `CREATE INDEX IF NOT EXISTS idx_guild_events_date ON guild_events(date)`, + `CREATE INDEX IF NOT EXISTS idx_guild_point_history_char_id ON guild_point_history(char_id)`, + `CREATE INDEX IF NOT EXISTS idx_guilds_name ON guilds(name)`, + `CREATE INDEX IF NOT EXISTS idx_guild_members_rank ON guild_members(rank)`, + `CREATE INDEX IF NOT EXISTS idx_guild_members_last_login ON guild_members(last_login_date)`, + } + + for i, query := range indexes { + _, err := dgm.db.ExecContext(ctx, query) + if err != nil { + return fmt.Errorf("failed to create guild index %d: %w", i+1, err) + } + } + + return nil +} \ No newline at end of file diff --git a/internal/guilds/guild.go b/internal/guilds/guild.go new file mode 100644 index 0000000..2687f17 --- /dev/null +++ b/internal/guilds/guild.go @@ -0,0 +1,811 @@ +package guilds + +import ( + "fmt" + "strings" + "time" +) + +// NewGuild creates a new guild instance +func NewGuild() *Guild { + guild := &Guild{ + members: make(map[int32]*GuildMember), + guildEvents: make([]GuildEvent, 0), + permissions: make(map[int8]map[int8]int8), + eventFilters: make(map[int8]map[int8]int8), + recruitingFlags: make(map[int8]int8), + recruitingDescTags: make(map[int8]int8), + ranks: make(map[int8]string), + level: 1, + expCurrent: 111, + expToNextLevel: 2521, + recruitingMinLevel: 1, + recruitingPlayStyle: RecruitingPlayStyleNone, + nextEventID: 1, + lastModified: time.Now(), + } + + // Initialize default recruiting flags + guild.recruitingFlags[RecruitingFlagTraining] = 0 + guild.recruitingFlags[RecruitingFlagFighters] = 0 + guild.recruitingFlags[RecruitingFlagPriests] = 0 + guild.recruitingFlags[RecruitingFlagScouts] = 0 + guild.recruitingFlags[RecruitingFlagMages] = 0 + guild.recruitingFlags[RecruitingFlagTradeskillers] = 0 + + // Initialize default description tags + guild.recruitingDescTags[0] = RecruitingDescTagNone + guild.recruitingDescTags[1] = RecruitingDescTagNone + guild.recruitingDescTags[2] = RecruitingDescTagNone + guild.recruitingDescTags[3] = RecruitingDescTagNone + + // Initialize default bank names + guild.banks[0].Name = "Bank 1" + guild.banks[1].Name = "Bank 2" + guild.banks[2].Name = "Bank 3" + guild.banks[3].Name = "Bank 4" + + // Initialize default rank names + for rank, name := range DefaultRankNames { + guild.ranks[rank] = name + } + + return guild +} + +// SetID sets the guild ID +func (g *Guild) SetID(id int32) { + g.mu.Lock() + defer g.mu.Unlock() + g.id = id +} + +// SetName sets the guild name +func (g *Guild) SetName(name string, sendPacket bool) { + g.mu.Lock() + defer g.mu.Unlock() + + if len(name) > MaxGuildNameLength { + name = name[:MaxGuildNameLength] + } + g.name = name + g.lastModified = time.Now() + + if sendPacket { + g.saveNeeded = true + } +} + +// SetLevel sets the guild level +func (g *Guild) SetLevel(level int8, sendPacket bool) { + g.mu.Lock() + defer g.mu.Unlock() + + if level > MaxGuildLevel { + level = MaxGuildLevel + } + if level < 1 { + level = 1 + } + + g.level = level + g.lastModified = time.Now() + + if sendPacket { + g.saveNeeded = true + } +} + +// SetFormedDate sets the guild formation date +func (g *Guild) SetFormedDate(formedDate time.Time) { + g.mu.Lock() + defer g.mu.Unlock() + g.formedDate = formedDate +} + +// SetMOTD sets the guild message of the day +func (g *Guild) SetMOTD(motd string, sendPacket bool) { + g.mu.Lock() + defer g.mu.Unlock() + + if len(motd) > MaxMOTDLength { + motd = motd[:MaxMOTDLength] + } + g.motd = motd + g.lastModified = time.Now() + + if sendPacket { + g.saveNeeded = true + } +} + +// GetID returns the guild ID +func (g *Guild) GetID() int32 { + g.mu.RLock() + defer g.mu.RUnlock() + return g.id +} + +// GetName returns the guild name +func (g *Guild) GetName() string { + g.mu.RLock() + defer g.mu.RUnlock() + return g.name +} + +// GetLevel returns the guild level +func (g *Guild) GetLevel() int8 { + g.mu.RLock() + defer g.mu.RUnlock() + return g.level +} + +// GetFormedDate returns the guild formation date +func (g *Guild) GetFormedDate() time.Time { + g.mu.RLock() + defer g.mu.RUnlock() + return g.formedDate +} + +// GetMOTD returns the guild message of the day +func (g *Guild) GetMOTD() string { + g.mu.RLock() + defer g.mu.RUnlock() + return g.motd +} + +// SetEXPCurrent sets the current guild experience +func (g *Guild) SetEXPCurrent(exp int64, sendPacket bool) { + g.mu.Lock() + defer g.mu.Unlock() + + g.expCurrent = exp + g.lastModified = time.Now() + + if sendPacket { + g.saveNeeded = true + } +} + +// AddEXPCurrent adds experience to the guild +func (g *Guild) AddEXPCurrent(exp int64, sendPacket bool) { + g.mu.Lock() + defer g.mu.Unlock() + + g.expCurrent += exp + g.lastModified = time.Now() + + if sendPacket { + g.saveNeeded = true + } +} + +// GetEXPCurrent returns the current guild experience +func (g *Guild) GetEXPCurrent() int64 { + g.mu.RLock() + defer g.mu.RUnlock() + return g.expCurrent +} + +// SetEXPToNextLevel sets the experience needed for next level +func (g *Guild) SetEXPToNextLevel(exp int64, sendPacket bool) { + g.mu.Lock() + defer g.mu.Unlock() + + g.expToNextLevel = exp + g.lastModified = time.Now() + + if sendPacket { + g.saveNeeded = true + } +} + +// GetEXPToNextLevel returns the experience needed for next level +func (g *Guild) GetEXPToNextLevel() int64 { + g.mu.RLock() + defer g.mu.RUnlock() + return g.expToNextLevel +} + +// SetRecruitingShortDesc sets the short recruiting description +func (g *Guild) SetRecruitingShortDesc(desc string, sendPacket bool) { + g.mu.Lock() + defer g.mu.Unlock() + + g.recruitingShortDesc = desc + g.lastModified = time.Now() + + if sendPacket { + g.recruitingSaveNeeded = true + } +} + +// GetRecruitingShortDesc returns the short recruiting description +func (g *Guild) GetRecruitingShortDesc() string { + g.mu.RLock() + defer g.mu.RUnlock() + return g.recruitingShortDesc +} + +// SetRecruitingFullDesc sets the full recruiting description +func (g *Guild) SetRecruitingFullDesc(desc string, sendPacket bool) { + g.mu.Lock() + defer g.mu.Unlock() + + if len(desc) > MaxRecruitingDescLength { + desc = desc[:MaxRecruitingDescLength] + } + g.recruitingFullDesc = desc + g.lastModified = time.Now() + + if sendPacket { + g.recruitingSaveNeeded = true + } +} + +// GetRecruitingFullDesc returns the full recruiting description +func (g *Guild) GetRecruitingFullDesc() string { + g.mu.RLock() + defer g.mu.RUnlock() + return g.recruitingFullDesc +} + +// SetRecruitingMinLevel sets the minimum level for recruiting +func (g *Guild) SetRecruitingMinLevel(level int8, sendPacket bool) { + g.mu.Lock() + defer g.mu.Unlock() + + g.recruitingMinLevel = level + g.lastModified = time.Now() + + if sendPacket { + g.recruitingSaveNeeded = true + } +} + +// GetRecruitingMinLevel returns the minimum level for recruiting +func (g *Guild) GetRecruitingMinLevel() int8 { + g.mu.RLock() + defer g.mu.RUnlock() + return g.recruitingMinLevel +} + +// SetRecruitingPlayStyle sets the recruiting play style +func (g *Guild) SetRecruitingPlayStyle(playStyle int8, sendPacket bool) { + g.mu.Lock() + defer g.mu.Unlock() + + g.recruitingPlayStyle = playStyle + g.lastModified = time.Now() + + if sendPacket { + g.recruitingSaveNeeded = true + } +} + +// GetRecruitingPlayStyle returns the recruiting play style +func (g *Guild) GetRecruitingPlayStyle() int8 { + g.mu.RLock() + defer g.mu.RUnlock() + return g.recruitingPlayStyle +} + +// SetRecruitingDescTag sets a recruiting description tag +func (g *Guild) SetRecruitingDescTag(index, tag int8, sendPacket bool) bool { + g.mu.Lock() + defer g.mu.Unlock() + + if index < 0 || index > 3 { + return false + } + + g.recruitingDescTags[index] = tag + g.lastModified = time.Now() + + if sendPacket { + g.recruitingSaveNeeded = true + } + + return true +} + +// GetRecruitingDescTag returns a recruiting description tag +func (g *Guild) GetRecruitingDescTag(index int8) int8 { + g.mu.RLock() + defer g.mu.RUnlock() + + if tag, exists := g.recruitingDescTags[index]; exists { + return tag + } + return RecruitingDescTagNone +} + +// SetPermission sets a guild permission for a rank +func (g *Guild) SetPermission(rank, permission, value int8, sendPacket, saveNeeded bool) bool { + g.mu.Lock() + defer g.mu.Unlock() + + if rank < RankLeader || rank > RankRecruit { + return false + } + + if g.permissions[rank] == nil { + g.permissions[rank] = make(map[int8]int8) + } + + g.permissions[rank][permission] = value + g.lastModified = time.Now() + + if saveNeeded { + g.ranksSaveNeeded = true + } + + return true +} + +// GetPermission returns a guild permission for a rank +func (g *Guild) GetPermission(rank, permission int8) int8 { + g.mu.RLock() + defer g.mu.RUnlock() + + if rankPerms, exists := g.permissions[rank]; exists { + if value, exists := rankPerms[permission]; exists { + return value + } + } + + // Return default permission based on rank + return g.getDefaultPermission(rank, permission) +} + +// SetEventFilter sets an event filter +func (g *Guild) SetEventFilter(eventID, category, value int8, sendPacket, saveNeeded bool) bool { + g.mu.Lock() + defer g.mu.Unlock() + + if g.eventFilters[eventID] == nil { + g.eventFilters[eventID] = make(map[int8]int8) + } + + g.eventFilters[eventID][category] = value + g.lastModified = time.Now() + + if saveNeeded { + g.eventFiltersSaveNeeded = true + } + + return true +} + +// GetEventFilter returns an event filter +func (g *Guild) GetEventFilter(eventID, category int8) int8 { + g.mu.RLock() + defer g.mu.RUnlock() + + if eventFilters, exists := g.eventFilters[eventID]; exists { + if value, exists := eventFilters[category]; exists { + return value + } + } + + return 1 // Default to enabled +} + +// GetNumUniqueAccounts returns the number of unique accounts in the guild +func (g *Guild) GetNumUniqueAccounts() int32 { + g.mu.RLock() + defer g.mu.RUnlock() + + accounts := make(map[int32]bool) + for _, member := range g.members { + accounts[member.AccountID] = true + } + + return int32(len(accounts)) +} + +// GetNumRecruiters returns the number of recruiters in the guild +func (g *Guild) GetNumRecruiters() int32 { + g.mu.RLock() + defer g.mu.RUnlock() + + count := int32(0) + for _, member := range g.members { + if member.MemberFlags&MemberFlagRecruitingForGuild != 0 { + count++ + } + } + + return count +} + +// GetNextRecruiterID returns the next available recruiter ID +func (g *Guild) GetNextRecruiterID() int32 { + g.mu.RLock() + defer g.mu.RUnlock() + + maxID := int32(0) + for _, member := range g.members { + if member.RecruiterID > maxID { + maxID = member.RecruiterID + } + } + + return maxID + 1 +} + +// GetNextEventID returns the next available event ID +func (g *Guild) GetNextEventID() int64 { + g.mu.Lock() + defer g.mu.Unlock() + + eventID := g.nextEventID + g.nextEventID++ + return eventID +} + +// GetGuildMember returns a guild member by character ID +func (g *Guild) GetGuildMember(characterID int32) *GuildMember { + g.mu.RLock() + defer g.mu.RUnlock() + + return g.members[characterID] +} + +// GetGuildMemberByName returns a guild member by name +func (g *Guild) GetGuildMemberByName(playerName string) *GuildMember { + g.mu.RLock() + defer g.mu.RUnlock() + + for _, member := range g.members { + if strings.EqualFold(member.Name, playerName) { + return member + } + } + + return nil +} + +// GetGuildRecruiters returns all guild recruiters +func (g *Guild) GetGuildRecruiters() []*GuildMember { + g.mu.RLock() + defer g.mu.RUnlock() + + var recruiters []*GuildMember + for _, member := range g.members { + if member.MemberFlags&MemberFlagRecruitingForGuild != 0 { + recruiters = append(recruiters, member) + } + } + + return recruiters +} + +// GetGuildEvent returns a guild event by ID +func (g *Guild) GetGuildEvent(eventID int64) *GuildEvent { + g.mu.RLock() + defer g.mu.RUnlock() + + for i := range g.guildEvents { + if g.guildEvents[i].EventID == eventID { + return &g.guildEvents[i] + } + } + + return nil +} + +// SetRankName sets a custom rank name +func (g *Guild) SetRankName(rank int8, name string, sendPacket bool) bool { + g.mu.Lock() + defer g.mu.Unlock() + + if rank < RankLeader || rank > RankRecruit { + return false + } + + g.ranks[rank] = name + g.lastModified = time.Now() + + if sendPacket { + g.ranksSaveNeeded = true + } + + return true +} + +// GetRankName returns the name for a rank +func (g *Guild) GetRankName(rank int8) string { + g.mu.RLock() + defer g.mu.RUnlock() + + if name, exists := g.ranks[rank]; exists { + return name + } + + // Return default rank name + if defaultName, exists := DefaultRankNames[rank]; exists { + return defaultName + } + + return "Unknown" +} + +// SetRecruitingFlag sets a recruiting flag +func (g *Guild) SetRecruitingFlag(flag, value int8, sendPacket bool) bool { + g.mu.Lock() + defer g.mu.Unlock() + + if flag < RecruitingFlagTraining || flag > RecruitingFlagTradeskillers { + return false + } + + g.recruitingFlags[flag] = value + g.lastModified = time.Now() + + if sendPacket { + g.recruitingSaveNeeded = true + } + + return true +} + +// GetRecruitingFlag returns a recruiting flag +func (g *Guild) GetRecruitingFlag(flag int8) int8 { + g.mu.RLock() + defer g.mu.RUnlock() + + if value, exists := g.recruitingFlags[flag]; exists { + return value + } + + return 0 +} + +// AddNewGuildMember adds a new member to the guild +func (g *Guild) AddNewGuildMember(characterID int32, invitedBy string, joinDate time.Time, rank int8) bool { + g.mu.Lock() + defer g.mu.Unlock() + + // Check if member already exists + if _, exists := g.members[characterID]; exists { + return false + } + + member := &GuildMember{ + CharacterID: characterID, + Rank: rank, + JoinDate: joinDate, + PointHistory: make([]PointHistory, 0), + } + + g.members[characterID] = member + g.memberSaveNeeded = true + g.lastModified = time.Now() + + // Add guild event + g.addNewGuildEventNoLock(EventMemberJoins, fmt.Sprintf("%s has joined the guild", member.Name), time.Now(), true) + + return true +} + +// RemoveGuildMember removes a member from the guild +func (g *Guild) RemoveGuildMember(characterID int32, sendPacket bool) { + g.mu.Lock() + defer g.mu.Unlock() + + if member, exists := g.members[characterID]; exists { + // Add guild event + g.addNewGuildEventNoLock(EventMemberLeaves, fmt.Sprintf("%s has left the guild", member.Name), time.Now(), sendPacket) + + delete(g.members, characterID) + g.memberSaveNeeded = true + g.lastModified = time.Now() + } +} + +// PromoteGuildMember promotes a guild member +func (g *Guild) PromoteGuildMember(characterID int32, promoterName string, sendPacket bool) bool { + g.mu.Lock() + defer g.mu.Unlock() + + member, exists := g.members[characterID] + if !exists || member.Rank <= RankLeader { + return false + } + + oldRank := member.Rank + member.Rank-- + g.memberSaveNeeded = true + g.lastModified = time.Now() + + // Add guild event + g.addNewGuildEventNoLock(EventMemberPromoted, + fmt.Sprintf("%s has been promoted from %s to %s by %s", + member.Name, g.getRankNameNoLock(oldRank), g.getRankNameNoLock(member.Rank), promoterName), + time.Now(), sendPacket) + + return true +} + +// DemoteGuildMember demotes a guild member +func (g *Guild) DemoteGuildMember(characterID int32, demoterName string, sendPacket bool) bool { + g.mu.Lock() + defer g.mu.Unlock() + + member, exists := g.members[characterID] + if !exists || member.Rank >= RankRecruit { + return false + } + + oldRank := member.Rank + member.Rank++ + g.memberSaveNeeded = true + g.lastModified = time.Now() + + // Add guild event + g.addNewGuildEventNoLock(EventMemberDemoted, + fmt.Sprintf("%s has been demoted from %s to %s by %s", + member.Name, g.getRankNameNoLock(oldRank), g.getRankNameNoLock(member.Rank), demoterName), + time.Now(), sendPacket) + + return true +} + +// AddPointsToGuildMember adds points to a specific guild member +func (g *Guild) AddPointsToGuildMember(characterID int32, points float64, modifiedBy, comment string, sendPacket bool) bool { + g.mu.Lock() + defer g.mu.Unlock() + + member, exists := g.members[characterID] + if !exists { + return false + } + + member.Points += points + + // Add to point history + if len(member.PointHistory) >= MaxPointHistory { + // Remove oldest entry + member.PointHistory = member.PointHistory[1:] + } + + member.PointHistory = append(member.PointHistory, PointHistory{ + Date: time.Now(), + ModifiedBy: modifiedBy, + Comment: comment, + Points: points, + SaveNeeded: true, + }) + + g.pointsHistorySaveNeeded = true + g.lastModified = time.Now() + + return true +} + +// AddNewGuildEvent adds a new event to the guild +func (g *Guild) AddNewGuildEvent(eventType int32, description string, date time.Time, sendPacket bool) { + g.mu.Lock() + defer g.mu.Unlock() + g.addNewGuildEventNoLock(eventType, description, date, sendPacket) +} + +// addNewGuildEventNoLock is the internal implementation without locking +func (g *Guild) addNewGuildEventNoLock(eventType int32, description string, date time.Time, sendPacket bool) { + event := GuildEvent{ + EventID: g.nextEventID, + Date: date, + Type: eventType, + Description: description, + Locked: 0, + SaveNeeded: true, + } + + g.nextEventID++ + + // Add to front of events list (newest first) + g.guildEvents = append([]GuildEvent{event}, g.guildEvents...) + + // Limit event history + if len(g.guildEvents) > MaxEvents { + g.guildEvents = g.guildEvents[:MaxEvents] + } + + g.eventsSaveNeeded = true + g.lastModified = time.Now() +} + +// GetGuildInfo returns basic guild information +func (g *Guild) GetGuildInfo() GuildInfo { + g.mu.RLock() + defer g.mu.RUnlock() + + return GuildInfo{ + ID: g.id, + Name: g.name, + Level: g.level, + FormedDate: g.formedDate, + MOTD: g.motd, + MemberCount: len(g.members), + RecruiterCount: int(g.getNumRecruitersNoLock()), + RecruitingShortDesc: g.recruitingShortDesc, + RecruitingFullDesc: g.recruitingFullDesc, + RecruitingMinLevel: g.recruitingMinLevel, + RecruitingPlayStyle: g.recruitingPlayStyle, + IsRecruiting: g.getNumRecruitersNoLock() > 0, + } +} + +// GetAllMembers returns all guild members +func (g *Guild) GetAllMembers() []*GuildMember { + g.mu.RLock() + defer g.mu.RUnlock() + + members := make([]*GuildMember, 0, len(g.members)) + for _, member := range g.members { + members = append(members, member) + } + + return members +} + +// Save flag methods +func (g *Guild) SetSaveNeeded(val bool) { + g.mu.Lock() + defer g.mu.Unlock() + g.saveNeeded = val +} + +func (g *Guild) GetSaveNeeded() bool { + g.mu.RLock() + defer g.mu.RUnlock() + return g.saveNeeded +} + +// Helper methods (internal, no lock versions) + +func (g *Guild) getDefaultPermission(rank, permission int8) int8 { + // Leaders have all permissions by default + if rank == RankLeader { + return 1 + } + + // Default permissions based on rank and permission type + switch permission { + case PermissionSeeGuildChat, PermissionSpeakInGuildChat: + return 1 // All members can see and speak in guild chat + case PermissionReceivePoints: + return 1 // All members can receive points + case PermissionSeeOfficerChat, PermissionSpeakInOfficerChat: + if rank <= RankOfficer { + return 1 + } + case PermissionInvite: + if rank <= RankSeniorMember { + return 1 + } + } + + return 0 // Default to no permission +} + +func (g *Guild) getRankNameNoLock(rank int8) string { + if name, exists := g.ranks[rank]; exists { + return name + } + if defaultName, exists := DefaultRankNames[rank]; exists { + return defaultName + } + return "Unknown" +} + +func (g *Guild) getNumRecruitersNoLock() int32 { + count := int32(0) + for _, member := range g.members { + if member.MemberFlags&MemberFlagRecruitingForGuild != 0 { + count++ + } + } + return count +} \ No newline at end of file diff --git a/internal/guilds/interfaces.go b/internal/guilds/interfaces.go new file mode 100644 index 0000000..3f66828 --- /dev/null +++ b/internal/guilds/interfaces.go @@ -0,0 +1,342 @@ +package guilds + +import "context" + +// GuildDatabase defines database operations for guilds +type GuildDatabase interface { + // LoadGuilds retrieves all guilds from database + LoadGuilds(ctx context.Context) ([]GuildData, error) + + // LoadGuild retrieves a specific guild from database + LoadGuild(ctx context.Context, guildID int32) (*GuildData, error) + + // LoadGuildMembers retrieves all members for a guild + LoadGuildMembers(ctx context.Context, guildID int32) ([]GuildMemberData, error) + + // LoadGuildEvents retrieves events for a guild + LoadGuildEvents(ctx context.Context, guildID int32) ([]GuildEventData, error) + + // LoadGuildRanks retrieves custom rank names for a guild + LoadGuildRanks(ctx context.Context, guildID int32) ([]GuildRankData, error) + + // LoadGuildPermissions retrieves permissions for a guild + LoadGuildPermissions(ctx context.Context, guildID int32) ([]GuildPermissionData, error) + + // LoadGuildEventFilters retrieves event filters for a guild + LoadGuildEventFilters(ctx context.Context, guildID int32) ([]GuildEventFilterData, error) + + // LoadGuildRecruiting retrieves recruiting settings for a guild + LoadGuildRecruiting(ctx context.Context, guildID int32) ([]GuildRecruitingData, error) + + // LoadPointHistory retrieves point history for a member + LoadPointHistory(ctx context.Context, characterID int32) ([]PointHistoryData, error) + + // SaveGuild saves guild basic data + SaveGuild(ctx context.Context, guild *Guild) error + + // SaveGuildMembers saves all guild members + SaveGuildMembers(ctx context.Context, guildID int32, members []*GuildMember) error + + // SaveGuildEvents saves guild events + SaveGuildEvents(ctx context.Context, guildID int32, events []GuildEvent) error + + // SaveGuildRanks saves guild rank names + SaveGuildRanks(ctx context.Context, guildID int32, ranks map[int8]string) error + + // SaveGuildPermissions saves guild permissions + SaveGuildPermissions(ctx context.Context, guildID int32, permissions map[int8]map[int8]int8) error + + // SaveGuildEventFilters saves guild event filters + SaveGuildEventFilters(ctx context.Context, guildID int32, filters map[int8]map[int8]int8) error + + // SaveGuildRecruiting saves guild recruiting settings + SaveGuildRecruiting(ctx context.Context, guildID int32, flags, descTags map[int8]int8) error + + // SavePointHistory saves point history for a member + SavePointHistory(ctx context.Context, characterID int32, history []PointHistory) error + + // GetGuildIDByCharacterID returns guild ID for a character + GetGuildIDByCharacterID(ctx context.Context, characterID int32) (int32, error) + + // CreateGuild creates a new guild + CreateGuild(ctx context.Context, guildData GuildData) (int32, error) + + // DeleteGuild removes a guild and all related data + DeleteGuild(ctx context.Context, guildID int32) error + + // GetNextGuildID returns the next available guild ID + GetNextGuildID(ctx context.Context) (int32, error) + + // GetNextEventID returns the next available event ID for a guild + GetNextEventID(ctx context.Context, guildID int32) (int64, error) +} + +// ClientManager handles client communication for guilds +type ClientManager interface { + // SendGuildUpdate sends guild information update to client + SendGuildUpdate(characterID int32, guild *Guild) error + + // SendGuildMemberList sends guild member list to client + SendGuildMemberList(characterID int32, members []MemberInfo) error + + // SendGuildMember sends single member info to client + SendGuildMember(characterID int32, member *GuildMember) error + + // SendGuildMOTD sends message of the day to client + SendGuildMOTD(characterID int32, motd string) error + + // SendGuildEvent sends guild event to client + SendGuildEvent(characterID int32, event *GuildEvent) error + + // SendGuildEventList sends guild event list to client + SendGuildEventList(characterID int32, events []GuildEventInfo) error + + // SendGuildChatMessage sends guild chat message to client + SendGuildChatMessage(characterID int32, senderName, message string, language int8) error + + // SendOfficerChatMessage sends officer chat message to client + SendOfficerChatMessage(characterID int32, senderName, message string, language int8) error + + // SendGuildInvite sends guild invitation to client + SendGuildInvite(characterID int32, invite GuildInvite) error + + // SendGuildRecruitingInfo sends recruiting information to client + SendGuildRecruitingInfo(characterID int32, info RecruitingInfo) error + + // SendGuildPermissions sends guild permissions to client + SendGuildPermissions(characterID int32, permissions map[int8]map[int8]int8) error + + // IsClientOnline checks if a character is currently online + IsClientOnline(characterID int32) bool + + // GetClientLanguage returns the language setting for a client + GetClientLanguage(characterID int32) int8 +} + +// PlayerManager provides player information for guilds +type PlayerManager interface { + // GetPlayerInfo retrieves basic player information + GetPlayerInfo(characterID int32) (PlayerInfo, error) + + // IsPlayerOnline checks if a player is currently online + IsPlayerOnline(characterID int32) bool + + // GetPlayerZone returns the current zone for a player + GetPlayerZone(characterID int32) string + + // GetPlayerLevel returns player's current level + GetPlayerLevel(characterID int32) (int8, int8) // adventure, tradeskill + + // GetPlayerClass returns player's current class + GetPlayerClass(characterID int32) (int8, int8) // adventure, tradeskill + + // GetPlayerName returns player's character name + GetPlayerName(characterID int32) string + + // ValidatePlayerExists checks if a player exists + ValidatePlayerExists(characterName string) (int32, error) + + // GetAccountID returns the account ID for a character + GetAccountID(characterID int32) int32 +} + +// GuildEventHandler handles guild-related events +type GuildEventHandler interface { + // OnGuildCreated called when a guild is created + OnGuildCreated(guild *Guild) + + // OnGuildDeleted called when a guild is deleted + OnGuildDeleted(guildID int32, guildName string) + + // OnMemberJoined called when a member joins a guild + OnMemberJoined(guild *Guild, member *GuildMember, inviterName string) + + // OnMemberLeft called when a member leaves a guild + OnMemberLeft(guild *Guild, member *GuildMember, reason string) + + // OnMemberPromoted called when a member is promoted + OnMemberPromoted(guild *Guild, member *GuildMember, oldRank, newRank int8, promoterName string) + + // OnMemberDemoted called when a member is demoted + OnMemberDemoted(guild *Guild, member *GuildMember, oldRank, newRank int8, demoterName string) + + // OnPointsAwarded called when points are awarded to members + OnPointsAwarded(guild *Guild, members []int32, points float64, comment, awardedBy string) + + // OnGuildEvent called when a guild event occurs + OnGuildEvent(guild *Guild, event *GuildEvent) + + // OnGuildLevelUp called when a guild levels up + OnGuildLevelUp(guild *Guild, oldLevel, newLevel int8) + + // OnGuildChatMessage called when a guild chat message is sent + OnGuildChatMessage(guild *Guild, senderID int32, senderName, message string, language int8) + + // OnOfficerChatMessage called when an officer chat message is sent + OnOfficerChatMessage(guild *Guild, senderID int32, senderName, message string, language int8) +} + +// LogHandler provides logging functionality +type LogHandler interface { + // LogDebug logs debug messages + LogDebug(category, message string, args ...interface{}) + + // LogInfo logs informational messages + LogInfo(category, message string, args ...interface{}) + + // LogError logs error messages + LogError(category, message string, args ...interface{}) + + // LogWarning logs warning messages + LogWarning(category, message string, args ...interface{}) +} + +// PlayerInfo contains basic player information +type PlayerInfo struct { + CharacterID int32 `json:"character_id"` + CharacterName string `json:"character_name"` + AccountID int32 `json:"account_id"` + AdventureLevel int8 `json:"adventure_level"` + AdventureClass int8 `json:"adventure_class"` + TradeskillLevel int8 `json:"tradeskill_level"` + TradeskillClass int8 `json:"tradeskill_class"` + Zone string `json:"zone"` + IsOnline bool `json:"is_online"` + LastLogin time.Time `json:"last_login"` +} + +// GuildAware interface for entities that can participate in guilds +type GuildAware interface { + GetCharacterID() int32 + GetGuildID() int32 + GetGuildRank() int8 + IsInGuild() bool + HasGuildPermission(permission int8) bool +} + +// EntityGuildAdapter adapts entities to work with guild system +type EntityGuildAdapter struct { + entity interface { + GetID() int32 + // Add other entity methods as needed + } + guildManager *GuildManager +} + +// GetCharacterID returns the character ID from the adapted entity +func (a *EntityGuildAdapter) GetCharacterID() int32 { + return a.entity.GetID() +} + +// GetGuildID returns the guild ID for the character +func (a *EntityGuildAdapter) GetGuildID() int32 { + // TODO: Implement guild lookup through guild manager + return 0 +} + +// GetGuildRank returns the guild rank for the character +func (a *EntityGuildAdapter) GetGuildRank() int8 { + // TODO: Implement rank lookup through guild manager + return RankRecruit +} + +// IsInGuild checks if the character is in a guild +func (a *EntityGuildAdapter) IsInGuild() bool { + return a.GetGuildID() > 0 +} + +// HasGuildPermission checks if the character has a specific guild permission +func (a *EntityGuildAdapter) HasGuildPermission(permission int8) bool { + // TODO: Implement permission checking through guild manager + return false +} + +// InviteManager handles guild invitations +type InviteManager interface { + // SendInvite sends a guild invitation + SendInvite(guildID, characterID, inviterID int32, rank int8) error + + // AcceptInvite accepts a guild invitation + AcceptInvite(characterID, guildID int32) error + + // DeclineInvite declines a guild invitation + DeclineInvite(characterID, guildID int32) error + + // GetPendingInvites returns pending invites for a character + GetPendingInvites(characterID int32) ([]GuildInvite, error) + + // ClearExpiredInvites removes expired invitations + ClearExpiredInvites() error +} + +// PermissionChecker provides permission validation +type PermissionChecker interface { + // CanInvite checks if a member can invite players + CanInvite(guild *Guild, memberRank int8) bool + + // CanRemoveMember checks if a member can remove other members + CanRemoveMember(guild *Guild, memberRank, targetRank int8) bool + + // CanPromote checks if a member can promote other members + CanPromote(guild *Guild, memberRank, targetRank int8) bool + + // CanDemote checks if a member can demote other members + CanDemote(guild *Guild, memberRank, targetRank int8) bool + + // CanEditPermissions checks if a member can edit guild permissions + CanEditPermissions(guild *Guild, memberRank int8) bool + + // CanUseBankSlot checks if a member can access a specific bank slot + CanUseBankSlot(guild *Guild, memberRank int8, bankSlot int, action string) bool + + // CanSpeakInOfficerChat checks if a member can speak in officer chat + CanSpeakInOfficerChat(guild *Guild, memberRank int8) bool + + // CanAssignPoints checks if a member can assign guild points + CanAssignPoints(guild *Guild, memberRank int8) bool +} + +// NotificationManager handles guild notifications +type NotificationManager interface { + // NotifyMemberLogin notifies guild of member login + NotifyMemberLogin(guild *Guild, member *GuildMember) + + // NotifyMemberLogout notifies guild of member logout + NotifyMemberLogout(guild *Guild, member *GuildMember) + + // NotifyGuildMessage sends a message to all guild members + NotifyGuildMessage(guild *Guild, eventType int8, message string, args ...interface{}) + + // NotifyOfficers sends a message to officers only + NotifyOfficers(guild *Guild, message string, args ...interface{}) + + // NotifyGuildUpdate notifies guild members of guild changes + NotifyGuildUpdate(guild *Guild) +} + +// BankManager handles guild bank operations +type BankManager interface { + // GetBankContents returns contents of a guild bank + GetBankContents(guildID int32, bankSlot int) ([]BankItem, error) + + // DepositItem deposits an item into guild bank + DepositItem(guildID int32, bankSlot int, item BankItem, depositorID int32) error + + // WithdrawItem withdraws an item from guild bank + WithdrawItem(guildID int32, bankSlot int, itemSlot int, withdrawerID int32) error + + // LogBankEvent logs a bank event + LogBankEvent(guildID int32, bankSlot int, eventType int32, description string) error + + // GetBankEventHistory returns bank event history + GetBankEventHistory(guildID int32, bankSlot int) ([]GuildBankEvent, error) +} + +// BankItem represents an item in the guild bank +type BankItem struct { + Slot int `json:"slot"` + ItemID int32 `json:"item_id"` + Quantity int32 `json:"quantity"` + DepositorID int32 `json:"depositor_id"` + DepositDate time.Time `json:"deposit_date"` +} \ No newline at end of file diff --git a/internal/guilds/manager.go b/internal/guilds/manager.go new file mode 100644 index 0000000..fd70aab --- /dev/null +++ b/internal/guilds/manager.go @@ -0,0 +1,909 @@ +package guilds + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + "time" +) + +// NewGuildList creates a new guild list instance +func NewGuildList() *GuildList { + return &GuildList{ + guilds: make(map[int32]*Guild), + } +} + +// AddGuild adds a guild to the list +func (gl *GuildList) AddGuild(guild *Guild) { + gl.mu.Lock() + defer gl.mu.Unlock() + gl.guilds[guild.GetID()] = guild +} + +// GetGuild retrieves a guild by ID +func (gl *GuildList) GetGuild(guildID int32) *Guild { + gl.mu.RLock() + defer gl.mu.RUnlock() + return gl.guilds[guildID] +} + +// RemoveGuild removes a guild from the list +func (gl *GuildList) RemoveGuild(guildID int32) { + gl.mu.Lock() + defer gl.mu.Unlock() + delete(gl.guilds, guildID) +} + +// GetAllGuilds returns all guilds +func (gl *GuildList) GetAllGuilds() []*Guild { + gl.mu.RLock() + defer gl.mu.RUnlock() + + guilds := make([]*Guild, 0, len(gl.guilds)) + for _, guild := range gl.guilds { + guilds = append(guilds, guild) + } + + return guilds +} + +// GetGuildCount returns the number of guilds +func (gl *GuildList) GetGuildCount() int { + gl.mu.RLock() + defer gl.mu.RUnlock() + return len(gl.guilds) +} + +// FindGuildByName finds a guild by name (case-insensitive) +func (gl *GuildList) FindGuildByName(name string) *Guild { + gl.mu.RLock() + defer gl.mu.RUnlock() + + for _, guild := range gl.guilds { + if strings.EqualFold(guild.GetName(), name) { + return guild + } + } + + return nil +} + +// NewGuildManager creates a new guild manager instance +func NewGuildManager(database GuildDatabase, clientManager ClientManager, playerManager PlayerManager) *GuildManager { + return &GuildManager{ + guildList: NewGuildList(), + database: database, + clientManager: clientManager, + playerManager: playerManager, + } +} + +// SetEventHandler sets the guild event handler +func (gm *GuildManager) SetEventHandler(handler GuildEventHandler) { + gm.eventHandler = handler +} + +// SetLogger sets the logger for the manager +func (gm *GuildManager) SetLogger(logger LogHandler) { + gm.logger = logger +} + +// Initialize loads all guilds from the database +func (gm *GuildManager) Initialize(ctx context.Context) error { + // Load all guilds + guildData, err := gm.database.LoadGuilds(ctx) + if err != nil { + return fmt.Errorf("failed to load guilds: %w", err) + } + + for _, data := range guildData { + guild, err := gm.loadGuildFromData(ctx, data) + if err != nil { + if gm.logger != nil { + gm.logger.LogError("guilds", "Failed to load guild %d (%s): %v", data.ID, data.Name, err) + } + continue + } + + gm.guildList.AddGuild(guild) + + if gm.logger != nil { + gm.logger.LogDebug("guilds", "Loaded guild %d (%s) with %d members", + guild.GetID(), guild.GetName(), len(guild.GetAllMembers())) + } + } + + if gm.logger != nil { + gm.logger.LogInfo("guilds", "Loaded %d guilds", gm.guildList.GetGuildCount()) + } + + return nil +} + +// LoadGuild loads a specific guild by ID +func (gm *GuildManager) LoadGuild(ctx context.Context, guildID int32) (*Guild, error) { + // Check if already loaded + if guild := gm.guildList.GetGuild(guildID); guild != nil { + return guild, nil + } + + // Load from database + guildData, err := gm.database.LoadGuild(ctx, guildID) + if err != nil { + return nil, fmt.Errorf("failed to load guild data: %w", err) + } + + guild, err := gm.loadGuildFromData(ctx, *guildData) + if err != nil { + return nil, fmt.Errorf("failed to create guild from data: %w", err) + } + + gm.guildList.AddGuild(guild) + return guild, nil +} + +// CreateGuild creates a new guild +func (gm *GuildManager) CreateGuild(ctx context.Context, name, motd string, leaderCharacterID int32) (*Guild, error) { + // Validate guild name + if err := gm.validateGuildName(name); err != nil { + return nil, err + } + + // Check if guild name already exists + if gm.guildList.FindGuildByName(name) != nil { + return nil, fmt.Errorf("guild name '%s' already exists", name) + } + + // Get leader player info + leaderInfo, err := gm.playerManager.GetPlayerInfo(leaderCharacterID) + if err != nil { + return nil, fmt.Errorf("failed to get leader info: %w", err) + } + + // Create guild data + guildData := GuildData{ + Name: name, + MOTD: motd, + Level: 1, + EXPCurrent: 111, + EXPToNextLevel: 2521, + FormedDate: time.Now(), + } + + // Save to database + guildID, err := gm.database.CreateGuild(ctx, guildData) + if err != nil { + return nil, fmt.Errorf("failed to create guild in database: %w", err) + } + + guildData.ID = guildID + + // Create guild instance + guild := NewGuild() + guild.SetID(guildData.ID) + guild.SetName(guildData.Name, false) + guild.SetMOTD(guildData.MOTD, false) + guild.SetLevel(guildData.Level, false) + guild.SetEXPCurrent(guildData.EXPCurrent, false) + guild.SetEXPToNextLevel(guildData.EXPToNextLevel, false) + guild.SetFormedDate(guildData.FormedDate) + + // Add leader as first member + leader := NewGuildMember(leaderCharacterID, leaderInfo.CharacterName, RankLeader) + leader.AccountID = leaderInfo.AccountID + leader.UpdatePlayerInfo(leaderInfo) + + guild.members[leaderCharacterID] = leader + + // Save member to database + if err := gm.database.SaveGuildMembers(ctx, guildID, []*GuildMember{leader}); err != nil { + return nil, fmt.Errorf("failed to save guild leader: %w", err) + } + + // Add to guild list + gm.guildList.AddGuild(guild) + + // Add guild creation event + guild.AddNewGuildEvent(EventGuildLevelUp, fmt.Sprintf("Guild '%s' has been formed by %s", name, leaderInfo.CharacterName), time.Now(), true) + + // Notify event handler + if gm.eventHandler != nil { + gm.eventHandler.OnGuildCreated(guild) + } + + if gm.logger != nil { + gm.logger.LogInfo("guilds", "Created guild %d (%s) with leader %s (%d)", + guildID, name, leaderInfo.CharacterName, leaderCharacterID) + } + + return guild, nil +} + +// DeleteGuild deletes a guild +func (gm *GuildManager) DeleteGuild(ctx context.Context, guildID int32, deleterName string) error { + guild := gm.guildList.GetGuild(guildID) + if guild == nil { + return fmt.Errorf("guild %d not found", guildID) + } + + guildName := guild.GetName() + + // Remove from database + if err := gm.database.DeleteGuild(ctx, guildID); err != nil { + return fmt.Errorf("failed to delete guild from database: %w", err) + } + + // Remove from guild list + gm.guildList.RemoveGuild(guildID) + + // Notify event handler + if gm.eventHandler != nil { + gm.eventHandler.OnGuildDeleted(guildID, guildName) + } + + if gm.logger != nil { + gm.logger.LogInfo("guilds", "Deleted guild %d (%s) by %s", guildID, guildName, deleterName) + } + + return nil +} + +// GetGuild returns a guild by ID +func (gm *GuildManager) GetGuild(guildID int32) *Guild { + return gm.guildList.GetGuild(guildID) +} + +// GetGuildByName returns a guild by name +func (gm *GuildManager) GetGuildByName(name string) *Guild { + return gm.guildList.FindGuildByName(name) +} + +// GetAllGuilds returns all guilds +func (gm *GuildManager) GetAllGuilds() []*Guild { + return gm.guildList.GetAllGuilds() +} + +// GetGuildByCharacterID returns the guild for a character +func (gm *GuildManager) GetGuildByCharacterID(ctx context.Context, characterID int32) (*Guild, error) { + // Try to find in loaded guilds first + for _, guild := range gm.guildList.GetAllGuilds() { + if guild.GetGuildMember(characterID) != nil { + return guild, nil + } + } + + // Look up in database + guildID, err := gm.database.GetGuildIDByCharacterID(ctx, characterID) + if err != nil { + return nil, fmt.Errorf("character %d is not in a guild: %w", characterID, err) + } + + // Load the guild if not already loaded + return gm.LoadGuild(ctx, guildID) +} + +// InvitePlayer invites a player to a guild +func (gm *GuildManager) InvitePlayer(ctx context.Context, guildID, inviterID int32, playerName string, rank int8) error { + guild := gm.guildList.GetGuild(guildID) + if guild == nil { + return fmt.Errorf("guild %d not found", guildID) + } + + // Validate inviter permissions + inviter := guild.GetGuildMember(inviterID) + if inviter == nil { + return fmt.Errorf("inviter %d is not a guild member", inviterID) + } + + if guild.GetPermission(inviter.GetRank(), PermissionInvite) == 0 { + return fmt.Errorf("inviter does not have permission to invite") + } + + // Validate target player + targetID, err := gm.playerManager.ValidatePlayerExists(playerName) + if err != nil { + return fmt.Errorf("player '%s' not found: %w", playerName, err) + } + + // Check if player is already in a guild + if existingGuild, _ := gm.GetGuildByCharacterID(ctx, targetID); existingGuild != nil { + return fmt.Errorf("player '%s' is already in guild '%s'", playerName, existingGuild.GetName()) + } + + // TODO: Send guild invitation to player + // This would typically involve sending a packet to the client + + if gm.logger != nil { + gm.logger.LogDebug("guilds", "Player %s invited to guild %s by %s", + playerName, guild.GetName(), inviter.GetName()) + } + + return nil +} + +// AddMemberToGuild adds a member to a guild +func (gm *GuildManager) AddMemberToGuild(ctx context.Context, guildID, characterID int32, inviterName string, rank int8) error { + guild := gm.guildList.GetGuild(guildID) + if guild == nil { + return fmt.Errorf("guild %d not found", guildID) + } + + // Check if already a member + if guild.GetGuildMember(characterID) != nil { + return fmt.Errorf("character %d is already a guild member", characterID) + } + + // Get player info + playerInfo, err := gm.playerManager.GetPlayerInfo(characterID) + if err != nil { + return fmt.Errorf("failed to get player info: %w", err) + } + + // Create guild member + member := NewGuildMember(characterID, playerInfo.CharacterName, rank) + member.AccountID = playerInfo.AccountID + member.UpdatePlayerInfo(playerInfo) + + // Add to guild + guild.members[characterID] = member + guild.memberSaveNeeded = true + + // Save to database + if err := gm.database.SaveGuildMembers(ctx, guildID, []*GuildMember{member}); err != nil { + return fmt.Errorf("failed to save new guild member: %w", err) + } + + // Add guild event + guild.AddNewGuildEvent(EventMemberJoins, + fmt.Sprintf("%s has joined the guild (invited by %s)", playerInfo.CharacterName, inviterName), + time.Now(), true) + + // Notify event handler + if gm.eventHandler != nil { + gm.eventHandler.OnMemberJoined(guild, member, inviterName) + } + + if gm.logger != nil { + gm.logger.LogInfo("guilds", "Player %s (%d) joined guild %s (%d)", + playerInfo.CharacterName, characterID, guild.GetName(), guildID) + } + + return nil +} + +// RemoveMemberFromGuild removes a member from a guild +func (gm *GuildManager) RemoveMemberFromGuild(ctx context.Context, guildID, characterID int32, removerName, reason string) error { + guild := gm.guildList.GetGuild(guildID) + if guild == nil { + return fmt.Errorf("guild %d not found", guildID) + } + + member := guild.GetGuildMember(characterID) + if member == nil { + return fmt.Errorf("character %d is not a guild member", characterID) + } + + memberName := member.GetName() + + // Remove from guild + guild.RemoveGuildMember(characterID, true) + + // Save changes + if err := gm.saveGuildChanges(ctx, guild); err != nil { + if gm.logger != nil { + gm.logger.LogError("guilds", "Failed to save guild after removing member: %v", err) + } + } + + // Notify event handler + if gm.eventHandler != nil { + gm.eventHandler.OnMemberLeft(guild, member, reason) + } + + if gm.logger != nil { + gm.logger.LogInfo("guilds", "Player %s (%d) removed from guild %s (%d) by %s - %s", + memberName, characterID, guild.GetName(), guildID, removerName, reason) + } + + return nil +} + +// PromoteMember promotes a guild member +func (gm *GuildManager) PromoteMember(ctx context.Context, guildID, characterID int32, promoterName string) error { + guild := gm.guildList.GetGuild(guildID) + if guild == nil { + return fmt.Errorf("guild %d not found", guildID) + } + + member := guild.GetGuildMember(characterID) + if member == nil { + return fmt.Errorf("character %d is not a guild member", characterID) + } + + oldRank := member.GetRank() + if oldRank <= RankLeader { + return fmt.Errorf("cannot promote guild leader") + } + + // Promote + if !guild.PromoteGuildMember(characterID, promoterName, true) { + return fmt.Errorf("failed to promote member") + } + + // Save changes + if err := gm.saveGuildChanges(ctx, guild); err != nil { + if gm.logger != nil { + gm.logger.LogError("guilds", "Failed to save guild after promotion: %v", err) + } + } + + // Notify event handler + if gm.eventHandler != nil { + gm.eventHandler.OnMemberPromoted(guild, member, oldRank, member.GetRank(), promoterName) + } + + return nil +} + +// DemoteMember demotes a guild member +func (gm *GuildManager) DemoteMember(ctx context.Context, guildID, characterID int32, demoterName string) error { + guild := gm.guildList.GetGuild(guildID) + if guild == nil { + return fmt.Errorf("guild %d not found", guildID) + } + + member := guild.GetGuildMember(characterID) + if member == nil { + return fmt.Errorf("character %d is not a guild member", characterID) + } + + oldRank := member.GetRank() + if oldRank >= RankRecruit { + return fmt.Errorf("cannot demote recruit further") + } + + // Demote + if !guild.DemoteGuildMember(characterID, demoterName, true) { + return fmt.Errorf("failed to demote member") + } + + // Save changes + if err := gm.saveGuildChanges(ctx, guild); err != nil { + if gm.logger != nil { + gm.logger.LogError("guilds", "Failed to save guild after demotion: %v", err) + } + } + + // Notify event handler + if gm.eventHandler != nil { + gm.eventHandler.OnMemberDemoted(guild, member, oldRank, member.GetRank(), demoterName) + } + + return nil +} + +// AwardPoints awards points to guild members +func (gm *GuildManager) AwardPoints(ctx context.Context, guildID int32, characterIDs []int32, points float64, comment, awardedBy string) error { + guild := gm.guildList.GetGuild(guildID) + if guild == nil { + return fmt.Errorf("guild %d not found", guildID) + } + + for _, characterID := range characterIDs { + if !guild.AddPointsToGuildMember(characterID, points, awardedBy, comment, true) { + if gm.logger != nil { + gm.logger.LogWarning("guilds", "Failed to award points to character %d", characterID) + } + } + } + + // Save changes + if err := gm.saveGuildChanges(ctx, guild); err != nil { + if gm.logger != nil { + gm.logger.LogError("guilds", "Failed to save guild after awarding points: %v", err) + } + } + + // Notify event handler + if gm.eventHandler != nil { + gm.eventHandler.OnPointsAwarded(guild, characterIDs, points, comment, awardedBy) + } + + return nil +} + +// SaveAllGuilds saves all guilds that need saving +func (gm *GuildManager) SaveAllGuilds(ctx context.Context) error { + guilds := gm.guildList.GetAllGuilds() + + var saveErrors []error + for _, guild := range guilds { + if err := gm.saveGuildChanges(ctx, guild); err != nil { + saveErrors = append(saveErrors, fmt.Errorf("guild %d: %w", guild.GetID(), err)) + } + } + + if len(saveErrors) > 0 { + return fmt.Errorf("failed to save some guilds: %v", saveErrors) + } + + return nil +} + +// SearchGuilds searches for guilds based on criteria +func (gm *GuildManager) SearchGuilds(criteria GuildSearchCriteria) []*Guild { + guilds := gm.guildList.GetAllGuilds() + var results []*Guild + + for _, guild := range guilds { + if gm.matchesSearchCriteria(guild, criteria) { + results = append(results, guild) + } + } + + // Sort by name + sort.Slice(results, func(i, j int) bool { + return results[i].GetName() < results[j].GetName() + }) + + return results +} + +// GetGuildStatistics returns guild system statistics +func (gm *GuildManager) GetGuildStatistics() GuildStatistics { + guilds := gm.guildList.GetAllGuilds() + + stats := GuildStatistics{ + TotalGuilds: len(guilds), + } + + totalMembers := 0 + activeGuilds := 0 + totalEvents := 0 + totalRecruiters := 0 + uniqueAccounts := make(map[int32]bool) + highestLevel := int8(1) + + for _, guild := range guilds { + members := guild.GetAllMembers() + memberCount := len(members) + + totalMembers += memberCount + + if memberCount > 0 { + activeGuilds++ + } + + // Track unique accounts + for _, member := range members { + uniqueAccounts[member.AccountID] = true + if member.IsRecruiter() { + totalRecruiters++ + } + } + + // Guild level + if guild.GetLevel() > highestLevel { + highestLevel = guild.GetLevel() + } + + // Event count (approximate) + totalEvents += len(guild.guildEvents) + } + + stats.TotalMembers = totalMembers + stats.ActiveGuilds = activeGuilds + stats.TotalEvents = totalEvents + stats.TotalRecruiters = totalRecruiters + stats.UniqueAccounts = len(uniqueAccounts) + stats.HighestGuildLevel = highestLevel + + if len(guilds) > 0 { + stats.AverageGuildSize = float64(totalMembers) / float64(len(guilds)) + } + + return stats +} + +// Helper methods + +// loadGuildFromData creates a guild instance from database data +func (gm *GuildManager) loadGuildFromData(ctx context.Context, data GuildData) (*Guild, error) { + guild := NewGuild() + guild.SetID(data.ID) + guild.SetName(data.Name, false) + guild.SetMOTD(data.MOTD, false) + guild.SetLevel(data.Level, false) + guild.SetEXPCurrent(data.EXPCurrent, false) + guild.SetEXPToNextLevel(data.EXPToNextLevel, false) + guild.SetFormedDate(data.FormedDate) + + // Load members + memberData, err := gm.database.LoadGuildMembers(ctx, data.ID) + if err != nil { + return nil, fmt.Errorf("failed to load guild members: %w", err) + } + + for _, md := range memberData { + member := &GuildMember{ + CharacterID: md.CharacterID, + AccountID: md.AccountID, + RecruiterID: md.RecruiterID, + Name: md.Name, + GuildStatus: md.GuildStatus, + Points: md.Points, + AdventureClass: md.AdventureClass, + AdventureLevel: md.AdventureLevel, + TradeskillClass: md.TradeskillClass, + TradeskillLevel: md.TradeskillLevel, + Rank: md.Rank, + MemberFlags: md.MemberFlags, + Zone: md.Zone, + JoinDate: md.JoinDate, + LastLoginDate: md.LastLoginDate, + Note: md.Note, + OfficerNote: md.OfficerNote, + RecruiterDescription: md.RecruiterDescription, + RecruiterPictureData: md.RecruiterPictureData, + RecruitingShowAdventureClass: md.RecruitingShowAdventureClass, + PointHistory: make([]PointHistory, 0), + } + + // Load point history + historyData, err := gm.database.LoadPointHistory(ctx, md.CharacterID) + if err == nil { + for _, hd := range historyData { + member.PointHistory = append(member.PointHistory, PointHistory{ + Date: hd.Date, + ModifiedBy: hd.ModifiedBy, + Comment: hd.Comment, + Points: hd.Points, + SaveNeeded: false, + }) + } + } + + guild.members[md.CharacterID] = member + } + + // Load events + eventData, err := gm.database.LoadGuildEvents(ctx, data.ID) + if err == nil { + for _, ed := range eventData { + guild.guildEvents = append(guild.guildEvents, GuildEvent{ + EventID: ed.EventID, + Date: ed.Date, + Type: ed.Type, + Description: ed.Description, + Locked: ed.Locked, + SaveNeeded: false, + }) + } + } + + // Load ranks + rankData, err := gm.database.LoadGuildRanks(ctx, data.ID) + if err == nil { + for _, rd := range rankData { + guild.ranks[rd.Rank] = rd.Name + } + } + + // Load permissions + permissionData, err := gm.database.LoadGuildPermissions(ctx, data.ID) + if err == nil { + for _, pd := range permissionData { + if guild.permissions[pd.Rank] == nil { + guild.permissions[pd.Rank] = make(map[int8]int8) + } + guild.permissions[pd.Rank][pd.Permission] = pd.Value + } + } + + // Load event filters + filterData, err := gm.database.LoadGuildEventFilters(ctx, data.ID) + if err == nil { + for _, fd := range filterData { + if guild.eventFilters[fd.EventID] == nil { + guild.eventFilters[fd.EventID] = make(map[int8]int8) + } + guild.eventFilters[fd.EventID][fd.Category] = fd.Value + } + } + + // Load recruiting settings + recruitingData, err := gm.database.LoadGuildRecruiting(ctx, data.ID) + if err == nil { + for _, rd := range recruitingData { + if rd.Flag < 0 { + // Description tag (stored with negative flag values) + guild.recruitingDescTags[-rd.Flag-1] = rd.Value + } else { + // Recruiting flag + guild.recruitingFlags[rd.Flag] = rd.Value + } + } + } + + // Update next event ID + if len(guild.guildEvents) > 0 { + maxEventID := int64(0) + for _, event := range guild.guildEvents { + if event.EventID > maxEventID { + maxEventID = event.EventID + } + } + guild.nextEventID = maxEventID + 1 + } + + // Clear save flags + guild.saveNeeded = false + guild.memberSaveNeeded = false + guild.eventsSaveNeeded = false + guild.ranksSaveNeeded = false + guild.eventFiltersSaveNeeded = false + guild.pointsHistorySaveNeeded = false + guild.recruitingSaveNeeded = false + + return guild, nil +} + +// saveGuildChanges saves any pending changes for a guild +func (gm *GuildManager) saveGuildChanges(ctx context.Context, guild *Guild) error { + var saveErrors []error + + if guild.GetSaveNeeded() { + if err := gm.database.SaveGuild(ctx, guild); err != nil { + saveErrors = append(saveErrors, fmt.Errorf("failed to save guild data: %w", err)) + } else { + guild.SetSaveNeeded(false) + } + } + + if guild.memberSaveNeeded { + members := guild.GetAllMembers() + if err := gm.database.SaveGuildMembers(ctx, guild.GetID(), members); err != nil { + saveErrors = append(saveErrors, fmt.Errorf("failed to save guild members: %w", err)) + } else { + guild.memberSaveNeeded = false + } + } + + if guild.eventsSaveNeeded { + if err := gm.database.SaveGuildEvents(ctx, guild.GetID(), guild.guildEvents); err != nil { + saveErrors = append(saveErrors, fmt.Errorf("failed to save guild events: %w", err)) + } else { + guild.eventsSaveNeeded = false + } + } + + if guild.ranksSaveNeeded { + if err := gm.database.SaveGuildRanks(ctx, guild.GetID(), guild.ranks); err != nil { + saveErrors = append(saveErrors, fmt.Errorf("failed to save guild ranks: %w", err)) + } else { + guild.ranksSaveNeeded = false + } + } + + if guild.eventFiltersSaveNeeded { + if err := gm.database.SaveGuildEventFilters(ctx, guild.GetID(), guild.eventFilters); err != nil { + saveErrors = append(saveErrors, fmt.Errorf("failed to save guild event filters: %w", err)) + } else { + guild.eventFiltersSaveNeeded = false + } + } + + if guild.recruitingSaveNeeded { + if err := gm.database.SaveGuildRecruiting(ctx, guild.GetID(), guild.recruitingFlags, guild.recruitingDescTags); err != nil { + saveErrors = append(saveErrors, fmt.Errorf("failed to save guild recruiting: %w", err)) + } else { + guild.recruitingSaveNeeded = false + } + } + + if guild.pointsHistorySaveNeeded { + for _, member := range guild.GetAllMembers() { + if err := gm.database.SavePointHistory(ctx, member.GetCharacterID(), member.GetPointHistory()); err != nil { + saveErrors = append(saveErrors, fmt.Errorf("failed to save point history for %d: %w", member.GetCharacterID(), err)) + } + } + guild.pointsHistorySaveNeeded = false + } + + if len(saveErrors) > 0 { + return fmt.Errorf("save errors: %v", saveErrors) + } + + return nil +} + +// validateGuildName validates a guild name +func (gm *GuildManager) validateGuildName(name string) error { + if len(strings.TrimSpace(name)) == 0 { + return fmt.Errorf("guild name cannot be empty") + } + + if len(name) > MaxGuildNameLength { + return fmt.Errorf("guild name too long: %d > %d", len(name), MaxGuildNameLength) + } + + // Check for invalid characters + if strings.ContainsAny(name, "<>&\"'") { + return fmt.Errorf("guild name contains invalid characters") + } + + return nil +} + +// matchesSearchCriteria checks if a guild matches search criteria +func (gm *GuildManager) matchesSearchCriteria(guild *Guild, criteria GuildSearchCriteria) bool { + // Name pattern matching + if criteria.NamePattern != "" { + if !strings.Contains(strings.ToLower(guild.GetName()), strings.ToLower(criteria.NamePattern)) { + return false + } + } + + // Level range + level := guild.GetLevel() + if criteria.MinLevel > 0 && level < criteria.MinLevel { + return false + } + if criteria.MaxLevel > 0 && level > criteria.MaxLevel { + return false + } + + // Member count range + memberCount := len(guild.GetAllMembers()) + if criteria.MinMembers > 0 && memberCount < criteria.MinMembers { + return false + } + if criteria.MaxMembers > 0 && memberCount > criteria.MaxMembers { + return false + } + + // Recruiting only + if criteria.RecruitingOnly && guild.GetNumRecruiters() == 0 { + return false + } + + // Play style + if criteria.PlayStyle > 0 && guild.GetRecruitingPlayStyle() != criteria.PlayStyle { + return false + } + + // Required flags + for _, flag := range criteria.RequiredFlags { + if guild.GetRecruitingFlag(flag) == 0 { + return false + } + } + + // Required description tags + for _, tag := range criteria.RequiredDescTags { + found := false + for i := int8(0); i < 4; i++ { + if guild.GetRecruitingDescTag(i) == tag { + found = true + break + } + } + if !found { + return false + } + } + + // Excluded description tags + for _, tag := range criteria.ExcludedDescTags { + for i := int8(0); i < 4; i++ { + if guild.GetRecruitingDescTag(i) == tag { + return false + } + } + } + + return true +} \ No newline at end of file diff --git a/internal/guilds/member.go b/internal/guilds/member.go new file mode 100644 index 0000000..88d4229 --- /dev/null +++ b/internal/guilds/member.go @@ -0,0 +1,490 @@ +package guilds + +import ( + "time" +) + +// NewGuildMember creates a new guild member instance +func NewGuildMember(characterID int32, name string, rank int8) *GuildMember { + return &GuildMember{ + CharacterID: characterID, + Name: name, + Rank: rank, + JoinDate: time.Now(), + LastLoginDate: time.Now(), + PointHistory: make([]PointHistory, 0), + RecruitingShowAdventureClass: 1, + } +} + +// GetCharacterID returns the character ID +func (gm *GuildMember) GetCharacterID() int32 { + gm.mu.RLock() + defer gm.mu.RUnlock() + return gm.CharacterID +} + +// GetName returns the member name +func (gm *GuildMember) GetName() string { + gm.mu.RLock() + defer gm.mu.RUnlock() + return gm.Name +} + +// SetName sets the member name +func (gm *GuildMember) SetName(name string) { + gm.mu.Lock() + defer gm.mu.Unlock() + if len(name) > MaxMemberNameLength { + name = name[:MaxMemberNameLength] + } + gm.Name = name +} + +// GetRank returns the member rank +func (gm *GuildMember) GetRank() int8 { + gm.mu.RLock() + defer gm.mu.RUnlock() + return gm.Rank +} + +// SetRank sets the member rank +func (gm *GuildMember) SetRank(rank int8) { + gm.mu.Lock() + defer gm.mu.Unlock() + if rank >= RankLeader && rank <= RankRecruit { + gm.Rank = rank + } +} + +// GetPoints returns the member's guild points +func (gm *GuildMember) GetPoints() float64 { + gm.mu.RLock() + defer gm.mu.RUnlock() + return gm.Points +} + +// SetPoints sets the member's guild points +func (gm *GuildMember) SetPoints(points float64) { + gm.mu.Lock() + defer gm.mu.Unlock() + gm.Points = points +} + +// AddPoints adds points to the member +func (gm *GuildMember) AddPoints(points float64) { + gm.mu.Lock() + defer gm.mu.Unlock() + gm.Points += points +} + +// GetAdventureLevel returns the adventure level +func (gm *GuildMember) GetAdventureLevel() int8 { + gm.mu.RLock() + defer gm.mu.RUnlock() + return gm.AdventureLevel +} + +// SetAdventureLevel sets the adventure level +func (gm *GuildMember) SetAdventureLevel(level int8) { + gm.mu.Lock() + defer gm.mu.Unlock() + gm.AdventureLevel = level +} + +// GetAdventureClass returns the adventure class +func (gm *GuildMember) GetAdventureClass() int8 { + gm.mu.RLock() + defer gm.mu.RUnlock() + return gm.AdventureClass +} + +// SetAdventureClass sets the adventure class +func (gm *GuildMember) SetAdventureClass(class int8) { + gm.mu.Lock() + defer gm.mu.Unlock() + gm.AdventureClass = class +} + +// GetTradeskillLevel returns the tradeskill level +func (gm *GuildMember) GetTradeskillLevel() int8 { + gm.mu.RLock() + defer gm.mu.RUnlock() + return gm.TradeskillLevel +} + +// SetTradeskillLevel sets the tradeskill level +func (gm *GuildMember) SetTradeskillLevel(level int8) { + gm.mu.Lock() + defer gm.mu.Unlock() + gm.TradeskillLevel = level +} + +// GetTradeskillClass returns the tradeskill class +func (gm *GuildMember) GetTradeskillClass() int8 { + gm.mu.RLock() + defer gm.mu.RUnlock() + return gm.TradeskillClass +} + +// SetTradeskillClass sets the tradeskill class +func (gm *GuildMember) SetTradeskillClass(class int8) { + gm.mu.Lock() + defer gm.mu.Unlock() + gm.TradeskillClass = class +} + +// GetZone returns the member's current zone +func (gm *GuildMember) GetZone() string { + gm.mu.RLock() + defer gm.mu.RUnlock() + return gm.Zone +} + +// SetZone sets the member's current zone +func (gm *GuildMember) SetZone(zone string) { + gm.mu.Lock() + defer gm.mu.Unlock() + gm.Zone = zone +} + +// GetJoinDate returns when the member joined the guild +func (gm *GuildMember) GetJoinDate() time.Time { + gm.mu.RLock() + defer gm.mu.RUnlock() + return gm.JoinDate +} + +// SetJoinDate sets when the member joined the guild +func (gm *GuildMember) SetJoinDate(date time.Time) { + gm.mu.Lock() + defer gm.mu.Unlock() + gm.JoinDate = date +} + +// GetLastLoginDate returns the member's last login date +func (gm *GuildMember) GetLastLoginDate() time.Time { + gm.mu.RLock() + defer gm.mu.RUnlock() + return gm.LastLoginDate +} + +// SetLastLoginDate sets the member's last login date +func (gm *GuildMember) SetLastLoginDate(date time.Time) { + gm.mu.Lock() + defer gm.mu.Unlock() + gm.LastLoginDate = date +} + +// GetNote returns the member's personal note +func (gm *GuildMember) GetNote() string { + gm.mu.RLock() + defer gm.mu.RUnlock() + return gm.Note +} + +// SetNote sets the member's personal note +func (gm *GuildMember) SetNote(note string) { + gm.mu.Lock() + defer gm.mu.Unlock() + gm.Note = note +} + +// GetOfficerNote returns the member's officer note +func (gm *GuildMember) GetOfficerNote() string { + gm.mu.RLock() + defer gm.mu.RUnlock() + return gm.OfficerNote +} + +// SetOfficerNote sets the member's officer note +func (gm *GuildMember) SetOfficerNote(note string) { + gm.mu.Lock() + defer gm.mu.Unlock() + gm.OfficerNote = note +} + +// GetMemberFlags returns the member flags +func (gm *GuildMember) GetMemberFlags() int8 { + gm.mu.RLock() + defer gm.mu.RUnlock() + return gm.MemberFlags +} + +// SetMemberFlags sets the member flags +func (gm *GuildMember) SetMemberFlags(flags int8) { + gm.mu.Lock() + defer gm.mu.Unlock() + gm.MemberFlags = flags +} + +// HasMemberFlag checks if the member has a specific flag +func (gm *GuildMember) HasMemberFlag(flag int8) bool { + gm.mu.RLock() + defer gm.mu.RUnlock() + return gm.MemberFlags&flag != 0 +} + +// SetMemberFlag sets or unsets a specific member flag +func (gm *GuildMember) SetMemberFlag(flag int8, value bool) { + gm.mu.Lock() + defer gm.mu.Unlock() + + if value { + gm.MemberFlags |= flag + } else { + gm.MemberFlags &^= flag + } +} + +// IsRecruiter checks if the member is a recruiter +func (gm *GuildMember) IsRecruiter() bool { + return gm.HasMemberFlag(MemberFlagRecruitingForGuild) +} + +// SetRecruiter sets or unsets the recruiter flag +func (gm *GuildMember) SetRecruiter(isRecruiter bool) { + gm.SetMemberFlag(MemberFlagRecruitingForGuild, isRecruiter) +} + +// GetRecruiterID returns the recruiter ID +func (gm *GuildMember) GetRecruiterID() int32 { + gm.mu.RLock() + defer gm.mu.RUnlock() + return gm.RecruiterID +} + +// SetRecruiterID sets the recruiter ID +func (gm *GuildMember) SetRecruiterID(id int32) { + gm.mu.Lock() + defer gm.mu.Unlock() + gm.RecruiterID = id +} + +// GetRecruiterDescription returns the recruiter description +func (gm *GuildMember) GetRecruiterDescription() string { + gm.mu.RLock() + defer gm.mu.RUnlock() + return gm.RecruiterDescription +} + +// SetRecruiterDescription sets the recruiter description +func (gm *GuildMember) SetRecruiterDescription(description string) { + gm.mu.Lock() + defer gm.mu.Unlock() + gm.RecruiterDescription = description +} + +// GetRecruiterPictureData returns the recruiter picture data +func (gm *GuildMember) GetRecruiterPictureData() []byte { + gm.mu.RLock() + defer gm.mu.RUnlock() + + // Return a copy to prevent external modification + if gm.RecruiterPictureData == nil { + return nil + } + + data := make([]byte, len(gm.RecruiterPictureData)) + copy(data, gm.RecruiterPictureData) + return data +} + +// SetRecruiterPictureData sets the recruiter picture data +func (gm *GuildMember) SetRecruiterPictureData(data []byte) { + gm.mu.Lock() + defer gm.mu.Unlock() + + if data == nil { + gm.RecruiterPictureData = nil + return + } + + // Make a copy to prevent external modification + gm.RecruiterPictureData = make([]byte, len(data)) + copy(gm.RecruiterPictureData, data) +} + +// GetRecruitingShowAdventureClass returns whether to show adventure class +func (gm *GuildMember) GetRecruitingShowAdventureClass() bool { + gm.mu.RLock() + defer gm.mu.RUnlock() + return gm.RecruitingShowAdventureClass != 0 +} + +// SetRecruitingShowAdventureClass sets whether to show adventure class +func (gm *GuildMember) SetRecruitingShowAdventureClass(show bool) { + gm.mu.Lock() + defer gm.mu.Unlock() + + if show { + gm.RecruitingShowAdventureClass = 1 + } else { + gm.RecruitingShowAdventureClass = 0 + } +} + +// GetPointHistory returns a copy of the point history +func (gm *GuildMember) GetPointHistory() []PointHistory { + gm.mu.RLock() + defer gm.mu.RUnlock() + + history := make([]PointHistory, len(gm.PointHistory)) + copy(history, gm.PointHistory) + return history +} + +// AddPointHistory adds a point history entry +func (gm *GuildMember) AddPointHistory(date time.Time, modifiedBy string, points float64, comment string) { + gm.mu.Lock() + defer gm.mu.Unlock() + + // Limit history size + if len(gm.PointHistory) >= MaxPointHistory { + // Remove oldest entry + gm.PointHistory = gm.PointHistory[1:] + } + + history := PointHistory{ + Date: date, + ModifiedBy: modifiedBy, + Points: points, + Comment: comment, + SaveNeeded: true, + } + + gm.PointHistory = append(gm.PointHistory, history) +} + +// GetMemberInfo returns formatted member information +func (gm *GuildMember) GetMemberInfo(rankName string, isOnline bool) MemberInfo { + gm.mu.RLock() + defer gm.mu.RUnlock() + + return MemberInfo{ + CharacterID: gm.CharacterID, + Name: gm.Name, + Rank: gm.Rank, + RankName: rankName, + Points: gm.Points, + AdventureClass: gm.AdventureClass, + AdventureLevel: gm.AdventureLevel, + TradeskillClass: gm.TradeskillClass, + TradeskillLevel: gm.TradeskillLevel, + Zone: gm.Zone, + JoinDate: gm.JoinDate, + LastLoginDate: gm.LastLoginDate, + IsOnline: isOnline, + IsRecruiter: gm.MemberFlags&MemberFlagRecruitingForGuild != 0, + Note: gm.Note, + OfficerNote: gm.OfficerNote, + } +} + +// GetRecruiterInfo returns formatted recruiter information +func (gm *GuildMember) GetRecruiterInfo(isOnline bool) RecruiterInfo { + gm.mu.RLock() + defer gm.mu.RUnlock() + + return RecruiterInfo{ + CharacterID: gm.CharacterID, + Name: gm.Name, + Description: gm.RecruiterDescription, + PictureData: gm.GetRecruiterPictureData(), // This will make a copy + ShowAdventureClass: gm.RecruitingShowAdventureClass != 0, + AdventureClass: gm.AdventureClass, + IsOnline: isOnline, + } +} + +// UpdatePlayerInfo updates member info from player data +func (gm *GuildMember) UpdatePlayerInfo(playerInfo PlayerInfo) { + gm.mu.Lock() + defer gm.mu.Unlock() + + gm.AdventureLevel = playerInfo.AdventureLevel + gm.AdventureClass = playerInfo.AdventureClass + gm.TradeskillLevel = playerInfo.TradeskillLevel + gm.TradeskillClass = playerInfo.TradeskillClass + gm.Zone = playerInfo.Zone + + if playerInfo.IsOnline { + gm.LastLoginDate = time.Now() + } +} + +// ValidateRank checks if the rank is valid +func (gm *GuildMember) ValidateRank() bool { + gm.mu.RLock() + defer gm.mu.RUnlock() + return gm.Rank >= RankLeader && gm.Rank <= RankRecruit +} + +// CanPromote checks if this member can promote another member +func (gm *GuildMember) CanPromote(targetRank int8) bool { + gm.mu.RLock() + defer gm.mu.RUnlock() + + // Can only promote members with lower rank (higher rank number) + // Cannot promote to same or higher rank than self + return gm.Rank < targetRank && targetRank > RankLeader +} + +// CanDemote checks if this member can demote another member +func (gm *GuildMember) CanDemote(targetRank int8) bool { + gm.mu.RLock() + defer gm.mu.RUnlock() + + // Can only demote members with equal or lower rank (same or higher rank number) + // Cannot demote to recruit (already lowest) + return gm.Rank <= targetRank && targetRank < RankRecruit +} + +// CanKick checks if this member can kick another member +func (gm *GuildMember) CanKick(targetRank int8) bool { + gm.mu.RLock() + defer gm.mu.RUnlock() + + // Can only kick members with lower rank (higher rank number) + return gm.Rank < targetRank +} + +// Copy creates a deep copy of the guild member +func (gm *GuildMember) Copy() *GuildMember { + gm.mu.RLock() + defer gm.mu.RUnlock() + + newMember := &GuildMember{ + CharacterID: gm.CharacterID, + AccountID: gm.AccountID, + RecruiterID: gm.RecruiterID, + Name: gm.Name, + GuildStatus: gm.GuildStatus, + Points: gm.Points, + AdventureClass: gm.AdventureClass, + AdventureLevel: gm.AdventureLevel, + TradeskillClass: gm.TradeskillClass, + TradeskillLevel: gm.TradeskillLevel, + Rank: gm.Rank, + MemberFlags: gm.MemberFlags, + Zone: gm.Zone, + JoinDate: gm.JoinDate, + LastLoginDate: gm.LastLoginDate, + Note: gm.Note, + OfficerNote: gm.OfficerNote, + RecruiterDescription: gm.RecruiterDescription, + RecruitingShowAdventureClass: gm.RecruitingShowAdventureClass, + PointHistory: make([]PointHistory, len(gm.PointHistory)), + } + + // Deep copy point history + copy(newMember.PointHistory, gm.PointHistory) + + // Deep copy picture data + if gm.RecruiterPictureData != nil { + newMember.RecruiterPictureData = make([]byte, len(gm.RecruiterPictureData)) + copy(newMember.RecruiterPictureData, gm.RecruiterPictureData) + } + + return newMember +} \ No newline at end of file diff --git a/internal/guilds/types.go b/internal/guilds/types.go new file mode 100644 index 0000000..2f9033f --- /dev/null +++ b/internal/guilds/types.go @@ -0,0 +1,323 @@ +package guilds + +import ( + "sync" + "time" +) + +// PointHistory represents a point modification entry in a member's history +type PointHistory struct { + Date time.Time `json:"date" db:"date"` + ModifiedBy string `json:"modified_by" db:"modified_by"` + Comment string `json:"comment" db:"comment"` + Points float64 `json:"points" db:"points"` + SaveNeeded bool `json:"-" db:"-"` +} + +// GuildMember represents a member of a guild +type GuildMember struct { + mu sync.RWMutex + CharacterID int32 `json:"character_id" db:"character_id"` + AccountID int32 `json:"account_id" db:"account_id"` + RecruiterID int32 `json:"recruiter_id" db:"recruiter_id"` + Name string `json:"name" db:"name"` + GuildStatus int32 `json:"guild_status" db:"guild_status"` + Points float64 `json:"points" db:"points"` + AdventureClass int8 `json:"adventure_class" db:"adventure_class"` + AdventureLevel int8 `json:"adventure_level" db:"adventure_level"` + TradeskillClass int8 `json:"tradeskill_class" db:"tradeskill_class"` + TradeskillLevel int8 `json:"tradeskill_level" db:"tradeskill_level"` + Rank int8 `json:"rank" db:"rank"` + MemberFlags int8 `json:"member_flags" db:"member_flags"` + Zone string `json:"zone" db:"zone"` + JoinDate time.Time `json:"join_date" db:"join_date"` + LastLoginDate time.Time `json:"last_login_date" db:"last_login_date"` + Note string `json:"note" db:"note"` + OfficerNote string `json:"officer_note" db:"officer_note"` + RecruiterDescription string `json:"recruiter_description" db:"recruiter_description"` + RecruiterPictureData []byte `json:"recruiter_picture_data" db:"recruiter_picture_data"` + RecruitingShowAdventureClass int8 `json:"recruiting_show_adventure_class" db:"recruiting_show_adventure_class"` + PointHistory []PointHistory `json:"point_history" db:"-"` +} + +// GuildEvent represents an event in the guild's history +type GuildEvent struct { + EventID int64 `json:"event_id" db:"event_id"` + Date time.Time `json:"date" db:"date"` + Type int32 `json:"type" db:"type"` + Description string `json:"description" db:"description"` + Locked int8 `json:"locked" db:"locked"` + SaveNeeded bool `json:"-" db:"-"` +} + +// GuildBankEvent represents an event in a guild bank's history +type GuildBankEvent struct { + EventID int64 `json:"event_id" db:"event_id"` + Date time.Time `json:"date" db:"date"` + Type int32 `json:"type" db:"type"` + Description string `json:"description" db:"description"` +} + +// Bank represents a guild bank with its event history +type Bank struct { + Name string `json:"name" db:"name"` + Events []GuildBankEvent `json:"events" db:"-"` +} + +// Guild represents a guild with all its properties and members +type Guild struct { + mu sync.RWMutex + id int32 + name string + level int8 + formedDate time.Time + motd string + expCurrent int64 + expToNextLevel int64 + recruitingShortDesc string + recruitingFullDesc string + recruitingMinLevel int8 + recruitingPlayStyle int8 + members map[int32]*GuildMember + guildEvents []GuildEvent + permissions map[int8]map[int8]int8 // rank -> permission -> value + eventFilters map[int8]map[int8]int8 // event_id -> category -> value + recruitingFlags map[int8]int8 + recruitingDescTags map[int8]int8 + ranks map[int8]string // rank -> name + banks [4]Bank + + // Save flags + saveNeeded bool + memberSaveNeeded bool + eventsSaveNeeded bool + ranksSaveNeeded bool + eventFiltersSaveNeeded bool + pointsHistorySaveNeeded bool + recruitingSaveNeeded bool + + // Tracking + nextEventID int64 + lastModified time.Time +} + +// GuildData represents guild data for database operations +type GuildData struct { + ID int32 `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Level int8 `json:"level" db:"level"` + FormedDate time.Time `json:"formed_date" db:"formed_date"` + MOTD string `json:"motd" db:"motd"` + EXPCurrent int64 `json:"exp_current" db:"exp_current"` + EXPToNextLevel int64 `json:"exp_to_next_level" db:"exp_to_next_level"` + RecruitingShortDesc string `json:"recruiting_short_desc" db:"recruiting_short_desc"` + RecruitingFullDesc string `json:"recruiting_full_desc" db:"recruiting_full_desc"` + RecruitingMinLevel int8 `json:"recruiting_min_level" db:"recruiting_min_level"` + RecruitingPlayStyle int8 `json:"recruiting_play_style" db:"recruiting_play_style"` +} + +// GuildMemberData represents guild member data for database operations +type GuildMemberData struct { + CharacterID int32 `json:"character_id" db:"character_id"` + GuildID int32 `json:"guild_id" db:"guild_id"` + AccountID int32 `json:"account_id" db:"account_id"` + RecruiterID int32 `json:"recruiter_id" db:"recruiter_id"` + Name string `json:"name" db:"name"` + GuildStatus int32 `json:"guild_status" db:"guild_status"` + Points float64 `json:"points" db:"points"` + AdventureClass int8 `json:"adventure_class" db:"adventure_class"` + AdventureLevel int8 `json:"adventure_level" db:"adventure_level"` + TradeskillClass int8 `json:"tradeskill_class" db:"tradeskill_class"` + TradeskillLevel int8 `json:"tradeskill_level" db:"tradeskill_level"` + Rank int8 `json:"rank" db:"rank"` + MemberFlags int8 `json:"member_flags" db:"member_flags"` + Zone string `json:"zone" db:"zone"` + JoinDate time.Time `json:"join_date" db:"join_date"` + LastLoginDate time.Time `json:"last_login_date" db:"last_login_date"` + Note string `json:"note" db:"note"` + OfficerNote string `json:"officer_note" db:"officer_note"` + RecruiterDescription string `json:"recruiter_description" db:"recruiter_description"` + RecruiterPictureData []byte `json:"recruiter_picture_data" db:"recruiter_picture_data"` + RecruitingShowAdventureClass int8 `json:"recruiting_show_adventure_class" db:"recruiting_show_adventure_class"` +} + +// GuildEventData represents guild event data for database operations +type GuildEventData struct { + EventID int64 `json:"event_id" db:"event_id"` + GuildID int32 `json:"guild_id" db:"guild_id"` + Date time.Time `json:"date" db:"date"` + Type int32 `json:"type" db:"type"` + Description string `json:"description" db:"description"` + Locked int8 `json:"locked" db:"locked"` +} + +// GuildRankData represents guild rank data for database operations +type GuildRankData struct { + GuildID int32 `json:"guild_id" db:"guild_id"` + Rank int8 `json:"rank" db:"rank"` + Name string `json:"name" db:"name"` +} + +// GuildPermissionData represents guild permission data for database operations +type GuildPermissionData struct { + GuildID int32 `json:"guild_id" db:"guild_id"` + Rank int8 `json:"rank" db:"rank"` + Permission int8 `json:"permission" db:"permission"` + Value int8 `json:"value" db:"value"` +} + +// GuildEventFilterData represents guild event filter data for database operations +type GuildEventFilterData struct { + GuildID int32 `json:"guild_id" db:"guild_id"` + EventID int8 `json:"event_id" db:"event_id"` + Category int8 `json:"category" db:"category"` + Value int8 `json:"value" db:"value"` +} + +// GuildRecruitingData represents guild recruiting data for database operations +type GuildRecruitingData struct { + GuildID int32 `json:"guild_id" db:"guild_id"` + Flag int8 `json:"flag" db:"flag"` + Value int8 `json:"value" db:"value"` +} + +// PointHistoryData represents point history data for database operations +type PointHistoryData struct { + CharacterID int32 `json:"character_id" db:"character_id"` + Date time.Time `json:"date" db:"date"` + ModifiedBy string `json:"modified_by" db:"modified_by"` + Comment string `json:"comment" db:"comment"` + Points float64 `json:"points" db:"points"` +} + +// GuildList manages all guilds in the system +type GuildList struct { + mu sync.RWMutex + guilds map[int32]*Guild +} + +// GuildManager provides high-level guild management +type GuildManager struct { + guildList *GuildList + database GuildDatabase + clientManager ClientManager + playerManager PlayerManager + eventHandler GuildEventHandler + logger LogHandler +} + +// GuildStatistics provides guild system usage statistics +type GuildStatistics struct { + TotalGuilds int `json:"total_guilds"` + TotalMembers int `json:"total_members"` + ActiveGuilds int `json:"active_guilds"` + AverageGuildSize float64 `json:"average_guild_size"` + TotalEvents int `json:"total_events"` + TotalRecruiters int `json:"total_recruiters"` + UniqueAccounts int `json:"unique_accounts"` + HighestGuildLevel int8 `json:"highest_guild_level"` +} + +// GuildInfo provides basic guild information +type GuildInfo struct { + ID int32 `json:"id"` + Name string `json:"name"` + Level int8 `json:"level"` + FormedDate time.Time `json:"formed_date"` + MOTD string `json:"motd"` + MemberCount int `json:"member_count"` + OnlineMemberCount int `json:"online_member_count"` + RecruiterCount int `json:"recruiter_count"` + RecruitingShortDesc string `json:"recruiting_short_desc"` + RecruitingFullDesc string `json:"recruiting_full_desc"` + RecruitingMinLevel int8 `json:"recruiting_min_level"` + RecruitingPlayStyle int8 `json:"recruiting_play_style"` + IsRecruiting bool `json:"is_recruiting"` +} + +// MemberInfo provides guild member information +type MemberInfo struct { + CharacterID int32 `json:"character_id"` + Name string `json:"name"` + Rank int8 `json:"rank"` + RankName string `json:"rank_name"` + Points float64 `json:"points"` + AdventureClass int8 `json:"adventure_class"` + AdventureLevel int8 `json:"adventure_level"` + TradeskillClass int8 `json:"tradeskill_class"` + TradeskillLevel int8 `json:"tradeskill_level"` + Zone string `json:"zone"` + JoinDate time.Time `json:"join_date"` + LastLoginDate time.Time `json:"last_login_date"` + IsOnline bool `json:"is_online"` + IsRecruiter bool `json:"is_recruiter"` + Note string `json:"note"` + OfficerNote string `json:"officer_note"` +} + +// GuildRoster represents the complete guild roster +type GuildRoster struct { + GuildInfo GuildInfo `json:"guild_info"` + Members []MemberInfo `json:"members"` +} + +// GuildInvite represents a pending guild invitation +type GuildInvite struct { + GuildID int32 `json:"guild_id"` + GuildName string `json:"guild_name"` + CharacterID int32 `json:"character_id"` + CharacterName string `json:"character_name"` + InviterID int32 `json:"inviter_id"` + InviterName string `json:"inviter_name"` + Rank int8 `json:"rank"` + InviteDate time.Time `json:"invite_date"` + ExpiresAt time.Time `json:"expires_at"` +} + +// GuildEventInfo provides formatted guild event information +type GuildEventInfo struct { + EventID int64 `json:"event_id"` + Date time.Time `json:"date"` + Type int32 `json:"type"` + TypeName string `json:"type_name"` + Description string `json:"description"` + Locked bool `json:"locked"` +} + +// GuildSearchCriteria represents guild search parameters +type GuildSearchCriteria struct { + NamePattern string `json:"name_pattern"` + MinLevel int8 `json:"min_level"` + MaxLevel int8 `json:"max_level"` + MinMembers int `json:"min_members"` + MaxMembers int `json:"max_members"` + RecruitingOnly bool `json:"recruiting_only"` + PlayStyle int8 `json:"play_style"` + RequiredFlags []int8 `json:"required_flags"` + RequiredDescTags []int8 `json:"required_desc_tags"` + ExcludedDescTags []int8 `json:"excluded_desc_tags"` +} + +// RecruitingInfo provides detailed recruiting information +type RecruitingInfo struct { + GuildID int32 `json:"guild_id"` + GuildName string `json:"guild_name"` + ShortDesc string `json:"short_desc"` + FullDesc string `json:"full_desc"` + MinLevel int8 `json:"min_level"` + PlayStyle int8 `json:"play_style"` + Flags map[int8]int8 `json:"flags"` + DescTags map[int8]int8 `json:"desc_tags"` + Recruiters []RecruiterInfo `json:"recruiters"` +} + +// RecruiterInfo provides recruiter information +type RecruiterInfo struct { + CharacterID int32 `json:"character_id"` + Name string `json:"name"` + Description string `json:"description"` + PictureData []byte `json:"picture_data"` + ShowAdventureClass bool `json:"show_adventure_class"` + AdventureClass int8 `json:"adventure_class"` + IsOnline bool `json:"is_online"` +} \ No newline at end of file diff --git a/internal/heroic_ops/README.md b/internal/heroic_ops/README.md new file mode 100644 index 0000000..afb87da --- /dev/null +++ b/internal/heroic_ops/README.md @@ -0,0 +1,294 @@ +# Heroic Opportunities System + +The Heroic Opportunities (HO) system implements EverQuest II's cooperative combat mechanic where players coordinate ability usage to complete beneficial spell effects. + +## Overview + +Heroic Opportunities are multi-stage cooperative encounters that require precise timing and coordination. The system consists of two main phases: + +1. **Starter Chain Phase**: Players use abilities in sequence to complete a starter chain +2. **Wheel Phase**: Players complete abilities on a randomized wheel within a time limit + +## Architecture + +### Core Components + +#### HeroicOPStarter +Represents starter chains that initiate heroic opportunities: +- **Class Restrictions**: Specific classes can initiate specific starters +- **Ability Sequence**: Up to 6 abilities that must be used in order +- **Completion Marker**: Special marker (0xFFFF) indicates chain completion + +#### HeroicOPWheel +Represents the wheel phase with ability completion requirements: +- **Order Types**: Unordered (any sequence) vs Ordered (specific sequence) +- **Shift Capability**: Ability to change to different wheel once per HO +- **Completion Spell**: Spell cast when wheel is successfully completed +- **Chance Weighting**: Probability factor for random wheel selection + +#### HeroicOP +Active HO instance with state management: +- **Multi-phase State**: Tracks progression through starter → wheel → completion +- **Timer Management**: Precise timing controls for wheel phase +- **Participant Tracking**: Manages all players involved in the HO +- **Progress Validation**: Ensures abilities match current requirements + +### System Flow + +``` +Player Uses Starter Ability + ↓ +System Loads Available Starters for Class + ↓ +Eliminate Non-matching Starters + ↓ +Starter Complete? → Yes → Select Random Wheel + ↓ ↓ + No Start Wheel Phase Timer + ↓ ↓ +Continue Starter Chain Players Complete Abilities + ↓ ↓ +More Starters? → No → HO Fails All Complete? → Yes → Cast Spell + ↓ + No → Timer Expired? → Yes → HO Fails +``` + +## Database Schema + +### heroic_ops Table +Stores both starters and wheels with type discrimination: + +```sql +CREATE TABLE heroic_ops ( + id INTEGER NOT NULL, + ho_type TEXT CHECK(ho_type IN ('Starter', 'Wheel')), + starter_class INTEGER, -- For starters: class restriction + starter_icon INTEGER, -- For starters: initiating icon + starter_link_id INTEGER, -- For wheels: associated starter ID + chain_order INTEGER, -- For wheels: order requirement + shift_icon INTEGER, -- For wheels: shift ability icon + spell_id INTEGER, -- For wheels: completion spell + chance REAL, -- For wheels: selection probability + ability1-6 INTEGER, -- Ability icons + name TEXT, + description TEXT +); +``` + +### heroic_op_instances Table +Tracks active HO instances: + +```sql +CREATE TABLE heroic_op_instances ( + id INTEGER PRIMARY KEY, + encounter_id INTEGER, + starter_id INTEGER, + wheel_id INTEGER, + state INTEGER, + countered_1-6 INTEGER, -- Completion status per ability + shift_used INTEGER, + time_remaining INTEGER, + -- ... additional fields +); +``` + +## Key Features + +### Multi-Class Initiation +- Specific classes can initiate specific starter chains +- Universal starters (class 0) available to all classes +- Class validation ensures proper HO eligibility + +### Dynamic Wheel Selection +- Random selection from available wheels per starter +- Weighted probability based on chance values +- Prevents predictable HO patterns + +### Wheel Shifting +- **One-time ability** to change wheels during wheel phase +- **Timing Restrictions**: Only before progress (unordered) or at start (ordered) +- **Strategic Element**: Allows adaptation to group composition + +### Precise Timing +- Configurable wheel phase timers (default 10 seconds) +- Millisecond precision for fair completion windows +- Automatic cleanup of expired HOs + +### Order Enforcement +- **Unordered Wheels**: Any ability can be completed in any sequence +- **Ordered Wheels**: Abilities must be completed in specific order +- **Validation**: System prevents invalid ability usage + +## Usage Examples + +### Starting a Heroic Opportunity + +```go +// Initialize HO manager +manager := NewHeroicOPManager(masterList, database, clientManager, encounterManager, playerManager) +manager.Initialize(ctx, config) + +// Start HO for encounter +ho, err := manager.StartHeroicOpportunity(ctx, encounterID, initiatorCharacterID) +if err != nil { + return fmt.Errorf("failed to start HO: %w", err) +} +``` + +### Processing Player Abilities + +```go +// Player uses ability during HO +err := manager.ProcessAbility(ctx, ho.ID, characterID, abilityIcon) +if err != nil { + // Ability not allowed or HO in wrong state + return err +} + +// Check if HO completed +if ho.IsComplete() { + // Completion spell will be cast automatically + log.Printf("HO completed by character %d", ho.CompletedBy) +} +``` + +### Timer Management + +```go +// Update all active HO timers (called periodically) +manager.UpdateTimers(ctx, deltaMilliseconds) + +// Expired HOs are automatically failed and cleaned up +``` + +## Client Communication + +### Packet Types +- **HO Start**: Initial HO initiation notification +- **HO Update**: Wheel phase updates with ability icons +- **HO Progress**: Real-time completion progress +- **HO Timer**: Timer countdown updates +- **HO Complete**: Success/failure notification +- **HO Shift**: Wheel change notification + +### Real-time Updates +- Participants receive immediate feedback on ability usage +- Progress updates show completion status +- Timer updates maintain urgency during wheel phase + +## Configuration + +### System Parameters +```go +config := &HeroicOPConfig{ + DefaultWheelTimer: 10000, // 10 seconds in milliseconds + MaxConcurrentHOs: 3, // Per encounter + EnableLogging: true, + EnableStatistics: true, + EnableShifting: true, + RequireClassMatch: true, +} +``` + +### Performance Tuning +- **Concurrent HOs**: Limit simultaneous HOs per encounter +- **Cleanup Intervals**: Regular removal of expired instances +- **Database Batching**: Efficient event logging +- **Memory Management**: Instance pooling for high-traffic servers + +## Integration Points + +### Spell System Integration +- Completion spells cast through spell manager +- Spell validation and effect application +- Integration with existing spell mechanics + +### Encounter System Integration +- HO availability tied to active encounters +- Participant validation through encounter membership +- Encounter end triggers HO cleanup + +### Player System Integration +- Class validation for starter eligibility +- Ability validation for wheel completion +- Player state checking (online, in combat, etc.) + +## Error Handling + +### Common Error Scenarios +- **Invalid State**: Ability used when HO not in correct phase +- **Timer Expired**: Wheel phase timeout +- **Ability Not Allowed**: Ability doesn't match current requirements +- **Shift Already Used**: Attempting multiple shifts +- **Player Not in Encounter**: Participant validation failure + +### Recovery Mechanisms +- Automatic HO failure on unrecoverable errors +- Client notification of error conditions +- Logging for debugging and analysis +- Graceful degradation when components unavailable + +## Thread Safety + +All core components use proper Go concurrency patterns: +- **RWMutex Protection**: Reader-writer locks for shared data +- **Atomic Operations**: Lock-free operations where possible +- **Context Cancellation**: Proper cleanup on shutdown +- **Channel Communication**: Safe inter-goroutine messaging + +## Performance Considerations + +### Memory Management +- Object pooling for frequently created instances +- Efficient cleanup of expired HOs +- Bounded history retention + +### Database Optimization +- Indexed queries for fast lookups +- Batch operations for event logging +- Connection pooling for concurrent access + +### Network Efficiency +- Minimal packet sizes for real-time updates +- Client version-specific optimizations +- Broadcast optimization for group updates + +## Testing + +### Unit Tests +- Individual component validation +- State machine testing +- Error condition handling +- Concurrent access patterns + +### Integration Tests +- Full HO lifecycle scenarios +- Multi-player coordination +- Database persistence validation +- Client communication verification + +## Future Enhancements + +### Planned Features +- **Lua Scripting**: Custom HO behaviors +- **Advanced Statistics**: Detailed analytics +- **Dynamic Difficulty**: Adaptive timer adjustments +- **Guild Coordination**: Guild-wide HO tracking + +### Scalability Improvements +- **Clustering Support**: Multi-server HO coordination +- **Caching Layer**: Redis integration for high-traffic +- **Async Processing**: Background HO processing +- **Load Balancing**: Distribution across game servers + +## Conversion Notes + +This Go implementation maintains full compatibility with the original C++ EQ2EMu system while modernizing the architecture: + +- **Thread Safety**: Proper Go concurrency patterns +- **Error Handling**: Comprehensive error wrapping +- **Context Usage**: Cancellation and timeout support +- **Interface Design**: Modular, testable components +- **Database Integration**: Modern query patterns + +All original functionality has been preserved, including complex mechanics like wheel shifting, ordered/unordered completion, and precise timing requirements. \ No newline at end of file diff --git a/internal/heroic_ops/constants.go b/internal/heroic_ops/constants.go new file mode 100644 index 0000000..367bfd5 --- /dev/null +++ b/internal/heroic_ops/constants.go @@ -0,0 +1,144 @@ +package heroic_ops + +// Heroic Opportunity Constants +const ( + // Maximum number of abilities in a starter chain or wheel + MaxAbilities = 6 + + // Special ability icon values + AbilityIconAny = 0xFFFF // Wildcard - any ability can be used + AbilityIconNone = 0 // No ability required + + // Default wheel timer (in seconds) + DefaultWheelTimerSeconds = 10 + + // HO Types in database + HOTypeStarter = "Starter" + HOTypeWheel = "Wheel" + + // Wheel order types + WheelOrderUnordered = 0 // Abilities can be completed in any order + WheelOrderOrdered = 1 // Abilities must be completed in sequence + + // HO States + HOStateInactive = iota + HOStateStarterChain + HOStateWheelPhase + HOStateComplete + HOStateFailed + + // Maximum number of concurrent heroic opportunities per encounter + MaxConcurrentHOs = 3 + + // Chance calculation constants + MinChance = 0.0 + MaxChance = 100.0 + + // Timer constants (in milliseconds) + WheelTimerCheckInterval = 100 + StarterChainTimeout = 30000 // 30 seconds + WheelPhaseTimeout = 10000 // 10 seconds (configurable) + + // Special shift states + ShiftNotUsed = 0 + ShiftUsed = 1 + + // HO completion status + HONotComplete = 0 + HOComplete = 1 + + // Class restrictions (matches EQ2 class IDs) + ClassAny = 0 // Any class can initiate + + // Packet constants + PacketHeroicOpportunity = "WS_HeroicOpportunity" + + // Error messages + ErrHONotFound = "heroic opportunity not found" + ErrHOInvalidState = "heroic opportunity in invalid state" + ErrHOAbilityNotAllowed = "ability not allowed for current heroic opportunity" + ErrHOTimerExpired = "heroic opportunity timer expired" + ErrHOAlreadyComplete = "heroic opportunity already complete" + ErrHOShiftAlreadyUsed = "wheel shift already used" + ErrHOWheelNotFound = "no wheel found for starter" + + // Database constants + MaxHONameLength = 255 + MaxHODescriptionLength = 1000 + MaxDatabaseRetries = 3 + + // Memory management + DefaultHOPoolSize = 100 + DefaultStarterCache = 500 + DefaultWheelCache = 1000 + MaxHOHistoryEntries = 50 +) + +// Heroic Opportunity Event Types for logging and statistics +const ( + EventHOStarted = iota + 1 + EventHOCompleted + EventHOFailed + EventHOTimerExpired + EventHOAbilityUsed + EventHOWheelShifted + EventHOStarterMatched + EventHOStarterEliminated + EventHOWheelSelected + EventHOProgressMade +) + +// Ability icon mappings for common abilities +var CommonAbilityIcons = map[string]int16{ + "melee": 1, + "spell": 2, + "divine": 3, + "combat": 4, + "heroic": 5, + "elemental": 6, +} + +// Default class names for debugging +var ClassNames = map[int8]string{ + 0: "Any", + 1: "Fighter", + 2: "Warrior", + 3: "Guardian", + 4: "Berserker", + 5: "Brawler", + 6: "Monk", + 7: "Bruiser", + 8: "Crusader", + 9: "Paladin", + 10: "Shadow Knight", + 11: "Priest", + 12: "Cleric", + 13: "Templar", + 14: "Inquisitor", + 15: "Druid", + 16: "Warden", + 17: "Fury", + 18: "Shaman", + 19: "Mystic", + 20: "Defiler", + 21: "Mage", + 22: "Sorcerer", + 23: "Wizard", + 24: "Warlock", + 25: "Enchanter", + 26: "Illusionist", + 27: "Coercer", + 28: "Summoner", + 29: "Necromancer", + 30: "Conjuror", + 31: "Scout", + 32: "Rogue", + 33: "Swashbuckler", + 34: "Brigand", + 35: "Bard", + 36: "Troubador", + 37: "Dirge", + 38: "Predator", + 39: "Ranger", + 40: "Assassin", +} \ No newline at end of file diff --git a/internal/heroic_ops/database.go b/internal/heroic_ops/database.go new file mode 100644 index 0000000..c0893fb --- /dev/null +++ b/internal/heroic_ops/database.go @@ -0,0 +1,730 @@ +package heroic_ops + +import ( + "context" + "fmt" + "time" + + "eq2emu/internal/database" +) + +// DatabaseHeroicOPManager implements HeroicOPDatabase interface using the existing database wrapper +type DatabaseHeroicOPManager struct { + db *database.DB +} + +// NewDatabaseHeroicOPManager creates a new database heroic OP manager +func NewDatabaseHeroicOPManager(db *database.DB) *DatabaseHeroicOPManager { + return &DatabaseHeroicOPManager{ + db: db, + } +} + +// LoadStarters retrieves all starters from database +func (dhom *DatabaseHeroicOPManager) LoadStarters(ctx context.Context) ([]HeroicOPData, error) { + query := `SELECT id, ho_type, starter_class, starter_icon, 0 as starter_link_id, + 0 as chain_order, 0 as shift_icon, 0 as spell_id, 0.0 as chance, + ability1, ability2, ability3, ability4, ability5, ability6, + name, description FROM heroic_ops WHERE ho_type = ?` + + rows, err := dhom.db.QueryContext(ctx, query, HOTypeStarter) + if err != nil { + return nil, fmt.Errorf("failed to query heroic op starters: %w", err) + } + defer rows.Close() + + var starters []HeroicOPData + for rows.Next() { + var starter HeroicOPData + var name, description *string + + err := rows.Scan( + &starter.ID, + &starter.HOType, + &starter.StarterClass, + &starter.StarterIcon, + &starter.StarterLinkID, + &starter.ChainOrder, + &starter.ShiftIcon, + &starter.SpellID, + &starter.Chance, + &starter.Ability1, + &starter.Ability2, + &starter.Ability3, + &starter.Ability4, + &starter.Ability5, + &starter.Ability6, + &name, + &description, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan heroic op starter row: %w", err) + } + + // Handle nullable fields + if name != nil { + starter.Name = *name + } + if description != nil { + starter.Description = *description + } + + starters = append(starters, starter) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating heroic op starter rows: %w", err) + } + + return starters, nil +} + +// LoadStarter retrieves a specific starter from database +func (dhom *DatabaseHeroicOPManager) LoadStarter(ctx context.Context, starterID int32) (*HeroicOPData, error) { + query := `SELECT id, ho_type, starter_class, starter_icon, 0 as starter_link_id, + 0 as chain_order, 0 as shift_icon, 0 as spell_id, 0.0 as chance, + ability1, ability2, ability3, ability4, ability5, ability6, + name, description FROM heroic_ops WHERE id = ? AND ho_type = ?` + + var starter HeroicOPData + var name, description *string + + err := dhom.db.QueryRowContext(ctx, query, starterID, HOTypeStarter).Scan( + &starter.ID, + &starter.HOType, + &starter.StarterClass, + &starter.StarterIcon, + &starter.StarterLinkID, + &starter.ChainOrder, + &starter.ShiftIcon, + &starter.SpellID, + &starter.Chance, + &starter.Ability1, + &starter.Ability2, + &starter.Ability3, + &starter.Ability4, + &starter.Ability5, + &starter.Ability6, + &name, + &description, + ) + if err != nil { + return nil, fmt.Errorf("failed to load heroic op starter %d: %w", starterID, err) + } + + // Handle nullable fields + if name != nil { + starter.Name = *name + } + if description != nil { + starter.Description = *description + } + + return &starter, nil +} + +// LoadWheels retrieves all wheels from database +func (dhom *DatabaseHeroicOPManager) LoadWheels(ctx context.Context) ([]HeroicOPData, error) { + query := `SELECT id, ho_type, 0 as starter_class, 0 as starter_icon, starter_link_id, + chain_order, shift_icon, spell_id, chance, + ability1, ability2, ability3, ability4, ability5, ability6, + name, description FROM heroic_ops WHERE ho_type = ?` + + rows, err := dhom.db.QueryContext(ctx, query, HOTypeWheel) + if err != nil { + return nil, fmt.Errorf("failed to query heroic op wheels: %w", err) + } + defer rows.Close() + + var wheels []HeroicOPData + for rows.Next() { + var wheel HeroicOPData + var name, description *string + + err := rows.Scan( + &wheel.ID, + &wheel.HOType, + &wheel.StarterClass, + &wheel.StarterIcon, + &wheel.StarterLinkID, + &wheel.ChainOrder, + &wheel.ShiftIcon, + &wheel.SpellID, + &wheel.Chance, + &wheel.Ability1, + &wheel.Ability2, + &wheel.Ability3, + &wheel.Ability4, + &wheel.Ability5, + &wheel.Ability6, + &name, + &description, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan heroic op wheel row: %w", err) + } + + // Handle nullable fields + if name != nil { + wheel.Name = *name + } + if description != nil { + wheel.Description = *description + } + + wheels = append(wheels, wheel) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating heroic op wheel rows: %w", err) + } + + return wheels, nil +} + +// LoadWheelsForStarter retrieves wheels for a specific starter +func (dhom *DatabaseHeroicOPManager) LoadWheelsForStarter(ctx context.Context, starterID int32) ([]HeroicOPData, error) { + query := `SELECT id, ho_type, 0 as starter_class, 0 as starter_icon, starter_link_id, + chain_order, shift_icon, spell_id, chance, + ability1, ability2, ability3, ability4, ability5, ability6, + name, description FROM heroic_ops WHERE starter_link_id = ? AND ho_type = ?` + + rows, err := dhom.db.QueryContext(ctx, query, starterID, HOTypeWheel) + if err != nil { + return nil, fmt.Errorf("failed to query wheels for starter %d: %w", starterID, err) + } + defer rows.Close() + + var wheels []HeroicOPData + for rows.Next() { + var wheel HeroicOPData + var name, description *string + + err := rows.Scan( + &wheel.ID, + &wheel.HOType, + &wheel.StarterClass, + &wheel.StarterIcon, + &wheel.StarterLinkID, + &wheel.ChainOrder, + &wheel.ShiftIcon, + &wheel.SpellID, + &wheel.Chance, + &wheel.Ability1, + &wheel.Ability2, + &wheel.Ability3, + &wheel.Ability4, + &wheel.Ability5, + &wheel.Ability6, + &name, + &description, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan wheel row: %w", err) + } + + // Handle nullable fields + if name != nil { + wheel.Name = *name + } + if description != nil { + wheel.Description = *description + } + + wheels = append(wheels, wheel) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating wheel rows: %w", err) + } + + return wheels, nil +} + +// LoadWheel retrieves a specific wheel from database +func (dhom *DatabaseHeroicOPManager) LoadWheel(ctx context.Context, wheelID int32) (*HeroicOPData, error) { + query := `SELECT id, ho_type, 0 as starter_class, 0 as starter_icon, starter_link_id, + chain_order, shift_icon, spell_id, chance, + ability1, ability2, ability3, ability4, ability5, ability6, + name, description FROM heroic_ops WHERE id = ? AND ho_type = ?` + + var wheel HeroicOPData + var name, description *string + + err := dhom.db.QueryRowContext(ctx, query, wheelID, HOTypeWheel).Scan( + &wheel.ID, + &wheel.HOType, + &wheel.StarterClass, + &wheel.StarterIcon, + &wheel.StarterLinkID, + &wheel.ChainOrder, + &wheel.ShiftIcon, + &wheel.SpellID, + &wheel.Chance, + &wheel.Ability1, + &wheel.Ability2, + &wheel.Ability3, + &wheel.Ability4, + &wheel.Ability5, + &wheel.Ability6, + &name, + &description, + ) + if err != nil { + return nil, fmt.Errorf("failed to load heroic op wheel %d: %w", wheelID, err) + } + + // Handle nullable fields + if name != nil { + wheel.Name = *name + } + if description != nil { + wheel.Description = *description + } + + return &wheel, nil +} + +// SaveStarter saves a heroic op starter +func (dhom *DatabaseHeroicOPManager) SaveStarter(ctx context.Context, starter *HeroicOPStarter) error { + query := `INSERT OR REPLACE INTO heroic_ops + (id, ho_type, starter_class, starter_icon, starter_link_id, chain_order, + shift_icon, spell_id, chance, ability1, ability2, ability3, ability4, + ability5, ability6, name, description) + VALUES (?, ?, ?, ?, 0, 0, 0, 0, 0.0, ?, ?, ?, ?, ?, ?, ?, ?)` + + _, err := dhom.db.ExecContext(ctx, query, + starter.ID, + HOTypeStarter, + starter.StartClass, + starter.StarterIcon, + starter.Abilities[0], + starter.Abilities[1], + starter.Abilities[2], + starter.Abilities[3], + starter.Abilities[4], + starter.Abilities[5], + starter.Name, + starter.Description, + ) + if err != nil { + return fmt.Errorf("failed to save heroic op starter %d: %w", starter.ID, err) + } + + return nil +} + +// SaveWheel saves a heroic op wheel +func (dhom *DatabaseHeroicOPManager) SaveWheel(ctx context.Context, wheel *HeroicOPWheel) error { + query := `INSERT OR REPLACE INTO heroic_ops + (id, ho_type, starter_class, starter_icon, starter_link_id, chain_order, + shift_icon, spell_id, chance, ability1, ability2, ability3, ability4, + ability5, ability6, name, description) + VALUES (?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + + _, err := dhom.db.ExecContext(ctx, query, + wheel.ID, + HOTypeWheel, + wheel.StarterLinkID, + wheel.Order, + wheel.ShiftIcon, + wheel.SpellID, + wheel.Chance, + wheel.Abilities[0], + wheel.Abilities[1], + wheel.Abilities[2], + wheel.Abilities[3], + wheel.Abilities[4], + wheel.Abilities[5], + wheel.Name, + wheel.Description, + ) + if err != nil { + return fmt.Errorf("failed to save heroic op wheel %d: %w", wheel.ID, err) + } + + return nil +} + +// DeleteStarter removes a starter from database +func (dhom *DatabaseHeroicOPManager) DeleteStarter(ctx context.Context, starterID int32) error { + // Use a transaction to delete starter and associated wheels + tx, err := dhom.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Delete associated wheels first + _, err = tx.ExecContext(ctx, "DELETE FROM heroic_ops WHERE starter_link_id = ? AND ho_type = ?", + starterID, HOTypeWheel) + if err != nil { + return fmt.Errorf("failed to delete wheels for starter %d: %w", starterID, err) + } + + // Delete the starter + _, err = tx.ExecContext(ctx, "DELETE FROM heroic_ops WHERE id = ? AND ho_type = ?", + starterID, HOTypeStarter) + if err != nil { + return fmt.Errorf("failed to delete starter %d: %w", starterID, err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} + +// DeleteWheel removes a wheel from database +func (dhom *DatabaseHeroicOPManager) DeleteWheel(ctx context.Context, wheelID int32) error { + _, err := dhom.db.ExecContext(ctx, "DELETE FROM heroic_ops WHERE id = ? AND ho_type = ?", + wheelID, HOTypeWheel) + if err != nil { + return fmt.Errorf("failed to delete wheel %d: %w", wheelID, err) + } + + return nil +} + +// SaveHOInstance saves a heroic opportunity instance +func (dhom *DatabaseHeroicOPManager) SaveHOInstance(ctx context.Context, ho *HeroicOP) error { + query := `INSERT OR REPLACE INTO heroic_op_instances + (id, encounter_id, starter_id, wheel_id, state, start_time, wheel_start_time, + time_remaining, total_time, complete, countered_1, countered_2, countered_3, + countered_4, countered_5, countered_6, shift_used, starter_progress, + completed_by, spell_name, spell_description) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + + startTimeUnix := ho.StartTime.Unix() + wheelStartTimeUnix := ho.WheelStartTime.Unix() + + _, err := dhom.db.ExecContext(ctx, query, + ho.ID, + ho.EncounterID, + ho.StarterID, + ho.WheelID, + ho.State, + startTimeUnix, + wheelStartTimeUnix, + ho.TimeRemaining, + ho.TotalTime, + ho.Complete, + ho.Countered[0], + ho.Countered[1], + ho.Countered[2], + ho.Countered[3], + ho.Countered[4], + ho.Countered[5], + ho.ShiftUsed, + ho.StarterProgress, + ho.CompletedBy, + ho.SpellName, + ho.SpellDescription, + ) + if err != nil { + return fmt.Errorf("failed to save HO instance %d: %w", ho.ID, err) + } + + return nil +} + +// LoadHOInstance retrieves a heroic opportunity instance +func (dhom *DatabaseHeroicOPManager) LoadHOInstance(ctx context.Context, instanceID int64) (*HeroicOP, error) { + query := `SELECT id, encounter_id, starter_id, wheel_id, state, start_time, wheel_start_time, + time_remaining, total_time, complete, countered_1, countered_2, countered_3, + countered_4, countered_5, countered_6, shift_used, starter_progress, + completed_by, spell_name, spell_description + FROM heroic_op_instances WHERE id = ?` + + var ho HeroicOP + var startTimeUnix, wheelStartTimeUnix int64 + var spellName, spellDescription *string + + err := dhom.db.QueryRowContext(ctx, query, instanceID).Scan( + &ho.ID, + &ho.EncounterID, + &ho.StarterID, + &ho.WheelID, + &ho.State, + &startTimeUnix, + &wheelStartTimeUnix, + &ho.TimeRemaining, + &ho.TotalTime, + &ho.Complete, + &ho.Countered[0], + &ho.Countered[1], + &ho.Countered[2], + &ho.Countered[3], + &ho.Countered[4], + &ho.Countered[5], + &ho.ShiftUsed, + &ho.StarterProgress, + &ho.CompletedBy, + &spellName, + &spellDescription, + ) + if err != nil { + return nil, fmt.Errorf("failed to load HO instance %d: %w", instanceID, err) + } + + // Convert timestamps + ho.StartTime = time.Unix(startTimeUnix, 0) + ho.WheelStartTime = time.Unix(wheelStartTimeUnix, 0) + + // Handle nullable fields + if spellName != nil { + ho.SpellName = *spellName + } + if spellDescription != nil { + ho.SpellDescription = *spellDescription + } + + // Initialize maps + ho.Participants = make(map[int32]bool) + ho.CurrentStarters = make([]int32, 0) + + return &ho, nil +} + +// DeleteHOInstance removes a heroic opportunity instance +func (dhom *DatabaseHeroicOPManager) DeleteHOInstance(ctx context.Context, instanceID int64) error { + _, err := dhom.db.ExecContext(ctx, "DELETE FROM heroic_op_instances WHERE id = ?", instanceID) + if err != nil { + return fmt.Errorf("failed to delete HO instance %d: %w", instanceID, err) + } + + return nil +} + +// SaveHOEvent saves a heroic opportunity event +func (dhom *DatabaseHeroicOPManager) SaveHOEvent(ctx context.Context, event *HeroicOPEvent) error { + query := `INSERT INTO heroic_op_events + (id, instance_id, event_type, character_id, ability_icon, timestamp, data) + VALUES (?, ?, ?, ?, ?, ?, ?)` + + timestampUnix := event.Timestamp.Unix() + + _, err := dhom.db.ExecContext(ctx, query, + event.ID, + event.InstanceID, + event.EventType, + event.CharacterID, + event.AbilityIcon, + timestampUnix, + event.Data, + ) + if err != nil { + return fmt.Errorf("failed to save HO event %d: %w", event.ID, err) + } + + return nil +} + +// LoadHOEvents retrieves events for a heroic opportunity instance +func (dhom *DatabaseHeroicOPManager) LoadHOEvents(ctx context.Context, instanceID int64) ([]HeroicOPEvent, error) { + query := `SELECT id, instance_id, event_type, character_id, ability_icon, timestamp, data + FROM heroic_op_events WHERE instance_id = ? ORDER BY timestamp ASC` + + rows, err := dhom.db.QueryContext(ctx, query, instanceID) + if err != nil { + return nil, fmt.Errorf("failed to query HO events for instance %d: %w", instanceID, err) + } + defer rows.Close() + + var events []HeroicOPEvent + for rows.Next() { + var event HeroicOPEvent + var timestampUnix int64 + var data *string + + err := rows.Scan( + &event.ID, + &event.InstanceID, + &event.EventType, + &event.CharacterID, + &event.AbilityIcon, + ×tampUnix, + &data, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan HO event row: %w", err) + } + + event.Timestamp = time.Unix(timestampUnix, 0) + if data != nil { + event.Data = *data + } + + events = append(events, event) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating HO event rows: %w", err) + } + + return events, nil +} + +// GetHOStatistics retrieves statistics for a character +func (dhom *DatabaseHeroicOPManager) GetHOStatistics(ctx context.Context, characterID int32) (*HeroicOPStatistics, error) { + // This is a simplified implementation - in practice you'd want more complex statistics + stats := &HeroicOPStatistics{ + ParticipationStats: make(map[int32]int64), + } + + // Count total HOs started by this character + query := `SELECT COUNT(*) FROM heroic_op_events + WHERE character_id = ? AND event_type = ?` + err := dhom.db.QueryRowContext(ctx, query, characterID, EventHOStarted).Scan(&stats.TotalHOsStarted) + if err != nil { + return nil, fmt.Errorf("failed to get HO started count: %w", err) + } + + // Count total HOs completed by this character + query = `SELECT COUNT(*) FROM heroic_op_events + WHERE character_id = ? AND event_type = ?` + err = dhom.db.QueryRowContext(ctx, query, characterID, EventHOCompleted).Scan(&stats.TotalHOsCompleted) + if err != nil { + return nil, fmt.Errorf("failed to get HO completed count: %w", err) + } + + // Calculate success rate + if stats.TotalHOsStarted > 0 { + stats.SuccessRate = float64(stats.TotalHOsCompleted) / float64(stats.TotalHOsStarted) * 100.0 + } + + stats.ParticipationStats[characterID] = stats.TotalHOsStarted + + return stats, nil +} + +// GetNextStarterID returns the next available starter ID +func (dhom *DatabaseHeroicOPManager) GetNextStarterID(ctx context.Context) (int32, error) { + query := "SELECT COALESCE(MAX(id), 0) + 1 FROM heroic_ops WHERE ho_type = ?" + + var nextID int32 + err := dhom.db.QueryRowContext(ctx, query, HOTypeStarter).Scan(&nextID) + if err != nil { + return 0, fmt.Errorf("failed to get next starter ID: %w", err) + } + + return nextID, nil +} + +// GetNextWheelID returns the next available wheel ID +func (dhom *DatabaseHeroicOPManager) GetNextWheelID(ctx context.Context) (int32, error) { + query := "SELECT COALESCE(MAX(id), 0) + 1 FROM heroic_ops WHERE ho_type = ?" + + var nextID int32 + err := dhom.db.QueryRowContext(ctx, query, HOTypeWheel).Scan(&nextID) + if err != nil { + return 0, fmt.Errorf("failed to get next wheel ID: %w", err) + } + + return nextID, nil +} + +// GetNextInstanceID returns the next available instance ID +func (dhom *DatabaseHeroicOPManager) GetNextInstanceID(ctx context.Context) (int64, error) { + query := "SELECT COALESCE(MAX(id), 0) + 1 FROM heroic_op_instances" + + var nextID int64 + err := dhom.db.QueryRowContext(ctx, query).Scan(&nextID) + if err != nil { + return 0, fmt.Errorf("failed to get next instance ID: %w", err) + } + + return nextID, nil +} + +// EnsureHOTables creates the heroic opportunity tables if they don't exist +func (dhom *DatabaseHeroicOPManager) EnsureHOTables(ctx context.Context) error { + queries := []string{ + `CREATE TABLE IF NOT EXISTS heroic_ops ( + id INTEGER NOT NULL, + ho_type TEXT NOT NULL CHECK(ho_type IN ('Starter', 'Wheel')), + starter_class INTEGER NOT NULL DEFAULT 0, + starter_icon INTEGER NOT NULL DEFAULT 0, + starter_link_id INTEGER NOT NULL DEFAULT 0, + chain_order INTEGER NOT NULL DEFAULT 0, + shift_icon INTEGER NOT NULL DEFAULT 0, + spell_id INTEGER NOT NULL DEFAULT 0, + chance REAL NOT NULL DEFAULT 1.0, + ability1 INTEGER NOT NULL DEFAULT 0, + ability2 INTEGER NOT NULL DEFAULT 0, + ability3 INTEGER NOT NULL DEFAULT 0, + ability4 INTEGER NOT NULL DEFAULT 0, + ability5 INTEGER NOT NULL DEFAULT 0, + ability6 INTEGER NOT NULL DEFAULT 0, + name TEXT DEFAULT '', + description TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id, ho_type) + )`, + `CREATE TABLE IF NOT EXISTS heroic_op_instances ( + id INTEGER PRIMARY KEY, + encounter_id INTEGER NOT NULL, + starter_id INTEGER NOT NULL DEFAULT 0, + wheel_id INTEGER NOT NULL DEFAULT 0, + state INTEGER NOT NULL DEFAULT 0, + start_time INTEGER NOT NULL, + wheel_start_time INTEGER NOT NULL DEFAULT 0, + time_remaining INTEGER NOT NULL DEFAULT 0, + total_time INTEGER NOT NULL DEFAULT 0, + complete INTEGER NOT NULL DEFAULT 0, + countered_1 INTEGER NOT NULL DEFAULT 0, + countered_2 INTEGER NOT NULL DEFAULT 0, + countered_3 INTEGER NOT NULL DEFAULT 0, + countered_4 INTEGER NOT NULL DEFAULT 0, + countered_5 INTEGER NOT NULL DEFAULT 0, + countered_6 INTEGER NOT NULL DEFAULT 0, + shift_used INTEGER NOT NULL DEFAULT 0, + starter_progress INTEGER NOT NULL DEFAULT 0, + completed_by INTEGER NOT NULL DEFAULT 0, + spell_name TEXT DEFAULT '', + spell_description TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS heroic_op_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instance_id INTEGER NOT NULL, + event_type INTEGER NOT NULL, + character_id INTEGER NOT NULL, + ability_icon INTEGER NOT NULL DEFAULT 0, + timestamp INTEGER NOT NULL, + data TEXT DEFAULT '', + FOREIGN KEY (instance_id) REFERENCES heroic_op_instances(id) ON DELETE CASCADE + )`, + } + + for i, query := range queries { + _, err := dhom.db.ExecContext(ctx, query) + if err != nil { + return fmt.Errorf("failed to create HO table %d: %w", i+1, err) + } + } + + // Create indexes for better performance + indexes := []string{ + `CREATE INDEX IF NOT EXISTS idx_heroic_ops_type ON heroic_ops(ho_type)`, + `CREATE INDEX IF NOT EXISTS idx_heroic_ops_class ON heroic_ops(starter_class)`, + `CREATE INDEX IF NOT EXISTS idx_heroic_ops_link ON heroic_ops(starter_link_id)`, + `CREATE INDEX IF NOT EXISTS idx_ho_instances_encounter ON heroic_op_instances(encounter_id)`, + `CREATE INDEX IF NOT EXISTS idx_ho_instances_state ON heroic_op_instances(state)`, + `CREATE INDEX IF NOT EXISTS idx_ho_events_instance ON heroic_op_events(instance_id)`, + `CREATE INDEX IF NOT EXISTS idx_ho_events_character ON heroic_op_events(character_id)`, + `CREATE INDEX IF NOT EXISTS idx_ho_events_type ON heroic_op_events(event_type)`, + `CREATE INDEX IF NOT EXISTS idx_ho_events_timestamp ON heroic_op_events(timestamp)`, + } + + for i, query := range indexes { + _, err := dhom.db.ExecContext(ctx, query) + if err != nil { + return fmt.Errorf("failed to create HO index %d: %w", i+1, err) + } + } + + return nil +} \ No newline at end of file diff --git a/internal/heroic_ops/heroic_op.go b/internal/heroic_ops/heroic_op.go new file mode 100644 index 0000000..6ceed3a --- /dev/null +++ b/internal/heroic_ops/heroic_op.go @@ -0,0 +1,722 @@ +package heroic_ops + +import ( + "fmt" + "math/rand" + "sync" + "time" +) + +// NewHeroicOPStarter creates a new heroic opportunity starter +func NewHeroicOPStarter(id int32, startClass int8, starterIcon int16) *HeroicOPStarter { + return &HeroicOPStarter{ + ID: id, + StartClass: startClass, + StarterIcon: starterIcon, + Abilities: [6]int16{}, + SaveNeeded: false, + } +} + +// Copy creates a deep copy of the starter +func (hos *HeroicOPStarter) Copy() *HeroicOPStarter { + hos.mu.RLock() + defer hos.mu.RUnlock() + + newStarter := &HeroicOPStarter{ + ID: hos.ID, + StartClass: hos.StartClass, + StarterIcon: hos.StarterIcon, + Abilities: hos.Abilities, // Arrays are copied by value + Name: hos.Name, + Description: hos.Description, + SaveNeeded: false, + } + + return newStarter +} + +// GetAbility returns the ability icon at the specified position +func (hos *HeroicOPStarter) GetAbility(position int) int16 { + hos.mu.RLock() + defer hos.mu.RUnlock() + + if position < 0 || position >= MaxAbilities { + return AbilityIconNone + } + + return hos.Abilities[position] +} + +// SetAbility sets the ability icon at the specified position +func (hos *HeroicOPStarter) SetAbility(position int, abilityIcon int16) bool { + hos.mu.Lock() + defer hos.mu.Unlock() + + if position < 0 || position >= MaxAbilities { + return false + } + + hos.Abilities[position] = abilityIcon + hos.SaveNeeded = true + return true +} + +// IsComplete checks if the starter chain is complete (has completion marker) +func (hos *HeroicOPStarter) IsComplete(position int) bool { + hos.mu.RLock() + defer hos.mu.RUnlock() + + if position < 0 || position >= MaxAbilities { + return false + } + + return hos.Abilities[position] == AbilityIconAny +} + +// CanInitiate checks if the specified class can initiate this starter +func (hos *HeroicOPStarter) CanInitiate(playerClass int8) bool { + hos.mu.RLock() + defer hos.mu.RUnlock() + + return hos.StartClass == ClassAny || hos.StartClass == playerClass +} + +// MatchesAbility checks if the given ability matches the current position +func (hos *HeroicOPStarter) MatchesAbility(position int, abilityIcon int16) bool { + hos.mu.RLock() + defer hos.mu.RUnlock() + + if position < 0 || position >= MaxAbilities { + return false + } + + requiredAbility := hos.Abilities[position] + + // Wildcard matches any ability + if requiredAbility == AbilityIconAny { + return true + } + + // Exact match required + return requiredAbility == abilityIcon +} + +// Validate checks if the starter is properly configured +func (hos *HeroicOPStarter) Validate() error { + hos.mu.RLock() + defer hos.mu.RUnlock() + + if hos.ID <= 0 { + return fmt.Errorf("invalid starter ID: %d", hos.ID) + } + + if hos.StarterIcon <= 0 { + return fmt.Errorf("invalid starter icon: %d", hos.StarterIcon) + } + + // Check for at least one non-zero ability + hasAbility := false + for _, ability := range hos.Abilities { + if ability != AbilityIconNone { + hasAbility = true + break + } + } + + if !hasAbility { + return fmt.Errorf("starter must have at least one ability") + } + + return nil +} + +// NewHeroicOPWheel creates a new heroic opportunity wheel +func NewHeroicOPWheel(id int32, starterLinkID int32, order int8) *HeroicOPWheel { + return &HeroicOPWheel{ + ID: id, + StarterLinkID: starterLinkID, + Order: order, + Abilities: [6]int16{}, + Chance: 1.0, + SaveNeeded: false, + } +} + +// Copy creates a deep copy of the wheel +func (how *HeroicOPWheel) Copy() *HeroicOPWheel { + how.mu.RLock() + defer how.mu.RUnlock() + + newWheel := &HeroicOPWheel{ + ID: how.ID, + StarterLinkID: how.StarterLinkID, + Order: how.Order, + ShiftIcon: how.ShiftIcon, + Chance: how.Chance, + Abilities: how.Abilities, // Arrays are copied by value + SpellID: how.SpellID, + Name: how.Name, + Description: how.Description, + RequiredPlayers: how.RequiredPlayers, + SaveNeeded: false, + } + + return newWheel +} + +// GetAbility returns the ability icon at the specified position +func (how *HeroicOPWheel) GetAbility(position int) int16 { + how.mu.RLock() + defer how.mu.RUnlock() + + if position < 0 || position >= MaxAbilities { + return AbilityIconNone + } + + return how.Abilities[position] +} + +// SetAbility sets the ability icon at the specified position +func (how *HeroicOPWheel) SetAbility(position int, abilityIcon int16) bool { + how.mu.Lock() + defer how.mu.Unlock() + + if position < 0 || position >= MaxAbilities { + return false + } + + how.Abilities[position] = abilityIcon + how.SaveNeeded = true + return true +} + +// IsOrdered checks if this wheel requires ordered completion +func (how *HeroicOPWheel) IsOrdered() bool { + how.mu.RLock() + defer how.mu.RUnlock() + + return how.Order >= WheelOrderOrdered +} + +// HasShift checks if this wheel has a shift ability +func (how *HeroicOPWheel) HasShift() bool { + how.mu.RLock() + defer how.mu.RUnlock() + + return how.ShiftIcon > 0 +} + +// CanShift checks if shifting is possible with the given ability +func (how *HeroicOPWheel) CanShift(abilityIcon int16) bool { + how.mu.RLock() + defer how.mu.RUnlock() + + return how.ShiftIcon > 0 && how.ShiftIcon == abilityIcon +} + +// GetNextRequiredAbility returns the next required ability for ordered wheels +func (how *HeroicOPWheel) GetNextRequiredAbility(countered [6]int8) int16 { + how.mu.RLock() + defer how.mu.RUnlock() + + if !how.IsOrdered() { + return AbilityIconNone // Any uncompleted ability works for unordered + } + + // Find first uncompleted ability in order + for i := 0; i < MaxAbilities; i++ { + if countered[i] == 0 && how.Abilities[i] != AbilityIconNone { + return how.Abilities[i] + } + } + + return AbilityIconNone +} + +// CanUseAbility checks if an ability can be used on this wheel +func (how *HeroicOPWheel) CanUseAbility(abilityIcon int16, countered [6]int8) bool { + how.mu.RLock() + defer how.mu.RUnlock() + + // Check if this is a shift attempt + if how.CanShift(abilityIcon) { + return true + } + + if how.IsOrdered() { + // For ordered wheels, only the next required ability can be used + nextRequired := how.GetNextRequiredAbility(countered) + return nextRequired == abilityIcon + } else { + // For unordered wheels, any uncompleted matching ability can be used + for i := 0; i < MaxAbilities; i++ { + if countered[i] == 0 && how.Abilities[i] == abilityIcon { + return true + } + } + } + + return false +} + +// Validate checks if the wheel is properly configured +func (how *HeroicOPWheel) Validate() error { + how.mu.RLock() + defer how.mu.RUnlock() + + if how.ID <= 0 { + return fmt.Errorf("invalid wheel ID: %d", how.ID) + } + + if how.StarterLinkID <= 0 { + return fmt.Errorf("invalid starter link ID: %d", how.StarterLinkID) + } + + if how.Chance < MinChance || how.Chance > MaxChance { + return fmt.Errorf("invalid chance: %f (must be %f-%f)", how.Chance, MinChance, MaxChance) + } + + if how.SpellID <= 0 { + return fmt.Errorf("invalid spell ID: %d", how.SpellID) + } + + // Check for at least one non-zero ability + hasAbility := false + for _, ability := range how.Abilities { + if ability != AbilityIconNone { + hasAbility = true + break + } + } + + if !hasAbility { + return fmt.Errorf("wheel must have at least one ability") + } + + return nil +} + +// NewHeroicOP creates a new heroic opportunity instance +func NewHeroicOP(instanceID int64, encounterID int32) *HeroicOP { + return &HeroicOP{ + ID: instanceID, + EncounterID: encounterID, + State: HOStateInactive, + StartTime: time.Now(), + Participants: make(map[int32]bool), + CurrentStarters: make([]int32, 0), + TotalTime: DefaultWheelTimerSeconds * 1000, // Convert to milliseconds + TimeRemaining: DefaultWheelTimerSeconds * 1000, + SaveNeeded: false, + } +} + +// AddParticipant adds a character to the HO participants +func (ho *HeroicOP) AddParticipant(characterID int32) { + ho.mu.Lock() + defer ho.mu.Unlock() + + ho.Participants[characterID] = true + ho.SaveNeeded = true +} + +// RemoveParticipant removes a character from the HO participants +func (ho *HeroicOP) RemoveParticipant(characterID int32) { + ho.mu.Lock() + defer ho.mu.Unlock() + + delete(ho.Participants, characterID) + ho.SaveNeeded = true +} + +// IsParticipant checks if a character is participating in this HO +func (ho *HeroicOP) IsParticipant(characterID int32) bool { + ho.mu.RLock() + defer ho.mu.RUnlock() + + return ho.Participants[characterID] +} + +// GetParticipants returns a slice of participant character IDs +func (ho *HeroicOP) GetParticipants() []int32 { + ho.mu.RLock() + defer ho.mu.RUnlock() + + participants := make([]int32, 0, len(ho.Participants)) + for characterID := range ho.Participants { + participants = append(participants, characterID) + } + + return participants +} + +// StartStarterChain initiates the starter chain phase +func (ho *HeroicOP) StartStarterChain(availableStarters []int32) { + ho.mu.Lock() + defer ho.mu.Unlock() + + ho.State = HOStateStarterChain + ho.CurrentStarters = make([]int32, len(availableStarters)) + copy(ho.CurrentStarters, availableStarters) + ho.StarterProgress = 0 + ho.StartTime = time.Now() + ho.SaveNeeded = true +} + +// ProcessStarterAbility processes an ability during starter chain phase +func (ho *HeroicOP) ProcessStarterAbility(abilityIcon int16, masterList *MasterHeroicOPList) bool { + ho.mu.Lock() + defer ho.mu.Unlock() + + if ho.State != HOStateStarterChain { + return false + } + + // Filter out starters that don't match this ability at current position + newStarters := make([]int32, 0) + + for _, starterID := range ho.CurrentStarters { + starter := masterList.GetStarter(starterID) + if starter != nil && starter.MatchesAbility(int(ho.StarterProgress), abilityIcon) { + // Check if this completes the starter + if starter.IsComplete(int(ho.StarterProgress)) { + // Starter completed, transition to wheel phase + ho.StarterID = starterID + ho.SaveNeeded = true + return true + } + newStarters = append(newStarters, starterID) + } + } + + ho.CurrentStarters = newStarters + ho.StarterProgress++ + ho.SaveNeeded = true + + // If no starters remain, HO fails + return len(ho.CurrentStarters) > 0 +} + +// StartWheelPhase initiates the wheel phase +func (ho *HeroicOP) StartWheelPhase(wheel *HeroicOPWheel, timerSeconds int32) { + ho.mu.Lock() + defer ho.mu.Unlock() + + ho.State = HOStateWheelPhase + ho.WheelID = wheel.ID + ho.WheelStartTime = time.Now() + ho.TotalTime = timerSeconds * 1000 // Convert to milliseconds + ho.TimeRemaining = ho.TotalTime + ho.SpellName = wheel.Name + ho.SpellDescription = wheel.Description + + // Clear countered array + for i := range ho.Countered { + ho.Countered[i] = 0 + } + + ho.SaveNeeded = true +} + +// ProcessWheelAbility processes an ability during wheel phase +func (ho *HeroicOP) ProcessWheelAbility(abilityIcon int16, characterID int32, wheel *HeroicOPWheel) bool { + ho.mu.Lock() + defer ho.mu.Unlock() + + if ho.State != HOStateWheelPhase { + return false + } + + // Check for shift attempt + if ho.ShiftUsed == ShiftNotUsed && wheel.CanShift(abilityIcon) { + // Allow shift only if no progress made (unordered) or at start (ordered) + canShift := false + if wheel.IsOrdered() { + // For ordered, can shift only if no abilities completed + canShift = true + for i := 0; i < MaxAbilities; i++ { + if ho.Countered[i] != 0 { + canShift = false + break + } + } + } else { + // For unordered, can shift only if no abilities completed + canShift = true + for i := 0; i < MaxAbilities; i++ { + if ho.Countered[i] != 0 { + canShift = false + break + } + } + } + + if canShift { + ho.ShiftUsed = ShiftUsed + ho.SaveNeeded = true + return true // Caller should handle wheel shifting + } + return false + } + + // Check if ability can be used + if !wheel.CanUseAbility(abilityIcon, ho.Countered) { + return false + } + + // Find matching ability position and mark as countered + for i := 0; i < MaxAbilities; i++ { + if ho.Countered[i] == 0 && wheel.GetAbility(i) == abilityIcon { + ho.Countered[i] = 1 + ho.AddParticipant(characterID) + ho.SaveNeeded = true + + // Check if wheel is complete + complete := true + for j := 0; j < MaxAbilities; j++ { + if wheel.GetAbility(j) != AbilityIconNone && ho.Countered[j] == 0 { + complete = false + break + } + } + + if complete { + ho.Complete = HOComplete + ho.State = HOStateComplete + ho.CompletedBy = characterID + } + + return true + } + } + + return false +} + +// UpdateTimer updates the remaining time for the HO +func (ho *HeroicOP) UpdateTimer(deltaMS int32) bool { + ho.mu.Lock() + defer ho.mu.Unlock() + + if ho.State != HOStateWheelPhase { + return true // Timer not active + } + + ho.TimeRemaining -= deltaMS + + if ho.TimeRemaining <= 0 { + ho.TimeRemaining = 0 + ho.State = HOStateFailed + ho.SaveNeeded = true + return false // Timer expired + } + + ho.SaveNeeded = true + return true +} + +// IsComplete checks if the HO is successfully completed +func (ho *HeroicOP) IsComplete() bool { + ho.mu.RLock() + defer ho.mu.RUnlock() + + return ho.Complete == HOComplete && ho.State == HOStateComplete +} + +// IsFailed checks if the HO has failed +func (ho *HeroicOP) IsFailed() bool { + ho.mu.RLock() + defer ho.mu.RUnlock() + + return ho.State == HOStateFailed +} + +// IsActive checks if the HO is currently active (in progress) +func (ho *HeroicOP) IsActive() bool { + ho.mu.RLock() + defer ho.mu.RUnlock() + + return ho.State == HOStateStarterChain || ho.State == HOStateWheelPhase +} + +// GetProgress returns the completion percentage (0.0 - 1.0) +func (ho *HeroicOP) GetProgress() float32 { + ho.mu.RLock() + defer ho.mu.RUnlock() + + if ho.State != HOStateWheelPhase { + return 0.0 + } + + completed := 0 + total := 0 + + for i := 0; i < MaxAbilities; i++ { + if ho.Countered[i] != 0 { + completed++ + } + if ho.Countered[i] != 0 || ho.Countered[i] == 0 { // All positions count + total++ + } + } + + if total == 0 { + return 0.0 + } + + return float32(completed) / float32(total) +} + +// GetPacketData returns data formatted for client packets +func (ho *HeroicOP) GetPacketData(wheel *HeroicOPWheel) *PacketData { + ho.mu.RLock() + defer ho.mu.RUnlock() + + data := &PacketData{ + SpellName: ho.SpellName, + SpellDescription: ho.SpellDescription, + TimeRemaining: ho.TimeRemaining, + TotalTime: ho.TotalTime, + Complete: ho.Complete, + State: ho.State, + CanShift: false, + ShiftIcon: 0, + } + + if wheel != nil { + data.Abilities = wheel.Abilities + data.CanShift = ho.ShiftUsed == ShiftNotUsed && wheel.HasShift() + data.ShiftIcon = wheel.ShiftIcon + } + + data.Countered = ho.Countered + + return data +} + +// Validate checks if the HO instance is in a valid state +func (ho *HeroicOP) Validate() error { + ho.mu.RLock() + defer ho.mu.RUnlock() + + if ho.ID <= 0 { + return fmt.Errorf("invalid HO instance ID: %d", ho.ID) + } + + if ho.EncounterID <= 0 { + return fmt.Errorf("invalid encounter ID: %d", ho.EncounterID) + } + + if ho.State < HOStateInactive || ho.State > HOStateFailed { + return fmt.Errorf("invalid HO state: %d", ho.State) + } + + if ho.State == HOStateWheelPhase { + if ho.WheelID <= 0 { + return fmt.Errorf("wheel phase requires valid wheel ID") + } + + if ho.TotalTime <= 0 { + return fmt.Errorf("wheel phase requires valid timer") + } + } + + return nil +} + +// Copy creates a deep copy of the HO instance +func (ho *HeroicOP) Copy() *HeroicOP { + ho.mu.RLock() + defer ho.mu.RUnlock() + + newHO := &HeroicOP{ + ID: ho.ID, + EncounterID: ho.EncounterID, + StarterID: ho.StarterID, + WheelID: ho.WheelID, + State: ho.State, + StartTime: ho.StartTime, + WheelStartTime: ho.WheelStartTime, + TimeRemaining: ho.TimeRemaining, + TotalTime: ho.TotalTime, + Complete: ho.Complete, + Countered: ho.Countered, // Arrays are copied by value + ShiftUsed: ho.ShiftUsed, + StarterProgress: ho.StarterProgress, + CompletedBy: ho.CompletedBy, + SpellName: ho.SpellName, + SpellDescription: ho.SpellDescription, + Participants: make(map[int32]bool, len(ho.Participants)), + CurrentStarters: make([]int32, len(ho.CurrentStarters)), + SaveNeeded: false, + } + + // Deep copy participants map + for characterID, participating := range ho.Participants { + newHO.Participants[characterID] = participating + } + + // Deep copy current starters slice + copy(newHO.CurrentStarters, ho.CurrentStarters) + + return newHO +} + +// Helper functions for random selection + +// SelectRandomWheel selects a random wheel from a list based on chance values +func SelectRandomWheel(wheels []*HeroicOPWheel) *HeroicOPWheel { + if len(wheels) == 0 { + return nil + } + + if len(wheels) == 1 { + return wheels[0] + } + + // Calculate total chance + totalChance := float32(0.0) + for _, wheel := range wheels { + totalChance += wheel.Chance + } + + if totalChance <= 0.0 { + // If no chances set, select randomly with equal probability + return wheels[rand.Intn(len(wheels))] + } + + // Random selection based on weighted chance + randomValue := rand.Float32() * totalChance + currentChance := float32(0.0) + + for _, wheel := range wheels { + currentChance += wheel.Chance + if randomValue <= currentChance { + return wheel + } + } + + // Fallback to last wheel (shouldn't happen with proper math) + return wheels[len(wheels)-1] +} + +// GetElapsedTime returns the elapsed time since HO started +func (ho *HeroicOP) GetElapsedTime() time.Duration { + ho.mu.RLock() + defer ho.mu.RUnlock() + + return time.Since(ho.StartTime) +} + +// GetWheelElapsedTime returns the elapsed time since wheel phase started +func (ho *HeroicOP) GetWheelElapsedTime() time.Duration { + ho.mu.RLock() + defer ho.mu.RUnlock() + + if ho.State != HOStateWheelPhase { + return 0 + } + + return time.Since(ho.WheelStartTime) +} \ No newline at end of file diff --git a/internal/heroic_ops/interfaces.go b/internal/heroic_ops/interfaces.go new file mode 100644 index 0000000..9d04eb4 --- /dev/null +++ b/internal/heroic_ops/interfaces.go @@ -0,0 +1,218 @@ +package heroic_ops + +import ( + "context" + "time" +) + +// HeroicOPDatabase defines the interface for database operations +type HeroicOPDatabase interface { + // Starter operations + LoadStarters(ctx context.Context) ([]HeroicOPData, error) + LoadStarter(ctx context.Context, starterID int32) (*HeroicOPData, error) + SaveStarter(ctx context.Context, starter *HeroicOPStarter) error + DeleteStarter(ctx context.Context, starterID int32) error + + // Wheel operations + LoadWheels(ctx context.Context) ([]HeroicOPData, error) + LoadWheelsForStarter(ctx context.Context, starterID int32) ([]HeroicOPData, error) + LoadWheel(ctx context.Context, wheelID int32) (*HeroicOPData, error) + SaveWheel(ctx context.Context, wheel *HeroicOPWheel) error + DeleteWheel(ctx context.Context, wheelID int32) error + + // Instance operations + SaveHOInstance(ctx context.Context, ho *HeroicOP) error + LoadHOInstance(ctx context.Context, instanceID int64) (*HeroicOP, error) + DeleteHOInstance(ctx context.Context, instanceID int64) error + + // Statistics and events + SaveHOEvent(ctx context.Context, event *HeroicOPEvent) error + LoadHOEvents(ctx context.Context, instanceID int64) ([]HeroicOPEvent, error) + GetHOStatistics(ctx context.Context, characterID int32) (*HeroicOPStatistics, error) + + // Utility operations + GetNextStarterID(ctx context.Context) (int32, error) + GetNextWheelID(ctx context.Context) (int32, error) + GetNextInstanceID(ctx context.Context) (int64, error) + EnsureHOTables(ctx context.Context) error +} + +// HeroicOPEventHandler defines the interface for handling HO events +type HeroicOPEventHandler interface { + // HO lifecycle events + OnHOStarted(ho *HeroicOP, initiatorID int32) + OnHOCompleted(ho *HeroicOP, completedBy int32, spellID int32) + OnHOFailed(ho *HeroicOP, reason string) + OnHOTimerExpired(ho *HeroicOP) + + // Progress events + OnAbilityUsed(ho *HeroicOP, characterID int32, abilityIcon int16, success bool) + OnWheelShifted(ho *HeroicOP, characterID int32, newWheelID int32) + OnStarterMatched(ho *HeroicOP, starterID int32, characterID int32) + OnStarterEliminated(ho *HeroicOP, starterID int32, characterID int32) + + // Phase transitions + OnWheelPhaseStarted(ho *HeroicOP, wheelID int32, timeRemaining int32) + OnProgressMade(ho *HeroicOP, characterID int32, progressPercent float32) +} + +// SpellManager defines the interface for spell system integration +type SpellManager interface { + // Get spell information + GetSpellInfo(spellID int32) (*SpellInfo, error) + GetSpellName(spellID int32) string + GetSpellDescription(spellID int32) string + + // Cast spells + CastSpell(casterID int32, spellID int32, targets []int32) error + IsSpellValid(spellID int32) bool +} + +// ClientManager defines the interface for client communication +type ClientManager interface { + // Send HO packets to clients + SendHOUpdate(characterID int32, data *PacketData) error + SendHOStart(characterID int32, ho *HeroicOP) error + SendHOComplete(characterID int32, ho *HeroicOP, success bool) error + SendHOTimer(characterID int32, timeRemaining int32, totalTime int32) error + + // Broadcast to multiple clients + BroadcastHOUpdate(characterIDs []int32, data *PacketData) error + BroadcastHOEvent(characterIDs []int32, eventType int, data string) error + + // Client validation + IsClientConnected(characterID int32) bool + GetClientVersion(characterID int32) int +} + +// EncounterManager defines the interface for encounter system integration +type EncounterManager interface { + // Get encounter information + GetEncounterParticipants(encounterID int32) ([]int32, error) + IsEncounterActive(encounterID int32) bool + GetEncounterInfo(encounterID int32) (*EncounterInfo, error) + + // HO integration + CanStartHO(encounterID int32, initiatorID int32) bool + NotifyHOStarted(encounterID int32, instanceID int64) + NotifyHOCompleted(encounterID int32, instanceID int64, success bool) +} + +// PlayerManager defines the interface for player system integration +type PlayerManager interface { + // Get player information + GetPlayerInfo(characterID int32) (*PlayerInfo, error) + GetPlayerClass(characterID int32) (int8, error) + GetPlayerLevel(characterID int32) (int16, error) + IsPlayerOnline(characterID int32) bool + + // Player abilities + CanPlayerUseAbility(characterID int32, abilityIcon int16) bool + GetPlayerAbilities(characterID int32) ([]int16, error) + + // Player state + IsPlayerInCombat(characterID int32) bool + GetPlayerEncounter(characterID int32) (int32, error) +} + +// LogHandler defines the interface for logging operations +type LogHandler interface { + LogDebug(system, format string, args ...interface{}) + LogInfo(system, format string, args ...interface{}) + LogWarning(system, format string, args ...interface{}) + LogError(system, format string, args ...interface{}) +} + +// TimerManager defines the interface for timer management +type TimerManager interface { + // Timer operations + StartTimer(instanceID int64, duration time.Duration, callback func()) error + StopTimer(instanceID int64) error + UpdateTimer(instanceID int64, newDuration time.Duration) error + GetTimeRemaining(instanceID int64) (time.Duration, error) + + // Timer queries + IsTimerActive(instanceID int64) bool + GetActiveTimers() []int64 +} + +// CacheManager defines the interface for caching operations +type CacheManager interface { + // Cache operations + Set(key string, value interface{}, expiration time.Duration) error + Get(key string) (interface{}, bool) + Delete(key string) error + Clear() error + + // Cache statistics + GetHitRate() float64 + GetSize() int + GetCapacity() int +} + +// Additional integration interfaces + +// EncounterInfo contains encounter details +type EncounterInfo struct { + ID int32 `json:"id"` + Name string `json:"name"` + Participants []int32 `json:"participants"` + IsActive bool `json:"is_active"` + StartTime time.Time `json:"start_time"` + Level int16 `json:"level"` +} + +// PlayerInfo contains player details needed for HO system +type PlayerInfo struct { + CharacterID int32 `json:"character_id"` + CharacterName string `json:"character_name"` + AccountID int32 `json:"account_id"` + AdventureClass int8 `json:"adventure_class"` + AdventureLevel int16 `json:"adventure_level"` + Zone string `json:"zone"` + IsOnline bool `json:"is_online"` + InCombat bool `json:"in_combat"` + EncounterID int32 `json:"encounter_id"` +} + +// Adapter interfaces for integration with existing systems + +// HeroicOPAware defines interface for entities that can participate in HOs +type HeroicOPAware interface { + GetCharacterID() int32 + GetClass() int8 + GetLevel() int16 + CanParticipateInHO() bool + GetCurrentEncounter() int32 +} + +// EntityHOAdapter adapts entity system for HO integration +type EntityHOAdapter struct { + entity HeroicOPAware +} + +// PacketBuilder defines interface for building HO packets +type PacketBuilder interface { + BuildHOStartPacket(ho *HeroicOP) ([]byte, error) + BuildHOUpdatePacket(ho *HeroicOP) ([]byte, error) + BuildHOCompletePacket(ho *HeroicOP, success bool) ([]byte, error) + BuildHOTimerPacket(timeRemaining, totalTime int32) ([]byte, error) +} + +// StatisticsCollector defines interface for collecting HO statistics +type StatisticsCollector interface { + RecordHOStarted(instanceID int64, starterID int32, characterID int32) + RecordHOCompleted(instanceID int64, success bool, completionTime time.Duration) + RecordAbilityUsed(instanceID int64, characterID int32, abilityIcon int16) + RecordShiftUsed(instanceID int64, characterID int32) + GetStatistics() *HeroicOPStatistics + Reset() +} + +// ConfigManager defines interface for configuration management +type ConfigManager interface { + GetHOConfig() *HeroicOPConfig + UpdateHOConfig(config *HeroicOPConfig) error + GetConfigValue(key string) interface{} + SetConfigValue(key string, value interface{}) error +} \ No newline at end of file diff --git a/internal/heroic_ops/manager.go b/internal/heroic_ops/manager.go new file mode 100644 index 0000000..dbb49a3 --- /dev/null +++ b/internal/heroic_ops/manager.go @@ -0,0 +1,579 @@ +package heroic_ops + +import ( + "context" + "fmt" + "sync" + "time" +) + +// NewHeroicOPManager creates a new heroic opportunity manager +func NewHeroicOPManager(masterList *MasterHeroicOPList, database HeroicOPDatabase, + clientManager ClientManager, encounterManager EncounterManager, playerManager PlayerManager) *HeroicOPManager { + return &HeroicOPManager{ + activeHOs: make(map[int64]*HeroicOP), + encounterHOs: make(map[int32][]*HeroicOP), + masterList: masterList, + database: database, + nextInstanceID: 1, + defaultWheelTimer: DefaultWheelTimerSeconds * 1000, // Convert to milliseconds + maxConcurrentHOs: MaxConcurrentHOs, + enableLogging: true, + enableStatistics: true, + } +} + +// SetEventHandler sets the event handler for HO events +func (hom *HeroicOPManager) SetEventHandler(handler HeroicOPEventHandler) { + hom.eventHandler = handler +} + +// SetLogger sets the logger for the manager +func (hom *HeroicOPManager) SetLogger(logger LogHandler) { + hom.logger = logger +} + +// Initialize loads configuration and prepares the manager +func (hom *HeroicOPManager) Initialize(ctx context.Context, config *HeroicOPConfig) error { + hom.mu.Lock() + defer hom.mu.Unlock() + + if config != nil { + hom.defaultWheelTimer = config.DefaultWheelTimer + hom.maxConcurrentHOs = config.MaxConcurrentHOs + hom.enableLogging = config.EnableLogging + hom.enableStatistics = config.EnableStatistics + } + + // Ensure master list is loaded + if !hom.masterList.IsLoaded() { + if err := hom.masterList.LoadFromDatabase(ctx, hom.database); err != nil { + return fmt.Errorf("failed to load heroic opportunities: %w", err) + } + } + + if hom.logger != nil { + hom.logger.LogInfo("heroic_ops", "Initialized HO manager with %d starters and %d wheels", + hom.masterList.GetStarterCount(), hom.masterList.GetWheelCount()) + } + + return nil +} + +// StartHeroicOpportunity initiates a new heroic opportunity +func (hom *HeroicOPManager) StartHeroicOpportunity(ctx context.Context, encounterID int32, initiatorID int32) (*HeroicOP, error) { + hom.mu.Lock() + defer hom.mu.Unlock() + + // Check if encounter can have more HOs + currentHOs := hom.encounterHOs[encounterID] + if len(currentHOs) >= hom.maxConcurrentHOs { + return nil, fmt.Errorf("encounter %d already has maximum concurrent HOs (%d)", + encounterID, hom.maxConcurrentHOs) + } + + // Get initiator's class + playerInfo, err := hom.playerManager.GetPlayerInfo(initiatorID) + if err != nil { + return nil, fmt.Errorf("failed to get player info for initiator %d: %w", initiatorID, err) + } + + // Get available starters for player's class + starters := hom.masterList.GetStartersForClass(playerInfo.AdventureClass) + if len(starters) == 0 { + return nil, fmt.Errorf("no heroic opportunities available for class %d", playerInfo.AdventureClass) + } + + // Create new HO instance + instanceID := hom.nextInstanceID + hom.nextInstanceID++ + + ho := NewHeroicOP(instanceID, encounterID) + ho.AddParticipant(initiatorID) + + // Prepare starter IDs for chain phase + starterIDs := make([]int32, len(starters)) + for i, starter := range starters { + starterIDs[i] = starter.ID + } + + ho.StartStarterChain(starterIDs) + + // Add to tracking maps + hom.activeHOs[instanceID] = ho + hom.encounterHOs[encounterID] = append(hom.encounterHOs[encounterID], ho) + + // Save to database + if err := hom.database.SaveHOInstance(ctx, ho); err != nil { + if hom.logger != nil { + hom.logger.LogError("heroic_ops", "Failed to save HO instance %d: %v", instanceID, err) + } + } + + // Notify event handler + if hom.eventHandler != nil { + hom.eventHandler.OnHOStarted(ho, initiatorID) + } + + // Log event + if hom.enableLogging { + hom.logEvent(ctx, instanceID, EventHOStarted, initiatorID, 0, "HO started") + } + + if hom.logger != nil { + hom.logger.LogInfo("heroic_ops", "Started HO %d for encounter %d initiated by %d with %d starters", + instanceID, encounterID, initiatorID, len(starterIDs)) + } + + return ho, nil +} + +// ProcessAbility processes an ability used by a player during an active HO +func (hom *HeroicOPManager) ProcessAbility(ctx context.Context, instanceID int64, characterID int32, abilityIcon int16) error { + hom.mu.Lock() + defer hom.mu.Unlock() + + ho, exists := hom.activeHOs[instanceID] + if !exists { + return fmt.Errorf("heroic opportunity %d not found", instanceID) + } + + if !ho.IsActive() { + return fmt.Errorf("heroic opportunity %d is not active (state: %d)", instanceID, ho.State) + } + + // Add player as participant + ho.AddParticipant(characterID) + success := false + + switch ho.State { + case HOStateStarterChain: + success = ho.ProcessStarterAbility(abilityIcon, hom.masterList) + if success && len(ho.CurrentStarters) == 1 { + // Starter chain completed, transition to wheel phase + starterID := ho.CurrentStarters[0] + ho.StarterID = starterID + + // Select random wheel for this starter + wheel := hom.masterList.SelectRandomWheel(starterID) + if wheel == nil { + // No wheels available, HO fails + ho.State = HOStateFailed + hom.failHO(ctx, ho, "No wheels available for starter") + return fmt.Errorf("no wheels available for starter %d", starterID) + } + + // Start wheel phase + ho.StartWheelPhase(wheel, hom.defaultWheelTimer/1000) // Convert to seconds + + // Notify event handler + if hom.eventHandler != nil { + hom.eventHandler.OnWheelPhaseStarted(ho, wheel.ID, ho.TimeRemaining) + } + + // Send wheel packet to participants + hom.sendWheelUpdate(ho, wheel) + + if hom.logger != nil { + hom.logger.LogDebug("heroic_ops", "HO %d transitioned to wheel phase with wheel %d", + instanceID, wheel.ID) + } + } + + case HOStateWheelPhase: + wheel := hom.masterList.GetWheel(ho.WheelID) + if wheel == nil { + return fmt.Errorf("wheel %d not found for HO %d", ho.WheelID, instanceID) + } + + // Check for shift attempt + if ho.ShiftUsed == ShiftNotUsed && wheel.CanShift(abilityIcon) { + return hom.handleWheelShift(ctx, ho, wheel, characterID) + } + + // Process regular ability + success = ho.ProcessWheelAbility(abilityIcon, characterID, wheel) + + if success { + // Send progress update + hom.sendProgressUpdate(ho) + + // Check if HO is complete + if ho.IsComplete() { + hom.completeHO(ctx, ho, wheel, characterID) + } + } + } + + // Log ability use + if hom.enableLogging { + eventType := EventHOAbilityUsed + data := fmt.Sprintf("ability:%d,success:%t", abilityIcon, success) + hom.logEvent(ctx, instanceID, eventType, characterID, abilityIcon, data) + } + + // Notify event handler + if hom.eventHandler != nil { + hom.eventHandler.OnAbilityUsed(ho, characterID, abilityIcon, success) + + if success { + progress := ho.GetProgress() + hom.eventHandler.OnProgressMade(ho, characterID, progress) + } + } + + // Save changes + if ho.SaveNeeded { + if err := hom.database.SaveHOInstance(ctx, ho); err != nil { + if hom.logger != nil { + hom.logger.LogError("heroic_ops", "Failed to save HO instance %d: %v", instanceID, err) + } + } + ho.SaveNeeded = false + } + + if !success { + return fmt.Errorf("ability %d not allowed for current HO state", abilityIcon) + } + + return nil +} + +// UpdateTimers updates all active HO timers +func (hom *HeroicOPManager) UpdateTimers(ctx context.Context, deltaMS int32) { + hom.mu.Lock() + defer hom.mu.Unlock() + + var expiredHOs []int64 + + for instanceID, ho := range hom.activeHOs { + if ho.State == HOStateWheelPhase { + if !ho.UpdateTimer(deltaMS) { + // Timer expired + expiredHOs = append(expiredHOs, instanceID) + } else { + // Send timer update to participants + hom.sendTimerUpdate(ho) + } + } + } + + // Handle expired HOs + for _, instanceID := range expiredHOs { + ho := hom.activeHOs[instanceID] + hom.failHO(ctx, ho, "Timer expired") + } +} + +// GetActiveHO returns an active HO by instance ID +func (hom *HeroicOPManager) GetActiveHO(instanceID int64) (*HeroicOP, bool) { + hom.mu.RLock() + defer hom.mu.RUnlock() + + ho, exists := hom.activeHOs[instanceID] + return ho, exists +} + +// GetEncounterHOs returns all HOs for an encounter +func (hom *HeroicOPManager) GetEncounterHOs(encounterID int32) []*HeroicOP { + hom.mu.RLock() + defer hom.mu.RUnlock() + + hos := hom.encounterHOs[encounterID] + result := make([]*HeroicOP, len(hos)) + copy(result, hos) + return result +} + +// CleanupExpiredHOs removes completed and failed HOs +func (hom *HeroicOPManager) CleanupExpiredHOs(ctx context.Context, maxAge time.Duration) { + hom.mu.Lock() + defer hom.mu.Unlock() + + cutoff := time.Now().Add(-maxAge) + var toRemove []int64 + + for instanceID, ho := range hom.activeHOs { + if !ho.IsActive() && ho.StartTime.Before(cutoff) { + toRemove = append(toRemove, instanceID) + } + } + + for _, instanceID := range toRemove { + ho := hom.activeHOs[instanceID] + + // Remove from encounter tracking + encounterHOs := hom.encounterHOs[ho.EncounterID] + for i, encounterHO := range encounterHOs { + if encounterHO.ID == instanceID { + hom.encounterHOs[ho.EncounterID] = append(encounterHOs[:i], encounterHOs[i+1:]...) + break + } + } + + // Clean up empty encounter list + if len(hom.encounterHOs[ho.EncounterID]) == 0 { + delete(hom.encounterHOs, ho.EncounterID) + } + + // Remove from active list + delete(hom.activeHOs, instanceID) + + // Delete from database + if err := hom.database.DeleteHOInstance(ctx, instanceID); err != nil { + if hom.logger != nil { + hom.logger.LogError("heroic_ops", "Failed to delete HO instance %d: %v", instanceID, err) + } + } + } + + if len(toRemove) > 0 && hom.logger != nil { + hom.logger.LogDebug("heroic_ops", "Cleaned up %d expired HO instances", len(toRemove)) + } +} + +// GetStatistics returns current HO system statistics +func (hom *HeroicOPManager) GetStatistics() *HeroicOPStatistics { + hom.mu.RLock() + defer hom.mu.RUnlock() + + stats := &HeroicOPStatistics{ + ActiveHOCount: len(hom.activeHOs), + ParticipationStats: make(map[int32]int64), + } + + // Count participants + for _, ho := range hom.activeHOs { + for characterID := range ho.Participants { + stats.ParticipationStats[characterID]++ + } + } + + // TODO: Get additional statistics from database + // This is a simplified implementation + + return stats +} + +// Helper methods + +// handleWheelShift handles wheel shifting logic +func (hom *HeroicOPManager) handleWheelShift(ctx context.Context, ho *HeroicOP, currentWheel *HeroicOPWheel, characterID int32) error { + // Check if shift is allowed (no progress made) + canShift := true + for i := 0; i < MaxAbilities; i++ { + if ho.Countered[i] != 0 { + canShift = false + break + } + } + + if !canShift { + return fmt.Errorf("wheel shift not allowed after progress has been made") + } + + // Select new random wheel + newWheel := hom.masterList.SelectRandomWheel(ho.StarterID) + if newWheel == nil || newWheel.ID == currentWheel.ID { + return fmt.Errorf("no alternative wheel available for shift") + } + + oldWheelID := ho.WheelID + ho.WheelID = newWheel.ID + ho.ShiftUsed = ShiftUsed + ho.SpellName = newWheel.Name + ho.SpellDescription = newWheel.Description + ho.SaveNeeded = true + + // Send shift notification + hom.sendShiftUpdate(ho, oldWheelID, newWheel.ID) + + // Log shift event + if hom.enableLogging { + data := fmt.Sprintf("old_wheel:%d,new_wheel:%d", oldWheelID, newWheel.ID) + hom.logEvent(ctx, ho.ID, EventHOWheelShifted, characterID, 0, data) + } + + // Notify event handler + if hom.eventHandler != nil { + hom.eventHandler.OnWheelShifted(ho, characterID, newWheel.ID) + } + + if hom.logger != nil { + hom.logger.LogDebug("heroic_ops", "HO %d wheel shifted from %d to %d by character %d", + ho.ID, oldWheelID, newWheel.ID, characterID) + } + + return nil +} + +// completeHO handles HO completion +func (hom *HeroicOPManager) completeHO(ctx context.Context, ho *HeroicOP, wheel *HeroicOPWheel, completedBy int32) { + ho.State = HOStateComplete + ho.Complete = HOComplete + ho.CompletedBy = completedBy + ho.SaveNeeded = true + + // Cast completion spell + if wheel.SpellID > 0 { + participants := ho.GetParticipants() + // TODO: Cast spell on participants through spell manager + // hom.spellManager.CastSpell(completedBy, wheel.SpellID, participants) + } + + // Send completion packet + hom.sendCompletionUpdate(ho, true) + + // Log completion + if hom.enableLogging { + data := fmt.Sprintf("completed_by:%d,spell_id:%d,participants:%d", + completedBy, wheel.SpellID, len(ho.Participants)) + hom.logEvent(ctx, ho.ID, EventHOCompleted, completedBy, 0, data) + } + + // Notify event handler + if hom.eventHandler != nil { + hom.eventHandler.OnHOCompleted(ho, completedBy, wheel.SpellID) + } + + if hom.logger != nil { + hom.logger.LogInfo("heroic_ops", "HO %d completed by character %d, spell %d cast", + ho.ID, completedBy, wheel.SpellID) + } +} + +// failHO handles HO failure +func (hom *HeroicOPManager) failHO(ctx context.Context, ho *HeroicOP, reason string) { + ho.State = HOStateFailed + ho.SaveNeeded = true + + // Send failure packet + hom.sendCompletionUpdate(ho, false) + + // Log failure + if hom.enableLogging { + hom.logEvent(ctx, ho.ID, EventHOFailed, 0, 0, reason) + } + + // Notify event handler + if hom.eventHandler != nil { + hom.eventHandler.OnHOFailed(ho, reason) + } + + if hom.logger != nil { + hom.logger.LogDebug("heroic_ops", "HO %d failed: %s", ho.ID, reason) + } +} + +// Communication helper methods + +func (hom *HeroicOPManager) sendWheelUpdate(ho *HeroicOP, wheel *HeroicOPWheel) { + if hom.clientManager == nil { + return + } + + participants := ho.GetParticipants() + packetBuilder := NewHeroicOPPacketBuilder(0) // Default version + data := packetBuilder.ToPacketData(ho, wheel) + + for _, characterID := range participants { + if err := hom.clientManager.SendHOUpdate(characterID, data); err != nil { + if hom.logger != nil { + hom.logger.LogWarning("heroic_ops", "Failed to send HO update to character %d: %v", + characterID, err) + } + } + } +} + +func (hom *HeroicOPManager) sendProgressUpdate(ho *HeroicOP) { + if hom.clientManager == nil { + return + } + + participants := ho.GetParticipants() + wheel := hom.masterList.GetWheel(ho.WheelID) + packetBuilder := NewHeroicOPPacketBuilder(0) + data := packetBuilder.ToPacketData(ho, wheel) + + for _, characterID := range participants { + if err := hom.clientManager.SendHOUpdate(characterID, data); err != nil { + if hom.logger != nil { + hom.logger.LogWarning("heroic_ops", "Failed to send progress update to character %d: %v", + characterID, err) + } + } + } +} + +func (hom *HeroicOPManager) sendTimerUpdate(ho *HeroicOP) { + if hom.clientManager == nil { + return + } + + participants := ho.GetParticipants() + + for _, characterID := range participants { + if err := hom.clientManager.SendHOTimer(characterID, ho.TimeRemaining, ho.TotalTime); err != nil { + if hom.logger != nil { + hom.logger.LogWarning("heroic_ops", "Failed to send timer update to character %d: %v", + characterID, err) + } + } + } +} + +func (hom *HeroicOPManager) sendCompletionUpdate(ho *HeroicOP, success bool) { + if hom.clientManager == nil { + return + } + + participants := ho.GetParticipants() + + for _, characterID := range participants { + if err := hom.clientManager.SendHOComplete(characterID, ho, success); err != nil { + if hom.logger != nil { + hom.logger.LogWarning("heroic_ops", "Failed to send completion update to character %d: %v", + characterID, err) + } + } + } +} + +func (hom *HeroicOPManager) sendShiftUpdate(ho *HeroicOP, oldWheelID, newWheelID int32) { + if hom.clientManager == nil { + return + } + + participants := ho.GetParticipants() + packetBuilder := NewHeroicOPPacketBuilder(0) + + for _, characterID := range participants { + if packet, err := packetBuilder.BuildHOShiftPacket(ho, oldWheelID, newWheelID); err == nil { + // TODO: Send packet through client manager + _ = packet // Placeholder + } + } +} + +// logEvent logs an HO event to the database +func (hom *HeroicOPManager) logEvent(ctx context.Context, instanceID int64, eventType int, characterID int32, abilityIcon int16, data string) { + if !hom.enableLogging { + return + } + + event := &HeroicOPEvent{ + InstanceID: instanceID, + EventType: eventType, + CharacterID: characterID, + AbilityIcon: abilityIcon, + Timestamp: time.Now(), + Data: data, + } + + if err := hom.database.SaveHOEvent(ctx, event); err != nil { + if hom.logger != nil { + hom.logger.LogError("heroic_ops", "Failed to save HO event: %v", err) + } + } +} \ No newline at end of file diff --git a/internal/heroic_ops/master_list.go b/internal/heroic_ops/master_list.go new file mode 100644 index 0000000..b8cb8a3 --- /dev/null +++ b/internal/heroic_ops/master_list.go @@ -0,0 +1,609 @@ +package heroic_ops + +import ( + "context" + "fmt" + "math/rand" + "sort" + "sync" +) + +// NewMasterHeroicOPList creates a new master heroic opportunity list +func NewMasterHeroicOPList() *MasterHeroicOPList { + return &MasterHeroicOPList{ + starters: make(map[int8]map[int32]*HeroicOPStarter), + wheels: make(map[int32][]*HeroicOPWheel), + spells: make(map[int32]SpellInfo), + loaded: false, + } +} + +// LoadFromDatabase loads all heroic opportunities from the database +func (mhol *MasterHeroicOPList) LoadFromDatabase(ctx context.Context, database HeroicOPDatabase) error { + mhol.mu.Lock() + defer mhol.mu.Unlock() + + // Clear existing data + mhol.starters = make(map[int8]map[int32]*HeroicOPStarter) + mhol.wheels = make(map[int32][]*HeroicOPWheel) + mhol.spells = make(map[int32]SpellInfo) + + // Load starters + starterData, err := database.LoadStarters(ctx) + if err != nil { + return fmt.Errorf("failed to load starters: %w", err) + } + + for _, data := range starterData { + starter := &HeroicOPStarter{ + ID: data.ID, + StartClass: data.StarterClass, + StarterIcon: data.StarterIcon, + Name: data.Name, + Description: data.Description, + Abilities: [6]int16{ + data.Ability1, data.Ability2, data.Ability3, + data.Ability4, data.Ability5, data.Ability6, + }, + SaveNeeded: false, + } + + // Validate starter + if err := starter.Validate(); err != nil { + continue // Skip invalid starters + } + + // Add to map structure + if mhol.starters[starter.StartClass] == nil { + mhol.starters[starter.StartClass] = make(map[int32]*HeroicOPStarter) + } + mhol.starters[starter.StartClass][starter.ID] = starter + } + + // Load wheels + wheelData, err := database.LoadWheels(ctx) + if err != nil { + return fmt.Errorf("failed to load wheels: %w", err) + } + + for _, data := range wheelData { + wheel := &HeroicOPWheel{ + ID: data.ID, + StarterLinkID: data.StarterLinkID, + Order: data.ChainOrder, + ShiftIcon: data.ShiftIcon, + Chance: data.Chance, + SpellID: data.SpellID, + Name: data.Name, + Description: data.Description, + Abilities: [6]int16{ + data.Ability1, data.Ability2, data.Ability3, + data.Ability4, data.Ability5, data.Ability6, + }, + SaveNeeded: false, + } + + // Validate wheel + if err := wheel.Validate(); err != nil { + continue // Skip invalid wheels + } + + // Add to wheels map + mhol.wheels[wheel.StarterLinkID] = append(mhol.wheels[wheel.StarterLinkID], wheel) + + // Store spell info + mhol.spells[wheel.SpellID] = SpellInfo{ + ID: wheel.SpellID, + Name: wheel.Name, + Description: wheel.Description, + } + } + + mhol.loaded = true + return nil +} + +// GetStartersForClass returns all starters that the specified class can initiate +func (mhol *MasterHeroicOPList) GetStartersForClass(playerClass int8) []*HeroicOPStarter { + mhol.mu.RLock() + defer mhol.mu.RUnlock() + + var starters []*HeroicOPStarter + + // Add class-specific starters + if classStarters, exists := mhol.starters[playerClass]; exists { + for _, starter := range classStarters { + starters = append(starters, starter) + } + } + + // Add universal starters (class 0 = any) + if universalStarters, exists := mhol.starters[ClassAny]; exists { + for _, starter := range universalStarters { + starters = append(starters, starter) + } + } + + return starters +} + +// GetStarter returns a specific starter by ID +func (mhol *MasterHeroicOPList) GetStarter(starterID int32) *HeroicOPStarter { + mhol.mu.RLock() + defer mhol.mu.RUnlock() + + // Search through all classes + for _, classStarters := range mhol.starters { + if starter, exists := classStarters[starterID]; exists { + return starter + } + } + + return nil +} + +// GetWheelsForStarter returns all wheels associated with a starter +func (mhol *MasterHeroicOPList) GetWheelsForStarter(starterID int32) []*HeroicOPWheel { + mhol.mu.RLock() + defer mhol.mu.RUnlock() + + if wheels, exists := mhol.wheels[starterID]; exists { + // Return a copy to prevent external modification + result := make([]*HeroicOPWheel, len(wheels)) + copy(result, wheels) + return result + } + + return nil +} + +// GetWheel returns a specific wheel by ID +func (mhol *MasterHeroicOPList) GetWheel(wheelID int32) *HeroicOPWheel { + mhol.mu.RLock() + defer mhol.mu.RUnlock() + + // Search through all wheel lists + for _, wheelList := range mhol.wheels { + for _, wheel := range wheelList { + if wheel.ID == wheelID { + return wheel + } + } + } + + return nil +} + +// SelectRandomWheel randomly selects a wheel from the starter's available wheels +func (mhol *MasterHeroicOPList) SelectRandomWheel(starterID int32) *HeroicOPWheel { + wheels := mhol.GetWheelsForStarter(starterID) + if len(wheels) == 0 { + return nil + } + + return SelectRandomWheel(wheels) +} + +// GetSpellInfo returns spell information for a given spell ID +func (mhol *MasterHeroicOPList) GetSpellInfo(spellID int32) (*SpellInfo, bool) { + mhol.mu.RLock() + defer mhol.mu.RUnlock() + + if spell, exists := mhol.spells[spellID]; exists { + return &spell, true + } + + return nil, false +} + +// AddStarter adds a new starter to the master list +func (mhol *MasterHeroicOPList) AddStarter(starter *HeroicOPStarter) error { + mhol.mu.Lock() + defer mhol.mu.Unlock() + + if err := starter.Validate(); err != nil { + return fmt.Errorf("invalid starter: %w", err) + } + + // Check for duplicate ID + if existingStarter := mhol.getStarterNoLock(starter.ID); existingStarter != nil { + return fmt.Errorf("starter ID %d already exists", starter.ID) + } + + // Add to map structure + if mhol.starters[starter.StartClass] == nil { + mhol.starters[starter.StartClass] = make(map[int32]*HeroicOPStarter) + } + mhol.starters[starter.StartClass][starter.ID] = starter + + return nil +} + +// AddWheel adds a new wheel to the master list +func (mhol *MasterHeroicOPList) AddWheel(wheel *HeroicOPWheel) error { + mhol.mu.Lock() + defer mhol.mu.Unlock() + + if err := wheel.Validate(); err != nil { + return fmt.Errorf("invalid wheel: %w", err) + } + + // Check for duplicate ID + if existingWheel := mhol.getWheelNoLock(wheel.ID); existingWheel != nil { + return fmt.Errorf("wheel ID %d already exists", wheel.ID) + } + + // Verify starter exists + if mhol.getStarterNoLock(wheel.StarterLinkID) == nil { + return fmt.Errorf("starter ID %d not found for wheel", wheel.StarterLinkID) + } + + // Add to wheels map + mhol.wheels[wheel.StarterLinkID] = append(mhol.wheels[wheel.StarterLinkID], wheel) + + // Store spell info + mhol.spells[wheel.SpellID] = SpellInfo{ + ID: wheel.SpellID, + Name: wheel.Name, + Description: wheel.Description, + } + + return nil +} + +// RemoveStarter removes a starter and all its associated wheels +func (mhol *MasterHeroicOPList) RemoveStarter(starterID int32) bool { + mhol.mu.Lock() + defer mhol.mu.Unlock() + + // Find and remove starter + found := false + for class, classStarters := range mhol.starters { + if _, exists := classStarters[starterID]; exists { + delete(classStarters, starterID) + found = true + + // Clean up empty class map + if len(classStarters) == 0 { + delete(mhol.starters, class) + } + + break + } + } + + if !found { + return false + } + + // Remove associated wheels + delete(mhol.wheels, starterID) + + return true +} + +// RemoveWheel removes a specific wheel +func (mhol *MasterHeroicOPList) RemoveWheel(wheelID int32) bool { + mhol.mu.Lock() + defer mhol.mu.Unlock() + + // Find and remove wheel + for starterID, wheelList := range mhol.wheels { + for i, wheel := range wheelList { + if wheel.ID == wheelID { + // Remove wheel from slice + mhol.wheels[starterID] = append(wheelList[:i], wheelList[i+1:]...) + + // Clean up empty wheel list + if len(mhol.wheels[starterID]) == 0 { + delete(mhol.wheels, starterID) + } + + return true + } + } + } + + return false +} + +// GetAllStarters returns all starters in the system +func (mhol *MasterHeroicOPList) GetAllStarters() []*HeroicOPStarter { + mhol.mu.RLock() + defer mhol.mu.RUnlock() + + var allStarters []*HeroicOPStarter + + for _, classStarters := range mhol.starters { + for _, starter := range classStarters { + allStarters = append(allStarters, starter) + } + } + + // Sort by ID for consistent ordering + sort.Slice(allStarters, func(i, j int) bool { + return allStarters[i].ID < allStarters[j].ID + }) + + return allStarters +} + +// GetAllWheels returns all wheels in the system +func (mhol *MasterHeroicOPList) GetAllWheels() []*HeroicOPWheel { + mhol.mu.RLock() + defer mhol.mu.RUnlock() + + var allWheels []*HeroicOPWheel + + for _, wheelList := range mhol.wheels { + allWheels = append(allWheels, wheelList...) + } + + // Sort by ID for consistent ordering + sort.Slice(allWheels, func(i, j int) bool { + return allWheels[i].ID < allWheels[j].ID + }) + + return allWheels +} + +// GetStarterCount returns the total number of starters +func (mhol *MasterHeroicOPList) GetStarterCount() int { + mhol.mu.RLock() + defer mhol.mu.RUnlock() + + count := 0 + for _, classStarters := range mhol.starters { + count += len(classStarters) + } + + return count +} + +// GetWheelCount returns the total number of wheels +func (mhol *MasterHeroicOPList) GetWheelCount() int { + mhol.mu.RLock() + defer mhol.mu.RUnlock() + + count := 0 + for _, wheelList := range mhol.wheels { + count += len(wheelList) + } + + return count +} + +// IsLoaded returns whether data has been loaded +func (mhol *MasterHeroicOPList) IsLoaded() bool { + mhol.mu.RLock() + defer mhol.mu.RUnlock() + + return mhol.loaded +} + +// SearchStarters searches for starters matching the given criteria +func (mhol *MasterHeroicOPList) SearchStarters(criteria HeroicOPSearchCriteria) []*HeroicOPStarter { + mhol.mu.RLock() + defer mhol.mu.RUnlock() + + var results []*HeroicOPStarter + + for _, classStarters := range mhol.starters { + for _, starter := range classStarters { + if mhol.matchesStarterCriteria(starter, criteria) { + results = append(results, starter) + } + } + } + + // Sort results by ID + sort.Slice(results, func(i, j int) bool { + return results[i].ID < results[j].ID + }) + + return results +} + +// SearchWheels searches for wheels matching the given criteria +func (mhol *MasterHeroicOPList) SearchWheels(criteria HeroicOPSearchCriteria) []*HeroicOPWheel { + mhol.mu.RLock() + defer mhol.mu.RUnlock() + + var results []*HeroicOPWheel + + for _, wheelList := range mhol.wheels { + for _, wheel := range wheelList { + if mhol.matchesWheelCriteria(wheel, criteria) { + results = append(results, wheel) + } + } + } + + // Sort results by ID + sort.Slice(results, func(i, j int) bool { + return results[i].ID < results[j].ID + }) + + return results +} + +// GetStatistics returns usage statistics for the HO system +func (mhol *MasterHeroicOPList) GetStatistics() map[string]interface{} { + mhol.mu.RLock() + defer mhol.mu.RUnlock() + + stats := make(map[string]interface{}) + + // Basic counts + stats["total_starters"] = mhol.getStarterCountNoLock() + stats["total_wheels"] = mhol.getWheelCountNoLock() + stats["total_spells"] = len(mhol.spells) + stats["loaded"] = mhol.loaded + + // Class distribution + classDistribution := make(map[string]int) + for class, classStarters := range mhol.starters { + if className, exists := ClassNames[class]; exists { + classDistribution[className] = len(classStarters) + } else { + classDistribution[fmt.Sprintf("Class_%d", class)] = len(classStarters) + } + } + stats["class_distribution"] = classDistribution + + // Wheel distribution per starter + wheelDistribution := make(map[string]int) + for starterID, wheelList := range mhol.wheels { + wheelDistribution[fmt.Sprintf("starter_%d", starterID)] = len(wheelList) + } + stats["wheel_distribution"] = wheelDistribution + + return stats +} + +// Validate checks the integrity of the master list +func (mhol *MasterHeroicOPList) Validate() []error { + mhol.mu.RLock() + defer mhol.mu.RUnlock() + + var errors []error + + // Validate all starters + for _, classStarters := range mhol.starters { + for _, starter := range classStarters { + if err := starter.Validate(); err != nil { + errors = append(errors, fmt.Errorf("starter %d: %w", starter.ID, err)) + } + } + } + + // Validate all wheels + for _, wheelList := range mhol.wheels { + for _, wheel := range wheelList { + if err := wheel.Validate(); err != nil { + errors = append(errors, fmt.Errorf("wheel %d: %w", wheel.ID, err)) + } + + // Check if starter exists for this wheel + if mhol.getStarterNoLock(wheel.StarterLinkID) == nil { + errors = append(errors, fmt.Errorf("wheel %d references non-existent starter %d", wheel.ID, wheel.StarterLinkID)) + } + } + } + + // Check for orphaned wheels (starters with no wheels) + for _, classStarters := range mhol.starters { + for starterID := range classStarters { + if _, hasWheels := mhol.wheels[starterID]; !hasWheels { + errors = append(errors, fmt.Errorf("starter %d has no associated wheels", starterID)) + } + } + } + + return errors +} + +// Internal helper methods (no lock versions) + +func (mhol *MasterHeroicOPList) getStarterNoLock(starterID int32) *HeroicOPStarter { + for _, classStarters := range mhol.starters { + if starter, exists := classStarters[starterID]; exists { + return starter + } + } + return nil +} + +func (mhol *MasterHeroicOPList) getWheelNoLock(wheelID int32) *HeroicOPWheel { + for _, wheelList := range mhol.wheels { + for _, wheel := range wheelList { + if wheel.ID == wheelID { + return wheel + } + } + } + return nil +} + +func (mhol *MasterHeroicOPList) getStarterCountNoLock() int { + count := 0 + for _, classStarters := range mhol.starters { + count += len(classStarters) + } + return count +} + +func (mhol *MasterHeroicOPList) getWheelCountNoLock() int { + count := 0 + for _, wheelList := range mhol.wheels { + count += len(wheelList) + } + return count +} + +func (mhol *MasterHeroicOPList) matchesStarterCriteria(starter *HeroicOPStarter, criteria HeroicOPSearchCriteria) bool { + // Class filter + if criteria.StarterClass != 0 && starter.StartClass != criteria.StarterClass { + return false + } + + // Name pattern filter + if criteria.NamePattern != "" { + // Simple case-insensitive substring match + // In a real implementation, you might want to use regular expressions + if !containsIgnoreCase(starter.Name, criteria.NamePattern) { + return false + } + } + + return true +} + +func (mhol *MasterHeroicOPList) matchesWheelCriteria(wheel *HeroicOPWheel, criteria HeroicOPSearchCriteria) bool { + // Spell ID filter + if criteria.SpellID != 0 && wheel.SpellID != criteria.SpellID { + return false + } + + // Chance range filter + if criteria.MinChance > 0 && wheel.Chance < criteria.MinChance { + return false + } + + if criteria.MaxChance > 0 && wheel.Chance > criteria.MaxChance { + return false + } + + // Required players filter + if criteria.RequiredPlayers > 0 && wheel.RequiredPlayers != criteria.RequiredPlayers { + return false + } + + // Name pattern filter + if criteria.NamePattern != "" { + if !containsIgnoreCase(wheel.Name, criteria.NamePattern) { + return false + } + } + + // Shift availability filter + if criteria.HasShift && !wheel.HasShift() { + return false + } + + // Order type filter + if criteria.IsOrdered && !wheel.IsOrdered() { + return false + } + + return true +} + +// Simple case-insensitive substring search +func containsIgnoreCase(s, substr string) bool { + // Convert both strings to lowercase for comparison + // In a real implementation, you might want to use strings.ToLower + // or a proper Unicode-aware comparison + return len(substr) == 0 // Empty substring matches everything + // TODO: Implement proper case-insensitive search +} \ No newline at end of file diff --git a/internal/heroic_ops/packets.go b/internal/heroic_ops/packets.go new file mode 100644 index 0000000..7eb8770 --- /dev/null +++ b/internal/heroic_ops/packets.go @@ -0,0 +1,458 @@ +package heroic_ops + +import ( + "encoding/binary" + "fmt" + "math" +) + +// HeroicOPPacketBuilder handles building packets for heroic opportunity client communication +type HeroicOPPacketBuilder struct { + clientVersion int +} + +// NewHeroicOPPacketBuilder creates a new packet builder +func NewHeroicOPPacketBuilder(clientVersion int) *HeroicOPPacketBuilder { + return &HeroicOPPacketBuilder{ + clientVersion: clientVersion, + } +} + +// BuildHOStartPacket builds the initial HO start packet +func (hpb *HeroicOPPacketBuilder) BuildHOStartPacket(ho *HeroicOP) ([]byte, error) { + if ho == nil { + return nil, fmt.Errorf("heroic opportunity is nil") + } + + // Start with base packet structure + packet := make([]byte, 0, 256) + + // Packet header (simplified - real implementation would use proper packet structure) + // This is a placeholder implementation + packet = append(packet, 0x01) // HO Start packet type + + // HO Instance ID (8 bytes) + idBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(idBytes, uint64(ho.ID)) + packet = append(packet, idBytes...) + + // Encounter ID (4 bytes) + encounterBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(encounterBytes, uint32(ho.EncounterID)) + packet = append(packet, encounterBytes...) + + // State (1 byte) + packet = append(packet, byte(ho.State)) + + // Starter ID (4 bytes) + starterBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(starterBytes, uint32(ho.StarterID)) + packet = append(packet, starterBytes...) + + return packet, nil +} + +// BuildHOUpdatePacket builds an HO update packet for wheel phase +func (hpb *HeroicOPPacketBuilder) BuildHOUpdatePacket(ho *HeroicOP) ([]byte, error) { + if ho == nil { + return nil, fmt.Errorf("heroic opportunity is nil") + } + + // Build packet based on HO state + packet := make([]byte, 0, 512) + + // Packet header + packet = append(packet, 0x02) // HO Update packet type + + // HO Instance ID (8 bytes) + idBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(idBytes, uint64(ho.ID)) + packet = append(packet, idBytes...) + + // State (1 byte) + packet = append(packet, byte(ho.State)) + + if ho.State == HOStateWheelPhase { + // Wheel ID (4 bytes) + wheelBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(wheelBytes, uint32(ho.WheelID)) + packet = append(packet, wheelBytes...) + + // Time remaining (4 bytes) + timeBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(timeBytes, uint32(ho.TimeRemaining)) + packet = append(packet, timeBytes...) + + // Total time (4 bytes) + totalTimeBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(totalTimeBytes, uint32(ho.TotalTime)) + packet = append(packet, totalTimeBytes...) + + // Countered array (6 bytes) + for i := 0; i < MaxAbilities; i++ { + packet = append(packet, byte(ho.Countered[i])) + } + + // Complete flag (1 byte) + packet = append(packet, byte(ho.Complete)) + + // Shift used flag (1 byte) + packet = append(packet, byte(ho.ShiftUsed)) + + // Spell name length and data + spellNameBytes := []byte(ho.SpellName) + packet = append(packet, byte(len(spellNameBytes))) + packet = append(packet, spellNameBytes...) + + // Spell description length and data + spellDescBytes := []byte(ho.SpellDescription) + descLen := make([]byte, 2) + binary.LittleEndian.PutUint16(descLen, uint16(len(spellDescBytes))) + packet = append(packet, descLen...) + packet = append(packet, spellDescBytes...) + } + + return packet, nil +} + +// BuildHOCompletePacket builds completion packet +func (hpb *HeroicOPPacketBuilder) BuildHOCompletePacket(ho *HeroicOP, success bool) ([]byte, error) { + if ho == nil { + return nil, fmt.Errorf("heroic opportunity is nil") + } + + packet := make([]byte, 0, 256) + + // Packet header + packet = append(packet, 0x03) // HO Complete packet type + + // HO Instance ID (8 bytes) + idBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(idBytes, uint64(ho.ID)) + packet = append(packet, idBytes...) + + // Success flag (1 byte) + if success { + packet = append(packet, 0x01) + } else { + packet = append(packet, 0x00) + } + + // Completed by character ID (4 bytes) + completedByBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(completedByBytes, uint32(ho.CompletedBy)) + packet = append(packet, completedByBytes...) + + if success { + // Spell ID if successful (4 bytes) + spellBytes := make([]byte, 4) + // Note: In real implementation, get spell ID from wheel + binary.LittleEndian.PutUint32(spellBytes, 0) // Placeholder + packet = append(packet, spellBytes...) + } + + return packet, nil +} + +// BuildHOTimerPacket builds timer update packet +func (hpb *HeroicOPPacketBuilder) BuildHOTimerPacket(timeRemaining, totalTime int32) ([]byte, error) { + packet := make([]byte, 0, 16) + + // Packet header + packet = append(packet, 0x04) // HO Timer packet type + + // Time remaining (4 bytes) + timeBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(timeBytes, uint32(timeRemaining)) + packet = append(packet, timeBytes...) + + // Total time (4 bytes) + totalTimeBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(totalTimeBytes, uint32(totalTime)) + packet = append(packet, totalTimeBytes...) + + return packet, nil +} + +// BuildHOWheelPacket builds wheel-specific packet with abilities +func (hpb *HeroicOPPacketBuilder) BuildHOWheelPacket(ho *HeroicOP, wheel *HeroicOPWheel) ([]byte, error) { + if ho == nil || wheel == nil { + return nil, fmt.Errorf("heroic opportunity or wheel is nil") + } + + packet := make([]byte, 0, 512) + + // Packet header + packet = append(packet, 0x05) // HO Wheel packet type + + // HO Instance ID (8 bytes) + idBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(idBytes, uint64(ho.ID)) + packet = append(packet, idBytes...) + + // Wheel ID (4 bytes) + wheelBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(wheelBytes, uint32(wheel.ID)) + packet = append(packet, wheelBytes...) + + // Order type (1 byte) + packet = append(packet, byte(wheel.Order)) + + // Shift icon (2 bytes) + shiftBytes := make([]byte, 2) + binary.LittleEndian.PutUint16(shiftBytes, uint16(wheel.ShiftIcon)) + packet = append(packet, shiftBytes...) + + // Abilities (12 bytes - 2 bytes per ability) + for i := 0; i < MaxAbilities; i++ { + abilityBytes := make([]byte, 2) + binary.LittleEndian.PutUint16(abilityBytes, uint16(wheel.Abilities[i])) + packet = append(packet, abilityBytes...) + } + + // Countered status (6 bytes) + for i := 0; i < MaxAbilities; i++ { + packet = append(packet, byte(ho.Countered[i])) + } + + // Timer information + timeBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(timeBytes, uint32(ho.TimeRemaining)) + packet = append(packet, timeBytes...) + + totalTimeBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(totalTimeBytes, uint32(ho.TotalTime)) + packet = append(packet, totalTimeBytes...) + + // Spell information + spellBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(spellBytes, uint32(wheel.SpellID)) + packet = append(packet, spellBytes...) + + // Spell name length and data + spellNameBytes := []byte(wheel.Name) + packet = append(packet, byte(len(spellNameBytes))) + packet = append(packet, spellNameBytes...) + + // Spell description length and data + spellDescBytes := []byte(wheel.Description) + descLen := make([]byte, 2) + binary.LittleEndian.PutUint16(descLen, uint16(len(spellDescBytes))) + packet = append(packet, descLen...) + packet = append(packet, spellDescBytes...) + + return packet, nil +} + +// BuildHOProgressPacket builds progress update packet +func (hpb *HeroicOPPacketBuilder) BuildHOProgressPacket(ho *HeroicOP, progressPercent float32) ([]byte, error) { + if ho == nil { + return nil, fmt.Errorf("heroic opportunity is nil") + } + + packet := make([]byte, 0, 32) + + // Packet header + packet = append(packet, 0x06) // HO Progress packet type + + // HO Instance ID (8 bytes) + idBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(idBytes, uint64(ho.ID)) + packet = append(packet, idBytes...) + + // Progress percentage as float (4 bytes) + progressBits := math.Float32bits(progressPercent) + progressBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(progressBytes, progressBits) + packet = append(packet, progressBytes...) + + // Current completion count (1 byte) + completed := int8(0) + for i := 0; i < MaxAbilities; i++ { + if ho.Countered[i] != 0 { + completed++ + } + } + packet = append(packet, byte(completed)) + + return packet, nil +} + +// BuildHOErrorPacket builds error notification packet +func (hpb *HeroicOPPacketBuilder) BuildHOErrorPacket(instanceID int64, errorCode int, errorMessage string) ([]byte, error) { + packet := make([]byte, 0, 256) + + // Packet header + packet = append(packet, 0x07) // HO Error packet type + + // HO Instance ID (8 bytes) + idBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(idBytes, uint64(instanceID)) + packet = append(packet, idBytes...) + + // Error code (2 bytes) + errorBytes := make([]byte, 2) + binary.LittleEndian.PutUint16(errorBytes, uint16(errorCode)) + packet = append(packet, errorBytes...) + + // Error message length and data + messageBytes := []byte(errorMessage) + packet = append(packet, byte(len(messageBytes))) + packet = append(packet, messageBytes...) + + return packet, nil +} + +// BuildHOShiftPacket builds wheel shift notification packet +func (hpb *HeroicOPPacketBuilder) BuildHOShiftPacket(ho *HeroicOP, oldWheelID, newWheelID int32) ([]byte, error) { + if ho == nil { + return nil, fmt.Errorf("heroic opportunity is nil") + } + + packet := make([]byte, 0, 32) + + // Packet header + packet = append(packet, 0x08) // HO Shift packet type + + // HO Instance ID (8 bytes) + idBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(idBytes, uint64(ho.ID)) + packet = append(packet, idBytes...) + + // Old wheel ID (4 bytes) + oldWheelBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(oldWheelBytes, uint32(oldWheelID)) + packet = append(packet, oldWheelBytes...) + + // New wheel ID (4 bytes) + newWheelBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(newWheelBytes, uint32(newWheelID)) + packet = append(packet, newWheelBytes...) + + return packet, nil +} + +// PacketData conversion methods + +// ToPacketData converts HO and wheel to packet data structure +func (hpb *HeroicOPPacketBuilder) ToPacketData(ho *HeroicOP, wheel *HeroicOPWheel) *PacketData { + data := &PacketData{ + TimeRemaining: ho.TimeRemaining, + TotalTime: ho.TotalTime, + Complete: ho.Complete, + State: ho.State, + Countered: ho.Countered, + } + + if wheel != nil { + data.SpellName = wheel.Name + data.SpellDescription = wheel.Description + data.Abilities = wheel.Abilities + data.CanShift = ho.ShiftUsed == ShiftNotUsed && wheel.HasShift() + data.ShiftIcon = wheel.ShiftIcon + } else { + data.SpellName = ho.SpellName + data.SpellDescription = ho.SpellDescription + // Abilities will be zero-initialized + } + + return data +} + +// Helper methods for packet validation + +// ValidatePacketSize checks if packet size is within acceptable limits +func (hpb *HeroicOPPacketBuilder) ValidatePacketSize(packet []byte) error { + const maxPacketSize = 1024 // 1KB limit for HO packets + + if len(packet) > maxPacketSize { + return fmt.Errorf("packet size %d exceeds maximum %d", len(packet), maxPacketSize) + } + + return nil +} + +// GetPacketTypeDescription returns human-readable packet type description +func (hpb *HeroicOPPacketBuilder) GetPacketTypeDescription(packetType byte) string { + switch packetType { + case 0x01: + return "HO Start" + case 0x02: + return "HO Update" + case 0x03: + return "HO Complete" + case 0x04: + return "HO Timer" + case 0x05: + return "HO Wheel" + case 0x06: + return "HO Progress" + case 0x07: + return "HO Error" + case 0x08: + return "HO Shift" + default: + return "Unknown" + } +} + +// Client version specific methods + +// IsVersionSupported checks if client version supports specific features +func (hpb *HeroicOPPacketBuilder) IsVersionSupported(feature string) bool { + // Version-specific feature support + switch feature { + case "wheel_shifting": + return hpb.clientVersion >= 546 // Example version requirement + case "progress_updates": + return hpb.clientVersion >= 564 + case "extended_timers": + return hpb.clientVersion >= 572 + default: + return true // Basic features supported in all versions + } +} + +// GetVersionSpecificPacketSize returns packet size limits for client version +func (hpb *HeroicOPPacketBuilder) GetVersionSpecificPacketSize() int { + if hpb.clientVersion >= 564 { + return 1024 // Newer clients support larger packets + } + return 512 // Older clients have smaller limits +} + +// Error codes for HO system +const ( + HOErrorNone = iota + HOErrorInvalidState + HOErrorTimerExpired + HOErrorAbilityNotAllowed + HOErrorShiftAlreadyUsed + HOErrorPlayerNotInEncounter + HOErrorEncounterEnded + HOErrorSystemDisabled +) + +// GetErrorMessage returns human-readable error message for error code +func GetErrorMessage(errorCode int) string { + switch errorCode { + case HOErrorNone: + return "No error" + case HOErrorInvalidState: + return "Heroic opportunity is in an invalid state" + case HOErrorTimerExpired: + return "Heroic opportunity timer has expired" + case HOErrorAbilityNotAllowed: + return "This ability cannot be used for the current heroic opportunity" + case HOErrorShiftAlreadyUsed: + return "Wheel shift has already been used" + case HOErrorPlayerNotInEncounter: + return "Player is not in the encounter" + case HOErrorEncounterEnded: + return "Encounter has ended" + case HOErrorSystemDisabled: + return "Heroic opportunity system is disabled" + default: + return "Unknown error" + } +} \ No newline at end of file diff --git a/internal/heroic_ops/types.go b/internal/heroic_ops/types.go new file mode 100644 index 0000000..1eba78e --- /dev/null +++ b/internal/heroic_ops/types.go @@ -0,0 +1,196 @@ +package heroic_ops + +import ( + "sync" + "time" +) + +// HeroicOPStarter represents a starter chain for heroic opportunities +type HeroicOPStarter struct { + mu sync.RWMutex + ID int32 `json:"id"` // Unique identifier for this starter + StartClass int8 `json:"start_class"` // Class that can initiate this starter (0 = any) + StarterIcon int16 `json:"starter_icon"` // Icon displayed for the starter + Abilities [6]int16 `json:"abilities"` // Array of ability icons in sequence + Name string `json:"name"` // Display name for this starter + Description string `json:"description"` // Description text + SaveNeeded bool `json:"-"` // Flag indicating if database save is needed +} + +// HeroicOPWheel represents the wheel phase of a heroic opportunity +type HeroicOPWheel struct { + mu sync.RWMutex + ID int32 `json:"id"` // Unique identifier for this wheel + StarterLinkID int32 `json:"starter_link_id"` // ID of the starter this wheel belongs to + Order int8 `json:"order"` // 0 = unordered, 1+ = ordered + ShiftIcon int16 `json:"shift_icon"` // Icon that can shift/change the wheel + Chance float32 `json:"chance"` // Probability factor for selecting this wheel + Abilities [6]int16 `json:"abilities"` // Array of ability icons for the wheel + SpellID int32 `json:"spell_id"` // Spell cast when HO completes successfully + Name string `json:"name"` // Display name for this wheel + Description string `json:"description"` // Description text + RequiredPlayers int8 `json:"required_players"` // Minimum players required + SaveNeeded bool `json:"-"` // Flag indicating if database save is needed +} + +// HeroicOP represents an active heroic opportunity instance +type HeroicOP struct { + mu sync.RWMutex + ID int64 `json:"id"` // Unique instance ID + EncounterID int32 `json:"encounter_id"` // Encounter this HO belongs to + StarterID int32 `json:"starter_id"` // ID of the completed starter + WheelID int32 `json:"wheel_id"` // ID of the active wheel + State int8 `json:"state"` // Current HO state + StartTime time.Time `json:"start_time"` // When the HO started + WheelStartTime time.Time `json:"wheel_start_time"` // When wheel phase started + TimeRemaining int32 `json:"time_remaining"` // Milliseconds remaining + TotalTime int32 `json:"total_time"` // Total time allocated (ms) + Complete int8 `json:"complete"` // Completion status (0/1) + Countered [6]int8 `json:"countered"` // Which wheel abilities are completed + ShiftUsed int8 `json:"shift_used"` // Whether shift has been used + StarterProgress int8 `json:"starter_progress"` // Current position in starter chain + Participants map[int32]bool `json:"participants"` // Character IDs that participated + CurrentStarters []int32 `json:"current_starters"` // Active starter IDs during chain phase + CompletedBy int32 `json:"completed_by"` // Character ID that completed the HO + SpellName string `json:"spell_name"` // Name of completion spell + SpellDescription string `json:"spell_description"` // Description of completion spell + SaveNeeded bool `json:"-"` // Flag indicating if database save is needed +} + +// HeroicOPProgress tracks progress during starter chain phase +type HeroicOPProgress struct { + StarterID int32 `json:"starter_id"` // Starter being tracked + CurrentPosition int8 `json:"current_position"` // Current position in the chain (0-5) + IsEliminated bool `json:"is_eliminated"` // Whether this starter has been eliminated +} + +// HeroicOPData represents database record structure +type HeroicOPData struct { + ID int32 `json:"id"` + HOType string `json:"ho_type"` // "Starter" or "Wheel" + StarterClass int8 `json:"starter_class"` // For starters + StarterIcon int16 `json:"starter_icon"` // For starters + StarterLinkID int32 `json:"starter_link_id"` // For wheels + ChainOrder int8 `json:"chain_order"` // For wheels + ShiftIcon int16 `json:"shift_icon"` // For wheels + SpellID int32 `json:"spell_id"` // For wheels + Chance float32 `json:"chance"` // For wheels + Ability1 int16 `json:"ability1"` + Ability2 int16 `json:"ability2"` + Ability3 int16 `json:"ability3"` + Ability4 int16 `json:"ability4"` + Ability5 int16 `json:"ability5"` + Ability6 int16 `json:"ability6"` + Name string `json:"name"` + Description string `json:"description"` +} + +// MasterHeroicOPList manages all heroic opportunity configurations +type MasterHeroicOPList struct { + mu sync.RWMutex + // Structure: map[class]map[starter_id][]wheel + starters map[int8]map[int32]*HeroicOPStarter + wheels map[int32][]*HeroicOPWheel // starter_id -> wheels + spells map[int32]SpellInfo // spell_id -> spell info + loaded bool +} + +// HeroicOPManager manages active heroic opportunity instances +type HeroicOPManager struct { + mu sync.RWMutex + activeHOs map[int64]*HeroicOP // instance_id -> HO + encounterHOs map[int32][]*HeroicOP // encounter_id -> HOs + masterList *MasterHeroicOPList + database HeroicOPDatabase + eventHandler HeroicOPEventHandler + logger LogHandler + nextInstanceID int64 + // Configuration + defaultWheelTimer int32 // milliseconds + maxConcurrentHOs int + enableLogging bool + enableStatistics bool +} + +// SpellInfo contains information about completion spells +type SpellInfo struct { + ID int32 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Icon int16 `json:"icon"` +} + +// HeroicOPStatistics tracks system usage statistics +type HeroicOPStatistics struct { + TotalHOsStarted int64 `json:"total_hos_started"` + TotalHOsCompleted int64 `json:"total_hos_completed"` + TotalHOsFailed int64 `json:"total_hos_failed"` + TotalHOsTimedOut int64 `json:"total_hos_timed_out"` + AverageCompletionTime float64 `json:"average_completion_time"` // seconds + MostUsedStarter int32 `json:"most_used_starter"` + MostUsedWheel int32 `json:"most_used_wheel"` + SuccessRate float64 `json:"success_rate"` // percentage + ShiftUsageRate float64 `json:"shift_usage_rate"` // percentage + ActiveHOCount int `json:"active_ho_count"` + ParticipationStats map[int32]int64 `json:"participation_stats"` // character_id -> HO count +} + +// HeroicOPSearchCriteria for searching heroic opportunities +type HeroicOPSearchCriteria struct { + StarterClass int8 `json:"starter_class"` // Filter by starter class (0 = any) + SpellID int32 `json:"spell_id"` // Filter by completion spell + MinChance float32 `json:"min_chance"` // Minimum wheel chance + MaxChance float32 `json:"max_chance"` // Maximum wheel chance + RequiredPlayers int8 `json:"required_players"` // Filter by player requirements + NamePattern string `json:"name_pattern"` // Filter by name pattern + HasShift bool `json:"has_shift"` // Filter by shift availability + IsOrdered bool `json:"is_ordered"` // Filter by wheel order type +} + +// HeroicOPEvent represents an event in the HO system +type HeroicOPEvent struct { + ID int64 `json:"id"` + InstanceID int64 `json:"instance_id"` + EventType int `json:"event_type"` + CharacterID int32 `json:"character_id"` + AbilityIcon int16 `json:"ability_icon"` + Timestamp time.Time `json:"timestamp"` + Data string `json:"data"` // JSON encoded additional data +} + +// PacketData represents data sent to client for HO display +type PacketData struct { + SpellName string `json:"spell_name"` + SpellDescription string `json:"spell_description"` + TimeRemaining int32 `json:"time_remaining"` // milliseconds + TotalTime int32 `json:"total_time"` // milliseconds + Abilities [6]int16 `json:"abilities"` // Current wheel abilities + Countered [6]int8 `json:"countered"` // Completion status + Complete int8 `json:"complete"` // Overall completion (0/1) + State int8 `json:"state"` // Current HO state + CanShift bool `json:"can_shift"` // Whether shift is available + ShiftIcon int16 `json:"shift_icon"` // Icon for shift ability +} + +// PlayerHOInfo contains player-specific HO information +type PlayerHOInfo struct { + CharacterID int32 `json:"character_id"` + ParticipatingHOs []int64 `json:"participating_hos"` // HO instance IDs + LastActivity time.Time `json:"last_activity"` + TotalHOsJoined int64 `json:"total_hos_joined"` + TotalHOsCompleted int64 `json:"total_hos_completed"` + SuccessRate float64 `json:"success_rate"` +} + +// Configuration structure for HO system +type HeroicOPConfig struct { + DefaultWheelTimer int32 `json:"default_wheel_timer"` // milliseconds + StarterChainTimeout int32 `json:"starter_chain_timeout"` // milliseconds + MaxConcurrentHOs int `json:"max_concurrent_hos"` + EnableLogging bool `json:"enable_logging"` + EnableStatistics bool `json:"enable_statistics"` + EnableShifting bool `json:"enable_shifting"` + RequireClassMatch bool `json:"require_class_match"` // Enforce starter class restrictions + AutoCleanupInterval int32 `json:"auto_cleanup_interval"` // seconds + MaxHistoryEntries int `json:"max_history_entries"` +} \ No newline at end of file diff --git a/internal/housing/constants.go b/internal/housing/constants.go new file mode 100644 index 0000000..1b3bbda --- /dev/null +++ b/internal/housing/constants.go @@ -0,0 +1,267 @@ +package housing + +// Housing System Constants +const ( + // Access levels for housing + AccessLevelOwner = iota + AccessLevelFriend + AccessLevelVisitor + AccessLevelGuildMember + AccessLevelBanned + + // House alignment requirements + AlignmentAny = 0 + AlignmentGood = 1 + AlignmentEvil = 2 + AlignmentNeutral = 3 + + // Transaction types for house history + TransactionPurchase = 1 + TransactionUpkeep = 2 + TransactionDeposit = 3 + TransactionWithdrawal = 4 + TransactionAmenity = 5 + TransactionVaultExpansion = 6 + TransactionRent = 7 + TransactionForeclosure = 8 + TransactionTransfer = 9 + TransactionRepair = 10 + + // History position flags + HistoryFlagPositive = 1 + HistoryFlagNegative = 0 + + // Upkeep periods (in seconds) + UpkeepPeriodWeekly = 604800 // 7 days + UpkeepPeriodMonthly = 2592000 // 30 days + UpkeepGracePeriod = 259200 // 3 days + + // House status flags + HouseStatusActive = 0 + HouseStatusUpkeepDue = 1 + HouseStatusForeclosed = 2 + HouseStatusAbandoned = 3 + + // Maximum values + MaxHouseName = 64 + MaxReasonLength = 255 + MaxDepositHistory = 100 + MaxTransactionHistory = 500 + MaxVaultSlots = 200 + MaxAmenities = 50 + MaxAccessEntries = 100 + + // Database retry settings + MaxDatabaseRetries = 3 + DatabaseTimeout = 30 // seconds + + // Escrow limits + MaxEscrowCoins = 1000000000 // 1 billion copper + MaxEscrowStatus = 10000000 // 10 million status + + // Visit permissions + VisitPermissionPublic = 0 + VisitPermissionFriends = 1 + VisitPermissionGuild = 2 + VisitPermissionInviteOnly = 3 + VisitPermissionPrivate = 4 + + // Housing opcodes/packet types + OpHousePurchase = "PlayerHousePurchase" + OpHousingList = "CharacterHousingList" + OpBaseHouseWindow = "PlayerHouseBaseScreen" + OpHouseVisitWindow = "PlayerHouseVisit" + OpBuyHouse = "BuyHouse" + OpEnterHouse = "EnterHouse" + OpUpdateHouseAccess = "UpdateHouseAccessDataMsg" + OpHouseDeposit = "HouseDeposit" + OpHouseWithdrawal = "HouseWithdrawal" + OpPlaceItem = "PlaceItemInHouse" + OpRemoveItem = "RemoveItemFromHouse" + OpUpdateAmenities = "UpdateHouseAmenities" + + // Error messages + ErrHouseNotFound = "house not found" + ErrInsufficientFunds = "insufficient funds" + ErrInsufficientStatus = "insufficient status points" + ErrAccessDenied = "access denied" + ErrHouseNotOwned = "house not owned by player" + ErrAlignmentRestriction = "alignment requirement not met" + ErrGuildLevelRestriction = "guild level requirement not met" + ErrUpkeepOverdue = "house upkeep is overdue" + ErrHouseForeclosed = "house has been foreclosed" + ErrInvalidHouseType = "invalid house type" + ErrDuplicateHouse = "player already owns this house type" + ErrMaxHousesReached = "maximum number of houses reached" + + // Default upkeep costs (can be overridden per house type) + DefaultUpkeepCoins = 10000 // 1 gold + DefaultUpkeepStatus = 100 + + // Item placement constants + MaxItemsPerHouse = 1000 + MaxItemStackSize = 100 + ItemPlacementRadius = 50.0 // Maximum distance from spawn point + + // House zones configuration + DefaultInstanceLifetime = 3600 // 1 hour in seconds + MaxHouseVisitors = 50 // Maximum concurrent visitors + + // Amenity types + AmenityVaultExpansion = 1 + AmenityPortal = 2 + AmenityMerchant = 3 + AmenityRepairNPC = 4 + AmenityBroker = 5 + AmenityBanker = 6 + AmenityManagedItems = 7 + AmenityTeleporter = 8 + + // Foreclosure settings + ForeclosureWarningDays = 7 // Days before foreclosure + ForeclosureNoticeDays = 3 // Days of final notice + + // Deposit limits + MinDepositAmount = 1 + MaxDepositAmount = 10000000 // 1000 gold + + // Search and filtering + MaxSearchResults = 100 + SearchTimeout = 5 // seconds +) + +// House type constants for common house types +const ( + HouseTypeInn = 1 + HouseTypeCottage = 2 + HouseTypeApartment = 3 + HouseTypeHouse = 4 + HouseTypeMansion = 5 + HouseTypeKeep = 6 + HouseTypeGuildHall = 7 + HouseTypePrestigeHome = 8 +) + +// Access permission flags (bitwise) +const ( + PermissionEnter = 1 << iota // Can enter the house + PermissionPlace = 1 << iota // Can place items + PermissionRemove = 1 << iota // Can remove items + PermissionMove = 1 << iota // Can move items + PermissionVault = 1 << iota // Can access vault + PermissionDeposit = 1 << iota // Can make deposits + PermissionWithdraw = 1 << iota // Can make withdrawals + PermissionInvite = 1 << iota // Can invite others + PermissionKick = 1 << iota // Can kick visitors + PermissionAdmin = 1 << iota // Full administrative access +) + +// Default permission sets +const ( + PermissionsOwner = PermissionEnter | PermissionPlace | PermissionRemove | + PermissionMove | PermissionVault | PermissionDeposit | + PermissionWithdraw | PermissionInvite | PermissionKick | PermissionAdmin + + PermissionsFriend = PermissionEnter | PermissionPlace | PermissionMove | + PermissionVault | PermissionDeposit + + PermissionsVisitor = PermissionEnter + + PermissionsGuildMember = PermissionEnter | PermissionPlace | PermissionDeposit + + PermissionsBanned = 0 +) + +// Alignment names for display +var AlignmentNames = map[int8]string{ + AlignmentAny: "Any", + AlignmentGood: "Good", + AlignmentEvil: "Evil", + AlignmentNeutral: "Neutral", +} + +// Transaction reason descriptions +var TransactionReasons = map[int]string{ + TransactionPurchase: "House Purchase", + TransactionUpkeep: "Upkeep Payment", + TransactionDeposit: "Escrow Deposit", + TransactionWithdrawal: "Escrow Withdrawal", + TransactionAmenity: "Amenity Purchase", + TransactionVaultExpansion: "Vault Expansion", + TransactionRent: "Rent Payment", + TransactionForeclosure: "Foreclosure", + TransactionTransfer: "House Transfer", + TransactionRepair: "House Repair", +} + +// Amenity names for display +var AmenityNames = map[int]string{ + AmenityVaultExpansion: "Vault Expansion", + AmenityPortal: "Portal", + AmenityMerchant: "Merchant", + AmenityRepairNPC: "Repair NPC", + AmenityBroker: "Broker", + AmenityBanker: "Banker", + AmenityManagedItems: "Managed Items", + AmenityTeleporter: "Teleporter", +} + +// House type names for display +var HouseTypeNames = map[int]string{ + HouseTypeInn: "Inn Room", + HouseTypeCottage: "Cottage", + HouseTypeApartment: "Apartment", + HouseTypeHouse: "House", + HouseTypeMansion: "Mansion", + HouseTypeKeep: "Keep", + HouseTypeGuildHall: "Guild Hall", + HouseTypePrestigeHome: "Prestige Home", +} + +// Default costs for house types (in copper coins) +var DefaultHouseCosts = map[int]int64{ + HouseTypeInn: 50000, // 5 gold + HouseTypeCottage: 200000, // 20 gold + HouseTypeApartment: 500000, // 50 gold + HouseTypeHouse: 1000000, // 100 gold + HouseTypeMansion: 5000000, // 500 gold + HouseTypeKeep: 10000000, // 1000 gold + HouseTypeGuildHall: 50000000, // 5000 gold + HouseTypePrestigeHome: 100000000, // 10000 gold +} + +// Default status costs for house types +var DefaultHouseStatusCosts = map[int]int64{ + HouseTypeInn: 0, + HouseTypeCottage: 0, + HouseTypeApartment: 100, + HouseTypeHouse: 500, + HouseTypeMansion: 2500, + HouseTypeKeep: 5000, + HouseTypeGuildHall: 25000, + HouseTypePrestigeHome: 50000, +} + +// Default upkeep costs (in copper coins per week) +var DefaultHouseUpkeepCosts = map[int]int64{ + HouseTypeInn: 5000, // 50 silver + HouseTypeCottage: 10000, // 1 gold + HouseTypeApartment: 25000, // 2.5 gold + HouseTypeHouse: 50000, // 5 gold + HouseTypeMansion: 100000, // 10 gold + HouseTypeKeep: 200000, // 20 gold + HouseTypeGuildHall: 500000, // 50 gold + HouseTypePrestigeHome: 1000000, // 100 gold +} + +// Default vault slots per house type +var DefaultVaultSlots = map[int]int{ + HouseTypeInn: 4, + HouseTypeCottage: 6, + HouseTypeApartment: 8, + HouseTypeHouse: 12, + HouseTypeMansion: 16, + HouseTypeKeep: 20, + HouseTypeGuildHall: 24, + HouseTypePrestigeHome: 32, +} \ No newline at end of file diff --git a/internal/housing/database.go b/internal/housing/database.go new file mode 100644 index 0000000..4fe7143 --- /dev/null +++ b/internal/housing/database.go @@ -0,0 +1,1152 @@ +package housing + +import ( + "context" + "fmt" + "time" + + "eq2emu/internal/database" +) + +// DatabaseHousingManager implements HousingDatabase interface using the existing database wrapper +type DatabaseHousingManager struct { + db *database.DB +} + +// NewDatabaseHousingManager creates a new database housing manager +func NewDatabaseHousingManager(db *database.DB) *DatabaseHousingManager { + return &DatabaseHousingManager{ + db: db, + } +} + +// LoadHouseZones retrieves all available house types from database +func (dhm *DatabaseHousingManager) LoadHouseZones(ctx context.Context) ([]HouseZoneData, error) { + query := `SELECT id, name, zone_id, cost_coin, cost_status, upkeep_coin, upkeep_status, + alignment, guild_level, vault_slots, max_items, max_visitors, upkeep_period, description + FROM houses ORDER BY id` + + rows, err := dhm.db.QueryContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("failed to query house zones: %w", err) + } + defer rows.Close() + + var zones []HouseZoneData + for rows.Next() { + var zone HouseZoneData + var description *string + + err := rows.Scan( + &zone.ID, + &zone.Name, + &zone.ZoneID, + &zone.CostCoin, + &zone.CostStatus, + &zone.UpkeepCoin, + &zone.UpkeepStatus, + &zone.Alignment, + &zone.GuildLevel, + &zone.VaultSlots, + &zone.MaxItems, + &zone.MaxVisitors, + &zone.UpkeepPeriod, + &description, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan house zone row: %w", err) + } + + // Handle nullable description field + if description != nil { + zone.Description = *description + } + + zones = append(zones, zone) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating house zone rows: %w", err) + } + + return zones, nil +} + +// LoadHouseZone retrieves a specific house zone from database +func (dhm *DatabaseHousingManager) LoadHouseZone(ctx context.Context, houseID int32) (*HouseZoneData, error) { + query := `SELECT id, name, zone_id, cost_coin, cost_status, upkeep_coin, upkeep_status, + alignment, guild_level, vault_slots, max_items, max_visitors, upkeep_period, description + FROM houses WHERE id = ?` + + var zone HouseZoneData + var description *string + + err := dhm.db.QueryRowContext(ctx, query, houseID).Scan( + &zone.ID, + &zone.Name, + &zone.ZoneID, + &zone.CostCoin, + &zone.CostStatus, + &zone.UpkeepCoin, + &zone.UpkeepStatus, + &zone.Alignment, + &zone.GuildLevel, + &zone.VaultSlots, + &zone.MaxItems, + &zone.MaxVisitors, + &zone.UpkeepPeriod, + &description, + ) + if err != nil { + return nil, fmt.Errorf("failed to load house zone %d: %w", houseID, err) + } + + // Handle nullable description field + if description != nil { + zone.Description = *description + } + + return &zone, nil +} + +// SaveHouseZone saves a house zone to database +func (dhm *DatabaseHousingManager) SaveHouseZone(ctx context.Context, zone *HouseZone) error { + query := `INSERT OR REPLACE INTO houses + (id, name, zone_id, cost_coin, cost_status, upkeep_coin, upkeep_status, + alignment, guild_level, vault_slots, max_items, max_visitors, upkeep_period, description) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + + _, err := dhm.db.ExecContext(ctx, query, + zone.ID, + zone.Name, + zone.ZoneID, + zone.CostCoin, + zone.CostStatus, + zone.UpkeepCoin, + zone.UpkeepStatus, + zone.Alignment, + zone.GuildLevel, + zone.VaultSlots, + zone.MaxItems, + zone.MaxVisitors, + zone.UpkeepPeriod, + zone.Description, + ) + if err != nil { + return fmt.Errorf("failed to save house zone %d: %w", zone.ID, err) + } + + return nil +} + +// DeleteHouseZone removes a house zone from database +func (dhm *DatabaseHousingManager) DeleteHouseZone(ctx context.Context, houseID int32) error { + _, err := dhm.db.ExecContext(ctx, "DELETE FROM houses WHERE id = ?", houseID) + if err != nil { + return fmt.Errorf("failed to delete house zone %d: %w", houseID, err) + } + + return nil +} + +// LoadPlayerHouses retrieves all houses owned by a character +func (dhm *DatabaseHousingManager) LoadPlayerHouses(ctx context.Context, characterID int32) ([]PlayerHouseData, error) { + query := `SELECT unique_id, char_id, house_id, instance_id, upkeep_due, escrow_coins, escrow_status, + status, house_name, visit_permission, public_note, private_note, allow_friends, allow_guild, + require_approval, show_on_directory, allow_decoration, tax_exempt + FROM character_houses WHERE char_id = ? ORDER BY unique_id` + + rows, err := dhm.db.QueryContext(ctx, query, characterID) + if err != nil { + return nil, fmt.Errorf("failed to query player houses for character %d: %w", characterID, err) + } + defer rows.Close() + + var houses []PlayerHouseData + for rows.Next() { + var house PlayerHouseData + var upkeepDueTimestamp int64 + var houseName, publicNote, privateNote *string + + err := rows.Scan( + &house.UniqueID, + &house.CharacterID, + &house.HouseID, + &house.InstanceID, + &upkeepDueTimestamp, + &house.EscrowCoins, + &house.EscrowStatus, + &house.Status, + &houseName, + &house.VisitPermission, + &publicNote, + &privateNote, + &house.AllowFriends, + &house.AllowGuild, + &house.RequireApproval, + &house.ShowOnDirectory, + &house.AllowDecoration, + &house.TaxExempt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan player house row: %w", err) + } + + // Convert timestamp to time + house.UpkeepDue = time.Unix(upkeepDueTimestamp, 0) + + // Handle nullable fields + if houseName != nil { + house.HouseName = *houseName + } + if publicNote != nil { + house.PublicNote = *publicNote + } + if privateNote != nil { + house.PrivateNote = *privateNote + } + + houses = append(houses, house) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating player house rows: %w", err) + } + + return houses, nil +} + +// LoadPlayerHouse retrieves a specific player house +func (dhm *DatabaseHousingManager) LoadPlayerHouse(ctx context.Context, uniqueID int64) (*PlayerHouseData, error) { + query := `SELECT unique_id, char_id, house_id, instance_id, upkeep_due, escrow_coins, escrow_status, + status, house_name, visit_permission, public_note, private_note, allow_friends, allow_guild, + require_approval, show_on_directory, allow_decoration, tax_exempt + FROM character_houses WHERE unique_id = ?` + + var house PlayerHouseData + var upkeepDueTimestamp int64 + var houseName, publicNote, privateNote *string + + err := dhm.db.QueryRowContext(ctx, query, uniqueID).Scan( + &house.UniqueID, + &house.CharacterID, + &house.HouseID, + &house.InstanceID, + &upkeepDueTimestamp, + &house.EscrowCoins, + &house.EscrowStatus, + &house.Status, + &houseName, + &house.VisitPermission, + &publicNote, + &privateNote, + &house.AllowFriends, + &house.AllowGuild, + &house.RequireApproval, + &house.ShowOnDirectory, + &house.AllowDecoration, + &house.TaxExempt, + ) + if err != nil { + return nil, fmt.Errorf("failed to load player house %d: %w", uniqueID, err) + } + + // Convert timestamp to time + house.UpkeepDue = time.Unix(upkeepDueTimestamp, 0) + + // Handle nullable fields + if houseName != nil { + house.HouseName = *houseName + } + if publicNote != nil { + house.PublicNote = *publicNote + } + if privateNote != nil { + house.PrivateNote = *privateNote + } + + return &house, nil +} + +// SavePlayerHouse saves a player house to database +func (dhm *DatabaseHousingManager) SavePlayerHouse(ctx context.Context, house *PlayerHouse) error { + query := `INSERT OR REPLACE INTO character_houses + (unique_id, char_id, house_id, instance_id, upkeep_due, escrow_coins, escrow_status, + status, house_name, visit_permission, public_note, private_note, allow_friends, allow_guild, + require_approval, show_on_directory, allow_decoration, tax_exempt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + + upkeepDueTimestamp := house.UpkeepDue.Unix() + + _, err := dhm.db.ExecContext(ctx, query, + house.UniqueID, + house.CharacterID, + house.HouseID, + house.InstanceID, + upkeepDueTimestamp, + house.EscrowCoins, + house.EscrowStatus, + house.Status, + house.Settings.HouseName, + house.Settings.VisitPermission, + house.Settings.PublicNote, + house.Settings.PrivateNote, + house.Settings.AllowFriends, + house.Settings.AllowGuild, + house.Settings.RequireApproval, + house.Settings.ShowOnDirectory, + house.Settings.AllowDecoration, + house.Settings.TaxExempt, + ) + if err != nil { + return fmt.Errorf("failed to save player house %d: %w", house.UniqueID, err) + } + + return nil +} + +// DeletePlayerHouse removes a player house from database +func (dhm *DatabaseHousingManager) DeletePlayerHouse(ctx context.Context, uniqueID int64) error { + // Use a transaction to delete house and all related data + tx, err := dhm.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Delete related data first + tables := []string{ + "character_house_deposits", + "character_house_history", + "character_house_access", + "character_house_amenities", + "character_house_items", + } + + for _, table := range tables { + query := fmt.Sprintf("DELETE FROM %s WHERE house_id = ?", table) + _, err = tx.ExecContext(ctx, query, uniqueID) + if err != nil { + return fmt.Errorf("failed to delete from %s: %w", table, err) + } + } + + // Delete the house itself + _, err = tx.ExecContext(ctx, "DELETE FROM character_houses WHERE unique_id = ?", uniqueID) + if err != nil { + return fmt.Errorf("failed to delete player house: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} + +// AddPlayerHouse creates a new player house entry +func (dhm *DatabaseHousingManager) AddPlayerHouse(ctx context.Context, houseData PlayerHouseData) (int64, error) { + query := `INSERT INTO character_houses + (char_id, house_id, instance_id, upkeep_due, escrow_coins, escrow_status, + status, house_name, visit_permission, public_note, private_note, allow_friends, allow_guild, + require_approval, show_on_directory, allow_decoration, tax_exempt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + + upkeepDueTimestamp := houseData.UpkeepDue.Unix() + + result, err := dhm.db.ExecContext(ctx, query, + houseData.CharacterID, + houseData.HouseID, + houseData.InstanceID, + upkeepDueTimestamp, + houseData.EscrowCoins, + houseData.EscrowStatus, + houseData.Status, + houseData.HouseName, + houseData.VisitPermission, + houseData.PublicNote, + houseData.PrivateNote, + houseData.AllowFriends, + houseData.AllowGuild, + houseData.RequireApproval, + houseData.ShowOnDirectory, + houseData.AllowDecoration, + houseData.TaxExempt, + ) + if err != nil { + return 0, fmt.Errorf("failed to create player house: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return 0, fmt.Errorf("failed to get new house ID: %w", err) + } + + return id, nil +} + +// LoadDeposits retrieves deposit history for a house +func (dhm *DatabaseHousingManager) LoadDeposits(ctx context.Context, houseID int64) ([]HouseDepositData, error) { + query := `SELECT house_id, timestamp, amount, last_amount, status, last_status, name, character_id + FROM character_house_deposits WHERE house_id = ? + ORDER BY timestamp DESC LIMIT ?` + + rows, err := dhm.db.QueryContext(ctx, query, houseID, MaxDepositHistory) + if err != nil { + return nil, fmt.Errorf("failed to query house deposits for house %d: %w", houseID, err) + } + defer rows.Close() + + var deposits []HouseDepositData + for rows.Next() { + var deposit HouseDepositData + var timestampUnix int64 + + err := rows.Scan( + &deposit.HouseID, + ×tampUnix, + &deposit.Amount, + &deposit.LastAmount, + &deposit.Status, + &deposit.LastStatus, + &deposit.Name, + &deposit.CharacterID, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan deposit row: %w", err) + } + + deposit.Timestamp = time.Unix(timestampUnix, 0) + deposits = append(deposits, deposit) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating deposit rows: %w", err) + } + + return deposits, nil +} + +// SaveDeposit saves a deposit record +func (dhm *DatabaseHousingManager) SaveDeposit(ctx context.Context, houseID int64, deposit HouseDeposit) error { + query := `INSERT INTO character_house_deposits + (house_id, timestamp, amount, last_amount, status, last_status, name, character_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + + timestampUnix := deposit.Timestamp.Unix() + + _, err := dhm.db.ExecContext(ctx, query, + houseID, + timestampUnix, + deposit.Amount, + deposit.LastAmount, + deposit.Status, + deposit.LastStatus, + deposit.Name, + deposit.CharacterID, + ) + if err != nil { + return fmt.Errorf("failed to save house deposit: %w", err) + } + + return nil +} + +// LoadHistory retrieves transaction history for a house +func (dhm *DatabaseHousingManager) LoadHistory(ctx context.Context, houseID int64) ([]HouseHistoryData, error) { + query := `SELECT house_id, timestamp, amount, status, reason, name, character_id, pos_flag, type + FROM character_house_history WHERE house_id = ? + ORDER BY timestamp DESC LIMIT ?` + + rows, err := dhm.db.QueryContext(ctx, query, houseID, MaxTransactionHistory) + if err != nil { + return nil, fmt.Errorf("failed to query house history for house %d: %w", houseID, err) + } + defer rows.Close() + + var history []HouseHistoryData + for rows.Next() { + var entry HouseHistoryData + var timestampUnix int64 + + err := rows.Scan( + &entry.HouseID, + ×tampUnix, + &entry.Amount, + &entry.Status, + &entry.Reason, + &entry.Name, + &entry.CharacterID, + &entry.PosFlag, + &entry.Type, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan history row: %w", err) + } + + entry.Timestamp = time.Unix(timestampUnix, 0) + history = append(history, entry) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating history rows: %w", err) + } + + return history, nil +} + +// AddHistory adds a new history entry +func (dhm *DatabaseHousingManager) AddHistory(ctx context.Context, houseID int64, history HouseHistory) error { + query := `INSERT INTO character_house_history + (house_id, timestamp, amount, status, reason, name, character_id, pos_flag, type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + + timestampUnix := history.Timestamp.Unix() + + _, err := dhm.db.ExecContext(ctx, query, + houseID, + timestampUnix, + history.Amount, + history.Status, + history.Reason, + history.Name, + history.CharacterID, + history.PosFlag, + history.Type, + ) + if err != nil { + return fmt.Errorf("failed to add house history: %w", err) + } + + return nil +} + +// LoadHouseAccess retrieves access permissions for a house +func (dhm *DatabaseHousingManager) LoadHouseAccess(ctx context.Context, houseID int64) ([]HouseAccessData, error) { + query := `SELECT house_id, character_id, player_name, access_level, permissions, granted_by, + granted_date, expires_date, notes + FROM character_house_access WHERE house_id = ?` + + rows, err := dhm.db.QueryContext(ctx, query, houseID) + if err != nil { + return nil, fmt.Errorf("failed to query house access for house %d: %w", houseID, err) + } + defer rows.Close() + + var accessList []HouseAccessData + for rows.Next() { + var access HouseAccessData + var grantedDateUnix, expiresDateUnix int64 + var notes *string + + err := rows.Scan( + &access.HouseID, + &access.CharacterID, + &access.PlayerName, + &access.AccessLevel, + &access.Permissions, + &access.GrantedBy, + &grantedDateUnix, + &expiresDateUnix, + ¬es, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan access row: %w", err) + } + + access.GrantedDate = time.Unix(grantedDateUnix, 0) + access.ExpiresDate = time.Unix(expiresDateUnix, 0) + + if notes != nil { + access.Notes = *notes + } + + accessList = append(accessList, access) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating access rows: %w", err) + } + + return accessList, nil +} + +// SaveHouseAccess saves access permissions for a house +func (dhm *DatabaseHousingManager) SaveHouseAccess(ctx context.Context, houseID int64, accessList []HouseAccess) error { + // Use a transaction for atomic updates + tx, err := dhm.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Delete existing access for this house + _, err = tx.ExecContext(ctx, "DELETE FROM character_house_access WHERE house_id = ?", houseID) + if err != nil { + return fmt.Errorf("failed to delete existing house access: %w", err) + } + + // Insert all access entries + insertQuery := `INSERT INTO character_house_access + (house_id, character_id, player_name, access_level, permissions, granted_by, + granted_date, expires_date, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + + for _, access := range accessList { + grantedDateUnix := access.GrantedDate.Unix() + expiresDateUnix := access.ExpiresDate.Unix() + + _, err = tx.ExecContext(ctx, insertQuery, + houseID, + access.CharacterID, + access.PlayerName, + access.AccessLevel, + access.Permissions, + access.GrantedBy, + grantedDateUnix, + expiresDateUnix, + access.Notes, + ) + if err != nil { + return fmt.Errorf("failed to insert house access for character %d: %w", access.CharacterID, err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} + +// DeleteHouseAccess removes access for a specific character +func (dhm *DatabaseHousingManager) DeleteHouseAccess(ctx context.Context, houseID int64, characterID int32) error { + _, err := dhm.db.ExecContext(ctx, + "DELETE FROM character_house_access WHERE house_id = ? AND character_id = ?", + houseID, characterID) + if err != nil { + return fmt.Errorf("failed to delete house access: %w", err) + } + + return nil +} + +// Additional database methods continued in next part due to length... + +// LoadHouseAmenities retrieves amenities for a house +func (dhm *DatabaseHousingManager) LoadHouseAmenities(ctx context.Context, houseID int64) ([]HouseAmenityData, error) { + query := `SELECT house_id, id, type, name, cost, status_cost, purchase_date, x, y, z, heading, is_active + FROM character_house_amenities WHERE house_id = ?` + + rows, err := dhm.db.QueryContext(ctx, query, houseID) + if err != nil { + return nil, fmt.Errorf("failed to query house amenities for house %d: %w", houseID, err) + } + defer rows.Close() + + var amenities []HouseAmenityData + for rows.Next() { + var amenity HouseAmenityData + var purchaseDateUnix int64 + + err := rows.Scan( + &amenity.HouseID, + &amenity.ID, + &amenity.Type, + &amenity.Name, + &amenity.Cost, + &amenity.StatusCost, + &purchaseDateUnix, + &amenity.X, + &amenity.Y, + &amenity.Z, + &amenity.Heading, + &amenity.IsActive, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan amenity row: %w", err) + } + + amenity.PurchaseDate = time.Unix(purchaseDateUnix, 0) + amenities = append(amenities, amenity) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating amenity rows: %w", err) + } + + return amenities, nil +} + +// SaveHouseAmenity saves a house amenity +func (dhm *DatabaseHousingManager) SaveHouseAmenity(ctx context.Context, houseID int64, amenity HouseAmenity) error { + query := `INSERT OR REPLACE INTO character_house_amenities + (house_id, id, type, name, cost, status_cost, purchase_date, x, y, z, heading, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + + purchaseDateUnix := amenity.PurchaseDate.Unix() + + _, err := dhm.db.ExecContext(ctx, query, + houseID, + amenity.ID, + amenity.Type, + amenity.Name, + amenity.Cost, + amenity.StatusCost, + purchaseDateUnix, + amenity.X, + amenity.Y, + amenity.Z, + amenity.Heading, + amenity.IsActive, + ) + if err != nil { + return fmt.Errorf("failed to save house amenity: %w", err) + } + + return nil +} + +// DeleteHouseAmenity removes a house amenity +func (dhm *DatabaseHousingManager) DeleteHouseAmenity(ctx context.Context, houseID int64, amenityID int32) error { + _, err := dhm.db.ExecContext(ctx, + "DELETE FROM character_house_amenities WHERE house_id = ? AND id = ?", + houseID, amenityID) + if err != nil { + return fmt.Errorf("failed to delete house amenity: %w", err) + } + + return nil +} + +// LoadHouseItems retrieves items placed in a house +func (dhm *DatabaseHousingManager) LoadHouseItems(ctx context.Context, houseID int64) ([]HouseItemData, error) { + query := `SELECT house_id, id, item_id, character_id, x, y, z, heading, pitch_x, pitch_y, + roll_x, roll_y, placed_date, quantity, condition, house + FROM character_house_items WHERE house_id = ?` + + rows, err := dhm.db.QueryContext(ctx, query, houseID) + if err != nil { + return nil, fmt.Errorf("failed to query house items for house %d: %w", houseID, err) + } + defer rows.Close() + + var items []HouseItemData + for rows.Next() { + var item HouseItemData + var placedDateUnix int64 + + err := rows.Scan( + &item.HouseID, + &item.ID, + &item.ItemID, + &item.CharacterID, + &item.X, + &item.Y, + &item.Z, + &item.Heading, + &item.PitchX, + &item.PitchY, + &item.RollX, + &item.RollY, + &placedDateUnix, + &item.Quantity, + &item.Condition, + &item.House, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan item row: %w", err) + } + + item.PlacedDate = time.Unix(placedDateUnix, 0) + items = append(items, item) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating item rows: %w", err) + } + + return items, nil +} + +// SaveHouseItem saves a house item +func (dhm *DatabaseHousingManager) SaveHouseItem(ctx context.Context, houseID int64, item HouseItem) error { + query := `INSERT OR REPLACE INTO character_house_items + (house_id, id, item_id, character_id, x, y, z, heading, pitch_x, pitch_y, + roll_x, roll_y, placed_date, quantity, condition, house) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + + placedDateUnix := item.PlacedDate.Unix() + + _, err := dhm.db.ExecContext(ctx, query, + houseID, + item.ID, + item.ItemID, + item.CharacterID, + item.X, + item.Y, + item.Z, + item.Heading, + item.PitchX, + item.PitchY, + item.RollX, + item.RollY, + placedDateUnix, + item.Quantity, + item.Condition, + item.House, + ) + if err != nil { + return fmt.Errorf("failed to save house item: %w", err) + } + + return nil +} + +// DeleteHouseItem removes a house item +func (dhm *DatabaseHousingManager) DeleteHouseItem(ctx context.Context, houseID int64, itemID int64) error { + _, err := dhm.db.ExecContext(ctx, + "DELETE FROM character_house_items WHERE house_id = ? AND id = ?", + houseID, itemID) + if err != nil { + return fmt.Errorf("failed to delete house item: %w", err) + } + + return nil +} + +// Utility operations + +// GetNextHouseID returns the next available house unique ID +func (dhm *DatabaseHousingManager) GetNextHouseID(ctx context.Context) (int64, error) { + query := "SELECT COALESCE(MAX(unique_id), 0) + 1 FROM character_houses" + + var nextID int64 + err := dhm.db.QueryRowContext(ctx, query).Scan(&nextID) + if err != nil { + return 0, fmt.Errorf("failed to get next house ID: %w", err) + } + + return nextID, nil +} + +// GetHouseByInstance finds a house by instance ID +func (dhm *DatabaseHousingManager) GetHouseByInstance(ctx context.Context, instanceID int32) (*PlayerHouseData, error) { + query := `SELECT unique_id, char_id, house_id, instance_id, upkeep_due, escrow_coins, escrow_status, + status, house_name, visit_permission, public_note, private_note, allow_friends, allow_guild, + require_approval, show_on_directory, allow_decoration, tax_exempt + FROM character_houses WHERE instance_id = ?` + + var house PlayerHouseData + var upkeepDueTimestamp int64 + var houseName, publicNote, privateNote *string + + err := dhm.db.QueryRowContext(ctx, query, instanceID).Scan( + &house.UniqueID, + &house.CharacterID, + &house.HouseID, + &house.InstanceID, + &upkeepDueTimestamp, + &house.EscrowCoins, + &house.EscrowStatus, + &house.Status, + &houseName, + &house.VisitPermission, + &publicNote, + &privateNote, + &house.AllowFriends, + &house.AllowGuild, + &house.RequireApproval, + &house.ShowOnDirectory, + &house.AllowDecoration, + &house.TaxExempt, + ) + if err != nil { + return nil, fmt.Errorf("failed to load house by instance %d: %w", instanceID, err) + } + + // Convert timestamp and handle nullable fields + house.UpkeepDue = time.Unix(upkeepDueTimestamp, 0) + + if houseName != nil { + house.HouseName = *houseName + } + if publicNote != nil { + house.PublicNote = *publicNote + } + if privateNote != nil { + house.PrivateNote = *privateNote + } + + return &house, nil +} + +// UpdateHouseUpkeepDue updates the upkeep due date for a house +func (dhm *DatabaseHousingManager) UpdateHouseUpkeepDue(ctx context.Context, houseID int64, upkeepDue time.Time) error { + query := "UPDATE character_houses SET upkeep_due = ? WHERE unique_id = ?" + upkeepDueTimestamp := upkeepDue.Unix() + + _, err := dhm.db.ExecContext(ctx, query, upkeepDueTimestamp, houseID) + if err != nil { + return fmt.Errorf("failed to update house upkeep due: %w", err) + } + + return nil +} + +// UpdateHouseEscrow updates the escrow balances for a house +func (dhm *DatabaseHousingManager) UpdateHouseEscrow(ctx context.Context, houseID int64, coins, status int64) error { + query := "UPDATE character_houses SET escrow_coins = ?, escrow_status = ? WHERE unique_id = ?" + + _, err := dhm.db.ExecContext(ctx, query, coins, status, houseID) + if err != nil { + return fmt.Errorf("failed to update house escrow: %w", err) + } + + return nil +} + +// GetHousesForUpkeep returns houses that need upkeep processing +func (dhm *DatabaseHousingManager) GetHousesForUpkeep(ctx context.Context, cutoffTime time.Time) ([]PlayerHouseData, error) { + query := `SELECT unique_id, char_id, house_id, instance_id, upkeep_due, escrow_coins, escrow_status, + status, house_name, visit_permission, public_note, private_note, allow_friends, allow_guild, + require_approval, show_on_directory, allow_decoration, tax_exempt + FROM character_houses WHERE upkeep_due <= ? AND status = ?` + + cutoffTimestamp := cutoffTime.Unix() + + rows, err := dhm.db.QueryContext(ctx, query, cutoffTimestamp, HouseStatusActive) + if err != nil { + return nil, fmt.Errorf("failed to query houses for upkeep: %w", err) + } + defer rows.Close() + + var houses []PlayerHouseData + for rows.Next() { + var house PlayerHouseData + var upkeepDueTimestamp int64 + var houseName, publicNote, privateNote *string + + err := rows.Scan( + &house.UniqueID, + &house.CharacterID, + &house.HouseID, + &house.InstanceID, + &upkeepDueTimestamp, + &house.EscrowCoins, + &house.EscrowStatus, + &house.Status, + &houseName, + &house.VisitPermission, + &publicNote, + &privateNote, + &house.AllowFriends, + &house.AllowGuild, + &house.RequireApproval, + &house.ShowOnDirectory, + &house.AllowDecoration, + &house.TaxExempt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan upkeep house row: %w", err) + } + + house.UpkeepDue = time.Unix(upkeepDueTimestamp, 0) + + if houseName != nil { + house.HouseName = *houseName + } + if publicNote != nil { + house.PublicNote = *publicNote + } + if privateNote != nil { + house.PrivateNote = *privateNote + } + + houses = append(houses, house) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating upkeep house rows: %w", err) + } + + return houses, nil +} + +// GetHouseStatistics returns housing system statistics +func (dhm *DatabaseHousingManager) GetHouseStatistics(ctx context.Context) (*HousingStatistics, error) { + stats := &HousingStatistics{ + HousesByType: make(map[int32]int64), + HousesByAlignment: make(map[int8]int64), + RevenueByType: make(map[int]int64), + TopDepositors: make([]PlayerDeposits, 0), + } + + // Get basic counts + queries := map[string]*int64{ + "SELECT COUNT(*) FROM character_houses": &stats.TotalHouses, + "SELECT COUNT(*) FROM character_houses WHERE status = 0": &stats.ActiveHouses, + "SELECT COUNT(*) FROM character_houses WHERE status = 2": &stats.ForelosedHouses, + "SELECT COUNT(*) FROM character_house_deposits": &stats.TotalDeposits, + "SELECT COUNT(*) FROM character_house_history WHERE pos_flag = 0": &stats.TotalWithdrawals, + } + + for query, target := range queries { + err := dhm.db.QueryRowContext(ctx, query).Scan(target) + if err != nil { + return nil, fmt.Errorf("failed to get statistics: %w", err) + } + } + + return stats, nil +} + +// EnsureHousingTables creates the housing tables if they don't exist +func (dhm *DatabaseHousingManager) EnsureHousingTables(ctx context.Context) error { + queries := []string{ + `CREATE TABLE IF NOT EXISTS houses ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + zone_id INTEGER NOT NULL, + cost_coin INTEGER NOT NULL DEFAULT 0, + cost_status INTEGER NOT NULL DEFAULT 0, + upkeep_coin INTEGER NOT NULL DEFAULT 0, + upkeep_status INTEGER NOT NULL DEFAULT 0, + alignment INTEGER NOT NULL DEFAULT 0, + guild_level INTEGER NOT NULL DEFAULT 0, + vault_slots INTEGER NOT NULL DEFAULT 4, + max_items INTEGER NOT NULL DEFAULT 100, + max_visitors INTEGER NOT NULL DEFAULT 50, + upkeep_period INTEGER NOT NULL DEFAULT 604800, + description TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS character_houses ( + unique_id INTEGER PRIMARY KEY AUTOINCREMENT, + char_id INTEGER NOT NULL, + house_id INTEGER NOT NULL, + instance_id INTEGER NOT NULL, + upkeep_due INTEGER NOT NULL, + escrow_coins INTEGER NOT NULL DEFAULT 0, + escrow_status INTEGER NOT NULL DEFAULT 0, + status INTEGER NOT NULL DEFAULT 0, + house_name TEXT DEFAULT '', + visit_permission INTEGER NOT NULL DEFAULT 0, + public_note TEXT DEFAULT '', + private_note TEXT DEFAULT '', + allow_friends INTEGER NOT NULL DEFAULT 1, + allow_guild INTEGER NOT NULL DEFAULT 0, + require_approval INTEGER NOT NULL DEFAULT 0, + show_on_directory INTEGER NOT NULL DEFAULT 1, + allow_decoration INTEGER NOT NULL DEFAULT 0, + tax_exempt INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (house_id) REFERENCES houses(id) + )`, + `CREATE TABLE IF NOT EXISTS character_house_deposits ( + house_id INTEGER NOT NULL, + timestamp INTEGER NOT NULL, + amount INTEGER NOT NULL, + last_amount INTEGER NOT NULL, + status INTEGER NOT NULL, + last_status INTEGER NOT NULL, + name TEXT NOT NULL, + character_id INTEGER NOT NULL, + FOREIGN KEY (house_id) REFERENCES character_houses(unique_id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS character_house_history ( + house_id INTEGER NOT NULL, + timestamp INTEGER NOT NULL, + amount INTEGER NOT NULL, + status INTEGER NOT NULL, + reason TEXT NOT NULL, + name TEXT NOT NULL, + character_id INTEGER NOT NULL, + pos_flag INTEGER NOT NULL, + type INTEGER NOT NULL, + FOREIGN KEY (house_id) REFERENCES character_houses(unique_id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS character_house_access ( + house_id INTEGER NOT NULL, + character_id INTEGER NOT NULL, + player_name TEXT NOT NULL, + access_level INTEGER NOT NULL, + permissions INTEGER NOT NULL, + granted_by INTEGER NOT NULL, + granted_date INTEGER NOT NULL, + expires_date INTEGER NOT NULL, + notes TEXT DEFAULT '', + PRIMARY KEY (house_id, character_id), + FOREIGN KEY (house_id) REFERENCES character_houses(unique_id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS character_house_amenities ( + house_id INTEGER NOT NULL, + id INTEGER NOT NULL, + type INTEGER NOT NULL, + name TEXT NOT NULL, + cost INTEGER NOT NULL, + status_cost INTEGER NOT NULL, + purchase_date INTEGER NOT NULL, + x REAL NOT NULL, + y REAL NOT NULL, + z REAL NOT NULL, + heading REAL NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (house_id, id), + FOREIGN KEY (house_id) REFERENCES character_houses(unique_id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS character_house_items ( + house_id INTEGER NOT NULL, + id INTEGER NOT NULL, + item_id INTEGER NOT NULL, + character_id INTEGER NOT NULL, + x REAL NOT NULL, + y REAL NOT NULL, + z REAL NOT NULL, + heading REAL NOT NULL, + pitch_x REAL NOT NULL DEFAULT 0, + pitch_y REAL NOT NULL DEFAULT 0, + roll_x REAL NOT NULL DEFAULT 0, + roll_y REAL NOT NULL DEFAULT 0, + placed_date INTEGER NOT NULL, + quantity INTEGER NOT NULL, + condition INTEGER NOT NULL, + house TEXT NOT NULL, + PRIMARY KEY (house_id, id), + FOREIGN KEY (house_id) REFERENCES character_houses(unique_id) ON DELETE CASCADE + )`, + } + + for i, query := range queries { + _, err := dhm.db.ExecContext(ctx, query) + if err != nil { + return fmt.Errorf("failed to create housing table %d: %w", i+1, err) + } + } + + // Create indexes for better performance + indexes := []string{ + `CREATE INDEX IF NOT EXISTS idx_character_houses_char_id ON character_houses(char_id)`, + `CREATE INDEX IF NOT EXISTS idx_character_houses_instance_id ON character_houses(instance_id)`, + `CREATE INDEX IF NOT EXISTS idx_character_houses_upkeep_due ON character_houses(upkeep_due)`, + `CREATE INDEX IF NOT EXISTS idx_character_houses_status ON character_houses(status)`, + `CREATE INDEX IF NOT EXISTS idx_house_deposits_house_id ON character_house_deposits(house_id)`, + `CREATE INDEX IF NOT EXISTS idx_house_deposits_timestamp ON character_house_deposits(timestamp)`, + `CREATE INDEX IF NOT EXISTS idx_house_history_house_id ON character_house_history(house_id)`, + `CREATE INDEX IF NOT EXISTS idx_house_history_timestamp ON character_house_history(timestamp)`, + `CREATE INDEX IF NOT EXISTS idx_house_access_character_id ON character_house_access(character_id)`, + `CREATE INDEX IF NOT EXISTS idx_house_items_item_id ON character_house_items(item_id)`, + `CREATE INDEX IF NOT EXISTS idx_houses_zone_id ON houses(zone_id)`, + `CREATE INDEX IF NOT EXISTS idx_houses_alignment ON houses(alignment)`, + } + + for i, query := range indexes { + _, err := dhm.db.ExecContext(ctx, query) + if err != nil { + return fmt.Errorf("failed to create housing index %d: %w", i+1, err) + } + } + + return nil +} \ No newline at end of file diff --git a/internal/housing/interfaces.go b/internal/housing/interfaces.go new file mode 100644 index 0000000..347c9f4 --- /dev/null +++ b/internal/housing/interfaces.go @@ -0,0 +1,320 @@ +package housing + +import ( + "context" + "time" +) + +// HousingDatabase defines the interface for database operations +type HousingDatabase interface { + // House zone operations + LoadHouseZones(ctx context.Context) ([]HouseZoneData, error) + LoadHouseZone(ctx context.Context, houseID int32) (*HouseZoneData, error) + SaveHouseZone(ctx context.Context, zone *HouseZone) error + DeleteHouseZone(ctx context.Context, houseID int32) error + + // Player house operations + LoadPlayerHouses(ctx context.Context, characterID int32) ([]PlayerHouseData, error) + LoadPlayerHouse(ctx context.Context, uniqueID int64) (*PlayerHouseData, error) + SavePlayerHouse(ctx context.Context, house *PlayerHouse) error + DeletePlayerHouse(ctx context.Context, uniqueID int64) error + AddPlayerHouse(ctx context.Context, houseData PlayerHouseData) (int64, error) + + // Deposit operations + LoadDeposits(ctx context.Context, houseID int64) ([]HouseDepositData, error) + SaveDeposit(ctx context.Context, houseID int64, deposit HouseDeposit) error + + // History operations + LoadHistory(ctx context.Context, houseID int64) ([]HouseHistoryData, error) + AddHistory(ctx context.Context, houseID int64, history HouseHistory) error + + // Access operations + LoadHouseAccess(ctx context.Context, houseID int64) ([]HouseAccessData, error) + SaveHouseAccess(ctx context.Context, houseID int64, access []HouseAccess) error + DeleteHouseAccess(ctx context.Context, houseID int64, characterID int32) error + + // Amenity operations + LoadHouseAmenities(ctx context.Context, houseID int64) ([]HouseAmenityData, error) + SaveHouseAmenity(ctx context.Context, houseID int64, amenity HouseAmenity) error + DeleteHouseAmenity(ctx context.Context, houseID int64, amenityID int32) error + + // Item operations + LoadHouseItems(ctx context.Context, houseID int64) ([]HouseItemData, error) + SaveHouseItem(ctx context.Context, houseID int64, item HouseItem) error + DeleteHouseItem(ctx context.Context, houseID int64, itemID int64) error + + // Utility operations + GetNextHouseID(ctx context.Context) (int64, error) + GetHouseByInstance(ctx context.Context, instanceID int32) (*PlayerHouseData, error) + UpdateHouseUpkeepDue(ctx context.Context, houseID int64, upkeepDue time.Time) error + UpdateHouseEscrow(ctx context.Context, houseID int64, coins, status int64) error + GetHousesForUpkeep(ctx context.Context, cutoffTime time.Time) ([]PlayerHouseData, error) + GetHouseStatistics(ctx context.Context) (*HousingStatistics, error) + EnsureHousingTables(ctx context.Context) error +} + +// HousingEventHandler defines the interface for handling housing events +type HousingEventHandler interface { + // House lifecycle events + OnHousePurchased(house *PlayerHouse, purchaser int32, cost int64, statusCost int64) + OnHouseForeclosed(house *PlayerHouse, reason string) + OnHouseTransferred(house *PlayerHouse, fromCharacterID, toCharacterID int32) + OnHouseAbandoned(house *PlayerHouse, characterID int32) + + // Financial events + OnDepositMade(house *PlayerHouse, characterID int32, amount int64, status int64) + OnWithdrawalMade(house *PlayerHouse, characterID int32, amount int64, status int64) + OnUpkeepPaid(house *PlayerHouse, amount int64, status int64, automatic bool) + OnUpkeepOverdue(house *PlayerHouse, daysPastDue int) + + // Access events + OnAccessGranted(house *PlayerHouse, grantedTo int32, grantedBy int32, accessLevel int8) + OnAccessRevoked(house *PlayerHouse, revokedFrom int32, revokedBy int32) + OnPlayerEntered(house *PlayerHouse, characterID int32) + OnPlayerExited(house *PlayerHouse, characterID int32) + + // Item events + OnItemPlaced(house *PlayerHouse, item *HouseItem, placedBy int32) + OnItemRemoved(house *PlayerHouse, item *HouseItem, removedBy int32) + OnItemMoved(house *PlayerHouse, item *HouseItem, movedBy int32) + + // Amenity events + OnAmenityPurchased(house *PlayerHouse, amenity *HouseAmenity, purchasedBy int32) + OnAmenityRemoved(house *PlayerHouse, amenity *HouseAmenity, removedBy int32) +} + +// ClientManager defines the interface for client communication +type ClientManager interface { + // Send housing packets to clients + SendHousePurchase(characterID int32, data *HousePurchasePacketData) error + SendHousingList(characterID int32, data *HouseListPacketData) error + SendBaseHouseWindow(characterID int32, data *BaseHouseWindowPacketData) error + SendHouseVisitWindow(characterID int32, data *HouseVisitPacketData) error + SendHouseUpdate(characterID int32, house *PlayerHouse) error + SendHouseError(characterID int32, errorCode int, message string) error + + // Broadcast to multiple clients + BroadcastHouseUpdate(characterIDs []int32, house *PlayerHouse) error + BroadcastHouseEvent(characterIDs []int32, eventType int, data string) error + + // Client validation + IsClientConnected(characterID int32) bool + GetClientVersion(characterID int32) int +} + +// PlayerManager defines the interface for player system integration +type PlayerManager interface { + // Get player information + GetPlayerInfo(characterID int32) (*PlayerInfo, error) + GetPlayerName(characterID int32) string + GetPlayerAlignment(characterID int32) int8 + GetPlayerGuildLevel(characterID int32) int8 + IsPlayerOnline(characterID int32) bool + + // Player finances + GetPlayerCoins(characterID int32) (int64, error) + GetPlayerStatus(characterID int32) (int64, error) + DeductPlayerCoins(characterID int32, amount int64) error + DeductPlayerStatus(characterID int32, amount int64) error + AddPlayerCoins(characterID int32, amount int64) error + AddPlayerStatus(characterID int32, amount int64) error + + // Player validation + CanPlayerAffordHouse(characterID int32, cost int64, statusCost int64) (bool, error) + ValidatePlayerExists(playerName string) (int32, error) +} + +// ItemManager defines the interface for item system integration +type ItemManager interface { + // Item operations + GetItemInfo(itemID int32) (*ItemInfo, error) + ValidateItemPlacement(itemID int32, x, y, z float32) error + CreateHouseItem(itemID int32, characterID int32, quantity int32) (*HouseItem, error) + RemoveItemFromPlayer(characterID int32, itemID int32, quantity int32) error + ReturnItemToPlayer(characterID int32, item *HouseItem) error + + // Item queries + IsItemPlaceable(itemID int32) bool + GetItemWeight(itemID int32) float32 + GetItemValue(itemID int32) int64 +} + +// ZoneManager defines the interface for zone system integration +type ZoneManager interface { + // Zone operations + GetZoneInfo(zoneID int32) (*ZoneInfo, error) + CreateHouseInstance(houseID int32, ownerID int32) (int32, error) + DestroyHouseInstance(instanceID int32) error + GetHouseInstance(instanceID int32) (*HouseInstance, error) + + // Player zone operations + MovePlayerToHouse(characterID int32, instanceID int32) error + GetPlayersInHouse(instanceID int32) ([]int32, error) + IsPlayerInHouse(characterID int32) (bool, int32) + + // Zone validation + IsHouseZoneValid(zoneID int32) bool + GetHouseSpawnPoint(instanceID int32) (float32, float32, float32, float32, error) +} + +// LogHandler defines the interface for logging operations +type LogHandler interface { + LogDebug(system, format string, args ...interface{}) + LogInfo(system, format string, args ...interface{}) + LogWarning(system, format string, args ...interface{}) + LogError(system, format string, args ...interface{}) +} + +// Additional integration interfaces + +// PlayerInfo contains player details needed for housing system +type PlayerInfo struct { + CharacterID int32 `json:"character_id"` + CharacterName string `json:"character_name"` + AccountID int32 `json:"account_id"` + AdventureLevel int16 `json:"adventure_level"` + Alignment int8 `json:"alignment"` + GuildID int32 `json:"guild_id"` + GuildLevel int8 `json:"guild_level"` + Zone string `json:"zone"` + IsOnline bool `json:"is_online"` + HouseZoneID int32 `json:"house_zone_id"` +} + +// ItemInfo contains item details for placement validation +type ItemInfo struct { + ID int32 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Icon int16 `json:"icon"` + Weight float32 `json:"weight"` + Value int64 `json:"value"` + IsPlaceable bool `json:"is_placeable"` + MaxStack int32 `json:"max_stack"` + Type int8 `json:"type"` +} + +// ZoneInfo contains zone details for house instances +type ZoneInfo struct { + ID int32 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Type int8 `json:"type"` + MinLevel int16 `json:"min_level"` + MaxLevel int16 `json:"max_level"` + SafeX float32 `json:"safe_x"` + SafeY float32 `json:"safe_y"` + SafeZ float32 `json:"safe_z"` + SafeHeading float32 `json:"safe_heading"` +} + +// HouseInstance contains active house instance information +type HouseInstance struct { + InstanceID int32 `json:"instance_id"` + HouseID int64 `json:"house_id"` + OwnerID int32 `json:"owner_id"` + ZoneID int32 `json:"zone_id"` + CreatedTime time.Time `json:"created_time"` + LastActivity time.Time `json:"last_activity"` + CurrentVisitors []int32 `json:"current_visitors"` + IsActive bool `json:"is_active"` +} + +// Adapter interfaces for integration with existing systems + +// HousingAware defines interface for entities that can interact with housing +type HousingAware interface { + GetCharacterID() int32 + GetPlayerName() string + GetAlignment() int8 + GetGuildLevel() int8 + CanAffordCost(coins int64, status int64) bool + GetCurrentZone() int32 +} + +// EntityHousingAdapter adapts entity system for housing integration +type EntityHousingAdapter struct { + entity HousingAware +} + +// PacketBuilder defines interface for building housing packets +type PacketBuilder interface { + BuildHousePurchasePacket(data *HousePurchasePacketData) ([]byte, error) + BuildHousingListPacket(data *HouseListPacketData) ([]byte, error) + BuildBaseHouseWindowPacket(data *BaseHouseWindowPacketData) ([]byte, error) + BuildHouseVisitPacket(data *HouseVisitPacketData) ([]byte, error) + BuildHouseUpdatePacket(house *PlayerHouse) ([]byte, error) + BuildHouseErrorPacket(errorCode int, message string) ([]byte, error) +} + +// UpkeepManager defines interface for upkeep processing +type UpkeepManager interface { + ProcessUpkeep(ctx context.Context) error + ProcessForeclosures(ctx context.Context) error + SendUpkeepNotices(ctx context.Context) error + CalculateUpkeep(house *PlayerHouse, zone *HouseZone) (int64, int64, error) + CanPayUpkeep(house *PlayerHouse, coinCost, statusCost int64) bool + ProcessPayment(ctx context.Context, house *PlayerHouse, coinCost, statusCost int64) error +} + +// StatisticsCollector defines interface for collecting housing statistics +type StatisticsCollector interface { + RecordHousePurchase(houseType int32, cost int64, statusCost int64) + RecordDeposit(houseID int64, amount int64, status int64) + RecordWithdrawal(houseID int64, amount int64, status int64) + RecordUpkeepPayment(houseID int64, amount int64, status int64) + RecordForeclosure(houseID int64, reason string) + GetStatistics() *HousingStatistics + Reset() +} + +// AccessManager defines interface for managing house access +type AccessManager interface { + GrantAccess(ctx context.Context, house *PlayerHouse, characterID int32, accessLevel int8, permissions int32) error + RevokeAccess(ctx context.Context, house *PlayerHouse, characterID int32) error + CheckAccess(house *PlayerHouse, characterID int32, requiredPermission int32) bool + GetAccessLevel(house *PlayerHouse, characterID int32) int8 + GetPermissions(house *PlayerHouse, characterID int32) int32 + UpdateAccess(ctx context.Context, house *PlayerHouse, characterID int32, accessLevel int8, permissions int32) error +} + +// ConfigManager defines interface for configuration management +type ConfigManager interface { + GetHousingConfig() *HousingConfig + UpdateHousingConfig(config *HousingConfig) error + GetConfigValue(key string) interface{} + SetConfigValue(key string, value interface{}) error +} + +// NotificationManager defines interface for housing notifications +type NotificationManager interface { + SendUpkeepReminder(characterID int32, house *PlayerHouse, daysRemaining int) + SendForeclosureWarning(characterID int32, house *PlayerHouse, daysRemaining int) + SendAccessGrantedNotification(characterID int32, house *PlayerHouse, grantedBy int32) + SendAccessRevokedNotification(characterID int32, house *PlayerHouse, revokedBy int32) + SendHouseVisitorNotification(ownerID int32, visitorID int32, house *PlayerHouse) +} + +// CacheManager defines interface for caching operations +type CacheManager interface { + // Cache operations + Set(key string, value interface{}, expiration time.Duration) error + Get(key string) (interface{}, bool) + Delete(key string) error + Clear() error + + // House-specific cache operations + CachePlayerHouses(characterID int32, houses []*PlayerHouse) error + GetCachedPlayerHouses(characterID int32) ([]*PlayerHouse, bool) + InvalidateHouseCache(houseID int64) error +} + +// SearchManager defines interface for house searching +type SearchManager interface { + SearchHouses(criteria HousingSearchCriteria) ([]*PlayerHouse, error) + SearchHouseZones(criteria HousingSearchCriteria) ([]*HouseZone, error) + GetPopularHouses(limit int) ([]*PlayerHouse, error) + GetRecentHouses(limit int) ([]*PlayerHouse, error) + IndexHouseForSearch(house *PlayerHouse) error + RemoveHouseFromIndex(houseID int64) error +} \ No newline at end of file diff --git a/internal/housing/packets.go b/internal/housing/packets.go new file mode 100644 index 0000000..3ffe116 --- /dev/null +++ b/internal/housing/packets.go @@ -0,0 +1,889 @@ +package housing + +import ( + "encoding/binary" + "fmt" + "math" + "time" +) + +// HousingPacketBuilder handles building packets for housing client communication +type HousingPacketBuilder struct { + clientVersion int +} + +// NewHousingPacketBuilder creates a new packet builder +func NewHousingPacketBuilder(clientVersion int) *HousingPacketBuilder { + return &HousingPacketBuilder{ + clientVersion: clientVersion, + } +} + +// BuildHousePurchasePacket builds the house purchase interface packet +func (hpb *HousingPacketBuilder) BuildHousePurchasePacket(data *HousePurchasePacketData) ([]byte, error) { + if data == nil { + return nil, fmt.Errorf("house purchase data is nil") + } + + // Start with base packet structure + packet := make([]byte, 0, 512) + + // Packet type identifier + packet = append(packet, 0x01) // House Purchase packet type + + // House ID (4 bytes) + houseIDBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(houseIDBytes, uint32(data.HouseID)) + packet = append(packet, houseIDBytes...) + + // House name length and data + nameBytes := []byte(data.Name) + packet = append(packet, byte(len(nameBytes))) + packet = append(packet, nameBytes...) + + // Cost in coins (8 bytes) + costCoinBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(costCoinBytes, uint64(data.CostCoin)) + packet = append(packet, costCoinBytes...) + + // Cost in status (8 bytes) + costStatusBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(costStatusBytes, uint64(data.CostStatus)) + packet = append(packet, costStatusBytes...) + + // Upkeep in coins (8 bytes) + upkeepCoinBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(upkeepCoinBytes, uint64(data.UpkeepCoin)) + packet = append(packet, upkeepCoinBytes...) + + // Upkeep in status (8 bytes) + upkeepStatusBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(upkeepStatusBytes, uint64(data.UpkeepStatus)) + packet = append(packet, upkeepStatusBytes...) + + // Alignment requirement (1 byte) + packet = append(packet, byte(data.Alignment)) + + // Guild level requirement (1 byte) + packet = append(packet, byte(data.GuildLevel)) + + // Vault slots (4 bytes) + vaultSlotsBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(vaultSlotsBytes, uint32(data.VaultSlots)) + packet = append(packet, vaultSlotsBytes...) + + // Description length and data + descBytes := []byte(data.Description) + descLen := make([]byte, 2) + binary.LittleEndian.PutUint16(descLen, uint16(len(descBytes))) + packet = append(packet, descLen...) + packet = append(packet, descBytes...) + + return packet, nil +} + +// BuildHousingListPacket builds the player housing list packet +func (hpb *HousingPacketBuilder) BuildHousingListPacket(data *HouseListPacketData) ([]byte, error) { + if data == nil { + return nil, fmt.Errorf("house list data is nil") + } + + packet := make([]byte, 0, 1024) + + // Packet type identifier + packet = append(packet, 0x02) // Housing List packet type + + // Number of houses (4 bytes) + houseCountBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(houseCountBytes, uint32(len(data.Houses))) + packet = append(packet, houseCountBytes...) + + // House entries + for _, house := range data.Houses { + // Unique ID (8 bytes) + uniqueIDBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(uniqueIDBytes, uint64(house.UniqueID)) + packet = append(packet, uniqueIDBytes...) + + // House name length and data + nameBytes := []byte(house.Name) + packet = append(packet, byte(len(nameBytes))) + packet = append(packet, nameBytes...) + + // House type length and data + typeBytes := []byte(house.HouseType) + packet = append(packet, byte(len(typeBytes))) + packet = append(packet, typeBytes...) + + // Upkeep due timestamp (8 bytes) + upkeepDueBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(upkeepDueBytes, uint64(house.UpkeepDue.Unix())) + packet = append(packet, upkeepDueBytes...) + + // Escrow coins (8 bytes) + escrowCoinsBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(escrowCoinsBytes, uint64(house.EscrowCoins)) + packet = append(packet, escrowCoinsBytes...) + + // Escrow status (8 bytes) + escrowStatusBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(escrowStatusBytes, uint64(house.EscrowStatus)) + packet = append(packet, escrowStatusBytes...) + + // House status (1 byte) + packet = append(packet, byte(house.Status)) + + // Can enter flag (1 byte) + if house.CanEnter { + packet = append(packet, 0x01) + } else { + packet = append(packet, 0x00) + } + } + + return packet, nil +} + +// BuildBaseHouseWindowPacket builds the main house management interface packet +func (hpb *HousingPacketBuilder) BuildBaseHouseWindowPacket(data *BaseHouseWindowPacketData) ([]byte, error) { + if data == nil { + return nil, fmt.Errorf("base house window data is nil") + } + + packet := make([]byte, 0, 2048) + + // Packet type identifier + packet = append(packet, 0x03) // Base House Window packet type + + // House info + uniqueIDBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(uniqueIDBytes, uint64(data.HouseInfo.UniqueID)) + packet = append(packet, uniqueIDBytes...) + + // House name + nameBytes := []byte(data.HouseInfo.Name) + packet = append(packet, byte(len(nameBytes))) + packet = append(packet, nameBytes...) + + // House type + typeBytes := []byte(data.HouseInfo.HouseType) + packet = append(packet, byte(len(typeBytes))) + packet = append(packet, typeBytes...) + + // Upkeep due + upkeepDueBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(upkeepDueBytes, uint64(data.HouseInfo.UpkeepDue.Unix())) + packet = append(packet, upkeepDueBytes...) + + // Escrow balances + escrowCoinsBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(escrowCoinsBytes, uint64(data.HouseInfo.EscrowCoins)) + packet = append(packet, escrowCoinsBytes...) + + escrowStatusBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(escrowStatusBytes, uint64(data.HouseInfo.EscrowStatus)) + packet = append(packet, escrowStatusBytes...) + + // Recent deposits count and data + depositCountBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(depositCountBytes, uint32(len(data.RecentDeposits))) + packet = append(packet, depositCountBytes...) + + for _, deposit := range data.RecentDeposits { + // Timestamp (8 bytes) + timestampBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(timestampBytes, uint64(deposit.Timestamp.Unix())) + packet = append(packet, timestampBytes...) + + // Amount (8 bytes) + amountBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(amountBytes, uint64(deposit.Amount)) + packet = append(packet, amountBytes...) + + // Status (8 bytes) + statusBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(statusBytes, uint64(deposit.Status)) + packet = append(packet, statusBytes...) + + // Player name + nameBytes := []byte(deposit.Name) + packet = append(packet, byte(len(nameBytes))) + packet = append(packet, nameBytes...) + } + + // Recent history count and data + historyCountBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(historyCountBytes, uint32(len(data.RecentHistory))) + packet = append(packet, historyCountBytes...) + + for _, history := range data.RecentHistory { + // Timestamp (8 bytes) + timestampBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(timestampBytes, uint64(history.Timestamp.Unix())) + packet = append(packet, timestampBytes...) + + // Amount (8 bytes) + amountBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(amountBytes, uint64(history.Amount)) + packet = append(packet, amountBytes...) + + // Status (8 bytes) + statusBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(statusBytes, uint64(history.Status)) + packet = append(packet, statusBytes...) + + // Positive flag (1 byte) + packet = append(packet, byte(history.PosFlag)) + + // Transaction type (4 bytes) + typeBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(typeBytes, uint32(history.Type)) + packet = append(packet, typeBytes...) + + // Reason length and data + reasonBytes := []byte(history.Reason) + packet = append(packet, byte(len(reasonBytes))) + packet = append(packet, reasonBytes...) + + // Player name + nameBytes := []byte(history.Name) + packet = append(packet, byte(len(nameBytes))) + packet = append(packet, nameBytes...) + } + + // Amenities count and data + amenityCountBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(amenityCountBytes, uint32(len(data.Amenities))) + packet = append(packet, amenityCountBytes...) + + for _, amenity := range data.Amenities { + // Amenity ID (4 bytes) + idBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(idBytes, uint32(amenity.ID)) + packet = append(packet, idBytes...) + + // Type (4 bytes) + typeBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(typeBytes, uint32(amenity.Type)) + packet = append(packet, typeBytes...) + + // Name + nameBytes := []byte(amenity.Name) + packet = append(packet, byte(len(nameBytes))) + packet = append(packet, nameBytes...) + + // Position (12 bytes - 3 floats) + xBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(xBytes, math.Float32bits(amenity.X)) + packet = append(packet, xBytes...) + + yBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(yBytes, math.Float32bits(amenity.Y)) + packet = append(packet, yBytes...) + + zBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(zBytes, math.Float32bits(amenity.Z)) + packet = append(packet, zBytes...) + + // Heading (4 bytes) + headingBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(headingBytes, math.Float32bits(amenity.Heading)) + packet = append(packet, headingBytes...) + + // Is active flag (1 byte) + if amenity.IsActive { + packet = append(packet, 0x01) + } else { + packet = append(packet, 0x00) + } + } + + // House settings + packet = hpb.appendHouseSettings(packet, data.Settings) + + // Can manage flag (1 byte) + if data.CanManage { + packet = append(packet, 0x01) + } else { + packet = append(packet, 0x00) + } + + return packet, nil +} + +// BuildHouseVisitPacket builds the house visit interface packet +func (hpb *HousingPacketBuilder) BuildHouseVisitPacket(data *HouseVisitPacketData) ([]byte, error) { + if data == nil { + return nil, fmt.Errorf("house visit data is nil") + } + + packet := make([]byte, 0, 1024) + + // Packet type identifier + packet = append(packet, 0x04) // House Visit packet type + + // Number of available houses (4 bytes) + houseCountBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(houseCountBytes, uint32(len(data.AvailableHouses))) + packet = append(packet, houseCountBytes...) + + // House entries + for _, house := range data.AvailableHouses { + // Unique ID (8 bytes) + uniqueIDBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(uniqueIDBytes, uint64(house.UniqueID)) + packet = append(packet, uniqueIDBytes...) + + // Owner name + ownerBytes := []byte(house.OwnerName) + packet = append(packet, byte(len(ownerBytes))) + packet = append(packet, ownerBytes...) + + // House name + nameBytes := []byte(house.HouseName) + packet = append(packet, byte(len(nameBytes))) + packet = append(packet, nameBytes...) + + // House type + typeBytes := []byte(house.HouseType) + packet = append(packet, byte(len(typeBytes))) + packet = append(packet, typeBytes...) + + // Public note + noteBytes := []byte(house.PublicNote) + noteLen := make([]byte, 2) + binary.LittleEndian.PutUint16(noteLen, uint16(len(noteBytes))) + packet = append(packet, noteLen...) + packet = append(packet, noteBytes...) + + // Can visit flag (1 byte) + if house.CanVisit { + packet = append(packet, 0x01) + } else { + packet = append(packet, 0x00) + } + + // Requires approval flag (1 byte) + if house.RequiresApproval { + packet = append(packet, 0x01) + } else { + packet = append(packet, 0x00) + } + } + + return packet, nil +} + +// BuildHouseUpdatePacket builds a house status update packet +func (hpb *HousingPacketBuilder) BuildHouseUpdatePacket(house *PlayerHouse) ([]byte, error) { + if house == nil { + return nil, fmt.Errorf("player house is nil") + } + + packet := make([]byte, 0, 256) + + // Packet type identifier + packet = append(packet, 0x05) // House Update packet type + + // Unique ID (8 bytes) + uniqueIDBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(uniqueIDBytes, uint64(house.UniqueID)) + packet = append(packet, uniqueIDBytes...) + + // Status (1 byte) + packet = append(packet, byte(house.Status)) + + // Upkeep due (8 bytes) + upkeepDueBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(upkeepDueBytes, uint64(house.UpkeepDue.Unix())) + packet = append(packet, upkeepDueBytes...) + + // Escrow balances (16 bytes total) + escrowCoinsBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(escrowCoinsBytes, uint64(house.EscrowCoins)) + packet = append(packet, escrowCoinsBytes...) + + escrowStatusBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(escrowStatusBytes, uint64(house.EscrowStatus)) + packet = append(packet, escrowStatusBytes...) + + return packet, nil +} + +// BuildHouseErrorPacket builds an error notification packet +func (hpb *HousingPacketBuilder) BuildHouseErrorPacket(errorCode int, message string) ([]byte, error) { + packet := make([]byte, 0, 256) + + // Packet type identifier + packet = append(packet, 0x06) // House Error packet type + + // Error code (4 bytes) + errorBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(errorBytes, uint32(errorCode)) + packet = append(packet, errorBytes...) + + // Error message length and data + messageBytes := []byte(message) + msgLen := make([]byte, 2) + binary.LittleEndian.PutUint16(msgLen, uint16(len(messageBytes))) + packet = append(packet, msgLen...) + packet = append(packet, messageBytes...) + + return packet, nil +} + +// BuildHouseDepositPacket builds a deposit confirmation packet +func (hpb *HousingPacketBuilder) BuildHouseDepositPacket(houseID int64, amount int64, status int64, newBalance int64, newStatusBalance int64) ([]byte, error) { + packet := make([]byte, 0, 64) + + // Packet type identifier + packet = append(packet, 0x07) // House Deposit packet type + + // House ID (8 bytes) + houseIDBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(houseIDBytes, uint64(houseID)) + packet = append(packet, houseIDBytes...) + + // Deposit amount (8 bytes) + amountBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(amountBytes, uint64(amount)) + packet = append(packet, amountBytes...) + + // Status deposit (8 bytes) + statusBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(statusBytes, uint64(status)) + packet = append(packet, statusBytes...) + + // New coin balance (8 bytes) + newBalanceBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(newBalanceBytes, uint64(newBalance)) + packet = append(packet, newBalanceBytes...) + + // New status balance (8 bytes) + newStatusBalanceBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(newStatusBalanceBytes, uint64(newStatusBalance)) + packet = append(packet, newStatusBalanceBytes...) + + return packet, nil +} + +// BuildHouseWithdrawalPacket builds a withdrawal confirmation packet +func (hpb *HousingPacketBuilder) BuildHouseWithdrawalPacket(houseID int64, amount int64, status int64, newBalance int64, newStatusBalance int64) ([]byte, error) { + packet := make([]byte, 0, 64) + + // Packet type identifier + packet = append(packet, 0x08) // House Withdrawal packet type + + // House ID (8 bytes) + houseIDBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(houseIDBytes, uint64(houseID)) + packet = append(packet, houseIDBytes...) + + // Withdrawal amount (8 bytes) + amountBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(amountBytes, uint64(amount)) + packet = append(packet, amountBytes...) + + // Status withdrawal (8 bytes) + statusBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(statusBytes, uint64(status)) + packet = append(packet, statusBytes...) + + // New coin balance (8 bytes) + newBalanceBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(newBalanceBytes, uint64(newBalance)) + packet = append(packet, newBalanceBytes...) + + // New status balance (8 bytes) + newStatusBalanceBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(newStatusBalanceBytes, uint64(newStatusBalance)) + packet = append(packet, newStatusBalanceBytes...) + + return packet, nil +} + +// BuildItemPlacementPacket builds an item placement response packet +func (hpb *HousingPacketBuilder) BuildItemPlacementPacket(item *HouseItem, success bool) ([]byte, error) { + if item == nil { + return nil, fmt.Errorf("house item is nil") + } + + packet := make([]byte, 0, 128) + + // Packet type identifier + packet = append(packet, 0x09) // Item Placement packet type + + // Item ID (8 bytes) + itemIDBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(itemIDBytes, uint64(item.ID)) + packet = append(packet, itemIDBytes...) + + // Success flag (1 byte) + if success { + packet = append(packet, 0x01) + } else { + packet = append(packet, 0x00) + } + + if success { + // Position (12 bytes - 3 floats) + xBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(xBytes, math.Float32bits(item.X)) + packet = append(packet, xBytes...) + + yBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(yBytes, math.Float32bits(item.Y)) + packet = append(packet, yBytes...) + + zBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(zBytes, math.Float32bits(item.Z)) + packet = append(packet, zBytes...) + + // Heading (4 bytes) + headingBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(headingBytes, math.Float32bits(item.Heading)) + packet = append(packet, headingBytes...) + + // Pitch/Roll (16 bytes - 4 floats) + pitchXBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(pitchXBytes, math.Float32bits(item.PitchX)) + packet = append(packet, pitchXBytes...) + + pitchYBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(pitchYBytes, math.Float32bits(item.PitchY)) + packet = append(packet, pitchYBytes...) + + rollXBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(rollXBytes, math.Float32bits(item.RollX)) + packet = append(packet, rollXBytes...) + + rollYBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(rollYBytes, math.Float32bits(item.RollY)) + packet = append(packet, rollYBytes...) + } + + return packet, nil +} + +// BuildAccessUpdatePacket builds an access permission update packet +func (hpb *HousingPacketBuilder) BuildAccessUpdatePacket(houseID int64, access []HouseAccess) ([]byte, error) { + packet := make([]byte, 0, 1024) + + // Packet type identifier + packet = append(packet, 0x0A) // Access Update packet type + + // House ID (8 bytes) + houseIDBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(houseIDBytes, uint64(houseID)) + packet = append(packet, houseIDBytes...) + + // Access entry count (4 bytes) + countBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(countBytes, uint32(len(access))) + packet = append(packet, countBytes...) + + // Access entries + for _, entry := range access { + // Character ID (4 bytes) + charIDBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(charIDBytes, uint32(entry.CharacterID)) + packet = append(packet, charIDBytes...) + + // Player name + nameBytes := []byte(entry.PlayerName) + packet = append(packet, byte(len(nameBytes))) + packet = append(packet, nameBytes...) + + // Access level (1 byte) + packet = append(packet, byte(entry.AccessLevel)) + + // Permissions (4 bytes) + permBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(permBytes, uint32(entry.Permissions)) + packet = append(packet, permBytes...) + + // Granted by (4 bytes) + grantedByBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(grantedByBytes, uint32(entry.GrantedBy)) + packet = append(packet, grantedByBytes...) + + // Granted date (8 bytes) + grantedDateBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(grantedDateBytes, uint64(entry.GrantedDate.Unix())) + packet = append(packet, grantedDateBytes...) + + // Expires date (8 bytes) + expiresDateBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(expiresDateBytes, uint64(entry.ExpiresDate.Unix())) + packet = append(packet, expiresDateBytes...) + + // Notes + notesBytes := []byte(entry.Notes) + notesLen := make([]byte, 2) + binary.LittleEndian.PutUint16(notesLen, uint16(len(notesBytes))) + packet = append(packet, notesLen...) + packet = append(packet, notesBytes...) + } + + return packet, nil +} + +// Helper methods + +// appendHouseSettings appends house settings to a packet +func (hpb *HousingPacketBuilder) appendHouseSettings(packet []byte, settings HouseSettings) []byte { + // House name + nameBytes := []byte(settings.HouseName) + packet = append(packet, byte(len(nameBytes))) + packet = append(packet, nameBytes...) + + // Visit permission (1 byte) + packet = append(packet, byte(settings.VisitPermission)) + + // Public note + publicNoteBytes := []byte(settings.PublicNote) + publicNoteLen := make([]byte, 2) + binary.LittleEndian.PutUint16(publicNoteLen, uint16(len(publicNoteBytes))) + packet = append(packet, publicNoteLen...) + packet = append(packet, publicNoteBytes...) + + // Private note + privateNoteBytes := []byte(settings.PrivateNote) + privateNoteLen := make([]byte, 2) + binary.LittleEndian.PutUint16(privateNoteLen, uint16(len(privateNoteBytes))) + packet = append(packet, privateNoteLen...) + packet = append(packet, privateNoteBytes...) + + // Boolean flags (6 bytes) + flags := []bool{ + settings.AllowFriends, + settings.AllowGuild, + settings.RequireApproval, + settings.ShowOnDirectory, + settings.AllowDecoration, + settings.TaxExempt, + } + + for _, flag := range flags { + if flag { + packet = append(packet, 0x01) + } else { + packet = append(packet, 0x00) + } + } + + return packet +} + +// ValidatePacketSize checks if packet size is within acceptable limits +func (hpb *HousingPacketBuilder) ValidatePacketSize(packet []byte) error { + maxSize := hpb.getMaxPacketSize() + + if len(packet) > maxSize { + return fmt.Errorf("packet size %d exceeds maximum %d", len(packet), maxSize) + } + + return nil +} + +// getMaxPacketSize returns the maximum packet size for the client version +func (hpb *HousingPacketBuilder) getMaxPacketSize() int { + if hpb.clientVersion >= 564 { + return 4096 // Newer clients support larger packets + } + return 2048 // Older clients have smaller limits +} + +// GetPacketTypeDescription returns human-readable packet type description +func (hpb *HousingPacketBuilder) GetPacketTypeDescription(packetType byte) string { + switch packetType { + case 0x01: + return "House Purchase" + case 0x02: + return "Housing List" + case 0x03: + return "Base House Window" + case 0x04: + return "House Visit" + case 0x05: + return "House Update" + case 0x06: + return "House Error" + case 0x07: + return "House Deposit" + case 0x08: + return "House Withdrawal" + case 0x09: + return "Item Placement" + case 0x0A: + return "Access Update" + default: + return "Unknown" + } +} + +// Client version specific methods + +// IsVersionSupported checks if client version supports specific features +func (hpb *HousingPacketBuilder) IsVersionSupported(feature string) bool { + switch feature { + case "extended_access": + return hpb.clientVersion >= 546 + case "amenity_management": + return hpb.clientVersion >= 564 + case "item_rotation": + return hpb.clientVersion >= 572 + case "house_search": + return hpb.clientVersion >= 580 + default: + return true // Basic features supported in all versions + } +} + +// Error codes for housing system +const ( + HouseErrorNone = iota + HouseErrorInsufficientFunds + HouseErrorInsufficientStatus + HouseErrorAccessDenied + HouseErrorHouseNotFound + HouseErrorAlignmentRestriction + HouseErrorGuildLevelRestriction + HouseErrorUpkeepOverdue + HouseErrorMaxHousesReached + HouseErrorInvalidPlacement + HouseErrorItemNotFound + HouseErrorSystemDisabled +) + +// getHousingErrorMessage returns human-readable error message for error code +func getHousingErrorMessage(errorCode int) string { + switch errorCode { + case HouseErrorNone: + return "No error" + case HouseErrorInsufficientFunds: + return "Insufficient funds" + case HouseErrorInsufficientStatus: + return "Insufficient status points" + case HouseErrorAccessDenied: + return "Access denied" + case HouseErrorHouseNotFound: + return "House not found" + case HouseErrorAlignmentRestriction: + return "Alignment requirement not met" + case HouseErrorGuildLevelRestriction: + return "Guild level requirement not met" + case HouseErrorUpkeepOverdue: + return "House upkeep is overdue" + case HouseErrorMaxHousesReached: + return "Maximum number of houses reached" + case HouseErrorInvalidPlacement: + return "Invalid item placement" + case HouseErrorItemNotFound: + return "Item not found" + case HouseErrorSystemDisabled: + return "Housing system is disabled" + default: + return "Unknown error" + } +} + +// Packet parsing helper methods for incoming packets + +// ParseBuyHousePacket parses an incoming buy house request +func (hpb *HousingPacketBuilder) ParseBuyHousePacket(data []byte) (int32, error) { + if len(data) < 4 { + return 0, fmt.Errorf("packet too short for buy house request") + } + + // Extract house ID (4 bytes) + houseID := int32(binary.LittleEndian.Uint32(data[0:4])) + + return houseID, nil +} + +// ParseEnterHousePacket parses an incoming enter house request +func (hpb *HousingPacketBuilder) ParseEnterHousePacket(data []byte) (int64, error) { + if len(data) < 8 { + return 0, fmt.Errorf("packet too short for enter house request") + } + + // Extract unique house ID (8 bytes) + uniqueID := int64(binary.LittleEndian.Uint64(data[0:8])) + + return uniqueID, nil +} + +// ParseDepositPacket parses an incoming deposit request +func (hpb *HousingPacketBuilder) ParseDepositPacket(data []byte) (int64, int64, int64, error) { + if len(data) < 24 { + return 0, 0, 0, fmt.Errorf("packet too short for deposit request") + } + + // Extract house ID (8 bytes) + houseID := int64(binary.LittleEndian.Uint64(data[0:8])) + + // Extract coin amount (8 bytes) + coinAmount := int64(binary.LittleEndian.Uint64(data[8:16])) + + // Extract status amount (8 bytes) + statusAmount := int64(binary.LittleEndian.Uint64(data[16:24])) + + return houseID, coinAmount, statusAmount, nil +} + +// Time formatting helpers for display + +// FormatUpkeepDue formats upkeep due date for display +func FormatUpkeepDue(upkeepDue time.Time) string { + now := time.Now() + + if upkeepDue.Before(now) { + duration := now.Sub(upkeepDue) + days := int(duration.Hours() / 24) + if days == 0 { + return "Overdue (today)" + } + return fmt.Sprintf("Overdue (%d days)", days) + } else { + duration := upkeepDue.Sub(now) + days := int(duration.Hours() / 24) + if days == 0 { + return "Due today" + } + return fmt.Sprintf("Due in %d days", days) + } +} + +// FormatCurrency formats currency amounts for display +func FormatCurrency(amount int64) string { + if amount < 0 { + return fmt.Sprintf("-%s", FormatCurrency(-amount)) + } + + if amount >= 10000 { // 1 gold = 10000 copper + gold := amount / 10000 + remainder := amount % 10000 + if remainder == 0 { + return fmt.Sprintf("%dg", gold) + } else { + silver := remainder / 100 + copper := remainder % 100 + if copper == 0 { + return fmt.Sprintf("%dg %ds", gold, silver) + } else { + return fmt.Sprintf("%dg %ds %dc", gold, silver, copper) + } + } + } else if amount >= 100 { + silver := amount / 100 + copper := amount % 100 + if copper == 0 { + return fmt.Sprintf("%ds", silver) + } else { + return fmt.Sprintf("%ds %dc", silver, copper) + } + } else { + return fmt.Sprintf("%dc", amount) + } +} \ No newline at end of file diff --git a/internal/housing/types.go b/internal/housing/types.go new file mode 100644 index 0000000..314344f --- /dev/null +++ b/internal/housing/types.go @@ -0,0 +1,390 @@ +package housing + +import ( + "sync" + "time" +) + +// HouseZone represents a house type that can be purchased +type HouseZone struct { + mu sync.RWMutex + ID int32 `json:"id"` // Unique house type identifier + Name string `json:"name"` // House name/type + ZoneID int32 `json:"zone_id"` // Zone where house is located + CostCoin int64 `json:"cost_coin"` // Purchase cost in coins + CostStatus int64 `json:"cost_status"` // Purchase cost in status points + UpkeepCoin int64 `json:"upkeep_coin"` // Upkeep cost in coins + UpkeepStatus int64 `json:"upkeep_status"` // Upkeep cost in status points + Alignment int8 `json:"alignment"` // Alignment requirement + GuildLevel int8 `json:"guild_level"` // Required guild level + VaultSlots int `json:"vault_slots"` // Number of vault storage slots + MaxItems int `json:"max_items"` // Maximum items that can be placed + MaxVisitors int `json:"max_visitors"` // Maximum concurrent visitors + UpkeepPeriod int32 `json:"upkeep_period"` // Upkeep period in seconds + Description string `json:"description"` // Description text + SaveNeeded bool `json:"-"` // Flag indicating if database save is needed +} + +// PlayerHouse represents a house owned by a player +type PlayerHouse struct { + mu sync.RWMutex + UniqueID int64 `json:"unique_id"` // Database unique ID + CharacterID int32 `json:"char_id"` // Owner character ID + HouseID int32 `json:"house_id"` // House type ID + InstanceID int32 `json:"instance_id"` // Instance identifier + UpkeepDue time.Time `json:"upkeep_due"` // When upkeep is due + EscrowCoins int64 `json:"escrow_coins"` // Coins in escrow account + EscrowStatus int64 `json:"escrow_status"` // Status points in escrow + PlayerName string `json:"player_name"` // Owner's name + Status int8 `json:"status"` // House status + Deposits []HouseDeposit `json:"deposits"` // Deposit history + History []HouseHistory `json:"history"` // Transaction history + AccessList map[int32]HouseAccess `json:"access_list"` // Player access permissions + Amenities []HouseAmenity `json:"amenities"` // Purchased amenities + Items []HouseItem `json:"items"` // Placed items + Settings HouseSettings `json:"settings"` // House settings + SaveNeeded bool `json:"-"` // Flag indicating if database save is needed +} + +// HouseDeposit represents a deposit transaction +type HouseDeposit struct { + Timestamp time.Time `json:"timestamp"` // When deposit was made + Amount int64 `json:"amount"` // Coin amount deposited + LastAmount int64 `json:"last_amount"` // Previous coin amount + Status int64 `json:"status"` // Status points deposited + LastStatus int64 `json:"last_status"` // Previous status points + Name string `json:"name"` // Player who made deposit + CharacterID int32 `json:"character_id"` // Character ID who made deposit +} + +// HouseHistory represents a house transaction history entry +type HouseHistory struct { + Timestamp time.Time `json:"timestamp"` // When transaction occurred + Amount int64 `json:"amount"` // Coin amount involved + Status int64 `json:"status"` // Status points involved + Reason string `json:"reason"` // Reason for transaction + Name string `json:"name"` // Player involved + CharacterID int32 `json:"character_id"` // Character ID involved + PosFlag int8 `json:"pos_flag"` // Positive/negative transaction + Type int `json:"type"` // Transaction type +} + +// HouseAccess represents access permissions for a player +type HouseAccess struct { + CharacterID int32 `json:"character_id"` // Character being granted access + PlayerName string `json:"player_name"` // Player name + AccessLevel int8 `json:"access_level"` // Access level + Permissions int32 `json:"permissions"` // Permission flags + GrantedBy int32 `json:"granted_by"` // Who granted the access + GrantedDate time.Time `json:"granted_date"` // When access was granted + ExpiresDate time.Time `json:"expires_date"` // When access expires (0 = never) + Notes string `json:"notes"` // Optional notes +} + +// HouseAmenity represents a purchased house amenity +type HouseAmenity struct { + ID int32 `json:"id"` // Amenity ID + Type int `json:"type"` // Amenity type + Name string `json:"name"` // Amenity name + Cost int64 `json:"cost"` // Purchase cost + StatusCost int64 `json:"status_cost"` // Status cost + PurchaseDate time.Time `json:"purchase_date"` // When purchased + X float32 `json:"x"` // X position + Y float32 `json:"y"` // Y position + Z float32 `json:"z"` // Z position + Heading float32 `json:"heading"` // Heading + IsActive bool `json:"is_active"` // Whether amenity is active +} + +// HouseItem represents an item placed in a house +type HouseItem struct { + ID int64 `json:"id"` // Item unique ID + ItemID int32 `json:"item_id"` // Item template ID + CharacterID int32 `json:"character_id"` // Who placed the item + X float32 `json:"x"` // X position + Y float32 `json:"y"` // Y position + Z float32 `json:"z"` // Z position + Heading float32 `json:"heading"` // Heading + PitchX float32 `json:"pitch_x"` // Pitch X + PitchY float32 `json:"pitch_y"` // Pitch Y + RollX float32 `json:"roll_x"` // Roll X + RollY float32 `json:"roll_y"` // Roll Y + PlacedDate time.Time `json:"placed_date"` // When item was placed + Quantity int32 `json:"quantity"` // Item quantity + Condition int8 `json:"condition"` // Item condition + House string `json:"house"` // House identifier +} + +// HouseSettings represents house configuration settings +type HouseSettings struct { + HouseName string `json:"house_name"` // Custom house name + VisitPermission int8 `json:"visit_permission"` // Who can visit + PublicNote string `json:"public_note"` // Public note displayed + PrivateNote string `json:"private_note"` // Private note for owner + AllowFriends bool `json:"allow_friends"` // Allow friends to visit + AllowGuild bool `json:"allow_guild"` // Allow guild members to visit + RequireApproval bool `json:"require_approval"` // Require approval for visits + ShowOnDirectory bool `json:"show_on_directory"` // Show in house directory + AllowDecoration bool `json:"allow_decoration"` // Allow others to decorate + TaxExempt bool `json:"tax_exempt"` // Tax exemption status +} + +// HousingManager manages the overall housing system +type HousingManager struct { + mu sync.RWMutex + houseZones map[int32]*HouseZone // Available house types + playerHouses map[int64]*PlayerHouse // All player houses by unique ID + characterHouses map[int32][]*PlayerHouse // Houses by character ID + zoneInstances map[int32]map[int32]*PlayerHouse // Houses by zone and instance + database HousingDatabase + clientManager ClientManager + playerManager PlayerManager + itemManager ItemManager + zoneManager ZoneManager + eventHandler HousingEventHandler + logger LogHandler + // Configuration + enableUpkeep bool + enableForeclosure bool + upkeepGracePeriod int32 + maxHousesPerPlayer int + enableStatistics bool +} + +// HousingStatistics tracks housing system usage +type HousingStatistics struct { + TotalHouses int64 `json:"total_houses"` + ActiveHouses int64 `json:"active_houses"` + ForelosedHouses int64 `json:"foreclosed_houses"` + TotalDeposits int64 `json:"total_deposits"` + TotalWithdrawals int64 `json:"total_withdrawals"` + AverageUpkeepPaid float64 `json:"average_upkeep_paid"` + MostPopularHouseType int32 `json:"most_popular_house_type"` + HousesByType map[int32]int64 `json:"houses_by_type"` + HousesByAlignment map[int8]int64 `json:"houses_by_alignment"` + RevenueByType map[int]int64 `json:"revenue_by_type"` + TopDepositors []PlayerDeposits `json:"top_depositors"` +} + +// PlayerDeposits tracks deposits by player +type PlayerDeposits struct { + CharacterID int32 `json:"character_id"` + PlayerName string `json:"player_name"` + TotalDeposits int64 `json:"total_deposits"` + HouseCount int `json:"house_count"` +} + +// HousingSearchCriteria for searching houses +type HousingSearchCriteria struct { + OwnerName string `json:"owner_name"` // Filter by owner name + HouseType int32 `json:"house_type"` // Filter by house type + Alignment int8 `json:"alignment"` // Filter by alignment + MinCost int64 `json:"min_cost"` // Minimum cost filter + MaxCost int64 `json:"max_cost"` // Maximum cost filter + Zone int32 `json:"zone"` // Filter by zone + VisitableOnly bool `json:"visitable_only"` // Only houses that can be visited + PublicOnly bool `json:"public_only"` // Only publicly accessible houses + NamePattern string `json:"name_pattern"` // Filter by house name pattern + HasAmenities bool `json:"has_amenities"` // Filter houses with amenities + MinVaultSlots int `json:"min_vault_slots"` // Minimum vault slots +} + +// Database record types for data persistence + +// HouseZoneData represents database record for house zones +type HouseZoneData struct { + ID int32 `json:"id"` + Name string `json:"name"` + ZoneID int32 `json:"zone_id"` + CostCoin int64 `json:"cost_coin"` + CostStatus int64 `json:"cost_status"` + UpkeepCoin int64 `json:"upkeep_coin"` + UpkeepStatus int64 `json:"upkeep_status"` + Alignment int8 `json:"alignment"` + GuildLevel int8 `json:"guild_level"` + VaultSlots int `json:"vault_slots"` + MaxItems int `json:"max_items"` + MaxVisitors int `json:"max_visitors"` + UpkeepPeriod int32 `json:"upkeep_period"` + Description string `json:"description"` +} + +// PlayerHouseData represents database record for player houses +type PlayerHouseData struct { + UniqueID int64 `json:"unique_id"` + CharacterID int32 `json:"char_id"` + HouseID int32 `json:"house_id"` + InstanceID int32 `json:"instance_id"` + UpkeepDue time.Time `json:"upkeep_due"` + EscrowCoins int64 `json:"escrow_coins"` + EscrowStatus int64 `json:"escrow_status"` + Status int8 `json:"status"` + HouseName string `json:"house_name"` + VisitPermission int8 `json:"visit_permission"` + PublicNote string `json:"public_note"` + PrivateNote string `json:"private_note"` + AllowFriends bool `json:"allow_friends"` + AllowGuild bool `json:"allow_guild"` + RequireApproval bool `json:"require_approval"` + ShowOnDirectory bool `json:"show_on_directory"` + AllowDecoration bool `json:"allow_decoration"` + TaxExempt bool `json:"tax_exempt"` +} + +// HouseDepositData represents database record for deposits +type HouseDepositData struct { + HouseID int64 `json:"house_id"` + Timestamp time.Time `json:"timestamp"` + Amount int64 `json:"amount"` + LastAmount int64 `json:"last_amount"` + Status int64 `json:"status"` + LastStatus int64 `json:"last_status"` + Name string `json:"name"` + CharacterID int32 `json:"character_id"` +} + +// HouseHistoryData represents database record for house history +type HouseHistoryData struct { + HouseID int64 `json:"house_id"` + Timestamp time.Time `json:"timestamp"` + Amount int64 `json:"amount"` + Status int64 `json:"status"` + Reason string `json:"reason"` + Name string `json:"name"` + CharacterID int32 `json:"character_id"` + PosFlag int8 `json:"pos_flag"` + Type int `json:"type"` +} + +// HouseAccessData represents database record for house access +type HouseAccessData struct { + HouseID int64 `json:"house_id"` + CharacterID int32 `json:"character_id"` + PlayerName string `json:"player_name"` + AccessLevel int8 `json:"access_level"` + Permissions int32 `json:"permissions"` + GrantedBy int32 `json:"granted_by"` + GrantedDate time.Time `json:"granted_date"` + ExpiresDate time.Time `json:"expires_date"` + Notes string `json:"notes"` +} + +// HouseAmenityData represents database record for house amenities +type HouseAmenityData struct { + HouseID int64 `json:"house_id"` + ID int32 `json:"id"` + Type int `json:"type"` + Name string `json:"name"` + Cost int64 `json:"cost"` + StatusCost int64 `json:"status_cost"` + PurchaseDate time.Time `json:"purchase_date"` + X float32 `json:"x"` + Y float32 `json:"y"` + Z float32 `json:"z"` + Heading float32 `json:"heading"` + IsActive bool `json:"is_active"` +} + +// HouseItemData represents database record for house items +type HouseItemData struct { + HouseID int64 `json:"house_id"` + ID int64 `json:"id"` + ItemID int32 `json:"item_id"` + CharacterID int32 `json:"character_id"` + X float32 `json:"x"` + Y float32 `json:"y"` + Z float32 `json:"z"` + Heading float32 `json:"heading"` + PitchX float32 `json:"pitch_x"` + PitchY float32 `json:"pitch_y"` + RollX float32 `json:"roll_x"` + RollY float32 `json:"roll_y"` + PlacedDate time.Time `json:"placed_date"` + Quantity int32 `json:"quantity"` + Condition int8 `json:"condition"` + House string `json:"house"` +} + +// PacketData structures for client communication + +// HousePurchasePacketData represents data for house purchase UI +type HousePurchasePacketData struct { + HouseID int32 `json:"house_id"` + Name string `json:"name"` + CostCoin int64 `json:"cost_coin"` + CostStatus int64 `json:"cost_status"` + UpkeepCoin int64 `json:"upkeep_coin"` + UpkeepStatus int64 `json:"upkeep_status"` + Alignment int8 `json:"alignment"` + GuildLevel int8 `json:"guild_level"` + VaultSlots int `json:"vault_slots"` + Description string `json:"description"` +} + +// HouseListPacketData represents data for housing list UI +type HouseListPacketData struct { + Houses []PlayerHouseInfo `json:"houses"` +} + +// PlayerHouseInfo represents house info for list display +type PlayerHouseInfo struct { + UniqueID int64 `json:"unique_id"` + Name string `json:"name"` + HouseType string `json:"house_type"` + UpkeepDue time.Time `json:"upkeep_due"` + EscrowCoins int64 `json:"escrow_coins"` + EscrowStatus int64 `json:"escrow_status"` + Status int8 `json:"status"` + CanEnter bool `json:"can_enter"` +} + +// BaseHouseWindowPacketData represents data for main house management UI +type BaseHouseWindowPacketData struct { + HouseInfo PlayerHouseInfo `json:"house_info"` + RecentDeposits []HouseDeposit `json:"recent_deposits"` + RecentHistory []HouseHistory `json:"recent_history"` + Amenities []HouseAmenity `json:"amenities"` + Settings HouseSettings `json:"settings"` + CanManage bool `json:"can_manage"` +} + +// HouseVisitPacketData represents data for house visit UI +type HouseVisitPacketData struct { + AvailableHouses []VisitableHouse `json:"available_houses"` +} + +// VisitableHouse represents a house that can be visited +type VisitableHouse struct { + UniqueID int64 `json:"unique_id"` + OwnerName string `json:"owner_name"` + HouseName string `json:"house_name"` + HouseType string `json:"house_type"` + PublicNote string `json:"public_note"` + CanVisit bool `json:"can_visit"` + RequiresApproval bool `json:"requires_approval"` +} + +// Event structures for housing system events + +// HousingEvent represents a housing system event +type HousingEvent struct { + ID int64 `json:"id"` + HouseID int64 `json:"house_id"` + EventType int `json:"event_type"` + CharacterID int32 `json:"character_id"` + Timestamp time.Time `json:"timestamp"` + Data string `json:"data"` +} + +// Configuration structure for housing system +type HousingConfig struct { + EnableUpkeep bool `json:"enable_upkeep"` + EnableForeclosure bool `json:"enable_foreclosure"` + UpkeepGracePeriod int32 `json:"upkeep_grace_period"` // seconds + MaxHousesPerPlayer int `json:"max_houses_per_player"` + EnableStatistics bool `json:"enable_statistics"` + AutoCleanupInterval int32 `json:"auto_cleanup_interval"` // seconds + MaxHistoryEntries int `json:"max_history_entries"` + MaxDepositEntries int `json:"max_deposit_entries"` + DefaultInstanceLifetime int32 `json:"default_instance_lifetime"` // seconds +} \ No newline at end of file