diff --git a/sql/updates/update_2025-01-14_1.sql b/sql/updates/update_2025-01-14_1.sql new file mode 100644 index 000000000..69fee9512 --- /dev/null +++ b/sql/updates/update_2025-01-14_1.sql @@ -0,0 +1,13 @@ +CREATE TABLE `storage_team` ( + `accountId` bigint(20) NOT NULL, + `itemId` bigint(20) NOT NULL, + `position` int(11) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; + +ALTER TABLE `storage_team` + ADD PRIMARY KEY (`accountId`,`itemId`), + ADD KEY `itemId` (`itemId`); + +ALTER TABLE `storage_team` + ADD CONSTRAINT `storage_team_ibfk_1` FOREIGN KEY (`accountId`) REFERENCES `accounts` (`accountId`) ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT `storage_team_ibfk_2` FOREIGN KEY (`itemId`) REFERENCES `items` (`itemUniqueId`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/Shared/Configuration/Files/Web.cs b/src/Shared/Configuration/Files/Web.cs index cae2ebbed..62a197907 100644 --- a/src/Shared/Configuration/Files/Web.cs +++ b/src/Shared/Configuration/Files/Web.cs @@ -20,6 +20,11 @@ public class WebConfFile : ConfFile /// public List CgiProcessors { get; private set; } + /// + /// Returns the port for the guild web server. + /// + public int GuildPort { get; private set; } + /// /// Loads conf file and its options from the given path. /// @@ -30,6 +35,7 @@ public void Load(string filePath) this.EnableApiAccountCreation = this.GetBool("enable_api_account_creation", false); this.CgiProcessors = this.GetCgiProcessors(); + this.GuildPort = this.GetInt("guild_port", 9004); } /// diff --git a/src/Shared/Configuration/Files/World.cs b/src/Shared/Configuration/Files/World.cs index 9d867274c..3df93ff2b 100644 --- a/src/Shared/Configuration/Files/World.cs +++ b/src/Shared/Configuration/Files/World.cs @@ -68,6 +68,12 @@ public class WorldConfFile : ConfFile public int StorageMaxSize { get; protected set; } public int StorageMaxExtensions { get; protected set; } public bool StorageMultiStack { get; protected set; } + public int TeamStorageFee { get; protected set; } + public int TeamStorageDefaultSize { get; protected set; } + public int TeamStorageExtCost { get; protected set; } + public int TeamStorageMaxSilverExpands { get; protected set; } + public int TeamStorageMaxSize { get; protected set; } + public int TeamStorageMinimumLevelRequired { get; protected set; } // summons.conf public bool BlueOrbFollowWarp { get; protected set; } @@ -123,6 +129,13 @@ public void Load(string filePath) this.StorageMaxSize = this.GetInt("storage_max_size", 110); this.StorageMultiStack = this.GetBool("storage_multi_stack", true); + this.TeamStorageFee = this.GetInt("team_storage_fee", 0); + this.TeamStorageDefaultSize = this.GetInt("team_storage_default_size", 5); + this.TeamStorageExtCost = this.GetInt("team_storage_ext_cost", 200000); + this.TeamStorageMaxSilverExpands = this.GetInt("team_storage_max_silver_expands", 9); + this.TeamStorageMaxSize = this.GetInt("team_storage_max_size", 70); + this.TeamStorageMinimumLevelRequired = this.GetInt("team_storage_min_level_req", 15); + this.ExpRate = this.GetFloat("exp_rate", 100); this.JobExpRate = this.GetFloat("job_exp_rate", 100); diff --git a/src/Shared/Game/Const/Items.cs b/src/Shared/Game/Const/Items.cs index a09587a89..6447a7306 100644 --- a/src/Shared/Game/Const/Items.cs +++ b/src/Shared/Game/Const/Items.cs @@ -357,7 +357,8 @@ public enum InventoryItemRemoveMsg : byte public enum InventoryType : byte { Inventory = 0, - Warehouse = 1, + PersonalStorage = 1, + TeamStorage = 6, } public enum InventoryAddType : byte diff --git a/src/Shared/Game/Const/Web/AccountWarehouse.cs b/src/Shared/Game/Const/Web/AccountWarehouse.cs new file mode 100644 index 000000000..76847924a --- /dev/null +++ b/src/Shared/Game/Const/Web/AccountWarehouse.cs @@ -0,0 +1,50 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Melia.Shared.Game.Const.Web +{ + public class AccountWarehouse + { + public AccountWarehouse() + { + this.List = new List + { + new List() + { + Index = "0", + Title = "", + }, + new List() + { + Index = "1", + Title = "", + }, + new List() + { + Index = "2", + Title = "", + }, + new List() + { + Index = "3", + Title = "", + } + }; + } + [JsonProperty("list")] + public List List { get; set; } + } + + public class List + { + [JsonProperty("index")] + public string Index { get; set; } + + [JsonProperty("title")] + public string Title { get; set; } + } +} diff --git a/src/Shared/Network/NormalOp.cs b/src/Shared/Network/NormalOp.cs index 597fda8d6..dd945d54c 100644 --- a/src/Shared/Network/NormalOp.cs +++ b/src/Shared/Network/NormalOp.cs @@ -75,6 +75,7 @@ public static class Zone public const int SetSessionKey = 0x14F; public const int ItemDrop = 0x152; public const int NGSCallback = 0x170; + public const int StorageSilverTransaction = 0x171; public const int HeadgearVisibilityUpdate = 0x17C; public const int UpdateSkillUI = 0x189; public const int AdventureBook = 0x197; diff --git a/src/WebServer/Controllers/BaseController.cs b/src/WebServer/Controllers/BaseController.cs index ed0e852fa..96af6b467 100644 --- a/src/WebServer/Controllers/BaseController.cs +++ b/src/WebServer/Controllers/BaseController.cs @@ -41,6 +41,20 @@ protected async Task SendText(string mimeType, HttpStatusCode statusCode, string await sw.WriteAsync(content); } + /// + /// Sends content as binary with the given mime type. + /// + /// + /// + protected void SendBinary(string mimeType, byte[] content) + { + this.Response.StatusCode = 200; + this.Response.ContentType = mimeType; + + using (var stream = this.Response.OutputStream) + stream.Write(content, 0, content.Length); + } + /// /// A string writer that uses UTF-8 without BOM (byte-order mark). /// diff --git a/src/WebServer/Controllers/TosGuildController.cs b/src/WebServer/Controllers/TosGuildController.cs new file mode 100644 index 000000000..3a634a8e2 --- /dev/null +++ b/src/WebServer/Controllers/TosGuildController.cs @@ -0,0 +1,49 @@ +using System.IO; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using EmbedIO; +using EmbedIO.Routing; +using Melia.Shared.Game.Const.Web; +using Melia.Web.Const; +using Melia.Web.Util; + +namespace Melia.Web.Controllers +{ + /// + /// Controller for TOS Guild and Team Storage related endpoints. + /// + public class TosGuildController : BaseController + { + /// + /// Returns the list of account warehouse (team storage) tabs. + /// + /// + /// This endpoint is called by the client when accessing team storage + /// to display the storage tabs/categories in the UI. + /// + [Route(HttpVerbs.Get, "/accountwarehouse/get")] + public async Task GetAccountWarehouse() + { + // Return the default account warehouse structure with 4 tabs + // The client uses this to display the storage tabs in the UI + var accountWarehouse = new AccountWarehouse(); + var json = JsonSerializer.Serialize(accountWarehouse); + + await this.SendText(MimeTypes.Text.Plain, json); + } + + /// + /// Stub implementation for guild emblem endpoint. + /// + /// The guild ID + /// + [Route(HttpVerbs.Get, "/guildemblem/{guildId}")] + public async Task GetGuildEmblem(long guildId) + { + // Stub: Just return empty success response + await this.SendText(MimeTypes.Text.Plain, string.Empty); + } + } +} diff --git a/src/WebServer/Util/FileUtils.cs b/src/WebServer/Util/FileUtils.cs new file mode 100644 index 000000000..771e8ed2d --- /dev/null +++ b/src/WebServer/Util/FileUtils.cs @@ -0,0 +1,50 @@ +using System.IO; +using System.Security.Cryptography; +using System.Text; + +namespace Melia.Web.Util +{ + /// + /// Utility class for file operations. + /// + public static class FileUtils + { + /// + /// Computes MD5 hash of the given byte array. + /// + /// + /// + public static string GetMD5Hash(byte[] input) + { + var md5 = MD5.Create(); + var array = md5.ComputeHash(input); + var stringBuilder = new StringBuilder(); + + for (var i = 0; i < array.Length; i++) + { + stringBuilder.Append(array[i].ToString("x2")); + } + + return stringBuilder.ToString(); + } + + /// + /// Reads a file and returns its contents as a byte array. + /// + /// + /// + public static byte[] FileToByteArray(string fileName) + { + byte[] fileData = null; + + using (var fs = File.OpenRead(fileName)) + { + using (var binaryReader = new BinaryReader(fs)) + { + fileData = binaryReader.ReadBytes((int)fs.Length); + } + } + return fileData; + } + } +} diff --git a/src/WebServer/WebServer.cs b/src/WebServer/WebServer.cs index ab9bf08e8..89cca2e28 100644 --- a/src/WebServer/WebServer.cs +++ b/src/WebServer/WebServer.cs @@ -29,6 +29,7 @@ public class WebServer : Server public readonly static WebServer Instance = new(); private EmbedIO.WebServer _server; + private EmbedIO.WebServer _guildServer; /// /// Returns the server's inter-server communicator. @@ -60,6 +61,7 @@ public override void Run(string[] args) this.CheckDependencies(); this.StartWebServer(); + this.StartGuildWebServer(); this.StartCommunicator(); ConsoleUtil.RunningTitle(); @@ -367,5 +369,53 @@ private void StartWebServer() ConsoleUtil.Exit(1); } } + + /// + /// Starts guild web server. + /// + private void StartGuildWebServer() + { + try + { + var url = string.Format("http://*:{0}/", this.Conf.Web.GuildPort); + + Swan.Logging.Logger.NoLogging(); + Swan.Logging.Logger.RegisterLogger(new YggdrasilLogger(this.Conf.Log.Filter)); + + EndPointManager.UseIpv6 = false; + + var options = new WebServerOptions() + .WithMode(HttpListenerMode.EmbedIO) + .WithUrlPrefix(url); + _guildServer = new EmbedIO.WebServer(options); + + var webFolder = "system/web/"; + if (Directory.Exists("user/web/")) + webFolder = "user/web/"; + + _guildServer + .WithWebApi("/", m => m.WithController()) + .WithStaticFolder("/", webFolder, false, fm => + { + fm.DefaultDocument = "index.htm"; + fm.OnMappingFailed = FileRequestHandler.PassThrough; + fm.OnDirectoryNotListable = FileRequestHandler.PassThrough; + }); + _guildServer.RunAsync(); + + if (_guildServer.State == WebServerState.Stopped) + { + Log.Error("Failed to start guild server, make sure there's only one instance running."); + ConsoleUtil.Exit(1); + } + + Log.Status("Guild Server now running on '{0}'", url); + } + catch (Exception ex) + { + Log.Error("Failed to start guild web server: {0}", ex); + ConsoleUtil.Exit(1); + } + } } } diff --git a/src/ZoneServer/Database/Account.cs b/src/ZoneServer/Database/Account.cs index be28919af..613f2e8a2 100644 --- a/src/ZoneServer/Database/Account.cs +++ b/src/ZoneServer/Database/Account.cs @@ -7,6 +7,7 @@ using Melia.Shared.Scripting; using Melia.Zone.World; using Melia.Zone.World.Maps; +using Melia.Zone.World.Storage; namespace Melia.Zone.Database { @@ -119,6 +120,11 @@ public PermissionLevel PermissionLevel /// public PremiumStatus Premium { get; } = new(); + /// + /// Returns the account's team storage. + /// + public TeamStorage TeamStorage { get; set; } + /// /// Creates new account. /// diff --git a/src/ZoneServer/Database/ZoneDb.cs b/src/ZoneServer/Database/ZoneDb.cs index b08d0afee..3bea2f393 100644 --- a/src/ZoneServer/Database/ZoneDb.cs +++ b/src/ZoneServer/Database/ZoneDb.cs @@ -195,13 +195,21 @@ public Character GetCharacter(long accountId, long characterId) // non-character information, like account properties, have to be // loaded from a different location. character.PersonalStorage.InitSize(); - character.TeamStorage.InitSize(); this.LoadStorage(character.PersonalStorage, "storage_personal", "characterId", character.DbId); - this.LoadStorage(character.TeamStorage, "storage_team", "accountId", character.AccountId); return character; } + /// + /// Finishes loading data that depends on both Account and Character objects being present. + /// + public void AfterLoad(Account account, Character character) + { + account.TeamStorage = new TeamStorage(character); + character.TeamStorage.InitSize(); + this.LoadStorage(character.TeamStorage, "storage_team", "accountId", character.AccountId); + } + /// /// Loads character's skills. /// @@ -696,7 +704,12 @@ internal void LoadStorage(Storage storage, string tableName, string idFieldName, var item = new Item(itemId, amount); - storage.AddAtPosition(item, position, out var addedAmount); + if (itemId == ItemId.Silver && storage is TeamStorage teamStorage) + { + teamStorage.SetSilver(amount); + } + else + storage.AddAtPosition(item, position, out var addedAmount); } } } diff --git a/src/ZoneServer/Network/PacketHandler.cs b/src/ZoneServer/Network/PacketHandler.cs index 652efadf3..2ede136d5 100644 --- a/src/ZoneServer/Network/PacketHandler.cs +++ b/src/ZoneServer/Network/PacketHandler.cs @@ -110,6 +110,9 @@ public void CZ_CONNECT(IZoneConnection conn, Packet packet) character.Connection = conn; conn.SelectedCharacter = character; + // Finish loading account-level data that requires both Account and Character + ZoneServer.Instance.Database.AfterLoad(conn.Account, character); + ZoneServer.Instance.ServerEvents.PlayerLoggedIn.Raise(new PlayerEventArgs(character)); map.AddCharacter(character); @@ -177,6 +180,7 @@ public void CZ_GAME_READY(IZoneConnection conn, Packet packet) Send.ZC_OBJECT_PROPERTY(conn, character.Etc); Send.ZC_START_GAME(conn); Send.ZC_UPDATE_ALL_STATUS(character, 0); + Send.ZC_SET_WEBSERVICE_URL(conn); Send.ZC_MOVE_SPEED(character); Send.ZC_STAMINA(character, character.Stamina); Send.ZC_UPDATE_SP(character, character.Sp, false); @@ -1462,16 +1466,29 @@ public void CZ_REQ_ITEM_LIST(IZoneConnection conn, Packet packet) var character = conn.SelectedCharacter; - if (type == StorageType.PersonalStorage) + if (type == StorageType.PersonalStorage && character.CurrentStorage is PersonalStorage storage && storage.IsBrowsing) { - var storage = character.CurrentStorage; - - if (storage.IsBrowsing) - Send.ZC_SOLD_ITEM_DIVISION_LIST(character, type, storage.GetItems()); + var items = storage.GetItems(); + Send.ZC_SOLD_ITEM_DIVISION_LIST(character, type, items); + // TODO: + // Check for item sockets + // -------------------------- + // foreach (var socketedItems in items.Values.Where(a => a.HasSockets)) + // Send.ZC_EQUIP_GEM_INFO(character, socketedItems); } - else if (type == StorageType.TeamStorage) + else if (type == StorageType.TeamStorage && character.CurrentStorage is TeamStorage teamStorage && teamStorage.IsBrowsing) { - character.ServerMessage(Localization.Get("Team storage has not been implemented yet.")); + var items = teamStorage.GetItems(); + Send.ZC_SOLD_ITEM_DIVISION_LIST(character, type, items); + // TODO: + // Check for item sockets + // -------------------------- + // foreach (var socketedItems in items.Values.Where(a => a.HasSockets)) + // Send.ZC_EQUIP_GEM_INFO(character, socketedItems); + } + else + { + Send.ZC_SOLD_ITEM_DIVISION_LIST(character, type, new Dictionary()); } } @@ -1531,7 +1548,46 @@ public void CZ_WAREHOUSE_CMD(IZoneConnection conn, Packet packet) } else if (type == StorageType.TeamStorage) { - character.ServerMessage(Localization.Get("Team storage has not been implemented yet.")); + var inventory = character.Inventory; + var storage = character.CurrentStorage as TeamStorage; + + var interactionCost = ZoneServer.Instance.Conf.World.TeamStorageFee; + var silver = inventory.CountItem(ItemId.Silver); + + if (silver < interactionCost) + { + Log.Warning("CZ_WAREHOUSE_CMD: User '{0}' tried to store or retrieve team items without silver", conn.Account.Name); + return; + } + + if (storage == null) + { + Log.Warning("CZ_WAREHOUSE_CMD: User '{0}' tried to manage their team storage with wrong storage type open.", conn.Account.Name); + return; + } + + if (!storage.IsBrowsing) + { + Log.Warning("CZ_WAREHOUSE_CMD: User '{0}' tried to manage their team storage without it being open.", conn.Account.Name); + return; + } + + if (interaction == StorageInteraction.Store) + { + var item = inventory.GetItem(worldId); + if (item?.Id == ItemId.Silver) + { + storage.StoreSilver(amount); + } + else if (storage.StoreItem(worldId, amount) == StorageResult.Success) + { + inventory.Remove(ItemId.Silver, interactionCost, InventoryItemRemoveMsg.Given); + } + } + else if (interaction == StorageInteraction.Retrieve && storage.RetrieveItem(worldId, amount) == StorageResult.Success) + { + inventory.Remove(ItemId.Silver, interactionCost, InventoryItemRemoveMsg.Given); + } } else { @@ -1580,13 +1636,22 @@ public void CZ_EXTEND_WAREHOUSE(IZoneConnection conn, Packet packet) { case StorageType.PersonalStorage: { - var storage = character.CurrentStorage; + var storage = character.CurrentStorage as PersonalStorage; var result = storage.TryExtendStorage(PersonalStorage.ExtensionSize); if (result != StorageResult.Success) Log.Warning("CZ_EXTEND_WAREHOUSE: User '{0}' tried to extend their personal storage, but failed ({1}).", conn.Account.Name, result); break; } + case StorageType.TeamStorage: + { + var storage = character.CurrentStorage as TeamStorage; + + var result = storage.TryExtendStorage(TeamStorage.ExtensionSize); + if (result != StorageResult.Success) + Log.Warning("CZ_EXTEND_WAREHOUSE: User '{0}' tried to extend their team storage, but failed ({1}).", conn.Account.Name, result); + break; + } default: { character.ServerMessage(Localization.Get("Something went wrong while extending the storage, please report this issue.")); @@ -1596,6 +1661,76 @@ public void CZ_EXTEND_WAREHOUSE(IZoneConnection conn, Packet packet) } } + /// + /// Sent when retrieving multiple items from team storage at once. + /// + /// + /// + [PacketHandler(Op.CZ_WAREHOUSE_TAKE_LIST)] + public void CZ_WAREHOUSE_TAKE_LIST(IZoneConnection conn, Packet packet) + { + var size = packet.GetShort(); + var type = (StorageType)packet.GetByte(); + var itemCount = packet.GetInt(); + var i0 = packet.GetInt(); + + var character = conn.SelectedCharacter; + + if (type == StorageType.TeamStorage) + { + if (character.CurrentStorage is not TeamStorage storage || !storage.IsBrowsing) + { + Log.Warning("CZ_WAREHOUSE_TAKE_LIST: User '{0}' tried to manage their team storage without it being open.", conn.Account.Name); + return; + } + + // Retrieve silver + var silverItem = storage.GetSilver(); + + for (var i = 0; i < itemCount; i++) + { + var worldId = packet.GetLong(); + var amount = packet.GetInt(); + var i1 = packet.GetInt(); + + // Note: For some reason, client may send worldId zero + // when trying to retrieve silver. + if ((silverItem != null && silverItem.ObjectId == worldId) || worldId == 0) + { + if (storage.RetrieveSilver(amount) != StorageResult.Success) + { + // Log.Debug("CZ_WAREHOUSE_TAKE_LIST: User '{0}' failed to retrieve silver {1} with amount {2}.", conn.Account.Name, worldId, amount); + } + } + else if (storage.RetrieveItem(worldId, amount) != StorageResult.Success) + { + Log.Warning("CZ_WAREHOUSE_TAKE_LIST: User '{0}' failed to retrieve item {1} with amount {2}.", conn.Account.Name, worldId, amount); + } + } + } + } + + /// + /// Sent when requesting team storage silver transaction history. + /// + /// + /// + [PacketHandler(Op.CZ_REQ_ACC_WARE_VIS_LOG)] + public void CZ_REQ_ACC_WARE_VIS_LOG(IZoneConnection conn, Packet packet) + { + var character = conn.SelectedCharacter; + + if (character.CurrentStorage is not TeamStorage storage || !storage.IsBrowsing) + { + Log.Warning("CZ_REQ_ACC_WARE_VIS_LOG: User '{0}' tried to manage their team storage without it being open.", conn.Account.Name); + return; + } + + var transList = storage.GetSilverTransactions(); + + Send.ZC_NORMAL.StorageSilverTransaction(character, transList, true); + } + /// /// Sent when clicking Confirm in a shop, with items in the "Bought" list. /// diff --git a/src/ZoneServer/Network/Send.Normal.cs b/src/ZoneServer/Network/Send.Normal.cs index ebb09e5e9..6646d5532 100644 --- a/src/ZoneServer/Network/Send.Normal.cs +++ b/src/ZoneServer/Network/Send.Normal.cs @@ -9,6 +9,7 @@ using Melia.Zone.World.Actors.Characters.Components; using Melia.Zone.World.Actors.Monsters; using Melia.Zone.World.Actors.Pads; +using Melia.Zone.World.Storage; namespace Melia.Zone.Network { @@ -790,6 +791,27 @@ public static void AccountProperties(Character character) character.Connection.Send(packet); } + /// + /// Sends specific account properties to character's client. + /// + /// + /// + public static void AccountProperties(Character character, params string[] propertyNames) + { + var account = character.Connection.Account; + var properties = propertyNames != null ? account.Properties.GetSelect(propertyNames) : account.Properties.GetAll(); + var propertySize = properties.GetByteCount(); + + var packet = new Packet(Op.ZC_NORMAL); + packet.PutInt(NormalOp.Zone.AccountProperties); + + packet.PutLong(account.Id); + packet.PutShort(propertySize); + packet.AddProperties(properties); + + character.Connection.Send(packet); + } + /// /// Makes monster fade out over the given amount of time. /// @@ -1357,6 +1379,33 @@ public static void OpenBook(Character character, string bookName) character.Connection.Send(packet); } + + /// + /// Updates silver transactions for storage + /// + /// Character browsing storage + /// Silver transaction list + /// 'True' will erase previous transactions. + public static void StorageSilverTransaction(Character character, StorageSilverTransaction[] transactions, bool init) + { + var packet = new Packet(Op.ZC_NORMAL); + packet.PutInt(NormalOp.Zone.StorageSilverTransaction); + + packet.Zlib(true, zpacket => + { + zpacket.PutByte(init); + zpacket.PutInt(transactions.Length); + foreach (var trans in transactions) + { + zpacket.PutByte((byte)trans.Interaction); + zpacket.PutLong(trans.SilverTransacted); + zpacket.PutLong(trans.SilverTotal); + zpacket.PutLong(trans.TransactionTime); + } + }); + + character.Connection.Send(packet); + } } } } diff --git a/src/ZoneServer/Network/Send.cs b/src/ZoneServer/Network/Send.cs index 8adb3d947..40408debc 100644 --- a/src/ZoneServer/Network/Send.cs +++ b/src/ZoneServer/Network/Send.cs @@ -3413,15 +3413,15 @@ public static void ZC_LEAVE_HOOK(IActor actor) } /// - /// Not too sure what this does, maybe for store purchases? + /// Sends the web service URLs to the client for guild and market features. /// /// public static void ZC_SET_WEBSERVICE_URL(IZoneConnection conn) { var packet = new Packet(Op.ZC_SET_WEBSERVICE_URL); - packet.PutString("https://52.58.92.141:9004", 128); - packet.PutString("https://52.29.227.229:9005", 128); + packet.PutString("http://127.0.0.1:9004", 128); + packet.PutString("http://127.0.0.1:9005", 128); conn.Send(packet); } diff --git a/src/ZoneServer/Scripting/Dialogues/Dialog.cs b/src/ZoneServer/Scripting/Dialogues/Dialog.cs index 8734902df..a505a2917 100644 --- a/src/ZoneServer/Scripting/Dialogues/Dialog.cs +++ b/src/ZoneServer/Scripting/Dialogues/Dialog.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Melia.Shared.Data.Database; +using Melia.Shared.Game.Const; using Melia.Zone.Events.Arguments; using Melia.Zone.Network; using Melia.Zone.Scripting.Hooking; @@ -544,7 +545,9 @@ public void Close() /// public async Task OpenPersonalStorage() { - this.Player.PersonalStorage.Open(); + var result = this.Player.PersonalStorage.Open(); + if (result != StorageResult.Success) + return; await this.GetClientResponse(); } @@ -554,7 +557,9 @@ public async Task OpenPersonalStorage() /// public async Task OpenTeamStorage() { - this.Player.TeamStorage.Open(); + var result = this.Player.TeamStorage.Open(); + if (result != StorageResult.Success) + return; await this.GetClientResponse(); } diff --git a/src/ZoneServer/World/Actors/Characters/Character.cs b/src/ZoneServer/World/Actors/Characters/Character.cs index 0d2d659c9..fd405b4fe 100644 --- a/src/ZoneServer/World/Actors/Characters/Character.cs +++ b/src/ZoneServer/World/Actors/Characters/Character.cs @@ -17,6 +17,7 @@ using Melia.Zone.World.Actors.Components; using Melia.Zone.World.Actors.Monsters; using Melia.Zone.World.Storage; +using StorageBase = Melia.Zone.World.Storage.Storage; using Yggdrasil.Composition; using Yggdrasil.Logging; using Yggdrasil.Scheduling; @@ -189,7 +190,7 @@ public class Character : Actor, IActor, ICombatEntity, ICommander, IPropertyObje /// /// Returns the character's account's team storage. /// - public PersonalStorage TeamStorage { get; } + public TeamStorage TeamStorage => this.Connection.Account.TeamStorage; /// /// Returns a reference to the character's current storage. @@ -199,9 +200,9 @@ public class Character : Actor, IActor, ICombatEntity, ICommander, IPropertyObje /// to support the dynamic opening of arbitrary storages. If no /// special storage was set, it defaults to the personal storage. /// - public PersonalStorage CurrentStorage + public StorageBase CurrentStorage { - get => this.Variables.Temp.Get("Melia.Storage") ?? this.PersonalStorage; + get => this.Variables.Temp.Get("Melia.Storage") ?? this.PersonalStorage; set => this.Variables.Temp.Set("Melia.Storage", value); } @@ -416,7 +417,6 @@ public Character() : base() // Init storage after etc, since it uses etc properties this.PersonalStorage = new PersonalStorage(this); - this.TeamStorage = new PersonalStorage(this); this.AddSessionObjects(); } @@ -1464,6 +1464,24 @@ public void PickUp(ItemMonster itemMonster) this.Map.RemoveMonster(itemMonster); } + /// + /// Returns true if the character has silver and has at least the + /// requested amount. + /// + /// Amount of silver required. + /// If false, sends a + /// "NotEnoughMoney" system message to the character. + /// + /// True if the character has enough silver, false otherwise. + /// + public bool HasSilver(int amount, bool silently = true) + { + var hasEnoughSilver = this.Inventory.CountItem(ItemId.Silver) >= amount; + if (!hasEnoughSilver && !silently) + this.SystemMessage("NotEnoughMoney"); + return hasEnoughSilver; + } + /// /// Toggles whether the character is sitting or not. /// diff --git a/src/ZoneServer/World/Storage/PersonalStorage.cs b/src/ZoneServer/World/Storage/PersonalStorage.cs index 65981722a..a28c25f07 100644 --- a/src/ZoneServer/World/Storage/PersonalStorage.cs +++ b/src/ZoneServer/World/Storage/PersonalStorage.cs @@ -20,11 +20,6 @@ public class PersonalStorage : Storage /// public Character Owner { get; private set; } - /// - /// Whether the owner is currently browsing this storage. - /// - public bool IsBrowsing { get; private set; } - /// /// Creates new personal storage. /// @@ -78,7 +73,7 @@ public override StorageResult Close() /// public override StorageResult StoreItem(long objectId, int amount) { - return this.StoreItem(this.Owner, objectId, amount, InventoryType.Warehouse); + return this.StoreItem(this.Owner, objectId, amount, InventoryType.PersonalStorage); } /// @@ -90,7 +85,7 @@ public override StorageResult StoreItem(long objectId, int amount) /// public override StorageResult RetrieveItem(long objectId, int amount) { - return this.RetrieveItem(this.Owner, objectId, amount, InventoryType.Warehouse); + return this.RetrieveItem(this.Owner, objectId, amount, InventoryType.PersonalStorage); } /// diff --git a/src/ZoneServer/World/Storage/Storage.cs b/src/ZoneServer/World/Storage/Storage.cs index 1a5af701a..6448acd85 100644 --- a/src/ZoneServer/World/Storage/Storage.cs +++ b/src/ZoneServer/World/Storage/Storage.cs @@ -20,6 +20,11 @@ public abstract class Storage private readonly SortedList _storageItems = new(); private int _storageSize = 0; + /// + /// Returns whether the storage is currently being browsed. + /// + public bool IsBrowsing { get; protected set; } + /// /// Gets the first available position in storage. /// Returns -1 if not found. @@ -527,7 +532,7 @@ public bool CheckStorageFull() /// of storage. /// /// - public Dictionary GetItems() + public virtual Dictionary GetItems() { lock (_syncLock) return _storageItems.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); diff --git a/src/ZoneServer/World/Storage/StorageSilverTransaction.cs b/src/ZoneServer/World/Storage/StorageSilverTransaction.cs new file mode 100644 index 000000000..e09fa5925 --- /dev/null +++ b/src/ZoneServer/World/Storage/StorageSilverTransaction.cs @@ -0,0 +1,31 @@ +using System; +using Melia.Shared.Game.Const; + +namespace Melia.Zone.World.Storage +{ + /// + /// This class represents a silver transaction for a storage. + /// + public class StorageSilverTransaction + { + /// Storing or retrieving silver + public StorageInteraction Interaction { get; set; } + + // Amount of silver exchanged in transaction + public int SilverTransacted { get; set; } + + // Total amount of silver in storage + public int SilverTotal { get; set; } + + // Time of transaction as Filetime + public long TransactionTime { get; set; } + + /// + /// Sets Filetime to current time + /// + public StorageSilverTransaction() + { + this.TransactionTime = DateTime.Now.Add(TimeZone.CurrentTimeZone.GetUtcOffset(DateTime.Now)).ToFileTime(); + } + } +} diff --git a/src/ZoneServer/World/Storage/TeamStorage.cs b/src/ZoneServer/World/Storage/TeamStorage.cs new file mode 100644 index 000000000..9378d70ab --- /dev/null +++ b/src/ZoneServer/World/Storage/TeamStorage.cs @@ -0,0 +1,477 @@ +using System; +using System.Collections.Generic; +using Melia.Shared.Game.Const; +using Melia.Shared.L10N; +using Melia.Zone.Network; +using Melia.Zone.World.Actors.Characters; +using Melia.Zone.World.Items; + +namespace Melia.Zone.World.Storage +{ + /// + /// Team storage of an account. + /// + public class TeamStorage : Storage + { + private int _silver; + private readonly int _silverMax; + private readonly Queue _silverTransactions; + // This is necessary because the client identifies the + // silver in storage by its objectId + private readonly Item _silverDummyItem; + private readonly int _maxSilverTransactions = 5; // Client limit + private readonly object _silverLock = new(); // Lock for silver operations + + public const int DefaultSize = 5; + public const int ExtensionSize = 1; + + /// + /// Returns the silver transactions in this storage. + /// Returns null if no transactions were made. + /// + /// + public StorageSilverTransaction[] GetSilverTransactions() + { + lock (_silverLock) + { + return _silverTransactions.ToArray(); + } + } + + /// + /// Character that owns this team storage. + /// + public Character Owner { get; private set; } + + /// + /// Creates new team storage. + /// + /// + public TeamStorage(Character owner) : base() + { + this.Owner = owner; + this.SetStorageSize(DefaultSize); + + _silverDummyItem = new Item(ItemId.Silver); + _silverMax = _silverDummyItem.Data.MaxStack; + _silverTransactions = new Queue(); + } + + /// + /// Opens storage. + /// Updates client for owner. + /// + /// + public override StorageResult Open() + { + var minLevel = ZoneServer.Instance.Conf.World.TeamStorageMinimumLevelRequired; + + // Check if character meets the minimum level requirement + if (this.Owner.Level < minLevel) + { + this.Owner.ServerMessage(Localization.Get("You must be at least level {0} to access team storage."), minLevel); + return StorageResult.InvalidOperation; + } + + this.IsBrowsing = true; + this.Owner.CurrentStorage = this; + + Send.ZC_CUSTOM_DIALOG(this.Owner, "accountwarehouse", ""); + + return StorageResult.Success; + } + + /// + /// Closes storage. + /// Updates client for owner. + /// + /// + public override StorageResult Close() + { + this.IsBrowsing = false; + this.Owner.CurrentStorage = null; + + Send.ZC_DIALOG_CLOSE(this.Owner.Connection); + + return StorageResult.Success; + } + + /// + /// Adds an item to storage. + /// Updates client for owner. + /// + /// + /// + /// + public override StorageResult StoreItem(long objectId, int amount) + { + return this.StoreItem(this.Owner, objectId, amount, InventoryType.TeamStorage); + } + + /// + /// Retrieves an item from storage. + /// Updates client for owner. + /// + /// + /// + /// + public override StorageResult RetrieveItem(long objectId, int amount) + { + return this.RetrieveItem(this.Owner, objectId, amount, InventoryType.TeamStorage); + } + + /// + /// Returns the silver item or null + /// if no silver exists + /// + /// + public Item GetSilver() + { + lock (_silverLock) + { + if (_silver <= 0) + return null; + + if (_silverDummyItem == null) + return null; + + // Return the dummy item with updated amount + // We lock to ensure thread safety when modifying Amount + _silverDummyItem.Amount = _silver; + return _silverDummyItem; + } + } + + /// + /// Sets the silver in storage + /// + public void SetSilver(int amount) + { + lock (_silverLock) + { + _silver = Math.Min(_silverMax, amount); + } + this.AddSilverTransaction(StorageInteraction.Store, amount); + } + + /// + /// Adds a silver transaction to this storage. + /// If number of transactions exceed the max, deletes older transactions. + /// Does not update client. + /// + /// Retrieve or Store interaction + /// Amount of silver + /// If not provided will automatically update total + /// If not provided will be set to current time + /// + public StorageResult AddSilverTransaction(StorageInteraction interaction, int silverTransacted, int silverTotal = -1, long fileTime = -1) + { + if ((silverTotal < -1) || (fileTime < -1)) + return StorageResult.InvalidOperation; + + if ((interaction != StorageInteraction.Store) && (interaction != StorageInteraction.Retrieve)) + return StorageResult.InvalidOperation; + + lock (_silverLock) + { + // Adds transaction + var transaction = new StorageSilverTransaction(); + transaction.Interaction = interaction; + transaction.SilverTransacted = silverTransacted; + transaction.SilverTotal = silverTotal != -1 ? silverTotal : _silver; + _silverTransactions.Enqueue(transaction); + + if (_silverTransactions.Count > _maxSilverTransactions) + { + _silverTransactions.Dequeue(); + } + } + + return StorageResult.Success; + } + + /// + /// Stores silver to storage. + /// Updates client for owner. + /// + /// + /// + public StorageResult StoreSilver(int amount) + { + return this.StoreSilver(this.Owner.Connection.SelectedCharacter, amount, InventoryType.TeamStorage); + } + + /// + /// Retrieves silver from storage. + /// Updates client for owner. + /// + /// + /// + public StorageResult RetrieveSilver(int amount) + { + return this.RetrieveSilver(this.Owner.Connection.SelectedCharacter, amount, InventoryType.TeamStorage); + } + + /// + /// Extends the storage by the given size. The operation may fail if + /// the owner does not have enough TP. + /// + /// + /// + public StorageResult TryExtendStorage(int addSize) + { + var account = this.Owner.Connection.Account; + var character = this.Owner.Connection.SelectedCharacter; + + // Check if player has reached the maximum number of silver expansions + var currentExtensions = (int)account.Properties.GetFloat(PropertyName.AccountWareHouseExtend, 0); + var maxSilverExpands = ZoneServer.Instance.Conf.World.TeamStorageMaxSilverExpands; + + if (currentExtensions >= maxSilverExpands) + { + character.ServerMessage(Localization.Get("You have reached the maximum number of silver expansions ({0}). Use other methods to expand further."), maxSilverExpands); + return StorageResult.InvalidOperation; + } + + var curSize = this.GetStorageSize(); + var newSize = curSize + addSize; + + var extCost = this.GetExtensionCost(newSize); + + if (!character.HasSilver(extCost, silently: false)) + return StorageResult.InvalidOperation; + + if (curSize >= ZoneServer.Instance.Conf.World.TeamStorageMaxSize) + return StorageResult.InvalidOperation; + + character.Inventory.Remove(ItemId.Silver, extCost, InventoryItemRemoveMsg.Given); + this.ModifySize(addSize); + account.Properties.Modify(PropertyName.AccountWareHouseExtend, addSize); + + // Send updated properties to client + Send.ZC_NORMAL.AccountProperties(character, PropertyName.AccountWareHouseExtend, PropertyName.BasicAccountWarehouseSlotCount); + + Send.ZC_ADDON_MSG(character, "ACCOUNT_WAREHOUSE_ITEM_LIST", 0, null); + Send.ZC_ADDON_MSG(character, "ACCOUNT_UPDATE", 0, null); + + return StorageResult.Success; + } + + /// + /// Returns the cost of extending the storage to the given size. + /// + /// + /// + private int GetExtensionCost(int newSize) + { + var account = this.Owner.Connection.Account; + var baseCost = ZoneServer.Instance.Conf.World.TeamStorageExtCost; + + // Get the number of extensions already purchased + var currentExtensions = (int)account.Properties.GetFloat(PropertyName.AccountWareHouseExtend, 0); + + // Cost doubles with each expansion + // 1st expansion (currentExtensions = 0): baseCost * 2^0 = 200k + // 2nd expansion (currentExtensions = 1): baseCost * 2^1 = 400k + // 3rd expansion (currentExtensions = 2): baseCost * 2^2 = 800k + // 4th expansion (currentExtensions = 3): baseCost * 2^3 = 1.6m + // 5th+ expansion: capped at 2m + var cost = baseCost * (int)Math.Pow(2, currentExtensions); + + return Math.Min(cost, 2000000); + } + + /// + /// Adds an amount of silver to storage. + /// Updates client. + /// + /// Character that is performing interaction + /// Amount of silver to store + /// Storage inventory type + /// + private StorageResult StoreSilver(Character character, int amount, InventoryType invType) + { + if (amount <= 0) + return StorageResult.InvalidOperation; + + var inventory = character.Inventory; + int actualAmount; + Item silverItem; + + lock (_silverLock) + { + // Transaction limit + actualAmount = Math.Min(inventory.CountItem(ItemId.Silver), amount); + actualAmount = Math.Min(_silverMax - _silver, actualAmount); + + // Storing + inventory.Remove(ItemId.Silver, actualAmount, InventoryItemRemoveMsg.Given); + _silver += actualAmount; + + // Use the dummy item for packet (it has the correct ObjectId) + // We just need to ensure its Amount is updated + _silverDummyItem.Amount = _silver; + silverItem = _silverDummyItem; + } + + // This packet updates how much silver client knows there is in storage, + // even if it is not visible in UI + Send.ZC_ITEM_ADD(character, silverItem, 0, actualAmount, InventoryAddType.New, invType); + + // Updates transaction list + this.AddSilverTransaction(StorageInteraction.Store, actualAmount); + Send.ZC_NORMAL.AccountProperties(character); + Send.ZC_NORMAL.StorageSilverTransaction(character, this.GetSilverTransactions(), false); + + return StorageResult.Success; + } + + /// + /// Removes an amount of silver from storage. + /// Updates client. Thread-safe. + /// + /// Character that is performing interaction + /// Amount of silver to retrieve + /// Storage inventory type + /// + private StorageResult RetrieveSilver(Character character, int amount, InventoryType invType) + { + if (amount <= 0) + return StorageResult.InvalidOperation; + + var inventory = character.Inventory; + int actualAmount; + long silverObjectId; + + lock (_silverLock) + { + if (_silver <= 0) + return StorageResult.InvalidOperation; + + // Transaction limit + actualAmount = Math.Min(_silver, amount); + actualAmount = Math.Min(_silverMax, actualAmount); + + // Retrieving + inventory.Add(ItemId.Silver, actualAmount, InventoryAddType.New); + _silver -= actualAmount; + + // Get the object ID for the packet + silverObjectId = _silverDummyItem.ObjectId; + } + + // This packet updates how much silver client knows there is in storage, + // even if it is not visible in UI + Send.ZC_ITEM_REMOVE(character, silverObjectId, actualAmount, InventoryItemRemoveMsg.Given, invType); + + // Updates transaction list + this.AddSilverTransaction(StorageInteraction.Retrieve, actualAmount); + Send.ZC_NORMAL.AccountProperties(character); + Send.ZC_NORMAL.StorageSilverTransaction(character, this.GetSilverTransactions(), false); + + return StorageResult.Success; + } + + /// + /// Gets items in this storage. + /// Client expects silver to be in item list of team storage. + /// + /// + public override Dictionary GetItems() + { + var items = new Dictionary(); + + // Get normal items at their actual positions (0 to size-1) + var normalItems = base.GetItems(); + foreach (var kvp in normalItems) + { + items[kvp.Key] = kvp.Value; + } + + // Add silver item AFTER the last storage position so it never + // conflicts with regular items. Silver does NOT count towards + // storage capacity. + var silverItem = this.GetSilver(); + if (silverItem != null) + { + var storageSize = this.GetStorageSize(); + items[storageSize] = silverItem; + } + + return items; + } + + /// + /// Initializes the size of the storage. + /// + public virtual void InitSize() + { + // My hope was that we would be able to adjust the size of the + // storage dynamically, so we could have arbitrary storages of + // various sizes that we can access through the personal storage + // system. You might have a guid storage, or chests, etc. However, + // it seems like the client is not a big fan of trying to resize + // the storage up and down on the fly. It's inherently designed + // for extension only, and in my attempts to force it to shrink + // the storage, I experienced some odd behavior, such as the + // client locking up, trying to connect to barrack servers that + // don't exist, and refusing to launch afterwards. As such, I'm + // going to put a pin in this for now and we'll live with all + // storages, including custom ones, having the same size. + // I recommend not messing with the sizing too much unless you + // want to try to get this working. + // -- exec + + this.SetStorageSize(this.GetSavedSize()); + } + + /// + /// Returns the saved size of the storage. + /// + /// + protected virtual int GetSavedSize() + { + var account = this.Owner.Connection.Account; + var defaultSize = ZoneServer.Instance.Conf.World.TeamStorageDefaultSize; + + // NOTE: The client's GetAccountWarehouseSlotCount() function adds +1 to the slot count + // it displays (formula: BasicAccountWarehouseSlotCount + AccountWareHouseExtend + 1). + // To make the client display the correct number of slots as configured, we need to + // subtract 1 from the default size when setting BasicAccountWarehouseSlotCount. + // + // Example with team_storage_default_size = 5: + // Server internal storage: 5 slots (can store 5 items) + // BasicAccountWarehouseSlotCount: 4 (what we tell the client) + // Client displays: 4 + 0 + 1 = 5 slots ✓ Correct! + + // The client property needs to be defaultSize - 1 + var clientBaseSlots = Math.Max(0, defaultSize - 1); + + // Get the current BasicAccountWarehouseSlotCount property + var currentClientSlots = (int)account.Properties.GetFloat(PropertyName.BasicAccountWarehouseSlotCount, 0); + + // If BasicAccountWarehouseSlotCount hasn't been set yet, initialize it + if (currentClientSlots == 0) + { + currentClientSlots = clientBaseSlots; + account.Properties.SetFloat(PropertyName.BasicAccountWarehouseSlotCount, clientBaseSlots); + } + + // Get the number of extensions purchased + var extensions = (int)account.Properties.GetFloat(PropertyName.AccountWareHouseExtend, 0); + + // Server's actual storage size = what the client WOULD show (before the +1) + // This gives us the correct number of usable slots + var totalSize = currentClientSlots + extensions + 1; + + // Upgrade base slots if config default has increased + if (currentClientSlots < clientBaseSlots) + { + account.Properties.SetFloat(PropertyName.BasicAccountWarehouseSlotCount, clientBaseSlots); + totalSize = clientBaseSlots + extensions + 1; + } + + return totalSize; + } + } +} diff --git a/system/conf/web.conf b/system/conf/web.conf index 2413b5815..e8f655889 100644 --- a/system/conf/web.conf +++ b/system/conf/web.conf @@ -21,4 +21,7 @@ enable_api_account_creation: yes cgi_processor_php: PHP; .php; user/tools/php/php-cgi.exe +// Port for the guild/team storage web server. +guild_port: 9004 + include "/user/conf/web.conf" diff --git a/system/conf/world/storage.conf b/system/conf/world/storage.conf index 3e7adf2cc..d83e8e05d 100644 --- a/system/conf/world/storage.conf +++ b/system/conf/world/storage.conf @@ -24,4 +24,30 @@ storage_max_size: 110 // Non-stackable items (i.e. equipment) are not affected. storage_multi_stack: yes +// Amount of silver it costs to store or retrieve an item from team storage. +team_storage_fee: 0 + +// Base amount of silver it costs to extend the team storage. +// Each time extension is bought, this price doubles. +team_storage_ext_cost: 200000 + +// Default size of team storage. +team_storage_default_size: 5 + +// Maximum number of expansions that can be purchased with silver. +// The official default is 9 expansions (5 base + 9 silver = 14 total). +// After this limit, players would need to use other methods (Token, Collection, etc.) +// to expand further, up to team_storage_max_size. +// Note: Increasing this value past 9 will NOT allow more slot purchases unless +// you also change it in the client! +team_storage_max_silver_expands: 9 + +// Maximum size to which the storage can be extended. The official +// default is 62 (5 slots + Silver 9 slots + Token 30 slots + Collection 11 slots + Adventure Journal 7 slots) +// but the team storage UI can actually accept up to 70 without needing client modifications. +team_storage_max_size: 62 + +// Minimum level at which team storage can be accessed. +team_storage_min_level_req: 15 + include "/user/conf/world/storage.conf" \ No newline at end of file