From 2e1ac6e323ec24b787423d732073bb87ac4cef53 Mon Sep 17 00:00:00 2001 From: sruon Date: Wed, 10 Jun 2026 10:22:01 -0600 Subject: [PATCH] Lua-driven Guild Shops --- scripts/data/guild_shops.lua | 66 ++++ scripts/enum/guild_price_floor.lua | 11 + scripts/enum/item.lua | 16 + scripts/globals/guild_shops.lua | 339 ++++++++++++++++++ scripts/specs/core/CBaseEntity.lua | 18 + .../specs/test/CClientEntityPairActions.lua | 31 ++ scripts/tests/systems/guild_shops/buying.lua | 109 ++++++ .../tests/systems/guild_shops/daily_roll.lua | 89 +++++ scripts/tests/systems/guild_shops/hours.lua | 49 +++ .../systems/guild_shops/price_curves.lua | 181 ++++++++++ scripts/tests/systems/guild_shops/selling.lua | 106 ++++++ .../Carpenters_Landing/npcs/Beugungel.lua | 2 +- scripts/zones/Mhaura/npcs/Kamilah.lua | 2 +- sql/guild_shops.sql | 30 -- sql/item_basic.sql | 6 +- src/map/entities/charentity.h | 1 + src/map/lua/lua_baseentity.cpp | 86 ++++- src/map/lua/lua_baseentity.h | 3 + src/map/lua/sol_bindings.cpp | 1 + src/map/lua/sol_bindings.h | 10 + src/map/packets/c2s/0x0aa_guild_buy.cpp | 48 ++- src/map/packets/c2s/0x0ab_guild_buylist.cpp | 43 ++- src/map/packets/c2s/0x0ac_guild_sell.cpp | 49 ++- src/map/packets/c2s/0x0ad_guild_selllist.cpp | 43 ++- src/map/packets/s2c/0x083_guild_buylist.cpp | 36 ++ src/map/packets/s2c/0x083_guild_buylist.h | 3 + src/map/packets/s2c/0x085_guild_selllist.cpp | 36 ++ src/map/packets/s2c/0x085_guild_selllist.h | 3 + src/map/time_server.cpp | 8 + .../lua_client_entity_pair_actions.cpp | 75 ++++ .../helpers/lua_client_entity_pair_actions.h | 5 + .../lua_client_entity_pair_packets.cpp | 35 ++ .../helpers/lua_client_entity_pair_packets.h | 1 + 33 files changed, 1490 insertions(+), 51 deletions(-) create mode 100644 scripts/data/guild_shops.lua create mode 100644 scripts/enum/guild_price_floor.lua create mode 100644 scripts/globals/guild_shops.lua create mode 100644 scripts/tests/systems/guild_shops/buying.lua create mode 100644 scripts/tests/systems/guild_shops/daily_roll.lua create mode 100644 scripts/tests/systems/guild_shops/hours.lua create mode 100644 scripts/tests/systems/guild_shops/price_curves.lua create mode 100644 scripts/tests/systems/guild_shops/selling.lua diff --git a/scripts/data/guild_shops.lua b/scripts/data/guild_shops.lua new file mode 100644 index 00000000000..59951c95404 --- /dev/null +++ b/scripts/data/guild_shops.lua @@ -0,0 +1,66 @@ +----------------------------------- +-- Guild shop definitions +----------------------------------- +xi = xi or {} +xi.data = xi.data or {} + +---@class GuildShopItem +---@field id xi.item +---@field initial integer -- stock on server restart +---@field maxStock integer -- hard ceiling the shop can hold +---@field targetStock integer -- stock each open settles to (restock fills up to it, overstock trims down) +---@field buyMax integer -- buy price when the shelf is empty (top of the buy curve) +---@field restockRate integer -- items restocked per day, up to targetStock +---@field hidden? boolean -- sell-list row hidden from the client + +---@class GuildShop +---@field hours integer[] -- { openHour, closeHour } +---@field priceFloor xi.guildPriceFloor -- buy-curve floor rule +---@field stock GuildShopItem[] + +---@type table +xi.data.guildShops = +{ + ['Beugungel'] = + { + hours = { 5, 22 }, + priceFloor = xi.guildPriceFloor.TARGET_STOCK, + stock = + { + { id = xi.item.SPOOL_OF_BUNDLING_TWINE, initial = 180, maxStock = 240, targetStock = 180, buyMax = 500, restockRate = 60 }, + { id = xi.item.HATCHET, initial = 180, maxStock = 200, targetStock = 180, buyMax = 2500, restockRate = 60 }, + { id = xi.item.ARROWWOOD_LOG, initial = 180, maxStock = 200, targetStock = 180, buyMax = 100, restockRate = 60 }, + { id = xi.item.ASH_LOG, initial = 180, maxStock = 200, targetStock = 180, buyMax = 480, restockRate = 60 }, + { id = xi.item.YEW_LOG, initial = 150, maxStock = 200, targetStock = 150, buyMax = 2200, restockRate = 50 }, + { id = xi.item.WILLOW_LOG, initial = 150, maxStock = 200, targetStock = 150, buyMax = 800, restockRate = 50 }, + { id = xi.item.WALNUT_LOG, initial = 180, maxStock = 240, targetStock = 180, buyMax = 4270, restockRate = 20 }, + }, + }, + ['Kamilah'] = + { + hours = { 8, 23 }, + priceFloor = xi.guildPriceFloor.THREE_QUARTER_MAX, + stock = + { + { id = xi.item.CHUNK_OF_TIN_ORE, initial = 110, maxStock = 240, targetStock = 110, buyMax = 200, restockRate = 20 }, + { id = xi.item.CHUNK_OF_IRON_ORE, initial = 110, maxStock = 240, targetStock = 110, buyMax = 4500, restockRate = 10 }, + { id = xi.item.BRONZE_INGOT, initial = 0, maxStock = 120, targetStock = 100, buyMax = 380, restockRate = 0 }, -- targetStock assumed + { id = xi.item.IRON_INGOT, initial = 0, maxStock = 120, targetStock = 100, buyMax = 18000, restockRate = 0 }, -- targetStock assumed + { id = xi.item.STEEL_INGOT, initial = 90, maxStock = 120, targetStock = 100, buyMax = 26250, restockRate = 10 }, + { id = xi.item.BRONZE_SHEET, initial = 36, maxStock = 120, targetStock = 100, buyMax = 460, restockRate = 2 }, + { id = xi.item.IRON_SHEET, initial = 0, maxStock = 120, targetStock = 100, buyMax = 27000, restockRate = 0 }, + { id = xi.item.HANDFUL_OF_BRONZE_SCALES, initial = 0, maxStock = 60, targetStock = 50, buyMax = 540, restockRate = 0 }, + { id = xi.item.HANDFUL_OF_IRON_SCALES, initial = 0, maxStock = 60, targetStock = 50, buyMax = 31500, restockRate = 0 }, + { id = xi.item.IRON_CHAIN, initial = 0, maxStock = 60, targetStock = 45, buyMax = 31500, restockRate = 0 }, -- targetStock assumed + { id = xi.item.CHAINMAIL, initial = 0, maxStock = 60, targetStock = 45, buyMax = 79200, restockRate = 0 }, + { id = xi.item.SCALE_MAIL, initial = 0, maxStock = 60, targetStock = 45, buyMax = 11150, restockRate = 0 }, -- targetStock assumed + { id = xi.item.PADDED_ARMOR, initial = 0, maxStock = 60, targetStock = 45, buyMax = 157440, restockRate = 0 }, -- targetStock assumed + { id = xi.item.GREAVES, initial = 0, maxStock = 60, targetStock = 45, buyMax = 38700, restockRate = 0 }, + { id = xi.item.SCALE_GREAVES, initial = 0, maxStock = 60, targetStock = 45, buyMax = 5420, restockRate = 0 }, + { id = xi.item.LEGGINGS, initial = 0, maxStock = 60, targetStock = 45, buyMax = 78720, restockRate = 0 }, + { id = xi.item.CHAIN_MITTENS, initial = 0, maxStock = 60, targetStock = 45, buyMax = 42300, restockRate = 0 }, + { id = xi.item.SCALE_FINGER_GAUNTLETS, initial = 0, maxStock = 60, targetStock = 45, buyMax = 5950, restockRate = 0 }, -- targetStock assumed + { id = xi.item.IRON_MITTENS, initial = 0, maxStock = 60, targetStock = 45, buyMax = 86400, restockRate = 0 }, + }, + }, +} diff --git a/scripts/enum/guild_price_floor.lua b/scripts/enum/guild_price_floor.lua new file mode 100644 index 00000000000..3c7af30336b --- /dev/null +++ b/scripts/enum/guild_price_floor.lua @@ -0,0 +1,11 @@ +----------------------------------- +-- Guild Shop Price Floor +----------------------------------- +xi = xi or {} + +---@enum xi.guildPriceFloor +xi.guildPriceFloor = +{ + THREE_QUARTER_MAX = 0, -- buy-price floor at 3/4 * maxStock + TARGET_STOCK = 1, -- buy-price floor at targetStock +} diff --git a/scripts/enum/item.lua b/scripts/enum/item.lua index 7b3a4eb29b1..2ab6ac39936 100644 --- a/scripts/enum/item.lua +++ b/scripts/enum/item.lua @@ -291,8 +291,13 @@ xi.item = STEEL_SHEET = 666, ORICHALCUM_SHEET = 668, ALUMINUM_SHEET = 670, + HANDFUL_OF_BRONZE_SCALES = 672, + HANDFUL_OF_BRASS_SCALES = 673, + HANDFUL_OF_IRON_SCALES = 674, CHUNK_OF_ALUMINUM_ORE = 678, ALUMINUM_INGOT = 679, + IRON_CHAIN = 680, + MYTHRIL_CHAIN = 681, ADAMAN_CHAIN = 683, CHUNK_OF_KHROMA_ORE = 685, IMPERIAL_WOOTZ_INGOT = 686, @@ -359,6 +364,7 @@ xi.item = CHUNK_OF_DURIUM_ORE = 756, SILVER_CHAIN = 760, GOLD_CHAIN = 761, + PLATINUM_CHAIN = 762, ORICHALCUM_CHAIN = 763, ORMOLU_INGOT = 766, FLINT_STONE = 768, @@ -1046,6 +1052,7 @@ xi.item = ETHEREAL_FRAGMENT = 1585, TITANICTUS_SHELL = 1586, HIGH_QUALITY_PUGIL_SCALES = 1587, + SLAB_OF_TUFA = 1588, SHARD_OF_NECROPSYCHE = 1589, SPRIG_OF_HOLY_BASIL = 1590, HIGH_QUALITY_COEURL_HIDE = 1591, @@ -5422,6 +5429,7 @@ xi.item = FUNGUS_HAT = 12485, EMPEROR_HAIRPIN = 12486, GOLD_HAIRPIN = 12494, + SILVER_HAIRPIN = 12495, COPPER_HAIRPIN = 12496, BRASS_HAIRPIN = 12497, COTTON_HEADBAND = 12498, @@ -5905,7 +5913,10 @@ xi.item = SHELL_EARRING = 13313, GOLD_EARRING = 13315, PLATINUM_EARRING = 13316, + PEARL_EARRING = 13317, TOPAZ_EARRING = 13318, + PERIDOT_EARRING = 13319, + BLACK_EARRING = 13320, BONE_EARRING = 13321, WING_EARRING = 13322, BEETLE_EARRING = 13323, @@ -5922,6 +5933,10 @@ xi.item = AMBER_EARRING = 13335, ONYX_EARRING = 13336, OPAL_EARRING = 13337, + BLOOD_EARRING = 13338, + GOSHENITE_EARRING = 13339, + AMETRINE_EARRING = 13340, + TURQUOISE_EARRING = 13341, SPHENE_EARRING = 13342, GREEN_EARRING = 13343, SUN_EARRING = 13344, @@ -6209,6 +6224,7 @@ xi.item = LIGHT_GAUNTLETS = 13977, SILVER_BANGLES = 13979, TURTLE_BANGLES = 13981, + GOLD_BANGLES = 13983, IRON_FINGER_GAUNTLETS = 14001, IRON_FINGER_GAUNTLETS_P1 = 14002, STEEL_FINGER_GAUNTLETS = 14003, diff --git a/scripts/globals/guild_shops.lua b/scripts/globals/guild_shops.lua new file mode 100644 index 00000000000..759f495caf6 --- /dev/null +++ b/scripts/globals/guild_shops.lua @@ -0,0 +1,339 @@ +----------------------------------- +-- Guild Shops +----------------------------------- + +xi = xi or {} +xi.guildShops = xi.guildShops or {} +xi.guildShops.state = xi.guildShops.state or {} -- In-memory shop state, keyed by NPC name. + +--- Buy-curve divisor for an item. +local priceFloorOf = function(shop, cfg) + local floorRatio = 3 / 4 -- By default the price floor occurs at 3 / 4 of max stock + if shop.priceFloor == xi.guildPriceFloor.TARGET_STOCK then + -- Some NPCs use target stock instead + return cfg.targetStock + end + + return cfg.maxStock * floorRatio +end + +--- Calculate buy price of an item at open +--- Two-segment discount curve. +--- - Below the knee (2/3*priceFloor) it ramps 0..80% off; +--- - Above it, 80%..90% off at maxStock. +--- The discount is floored before applying: 1/125 below the knee, 1/1000 above. +local calcBuyPrice = function(buyMax, priceFloor, maxStock, stock) + local kneeRatio = 2 / 3 + if priceFloor <= 0 then + return buyMax + end + + local knee = kneeRatio * priceFloor + if stock <= knee then + return math.floor(buyMax * (125 - math.floor(150 * stock / priceFloor)) / 125) + end + + return math.floor(buyMax * (200 - math.floor(100 * (stock - knee) / (maxStock - knee))) / 1000) +end + +---Calculate sell price of an item at open +---1.5 * base when empty, down to base when full (base = regular NPC sell price). +local calcSellPrice = function(base, maxStock, stock) + if maxStock <= 0 then + return math.floor(base * 3 / 2) + end + + local index = math.floor(200 * stock / maxStock) + return math.floor(base * (600 - index) / 400) +end + +local getShopState = function(name) + local state = xi.guildShops.state[name] + if state == nil then + state = + { + lastRoll = -1, -- Vanaday the snapshot was locked (-1 = uninitialized) + items = {}, -- [itemId] = { stock, buyPrice, sellPrice, offered } + } + + xi.guildShops.state[name] = state + end + + return state +end + +-- The stock config for an item, or nil if the shop does not carry it. +local shopConfig = function(shop, itemId) + for _, cfg in ipairs(shop.stock) do + if cfg.id == itemId then + return cfg + end + end +end + +local shopFor = function(npc) + return xi.data.guildShops[npc:getName()] +end + +---A rejected result: zeroed itemNo/count with a Trade reason code. +local rejected = function(trade) + return { itemNo = 0, count = 0, trade = trade } +end + +---Rolls the shop to the current day: restock/trim each item to targetStock, lock prices. +---Mutates only once per Vanaday +local rollShopDay = function(npc, shop) + local state = getShopState(npc:getName()) + local today = VanadielUniqueDay() + if state.lastRoll == today then + return state + end + + local firstRoll = state.lastRoll < 0 + local days = firstRoll and 0 or (today - state.lastRoll) + + for _, cfg in ipairs(shop.stock) do + local prev = state.items[cfg.id] + local stock = prev and prev.stock or cfg.initial + + if not firstRoll and cfg.restockRate > 0 and stock < cfg.targetStock then + stock = math.min(cfg.targetStock, stock + cfg.restockRate * days) + end + + -- Sales can pile stock up to maxStock during the day, but every open trims it back to targetStock. + stock = math.min(stock, cfg.targetStock) + + state.items[cfg.id] = + { + stock = stock, + buyPrice = calcBuyPrice(cfg.buyMax, priceFloorOf(shop, cfg), cfg.maxStock, stock), + sellPrice = calcSellPrice(GetReadOnlyItem(cfg.id):getBasePrice(), cfg.maxStock, stock), + offered = stock > 0, -- locked: 0 at open => not sold today + } + end + + state.lastRoll = today + + return state +end + +local guildShopIsOpen = function(npc) + local shop = shopFor(npc) + if shop == nil then + return false + end + + local hour = VanadielHour() + return hour >= shop.hours[1] and hour < shop.hours[2] +end + +---@param player CBaseEntity +---@param npc CBaseEntity +---@return boolean isOpen +xi.guildShops.onTrigger = function(player, npc) + local shop = shopFor(npc) + if shop == nil then + return false + end + + npc:facePlayer(player) + rollShopDay(npc, shop) + + return player:openGuildShop(npc, shop.hours[1], shop.hours[2]) +end + +---Process player purchase. +---@param player CBaseEntity +---@param npc CBaseEntity +---@param itemId xi.item +---@param quantity integer +---@return { itemNo: integer, count: integer, trade: integer } +xi.guildShops.onPlayerBuy = function(player, npc, itemId, quantity) + local shop = shopFor(npc) + + -- Invalid shop or the guild shop is now closed + if shop == nil or not guildShopIsOpen(npc) then + return rejected(-1) + end + + -- Invalid item + local cfg = shopConfig(shop, itemId) + if cfg == nil then + return rejected(-1) + end + + -- Get current item state + local state = rollShopDay(npc, shop) + local item = state.items[itemId] + + -- Item is not being offered today, even if someone sold some. + if not item.offered then + return rejected(-1) + end + + -- Bad quantity or no more in stock + quantity = math.min(quantity, item.stock) + if quantity <= 0 then + return rejected(-1) + end + + -- Player does not have money for purchase + local cost = item.buyPrice * quantity + if player:getGil() < cost then + return rejected(-1) + end + + -- Inventory is full + if not player:addItem(itemId, quantity) then + return rejected(-1) + end + + -- Delete player gil and adjust remaining stock + player:delGil(cost) + item.stock = item.stock - quantity + + -- Hand off result to core for packet purposes + return { itemNo = itemId, count = item.stock, trade = quantity } +end + +---Items the shop offers today. +---@param player CBaseEntity +---@param npc CBaseEntity +---@return { id: integer, count: integer, price: integer, max: integer }[] +xi.guildShops.onBuyList = function(player, npc) + local shop = shopFor(npc) + if shop == nil then + return {} + end + + local state = rollShopDay(npc, shop) + + local items = {} + for _, cfg in ipairs(shop.stock) do + local item = state.items[cfg.id] + if item.offered then + items[#items + 1] = + { + id = cfg.id, + count = item.stock, + price = item.buyPrice, + max = cfg.maxStock, + } + end + end + + return items +end + +---Process players selling items to the shop. +---@param player CBaseEntity +---@param npc CBaseEntity +---@param itemId xi.item +---@param quantity integer +---@return { itemNo: integer, count: integer, trade: integer, sold: integer, price: integer } +xi.guildShops.onPlayerSell = function(player, npc, itemId, quantity) + local shop = shopFor(npc) + + -- Invalid shop or closed + if shop == nil or not guildShopIsOpen(npc) then + return rejected(-4) + end + + -- Invalid item + local cfg = shopConfig(shop, itemId) + if cfg == nil then + return rejected(-4) + end + + -- Get current item state + local state = rollShopDay(npc, shop) + local item = state.items[itemId] + + -- Cap the purchase to the least of requested quantity or remaining stock + local want = math.min(quantity, cfg.maxStock - item.stock) + + -- Packet does NOT provide specific inventory slots, we have to iterate the player inventory ourselves + -- The sale can potentially span multiple stacks + local stacks = player:findItems(itemId, xi.inventoryLocation.INVENTORY) + local sold = 0 + for _ = 1, #stacks do + local front = player:findItems(itemId, xi.inventoryLocation.INVENTORY)[1] + local take = front and math.min(want - sold, front:getQuantity() - front:getReservedValue()) or 0 + if take <= 0 then + break + end + + player:delItem(itemId, take) + sold = sold + take + end + + if sold <= 0 then + return rejected(-4) + end + + player:addGil(item.sellPrice * sold) + item.stock = item.stock + sold + + -- Return sale status to core for packet and audit purposes. + local trade = (sold < quantity) and -1 or sold + return { itemNo = itemId, count = item.stock, trade = trade, sold = sold, price = item.sellPrice } +end + +---Items the shop buys. +---@param player CBaseEntity +---@param npc CBaseEntity +---@return { id: integer, count: integer, price: integer, max: integer }[] +xi.guildShops.onSellList = function(player, npc) + local shop = shopFor(npc) + if shop == nil then + return {} + end + + local state = rollShopDay(npc, shop) + + local items = {} + for _, cfg in ipairs(shop.stock) do + local item = state.items[cfg.id] + local price = item.sellPrice + if cfg.hidden then + -- When MSB is set in packet, the client hides the item from the initial sell menu + price = bit.bor(price, 0x80000000) + end + + items[#items + 1] = + { + id = cfg.id, + count = item.stock, + price = price, + max = cfg.maxStock, + } + end + + return items +end + +---Per-hour tick for a player with this shop open; closes at the close hour. +---@param player CBaseEntity +---@param npc CBaseEntity +xi.guildShops.onGameHour = function(player, npc) + local shop = shopFor(npc) + if shop == nil then + return + end + + if VanadielHour() == shop.hours[2] then + xi.guildShops.onShopClose(player, npc) + end +end + +---Notifies player currently browsing the shop that it closed. +---@param player CBaseEntity +---@param npc CBaseEntity +xi.guildShops.onShopClose = function(player, npc) + local shop = shopFor(npc) + if shop ~= nil then + player:sendGuildClose(shop.hours[1], shop.hours[2]) + end + + player:clearGuildShop() +end diff --git a/scripts/specs/core/CBaseEntity.lua b/scripts/specs/core/CBaseEntity.lua index 27650b798d1..8fdd598e40b 100644 --- a/scripts/specs/core/CBaseEntity.lua +++ b/scripts/specs/core/CBaseEntity.lua @@ -638,6 +638,24 @@ end function CBaseEntity:sendGuild(guildID, open, close, holiday) end +---@nodiscard +---@param npc CBaseEntity +---@param open integer +---@param close integer +---@return boolean +function CBaseEntity:openGuildShop(npc, open, close) +end + +---@return nil +function CBaseEntity:clearGuildShop() +end + +---@param open integer +---@param close integer +---@return nil +function CBaseEntity:sendGuildClose(open, close) +end + ---@return nil function CBaseEntity:openSendBox() end diff --git a/scripts/specs/test/CClientEntityPairActions.lua b/scripts/specs/test/CClientEntityPairActions.lua index bd9d359929b..b2f54ebc3b6 100644 --- a/scripts/specs/test/CClientEntityPairActions.lua +++ b/scripts/specs/test/CClientEntityPairActions.lua @@ -150,6 +150,37 @@ end function CClientEntityPairActions:skillchain(target, ...) end +---Buy an item from a guild shop +---@param itemId xi.item Item ID +---@param quantity integer Amount to buy +---@return nil +function CClientEntityPairActions:guildBuy(itemId, quantity) +end + +---Sell an item to a guild shop +---@param itemId xi.item Item ID +---@param quantity integer Amount to sell +---@return nil +function CClientEntityPairActions:guildSell(itemId, quantity) +end + +---@class GuildListEntry +---@field count integer Current stock +---@field max integer Max stock +---@field price integer Buy or sell price + +---Request a guild shop's buy list and return it decoded +---@nodiscard +---@return table list Entries keyed by item ID +function CClientEntityPairActions:guildBuyList() +end + +---Request a guild shop's sell list and return it decoded +---@nodiscard +---@return table list Entries keyed by item ID +function CClientEntityPairActions:guildSellList() +end + ---Move an item between containers or split a stack ---@param srcContainer xi.inventoryLocation Source container ---@param srcSlot integer Source slot index diff --git a/scripts/tests/systems/guild_shops/buying.lua b/scripts/tests/systems/guild_shops/buying.lua new file mode 100644 index 00000000000..54f4ea51ba7 --- /dev/null +++ b/scripts/tests/systems/guild_shops/buying.lua @@ -0,0 +1,109 @@ +describe('Guild shop buying', function() + ---@type CClientEntityPair + local player + + before_each(function() + player = xi.test.world:spawnPlayer({ zone = xi.zone.MHAURA }) + end) + + local offered = xi.item.CHUNK_OF_TIN_ORE -- initial 110 => offered at open + local notOffered = xi.item.CHAINMAIL -- initial 0 => only bought, never offered + + local function open(hour) + xi.test.world:setVanaTime(hour or 8, 0) + player.entities:gotoAndTrigger('Kamilah') + end + + local function buy(itemId, quantity) + player.packets:clear() + player.actions:guildBuy(itemId, quantity) + for _, pkt in pairs(player.packets:getIncoming()) do + if pkt.type == 0x082 then + return { itemNo = pkt.data[4] + pkt.data[5] * 256, count = pkt.data[6], trade = pkt.data[7] } + end + end + end + + local function offeredStock(itemId) + local entry = player.actions:guildBuyList()[itemId] + return entry and entry.count + end + + it('grants the item, charges gil, and decrements the listed stock', function() + open() + player:setGil(1000000) + local stock = offeredStock(offered) + local gil = player:getGil() + + local reply = buy(offered, 1) + + assert(reply.trade == 1, 'buy not accepted: trade ' .. tostring(reply.trade)) + assert(player:hasItem(offered), 'item not granted') + assert(player:getGil() < gil, 'gil not deducted') + assert(reply.count == stock - 1, 'reply stock not -1') + assert(offeredStock(offered) == stock - 1, 'list stock not -1') + end) + + it('rejects a buy over the stack size', function() + open() + player:setGil(10000000) + + local reply = buy(offered, 99) -- tin ore stacks to 12 + + assert(reply.trade == 0xFF, 'over-stack not rejected: trade ' .. tostring(reply.trade)) + assert(not player:hasItem(offered), 'item granted on reject') + end) + + it('rejects an item the shop does not offer', function() + open() + player:setGil(1000000) + local gil = player:getGil() + + local reply = buy(notOffered, 1) + + assert(reply.itemNo == 0 and reply.trade == 0xFF, 'unoffered item not rejected') + assert(player:getGil() == gil, 'gil changed on reject') + assert(not player:hasItem(notOffered), 'item granted on reject') + end) + + it('rejects buying while the shop is closed', function() + open(7) -- before opening hours + player:setGil(1000000) + local gil = player:getGil() + + local reply = buy(offered, 1) + + assert(reply.trade == 0xFF, 'bought while closed') + assert(player:getGil() == gil, 'gil changed while closed') + assert(not player:hasItem(offered), 'item granted while closed') + end) + + it('charges the same locked price across the day', function() + open() + player:setGil(1000000) + + local g1 = player:getGil() + buy(offered, 1) + local delta1 = g1 - player:getGil() + + xi.test.world:skipTime(1) + + local g2 = player:getGil() + buy(offered, 1) + local delta2 = g2 - player:getGil() + + assert(delta1 > 0 and delta1 == delta2, string.format('buy price not locked: delta1=%d delta2=%d', delta1, delta2)) + end) + + it('rejects a buy the player cannot afford', function() + open() + local stock = offeredStock(offered) + player:setGil(0) + + local reply = buy(offered, 1) + + assert(reply.trade == 0xFF, 'bought without gil') + assert(not player:hasItem(offered), 'item granted without gil') + assert(offeredStock(offered) == stock, 'stock changed on reject') + end) +end) diff --git a/scripts/tests/systems/guild_shops/daily_roll.lua b/scripts/tests/systems/guild_shops/daily_roll.lua new file mode 100644 index 00000000000..7c81ed7802e --- /dev/null +++ b/scripts/tests/systems/guild_shops/daily_roll.lua @@ -0,0 +1,89 @@ +describe('Guild shop daily roll', function() + ---@type CClientEntityPair + local player + + before_each(function() + player = xi.test.world:spawnPlayer({ zone = xi.zone.MHAURA }) + end) + + local offered = xi.item.CHUNK_OF_TIN_ORE + + local function open(hour) + xi.test.world:setVanaTime(hour or 8, 0) + player.entities:gotoAndTrigger('Kamilah') + end + + local function nextDayOpen() + xi.test.world:skipToNextVanaDay() + open() + end + + local function cfgOf(itemId) + for _, cfg in ipairs(xi.data.guildShops['Kamilah'].stock) do + if cfg.id == itemId then + return cfg + end + end + end + + local function buyEntry(itemId) + return player.actions:guildBuyList()[itemId] + end + + local function sellEntry(itemId) + return player.actions:guildSellList()[itemId] + end + + local function seedStock(itemId, stock) + xi.guildShops.state['Kamilah'].items[itemId].stock = stock + end + + it('keeps the locked prices steady as stock moves through the day', function() + open() + player:setGil(1000000) + + local buyBefore = buyEntry(offered).price + local sellBefore = sellEntry(offered).price + + player.actions:guildBuy(offered, 2) -- drains stock mid-day + + assert(buyEntry(offered).price == buyBefore, 'buy price changed mid-day') + assert(sellEntry(offered).price == sellBefore, 'sell price changed mid-day') + end) + + it('recomputes prices on the next day', function() + open() + local before = sellEntry(offered).price + + seedStock(offered, 50) -- below targetStock: next open restocks and reprices + nextDayOpen() + + assert(sellEntry(offered).price ~= before, 'price not recomputed next day') + end) + + it('restocks toward targetStock', function() + open() + local cfg = cfgOf(offered) + + seedStock(offered, cfg.targetStock - cfg.restockRate * 2) -- two days below targetStock + + nextDayOpen() + assert(sellEntry(offered).count == cfg.targetStock - cfg.restockRate, 'day 1 restock wrong') + + nextDayOpen() + assert(sellEntry(offered).count == cfg.targetStock, 'day 2 not at targetStock') + + nextDayOpen() + assert(sellEntry(offered).count == cfg.targetStock, 'restock overshot targetStock') + end) + + it('trims overstock back to targetStock', function() + open() + local cfg = cfgOf(offered) + + seedStock(offered, cfg.maxStock) -- sales can pile stock up to maxStock during the day + + nextDayOpen() + assert(sellEntry(offered).count == cfg.targetStock, 'overstock not trimmed') + end) +end) diff --git a/scripts/tests/systems/guild_shops/hours.lua b/scripts/tests/systems/guild_shops/hours.lua new file mode 100644 index 00000000000..54bcb308222 --- /dev/null +++ b/scripts/tests/systems/guild_shops/hours.lua @@ -0,0 +1,49 @@ +describe('Guild shop hours', function() + ---@type CClientEntityPair + local player + + before_each(function() + player = xi.test.world:spawnPlayer({ zone = xi.zone.MHAURA }) + end) + + local statOpen = 0 + local statClose = 1 + + local function guildStat() + local stat + for _, pkt in pairs(player.packets:getIncoming()) do + if pkt.type == 0x086 then + stat = pkt.data[4] + end + end + + return stat + end + + local function openAt(hour) + xi.test.world:setVanaTime(hour, 0) + player.packets:clear() + player.entities:gotoAndTrigger('Kamilah') + end + + it('reports closed before opening hours', function() + openAt(7) + assert(guildStat() == statClose, 'not closed before hours') + end) + + it('reports open during shop hours', function() + openAt(8) + assert(guildStat() == statOpen, 'not open during hours') + end) + + it('closes at the close hour', function() + openAt(22) + player.packets:clear() + + xi.test.world:tick(xi.tick.TIME) + xi.test.world:setVanaTime(23, 30) + xi.test.world:tick(xi.tick.TIME) + + assert(guildStat() == statClose, 'not closed at close hour') + end) +end) diff --git a/scripts/tests/systems/guild_shops/price_curves.lua b/scripts/tests/systems/guild_shops/price_curves.lua new file mode 100644 index 00000000000..7ebeeb5c1e1 --- /dev/null +++ b/scripts/tests/systems/guild_shops/price_curves.lua @@ -0,0 +1,181 @@ +describe('Guild shop price curves', function() + local function priceAt(player, name, itemId, stock, side) + local state = xi.guildShops.state[name] + state.items[itemId].stock = stock + state.lastRoll = -1 -- the next roll locks the price from the seeded stock, with no restock + + local list = side == 'sell' and player.actions:guildSellList() or player.actions:guildBuyList() + local entry = list[itemId] + return entry and entry.price + end + + local function checkCurve(player, name, curve) + for _, point in ipairs(curve.points) do + local stock, retail = point[1], point[2] + local got = priceAt(player, name, curve.item, stock, curve.side) + + assert(got == retail, + string.format('%s @ stock %d: got %s, retail %d', + curve.label, stock, tostring(got), retail)) + end + end + + describe('Kamilah', function() + ---@type CClientEntityPair + local player + before_each(function() + player = xi.test.world:spawnPlayer({ zone = xi.zone.MHAURA }) + player.entities:gotoAndTrigger('Kamilah') + end) + + local curves = + { + { + side = 'buy', + label = 'Steel Ingot', + item = xi.item.STEEL_INGOT, + points = + { + { 10, 22890 }, + { 20, 19320 }, + { 30, 15750 }, + { 40, 12390 }, + { 50, 8820 }, + { 60, 5250 }, + { 70, 4830 }, + { 80, 4383 }, + { 85, 4173 }, + { 90, 3937 }, + { 95, 3727 }, + { 100, 3517 }, + }, + }, + { + side = 'buy', + label = 'Bronze Sheet', + item = xi.item.BRONZE_SHEET, + points = + { + { 2, 448 }, + { 6, 423 }, + { 12, 386 }, + { 20, 338 }, + { 30, 276 }, + { 36, 239 }, + { 49, 161 }, + { 60, 92 }, + { 66, 87 }, + { 68, 86 }, + }, + }, + { + side = 'buy', + label = 'Iron Sheet', + item = xi.item.IRON_SHEET, + points = + { + { 12, 22680 }, { 100, 3618 }, + }, + }, + { + side = 'sell', + label = 'Iron Sheet', + item = xi.item.IRON_SHEET, + points = + { + { 0, 1350 }, { 9, 1316 }, { 10, 1314 }, { 11, 1309 }, { 12, 1305 }, + }, + }, + { + side = 'sell', + label = 'Steel Ingot', + item = xi.item.STEEL_INGOT, + points = + { + { 10, 1095 }, + { 20, 1063 }, + { 30, 1031 }, + { 40, 1001 }, + { 50, 969 }, + { 60, 937 }, + { 70, 907 }, + { 80, 875 }, + { 90, 843 }, + { 100, 813 }, + }, + }, + { + side = 'sell', + label = 'Bronze Sheet', + item = xi.item.BRONZE_SHEET, + points = + { + { 2, 34 }, + { 6, 33 }, + { 16, 33 }, + { 18, 32 }, + { 28, 31 }, + { 36, 31 }, + { 49, 29 }, + { 60, 28 }, + { 68, 28 }, + { 72, 27 }, + }, + }, + } + + for _, curve in ipairs(curves) do + it(curve.side .. ' -- ' .. curve.label, function() + checkCurve(player, 'Kamilah', curve) + end) + end + end) + + describe('Beugungel', function() + ---@type CClientEntityPair + local player + before_each(function() + player = xi.test.world:spawnPlayer({ zone = xi.zone.CARPENTERS_LANDING }) + player.entities:gotoAndTrigger('Beugungel') + end) + + local curves = + { + { + side = 'buy', + label = 'Walnut Log', + item = xi.item.WALNUT_LOG, + points = + { + { 20, 3723 }, + { 40, 3142 }, + { 60, 2562 }, + { 80, 2015 }, + { 100, 1434 }, + { 120, 854 }, + { 160, 713 }, + { 180, 640 }, + }, + }, + { + side = 'buy', + label = 'Hatchet', + item = xi.item.HATCHET, + points = + { + { 60, 1500 }, + { 100, 840 }, + { 120, 500 }, + { 160, 375 }, + { 180, 312 }, + }, + }, + } + + for _, curve in ipairs(curves) do + it(curve.side .. ' -- ' .. curve.label, function() + checkCurve(player, 'Beugungel', curve) + end) + end + end) +end) diff --git a/scripts/tests/systems/guild_shops/selling.lua b/scripts/tests/systems/guild_shops/selling.lua new file mode 100644 index 00000000000..f42c8884af8 --- /dev/null +++ b/scripts/tests/systems/guild_shops/selling.lua @@ -0,0 +1,106 @@ +describe('Guild shop selling', function() + ---@type CClientEntityPair + local player + + before_each(function() + player = xi.test.world:spawnPlayer({ zone = xi.zone.MHAURA }) + end) + + local offered = xi.item.CHUNK_OF_TIN_ORE -- initial 110 => offered and sellable + local notOffered = xi.item.CHAINMAIL -- initial 0 => only bought from players + + local function open(hour) + xi.test.world:setVanaTime(hour or 8, 0) + player.entities:gotoAndTrigger('Kamilah') + end + + local function cfgOf(itemId) + for _, cfg in ipairs(xi.data.guildShops['Kamilah'].stock) do + if cfg.id == itemId then + return cfg + end + end + end + + -- trade: amount sold; 0xFF (-1) partial fill, 0xFC (-4) over-stack reject + local function sell(itemId, quantity) + player.packets:clear() + player.actions:guildSell(itemId, quantity) + for _, pkt in pairs(player.packets:getIncoming()) do + if pkt.type == 0x084 then + return { itemNo = pkt.data[4] + pkt.data[5] * 256, stock = pkt.data[6], trade = pkt.data[7] } + end + end + end + + local function sellList() + return player.actions:guildSellList() + end + + local function offeredToday(itemId) + return player.actions:guildBuyList()[itemId] ~= nil + end + + it('credits the locked price, removes the item, and raises shop stock', function() + open() + player:addItem(offered, 1) + + local before = sellList()[offered] + local gil = player:getGil() + + local reply = sell(offered, 1) + + assert(reply.trade == 1, 'sale not accepted: trade ' .. tostring(reply.trade)) + assert(player:getGil() == gil + before.price, 'gil not credited') + assert(not player:hasItem(offered), 'item not taken') + assert(sellList()[offered].count == before.count + 1, 'stock not +1') + end) + + it('clamps a sale to the shop max stock', function() + open() + local cfg = cfgOf(offered) + + -- no packet sets shop stock; seed one below max so the 5-sale only has room for 1 + xi.guildShops.state['Kamilah'].items[offered].stock = cfg.maxStock - 1 + player:addItem(offered, 5) + local held = player:getItemCount(offered) + + local reply = sell(offered, 5) + + assert(sellList()[offered].count == cfg.maxStock, 'stock not clamped to max') + assert(player:getItemCount(offered) == held - 1, 'took more than the room left') + assert(reply.trade == 0xFF, 'partial fill not flagged: trade ' .. tostring(reply.trade)) + end) + + it('sells across multiple inventory stacks', function() + open() + + -- two stacks of 3 + 12, so a 12-sale has to span both (tin ore caps at 12) + player:addItem(offered, 12) + player:addItem(offered, 12) + player:delItem(offered, 9) -- drains the front stack: 12 -> 3 + assert(player:getItemCount(offered) == 15, 'setup: expected 3 + 12 = 15') + + local before = sellList()[offered] + local gil = player:getGil() + + local reply = sell(offered, 12) + + assert(reply.trade == 12, 'full sale not reported as 12: ' .. tostring(reply.trade)) + assert(player:getItemCount(offered) == 3, 'inventory not 3') + assert(player:getGil() == gil + before.price * 12, 'gil off for 12 sold') + assert(sellList()[offered].count == before.count + 12, 'stock not +12') + end) + + it('offers a seeded item the next day, not the same day', function() + open() + player:addItem(notOffered, 1) + + sell(notOffered, 1) + assert(not offeredToday(notOffered), 'offered the same day it was seeded') + + xi.test.world:skipToNextVanaDay() + open() + assert(offeredToday(notOffered), 'not offered the next day') + end) +end) diff --git a/scripts/zones/Carpenters_Landing/npcs/Beugungel.lua b/scripts/zones/Carpenters_Landing/npcs/Beugungel.lua index 707977fae2a..0018f5cc300 100644 --- a/scripts/zones/Carpenters_Landing/npcs/Beugungel.lua +++ b/scripts/zones/Carpenters_Landing/npcs/Beugungel.lua @@ -10,7 +10,7 @@ local ID = zones[xi.zone.CARPENTERS_LANDING] local entity = {} entity.onTrigger = function(player, npc) - if player:sendGuild(534, 5, 22, 0) then + if xi.guildShops.onTrigger(player, npc) then player:showText(npc, ID.text.BEUGUNGEL_SHOP_DIALOG) end end diff --git a/scripts/zones/Mhaura/npcs/Kamilah.lua b/scripts/zones/Mhaura/npcs/Kamilah.lua index 0d01058f094..a955301945a 100644 --- a/scripts/zones/Mhaura/npcs/Kamilah.lua +++ b/scripts/zones/Mhaura/npcs/Kamilah.lua @@ -10,7 +10,7 @@ local ID = zones[xi.zone.MHAURA] local entity = {} entity.onTrigger = function(player, npc) - if player:sendGuild(532, 8, 23, 2) then + if xi.guildShops.onTrigger(player, npc) then player:showText(npc, ID.text.SMITHING_GUILD) end end diff --git a/sql/guild_shops.sql b/sql/guild_shops.sql index 55524c226f7..e83fe651903 100644 --- a/sql/guild_shops.sql +++ b/sql/guild_shops.sql @@ -1157,27 +1157,6 @@ INSERT INTO `guild_shops` VALUES (531,13783,81084,162345,60,0,0); -- iron_scale -- INSERT INTO `guild_shops` VALUES (531,13785,20240,45600,60,0,0); -- steel_scale_mail TODO: missing min_price and max_price -- INSERT INTO `guild_shops` VALUES (531,12306,20240,45600,60,0,0); -- kite_shield TODO: missing min_price and max_price --- Kamilah (Mhaura) Smithing Guild (S) -INSERT INTO `guild_shops` VALUES (532,641,30,66,240,48,110); -- chunk_of_tin_ore -INSERT INTO `guild_shops` VALUES (532,643,675,3825,240,33,110); -- chunk_of_iron_ore -INSERT INTO `guild_shops` VALUES (532,649,115,349,120,0,0); -- bronze_ingot -INSERT INTO `guild_shops` VALUES (532,651,2700,13680,120,0,0); -- iron_ingot -INSERT INTO `guild_shops` VALUES (532,652,3517,25620,120,16,90); -- steel_ingot -INSERT INTO `guild_shops` VALUES (532,660,61,423,120,33,36); -- bronze_sheet -INSERT INTO `guild_shops` VALUES (532,662,4050,20520,120,0,0); -- iron_sheet -INSERT INTO `guild_shops` VALUES (532,672,81,254,60,0,0); -- handful_of_bronze_scales -INSERT INTO `guild_shops` VALUES (532,674,4945,30744,60,0,0); -- handful_of_iron_scales -INSERT INTO `guild_shops` VALUES (532,680,11781,12411,60,0,0); -- iron_chain --- INSERT INTO `guild_shops` VALUES (532,12552,20240,45600,60,0,0); -- chainmail TODO: missing min_price and max_price --- INSERT INTO `guild_shops` VALUES (532,12560,20240,45600,60,0,0); -- scale_mail TODO: missing min_price and max_price -INSERT INTO `guild_shops` VALUES (532,12578,61086,81086,60,0,0); -- padded_armor --- INSERT INTO `guild_shops` VALUES (532,12936,20240,45600,60,0,0); -- greaves TODO: missing min_price and max_price -INSERT INTO `guild_shops` VALUES (532,12944,1519,5294,60,0,0); -- scale_greaves -INSERT INTO `guild_shops` VALUES (532,12962,27866,76830,60,0,0); -- leggings --- INSERT INTO `guild_shops` VALUES (532,12680,20240,45600,60,0,0); -- chain_mittens TODO: missing min_price and max_price -INSERT INTO `guild_shops` VALUES (532,12688,1666,5664,60,0,0); -- scale_finger_gauntlets -INSERT INTO `guild_shops` VALUES (532,12706,21945,21945,60,0,0); -- iron_mittens - -- Amulya (Metalworks) Smithing Guild (S) INSERT INTO `guild_shops` VALUES (5332,641,30,66,240,48,180); -- chunk_of_tin_ore INSERT INTO `guild_shops` VALUES (5332,643,675,3825,240,33,180); -- chunk_of_iron_ore @@ -1251,15 +1230,6 @@ INSERT INTO `guild_shops` VALUES (5332,17336,4,11,240,0,0); -- crossbow_ INSERT INTO `guild_shops` VALUES (5332,17337,4,11,240,0,0); -- mythril_bolt INSERT INTO `guild_shops` VALUES (5332,17298,38,38,240,0,0); -- tathlum TODO: missing min_price and max_price --- Beugungel (Carpenter's Landing) Woodworking Guild -INSERT INTO `guild_shops` VALUES (534,1657,75,255,240,48,180); -- bundling_twine -INSERT INTO `guild_shops` VALUES (534,1021,312,500,200,48,180); -- hatchet -INSERT INTO `guild_shops` VALUES (534,688,15,30,200,48,180); -- arrowwood_log -INSERT INTO `guild_shops` VALUES (534,698,72,441,200,48,180); -- ash_log -INSERT INTO `guild_shops` VALUES (534,696,330,2024,200,48,150); -- yew_log -INSERT INTO `guild_shops` VALUES (534,695,120,736,200,48,150); -- willow_log -INSERT INTO `guild_shops` VALUES (534,693,640,3928,240,48,180); -- walnut_log - -- Akamafula (Lower Jeuno) Tenshodo Merchent -- TODO: Audit and update Akamafula.lua. Converted from a guild merchant to a standard shop as of April 2018. INSERT INTO `guild_shops` VALUES (60417,16896,517,884,20,10,20); -- kunai INSERT INTO `guild_shops` VALUES (60417,16900,1404,2160,20,7,15); -- wakizashi diff --git a/sql/item_basic.sql b/sql/item_basic.sql index 0be0bf696ee..db1084e1bd8 100644 --- a/sql/item_basic.sql +++ b/sql/item_basic.sql @@ -685,9 +685,9 @@ INSERT INTO `item_basic` VALUES (668,0,'orichalcum_sheet','ocl._sheet','オリ INSERT INTO `item_basic` VALUES (669,0,'molybdenum_sheet','mlbd._sheet','モリブデン板',@GENERAL_TYPE,12,@FLAG_MYSTERY_BOX,@SMITHING,8100); INSERT INTO `item_basic` VALUES (670,0,'aluminum_sheet','aluminum_sheet','アルミ板',@GENERAL_TYPE,12,@FLAG_MYSTERY_BOX,@GOLDSMITHING,822); INSERT INTO `item_basic` VALUES (671,0,'silver_sheet','silver_sheet','シルバー板',@GENERAL_TYPE,12,@FLAG_MYSTERY_BOX,@GOLDSMITHING,341); -INSERT INTO `item_basic` VALUES (672,0,'handful_of_bronze_scales','bronze_scales','ブロンズの小札',@GENERAL_TYPE,12,@FLAG_MYSTERY_BOX,@SMITHING,26); +INSERT INTO `item_basic` VALUES (672,0,'handful_of_bronze_scales','bronze_scales','ブロンズの小札',@GENERAL_TYPE,12,@FLAG_MYSTERY_BOX,@SMITHING,27); INSERT INTO `item_basic` VALUES (673,0,'handful_of_brass_scales','brass_scales','ブラスの小札',@GENERAL_TYPE,12,@FLAG_MYSTERY_BOX,@GOLDSMITHING,71); -INSERT INTO `item_basic` VALUES (674,0,'handful_of_iron_scales','iron_scales','アイアンの小札',@GENERAL_TYPE,12,@FLAG_MYSTERY_BOX,@SMITHING,1071); +INSERT INTO `item_basic` VALUES (674,0,'handful_of_iron_scales','iron_scales','アイアンの小札',@GENERAL_TYPE,12,@FLAG_MYSTERY_BOX,@SMITHING,1050); INSERT INTO `item_basic` VALUES (675,0,'handful_of_adaman_scales','adaman_scales','アダマンの小札',@GENERAL_TYPE,12,@FLAG_MYSTERY_BOX,@SMITHING,1512); INSERT INTO `item_basic` VALUES (676,0,'handful_of_steel_scales','steel_scales','スチールの小札',@GENERAL_TYPE,12,@FLAG_MYSTERY_BOX,@SMITHING,1400); INSERT INTO `item_basic` VALUES (677,0,'chunk_of_white_steel','white_steel','白鋼',@GENERAL_TYPE,12,@FLAG_MYSTERY_BOX | @FLAG_NOAUCTION | @FLAG_NOSALE | @FLAG_NODELIVERY,@NONE,0); @@ -1578,7 +1578,7 @@ INSERT INTO `item_basic` VALUES (1584,0,'mysterial_fragment','mysterial_frag.',' INSERT INTO `item_basic` VALUES (1585,0,'ethereal_fragment','ethereal_fragment','名銃の欠片',@GENERAL_TYPE,1,@FLAG_MYSTERY_BOX | @FLAG_CAN_SEND_ACCT | @FLAG_NOAUCTION | @FLAG_NOSALE | @FLAG_NODELIVERY | @FLAG_EX | @FLAG_RARE,@NONE,0); INSERT INTO `item_basic` VALUES (1586,0,'titanictus_shell','titanictus_shell','甲冑魚の甲殻',@GENERAL_TYPE,12,@FLAG_MYSTERY_BOX,@BONECRAFT,350); INSERT INTO `item_basic` VALUES (1587,0,'handful_of_high-quality_pugil_scales','h.q._pugil_scls.','上質な魚の鱗',@GENERAL_TYPE,12,@FLAG_MYSTERY_BOX,@BONECRAFT,260); -INSERT INTO `item_basic` VALUES (1588,0,'slab_of_tufa','tufa','凝灰岩',@GENERAL_TYPE,12,@FLAG_MYSTERY_BOX,@GOLDSMITHING,1982); +INSERT INTO `item_basic` VALUES (1588,0,'slab_of_tufa','tufa','凝灰岩',@GENERAL_TYPE,12,@FLAG_MYSTERY_BOX,@GOLDSMITHING,3400); INSERT INTO `item_basic` VALUES (1589,0,'shard_of_necropsyche','necropsyche','プシュケー',@GENERAL_TYPE,1,@FLAG_MYSTERY_BOX | @FLAG_CAN_SEND_ACCT | @FLAG_NOAUCTION | @FLAG_NODELIVERY | @FLAG_EX | @FLAG_RARE,@NONE,10600); INSERT INTO `item_basic` VALUES (1590,0,'sprig_of_holy_basil','holy_basil','ホーリーバジル',@GENERAL_TYPE,12,@FLAG_MYSTERY_BOX,@INGREDIENTS,271); INSERT INTO `item_basic` VALUES (1591,0,'high-quality_coeurl_hide','h.q._coeurl_hide','上質なクァール毛皮',@GENERAL_TYPE,12,@FLAG_MYSTERY_BOX,@LEATHERCRAFT,860); diff --git a/src/map/entities/charentity.h b/src/map/entities/charentity.h index deec4aa472d..5b0f271b965 100644 --- a/src/map/entities/charentity.h +++ b/src/map/entities/charentity.h @@ -510,6 +510,7 @@ class CCharEntity : public CBattleEntity bool retriggerLatents; // used to retrigger all latent effects if some event requires them to be retriggered CItemContainer* PGuildShop; + EntityID_t guildShopNpc_{}; // Lua-driven guild shop NPC the PC last opened CItemContainer* getStorage(uint8 locationId) const; CTradeContainer* TradeContainer; // Container used specifically for trading. diff --git a/src/map/lua/lua_baseentity.cpp b/src/map/lua/lua_baseentity.cpp index f8017d119fb..66f595b9711 100644 --- a/src/map/lua/lua_baseentity.cpp +++ b/src/map/lua/lua_baseentity.cpp @@ -150,6 +150,10 @@ #include "packets/s2c/0x063_miscdata_monstrosity.h" #include "packets/s2c/0x075_battlefield.h" #include "packets/s2c/0x077_entity_vis.h" +#include "packets/s2c/0x082_guild_buy.h" +#include "packets/s2c/0x083_guild_buylist.h" +#include "packets/s2c/0x084_guild_sell.h" +#include "packets/s2c/0x085_guild_selllist.h" #include "packets/s2c/0x086_guild_open.h" #include "packets/s2c/0x0aa_magic_data.h" #include "packets/s2c/0x0ac_command_data.h" @@ -2711,9 +2715,10 @@ void CLuaBaseEntity::sendMenu(uint32 menu) auto CLuaBaseEntity::sendGuild(const uint16 guildId, uint8 open, uint8 close, uint8 holiday) const -> bool { - if (m_PBaseEntity->objtype != TYPE_PC) + auto* PChar = dynamic_cast(m_PBaseEntity); + if (!PChar) { - ShowWarning("Invalid entity type calling function (%s).", m_PBaseEntity->getName()); + ShowWarningFmt("Invalid entity type calling function ({}).", m_PBaseEntity->getName()); return false; } @@ -2742,14 +2747,86 @@ auto CLuaBaseEntity::sendGuild(const uint16 guildId, uint8 open, uint8 close, ui } CItemContainer* PGuildShop = guildutils::GetGuildShop(guildId); - auto* PChar = static_cast(m_PBaseEntity); PChar->PGuildShop = PGuildShop; + PChar->guildShopNpc_.clean(); PChar->pushPacket(status, open, close, holiday); return status == GP_SERV_COMMAND_GUILD_OPEN_STAT::Open; } +/************************************************************************ + * Function: openGuildShop() + * Purpose : Opens a lua guild shop and remembers the NPC the PC opened it with + * Example : if player:openGuildShop(npc, 8, 23) then + ************************************************************************/ + +auto CLuaBaseEntity::openGuildShop(CLuaBaseEntity* PNpc, uint8 open, uint8 close) const -> bool +{ + auto* PChar = dynamic_cast(m_PBaseEntity); + if (!PChar) + { + ShowWarningFmt("Invalid entity type calling function ({}).", m_PBaseEntity->getName()); + return false; + } + + if (PNpc == nullptr || PNpc->GetBaseEntity() == nullptr) + { + ShowWarning("Invalid guild shop NPC passed to openGuildShop()."); + return false; + } + + const uint8 vanadielHour = static_cast(vanadiel_time::get_hour(vanadiel_time::now())); + const bool isOpen = vanadielHour >= open && vanadielHour < close; + const auto status = isOpen ? GP_SERV_COMMAND_GUILD_OPEN_STAT::Open : GP_SERV_COMMAND_GUILD_OPEN_STAT::Close; + + const auto* PNpcEntity = PNpc->GetBaseEntity(); + + PChar->guildShopNpc_.id = PNpcEntity->id; + PChar->guildShopNpc_.targid = PNpcEntity->targid; + PChar->PGuildShop = nullptr; + PChar->pushPacket(status, open, close, 0); + + return isOpen; +} + +/************************************************************************ + * Function: clearGuildShop() + * Purpose : Clears the PC's open guild shop handle + * Example : player:clearGuildShop() + ************************************************************************/ + +void CLuaBaseEntity::clearGuildShop() const +{ + auto* PChar = dynamic_cast(m_PBaseEntity); + if (!PChar) + { + ShowWarningFmt("Invalid entity type calling function ({}).", m_PBaseEntity->getName()); + return; + } + + PChar->guildShopNpc_.clean(); + PChar->PGuildShop = nullptr; +} + +/************************************************************************ + * Function: sendGuildClose() + * Purpose : Sends the guild-open packet with a Close status to the PC + * Example : player:sendGuildClose(8, 23) + ************************************************************************/ + +void CLuaBaseEntity::sendGuildClose(uint8 open, uint8 close) const +{ + auto* PChar = dynamic_cast(m_PBaseEntity); + if (!PChar) + { + ShowWarningFmt("Invalid entity type calling function ({}).", m_PBaseEntity->getName()); + return; + } + + PChar->pushPacket(GP_SERV_COMMAND_GUILD_OPEN_STAT::Close, open, close, 0); +} + /************************************************************************ * Function: openSendBox() * Purpose : Opens the send box for a PC @@ -20249,6 +20326,9 @@ void CLuaBaseEntity::Register() SOL_REGISTER("changeMusic", CLuaBaseEntity::changeMusic); SOL_REGISTER("sendMenu", CLuaBaseEntity::sendMenu); SOL_REGISTER("sendGuild", CLuaBaseEntity::sendGuild); + SOL_REGISTER("openGuildShop", CLuaBaseEntity::openGuildShop); + SOL_REGISTER("clearGuildShop", CLuaBaseEntity::clearGuildShop); + SOL_REGISTER("sendGuildClose", CLuaBaseEntity::sendGuildClose); SOL_REGISTER("openSendBox", CLuaBaseEntity::openSendBox); SOL_REGISTER("leaveGame", CLuaBaseEntity::leaveGame); SOL_REGISTER("sendEmote", CLuaBaseEntity::sendEmote); diff --git a/src/map/lua/lua_baseentity.h b/src/map/lua/lua_baseentity.h index b698fc5527c..bb35855bdd5 100644 --- a/src/map/lua/lua_baseentity.h +++ b/src/map/lua/lua_baseentity.h @@ -180,6 +180,9 @@ class CLuaBaseEntity void changeMusic(MusicSlot slotId, uint16 trackId) const; // Sets the specified music Track for specified music block. void sendMenu(uint32 menu); // Displays a menu (AH,Raise,Tractor,MH etc) auto sendGuild(uint16 guildId, uint8 open, uint8 close, uint8 holiday) const -> bool; // Sends guild shop menu + auto openGuildShop(CLuaBaseEntity* PNpc, uint8 open, uint8 close) const -> bool; // Opens a lua guild shop and remembers the NPC the PC opened it with + void clearGuildShop() const; // Clears the PC's open guild shop handle + void sendGuildClose(uint8 open, uint8 close) const; // Sends the guild-open packet with a Close status void openSendBox() const; // Opens send box (to deliver items) void leaveGame(); void sendEmote(const CLuaBaseEntity* target, uint8 emID, uint8 emMode, bool othersOnly) const; diff --git a/src/map/lua/sol_bindings.cpp b/src/map/lua/sol_bindings.cpp index 85cbdf98dab..405edb940e7 100644 --- a/src/map/lua/sol_bindings.cpp +++ b/src/map/lua/sol_bindings.cpp @@ -66,6 +66,7 @@ SOL_BIND_DEF(CLuaInstance, CInstance); #include "items/item.h" #include "lua_item.h" SOL_BIND_DEF(CLuaItem, CItem); +SOL_BIND_DEF_CONST(CLuaItem, CItem); #include "items/item_currency.h" #include "items/item_equipment.h" diff --git a/src/map/lua/sol_bindings.h b/src/map/lua/sol_bindings.h index 46797ae9762..9facfcf0dd5 100644 --- a/src/map/lua/sol_bindings.h +++ b/src/map/lua/sol_bindings.h @@ -60,6 +60,15 @@ { \ return obj ? sol::stack::push(L, (BaseCppType*)obj) : sol::stack::push(L, sol::lua_nil); \ } + +#define SOL_BIND_DEC_CONST(LuaType, CppType) \ + int sol_lua_push(sol::types, lua_State* L, const CppType* obj); + +#define SOL_BIND_DEF_CONST(LuaType, CppType) \ + int sol_lua_push(sol::types, lua_State* L, const CppType* obj) \ + { \ + return obj ? sol::stack::push(L, obj) : sol::stack::push(L, sol::lua_nil); \ + } // clang-format on // @@ -110,6 +119,7 @@ SOL_BIND_DEC(CLuaInstance, CInstance); class CLuaItem; class CItem; SOL_BIND_DEC(CLuaItem, CItem); +SOL_BIND_DEC_CONST(CLuaItem, CItem); class CItemCurrency; class CItemEquipment; diff --git a/src/map/packets/c2s/0x0aa_guild_buy.cpp b/src/map/packets/c2s/0x0aa_guild_buy.cpp index 67f5f289bbd..bb9fea1f601 100644 --- a/src/map/packets/c2s/0x0aa_guild_buy.cpp +++ b/src/map/packets/c2s/0x0aa_guild_buy.cpp @@ -24,31 +24,65 @@ #include "entities/charentity.h" #include "items/item.h" #include "items/item_shop.h" +#include "lua/luautils.h" #include "packets/s2c/0x01d_item_same.h" #include "packets/s2c/0x082_guild_buy.h" #include "utils/charutils.h" #include "utils/itemutils.h" +#include "utils/zoneutils.h" auto GP_CLI_COMMAND_GUILD_BUY::validate(MapSession* PSession, const CCharEntity* PChar) const -> PacketValidationResult { return PacketValidator(PChar) .blockedBy({ BlockedState::InEvent }) - .mustNotEqual(PChar->PGuildShop, nullptr, "Character does not have a guild shop") + .custom([&](PacketValidator& v) + { + if (PChar->PGuildShop == nullptr && PChar->guildShopNpc_.id == 0) + { + v.mustNotEqual(PChar->PGuildShop, nullptr, "Character does not have a guild shop"); + } + }) .range("ItemNum", this->ItemNum, 1, 99) .mustEqual(this->PropertyItemIndex, 0, "PropertyItemIndex not 0"); } void GP_CLI_COMMAND_GUILD_BUY::process(MapSession* PSession, CCharEntity* PChar) const { - uint8 quantity = this->ItemNum; - - const CItem* PItem = xi::items::lookup(this->ItemNo); + uint8 quantity = this->ItemNum; + const CItem* PItem = xi::items::lookup(this->ItemNo); if (!PItem) { ShowWarning("User '%s' attempting to buy an invalid item from guild vendor!", PChar->getName()); return; } + // You can't buy more than a stack at once; retail turns this away instead of quietly clamping it. + if (quantity > PItem->getStackSize()) + { + PChar->pushPacket(PChar, 0, 0, static_cast(-1)); + return; + } + + if (PChar->guildShopNpc_.id != 0) + { + if (auto* PNpc = zoneutils::GetEntity(PChar->guildShopNpc_.id, TYPE_NPC)) + { + // onPlayerBuy returns { itemNo, count, trade }; serialize it into the 0x082 result + // (a rejection is { 0, 0, -1 }). + const auto result = luautils::callGlobal("xi.guildShops.onPlayerBuy", PChar, PNpc, this->ItemNo, quantity); + if (result.valid()) + { + const auto itemNo = result.get_or("itemNo", uint16{ 0 }); + const auto count = result.get_or("count", uint8{ 0 }); + const auto trade = result.get_or("trade", int32{ 0 }); + PChar->pushPacket(PChar, count, itemNo, static_cast(trade)); + } + } + + return; + } + + // Handle legacy guild shops const uint8 shopSlotId = PChar->PGuildShop->SearchItem(this->ItemNo); if (shopSlotId == ERROR_SLOTID) @@ -65,12 +99,6 @@ void GP_CLI_COMMAND_GUILD_BUY::process(MapSession* PSession, CCharEntity* PChar) return; } - // Prevent purchasing larger stacks than the actual stack size in database. - if (quantity > PItem->getStackSize()) - { - quantity = PItem->getStackSize(); - } - if (item->getQuantity() >= quantity) { if (gil->getQuantity() > (item->getBasePrice() * quantity)) diff --git a/src/map/packets/c2s/0x0ab_guild_buylist.cpp b/src/map/packets/c2s/0x0ab_guild_buylist.cpp index ac9ea6a4442..4c0075609b4 100644 --- a/src/map/packets/c2s/0x0ab_guild_buylist.cpp +++ b/src/map/packets/c2s/0x0ab_guild_buylist.cpp @@ -22,16 +22,57 @@ #include "0x0ab_guild_buylist.h" #include "entities/charentity.h" +#include "lua/luautils.h" #include "packets/s2c/0x083_guild_buylist.h" +#include "utils/zoneutils.h" auto GP_CLI_COMMAND_GUILD_BUYLIST::validate(MapSession* PSession, const CCharEntity* PChar) const -> PacketValidationResult { return PacketValidator(PChar) .blockedBy({ BlockedState::InEvent }) - .mustNotEqual(PChar->PGuildShop, nullptr, "Character does not have a guild shop"); + .custom([&](PacketValidator& v) + { + if (PChar->PGuildShop == nullptr && PChar->guildShopNpc_.id == 0) + { + v.mustNotEqual(PChar->PGuildShop, nullptr, "Character does not have a guild shop"); + } + }); } void GP_CLI_COMMAND_GUILD_BUYLIST::process(MapSession* PSession, CCharEntity* PChar) const { + if (PChar->guildShopNpc_.id != 0) + { + if (auto* PNpc = zoneutils::GetEntity(PChar->guildShopNpc_.id, TYPE_NPC)) + { + const auto items = luautils::callGlobal("xi.guildShops.onBuyList", PChar, PNpc); + + std::vector list; + list.reserve(items.size()); + for (std::size_t i = 1; i <= items.size(); ++i) + { + const sol::object obj = items[i]; + if (!obj.is()) + { + continue; + } + + const auto entry = obj.as(); + GP_GUILD_ITEM gpItem{ + .ItemNo = entry.get_or("id", static_cast(0)), + .Count = entry.get_or("count", static_cast(0)), + .Max = entry.get_or("max", static_cast(0)), + .Price = entry.get_or("price", static_cast(0)), + }; + list.push_back(gpItem); + } + + PChar->pushPacket(PChar, list); + } + + return; + } + + // Fallback to legacy Guild Shops PChar->pushPacket(PChar, PChar->PGuildShop); } diff --git a/src/map/packets/c2s/0x0ac_guild_sell.cpp b/src/map/packets/c2s/0x0ac_guild_sell.cpp index c6893440336..003c92364a1 100644 --- a/src/map/packets/c2s/0x0ac_guild_sell.cpp +++ b/src/map/packets/c2s/0x0ac_guild_sell.cpp @@ -25,9 +25,12 @@ #include "common/settings.h" #include "entities/charentity.h" #include "items/item_shop.h" +#include "lua/luautils.h" #include "packets/s2c/0x01d_item_same.h" #include "packets/s2c/0x084_guild_sell.h" #include "utils/charutils.h" +#include "utils/itemutils.h" +#include "utils/zoneutils.h" namespace { @@ -61,12 +64,56 @@ auto GP_CLI_COMMAND_GUILD_SELL::validate(MapSession* PSession, const CCharEntity { return PacketValidator(PChar) .blockedBy({ BlockedState::InEvent, BlockedState::Crafting }) - .mustNotEqual(PChar->PGuildShop, nullptr, "Character does not have a guild shop") + .custom([&](PacketValidator& v) + { + if (PChar->PGuildShop == nullptr && PChar->guildShopNpc_.id == 0) + { + v.mustNotEqual(PChar->PGuildShop, nullptr, "Character does not have a guild shop"); + } + }) .range("ItemNum", this->ItemNum, 1, 99); } void GP_CLI_COMMAND_GUILD_SELL::process(MapSession* PSession, CCharEntity* PChar) const { + const CItem* PItem = xi::items::lookup(this->ItemNo); + if (!PItem) + { + ShowWarning("User '%s' attempting to sell an invalid item to guild vendor!", PChar->getName()); + return; + } + + // A guild shop never buys more than a single stack of an item per transaction. + if (this->ItemNum > PItem->getStackSize()) + { + PChar->pushPacket(PChar, 0, 0, static_cast(-4)); + return; + } + + if (PChar->guildShopNpc_.id != 0) + { + if (auto* PNpc = zoneutils::GetEntity(PChar->guildShopNpc_.id, TYPE_NPC)) + { + const auto result = luautils::callGlobal("xi.guildShops.onPlayerSell", PChar, PNpc, this->ItemNo, this->ItemNum); + if (result.valid()) + { + const auto itemNo = result.get_or("itemNo", uint16{ 0 }); + const auto count = result.get_or("count", uint8{ 0 }); + const auto trade = result.get_or("trade", int32{ 0 }); + const auto sold = result.get_or("sold", uint8{ 0 }); + const auto price = result.get_or("price", uint32{ 0 }); + PChar->pushPacket(PChar, count, itemNo, static_cast(trade)); + + if (sold > 0) + { + auditSale(*PSession->scheduler, PChar, itemNo, price, sold); + } + } + } + + return; + } + uint8 quantity = this->ItemNum; const uint8 shopSlotId = PChar->PGuildShop->SearchItem(this->ItemNo); diff --git a/src/map/packets/c2s/0x0ad_guild_selllist.cpp b/src/map/packets/c2s/0x0ad_guild_selllist.cpp index 9fc0e4b1209..ebb18c58aba 100644 --- a/src/map/packets/c2s/0x0ad_guild_selllist.cpp +++ b/src/map/packets/c2s/0x0ad_guild_selllist.cpp @@ -22,16 +22,57 @@ #include "0x0ad_guild_selllist.h" #include "entities/charentity.h" +#include "lua/luautils.h" #include "packets/s2c/0x085_guild_selllist.h" +#include "utils/zoneutils.h" auto GP_CLI_COMMAND_GUILD_SELLLIST::validate(MapSession* PSession, const CCharEntity* PChar) const -> PacketValidationResult { return PacketValidator(PChar) .blockedBy({ BlockedState::InEvent }) - .mustNotEqual(PChar->PGuildShop, nullptr, "Character does not have a guild shop"); + .custom([&](PacketValidator& v) + { + if (PChar->PGuildShop == nullptr && PChar->guildShopNpc_.id == 0) + { + v.mustNotEqual(PChar->PGuildShop, nullptr, "Character does not have a guild shop"); + } + }); } void GP_CLI_COMMAND_GUILD_SELLLIST::process(MapSession* PSession, CCharEntity* PChar) const { + if (PChar->guildShopNpc_.id != 0) + { + if (auto* PNpc = zoneutils::GetEntity(PChar->guildShopNpc_.id, TYPE_NPC)) + { + const auto items = luautils::callGlobal("xi.guildShops.onSellList", PChar, PNpc); + + std::vector list; + list.reserve(items.size()); + for (std::size_t i = 1; i <= items.size(); ++i) + { + const sol::object obj = items[i]; + if (!obj.is()) + { + continue; + } + + const auto entry = obj.as(); + GP_GUILD_ITEM gpItem{ + .ItemNo = entry.get_or("id", static_cast(0)), + .Count = entry.get_or("count", static_cast(0)), + .Max = entry.get_or("max", static_cast(0)), + .Price = entry.get_or("price", static_cast(0)), + }; + list.push_back(gpItem); + } + + PChar->pushPacket(PChar, list); + } + + return; + } + + // Fallback to legacy Guild Shops PChar->pushPacket(PChar, PChar->PGuildShop); } diff --git a/src/map/packets/s2c/0x083_guild_buylist.cpp b/src/map/packets/s2c/0x083_guild_buylist.cpp index 8c590705904..b1eeb3ff0ca 100644 --- a/src/map/packets/s2c/0x083_guild_buylist.cpp +++ b/src/map/packets/s2c/0x083_guild_buylist.cpp @@ -77,3 +77,39 @@ GP_SERV_COMMAND_GUILD_BUYLIST::GP_SERV_COMMAND_GUILD_BUYLIST(CCharEntity* PChar, packet.Count = ItemCount; packet.Stat = PacketCount + 0x80; } + +GP_SERV_COMMAND_GUILD_BUYLIST::GP_SERV_COMMAND_GUILD_BUYLIST(CCharEntity* PChar, const std::vector& items) +{ + if (PChar == nullptr) + { + ShowError("GP_SERV_COMMAND_GUILD_BUYLIST - PChar was null."); + return; + } + + auto& packet = this->data(); + + uint8 ItemCount = 0; + uint8 PacketCount = 0; + + for (const auto& item : items) + { + if (ItemCount == 30) + { + packet.Count = ItemCount; + packet.Stat = (PacketCount == 0 ? 0x40 : PacketCount); + + PChar->pushPacket(this->copy()); + + ItemCount = 0; + PacketCount++; + + std::memset(&packet, 0, sizeof(PacketData)); + } + + packet.List[ItemCount] = item; + ItemCount++; + } + + packet.Count = ItemCount; + packet.Stat = PacketCount + 0x80; +} diff --git a/src/map/packets/s2c/0x083_guild_buylist.h b/src/map/packets/s2c/0x083_guild_buylist.h index e655fdcbf0c..5eb3c9a0327 100644 --- a/src/map/packets/s2c/0x083_guild_buylist.h +++ b/src/map/packets/s2c/0x083_guild_buylist.h @@ -25,6 +25,8 @@ #include "base.h" +#include + class CCharEntity; class CItemContainer; @@ -49,4 +51,5 @@ class GP_SERV_COMMAND_GUILD_BUYLIST final : public GP_SERV_PACKET& items); }; diff --git a/src/map/packets/s2c/0x085_guild_selllist.cpp b/src/map/packets/s2c/0x085_guild_selllist.cpp index 0a7f429e8ee..649fe3391e8 100644 --- a/src/map/packets/s2c/0x085_guild_selllist.cpp +++ b/src/map/packets/s2c/0x085_guild_selllist.cpp @@ -80,3 +80,39 @@ GP_SERV_COMMAND_GUILD_SELLLIST::GP_SERV_COMMAND_GUILD_SELLLIST(CCharEntity* PCha packet.Count = ItemCount; packet.Stat = PacketCount + 0x80; } + +GP_SERV_COMMAND_GUILD_SELLLIST::GP_SERV_COMMAND_GUILD_SELLLIST(CCharEntity* PChar, const std::vector& items) +{ + if (PChar == nullptr) + { + ShowError("GP_SERV_COMMAND_GUILD_SELLLIST - PChar was null."); + return; + } + + auto& packet = this->data(); + + uint8 ItemCount = 0; + uint8 PacketCount = 0; + + for (const auto& item : items) + { + if (ItemCount == 30) + { + packet.Count = ItemCount; + packet.Stat = (PacketCount == 0 ? 0x40 : PacketCount); + + PChar->pushPacket(this->copy()); + + ItemCount = 0; + PacketCount++; + + std::memset(&packet, 0, sizeof(PacketData)); + } + + packet.List[ItemCount] = item; + ItemCount++; + } + + packet.Count = ItemCount; + packet.Stat = PacketCount + 0x80; +} diff --git a/src/map/packets/s2c/0x085_guild_selllist.h b/src/map/packets/s2c/0x085_guild_selllist.h index 8a785c4b28e..7b64d8c2a8a 100644 --- a/src/map/packets/s2c/0x085_guild_selllist.h +++ b/src/map/packets/s2c/0x085_guild_selllist.h @@ -26,6 +26,8 @@ #include "0x083_guild_buylist.h" #include "base.h" +#include + class CCharEntity; class CItemContainer; @@ -42,4 +44,5 @@ class GP_SERV_COMMAND_GUILD_SELLLIST final : public GP_SERV_PACKET& items); }; diff --git a/src/map/time_server.cpp b/src/map/time_server.cpp index 34a169284aa..1d3228875c3 100644 --- a/src/map/time_server.cpp +++ b/src/map/time_server.cpp @@ -123,6 +123,14 @@ auto time_server(Scheduler& scheduler, MapConfig config) -> Task { PChar->PLatentEffectContainer->CheckLatentsHours(); PChar->PLatentEffectContainer->CheckLatentsMoonPhase(); + + if (PChar->guildShopNpc_.id != 0) + { + if (auto* PNpc = zoneutils::GetEntity(PChar->guildShopNpc_.id, TYPE_NPC)) + { + luautils::callGlobal("xi.guildShops.onGameHour", PChar, PNpc); + } + } }); }); diff --git a/src/test/lua/helpers/lua_client_entity_pair_actions.cpp b/src/test/lua/helpers/lua_client_entity_pair_actions.cpp index 86db3af51c7..5a51f410bc4 100644 --- a/src/test/lua/helpers/lua_client_entity_pair_actions.cpp +++ b/src/test/lua/helpers/lua_client_entity_pair_actions.cpp @@ -25,6 +25,7 @@ #include "common/logging.h" #include "common/timer.h" #include "common/utils.h" +#include "enums/packet_s2c.h" #include "lua/helpers/lua_client_entity_pair_entities.h" #include "lua/helpers/lua_client_entity_pair_events.h" #include "lua/helpers/lua_client_entity_pair_packets.h" @@ -48,6 +49,10 @@ #include "map/packets/c2s/0x06e_group_solicit_req.h" #include "map/packets/c2s/0x074_group_solicit_res.h" #include "map/packets/c2s/0x096_combine_ask.h" +#include "map/packets/c2s/0x0aa_guild_buy.h" +#include "map/packets/c2s/0x0ab_guild_buylist.h" +#include "map/packets/c2s/0x0ac_guild_sell.h" +#include "map/packets/c2s/0x0ad_guild_selllist.h" #include "map/packets/c2s/0x102_extended_job.h" #include "map/spell.h" #include "map/status_effect_container.h" @@ -286,6 +291,72 @@ void CLuaClientEntityPairActions::trigger(CLuaBaseEntity* target, sol::optional< } } +/************************************************************************ + * Function: guildBuy() + * Purpose : Emits packet to buy an item from a guild shop. + * Example : player.actions:guildBuy(xi.item.CHUNK_OF_TIN_ORE, 1) + ************************************************************************/ + +void CLuaClientEntityPairActions::guildBuy(uint16 itemId, uint8 quantity) const +{ + const auto packet = parent_->packets().createPacket(); + auto* buy = packet->as(); + buy->ItemNo = itemId; + buy->PropertyItemIndex = 0; + buy->ItemNum = quantity; + + parent_->packets().sendBasicPacket(*packet); +} + +/************************************************************************ + * Function: guildSell() + * Purpose : Emits packet to sell an item to a guild shop. + * Example : player.actions:guildSell(xi.item.CHUNK_OF_TIN_ORE, 1) + ************************************************************************/ + +void CLuaClientEntityPairActions::guildSell(uint16 itemId, uint8 quantity) const +{ + const auto packet = parent_->packets().createPacket(); + auto* sell = packet->as(); + sell->ItemNo = itemId; + sell->PropertyItemIndex = 0; + sell->ItemNum = quantity; + + parent_->packets().sendBasicPacket(*packet); +} + +/************************************************************************ + * Function: guildBuyList() + * Purpose : Requests a guild shop's buy list and returns it decoded + * Example : local list = player.actions:guildBuyList() + ************************************************************************/ + +auto CLuaClientEntityPairActions::guildBuyList() const -> sol::table +{ + parent_->packets().clear(); + + const auto packet = parent_->packets().createPacket(); + parent_->packets().sendBasicPacket(*packet); + + return parent_->packets().guildList(static_cast(PacketS2C::GP_SERV_COMMAND_GUILD_BUYLIST)); +} + +/************************************************************************ + * Function: guildSellList() + * Purpose : Requests a guild shop's sell list and returns it decoded + * Example : local list = player.actions:guildSellList() + ************************************************************************/ + +auto CLuaClientEntityPairActions::guildSellList() const -> sol::table +{ + parent_->packets().clear(); + + const auto packet = parent_->packets().createPacket(); + parent_->packets().sendBasicPacket(*packet); + + return parent_->packets().guildList(static_cast(PacketS2C::GP_SERV_COMMAND_GUILD_SELLLIST)); +} + /************************************************************************ * Function: inviteToParty() * Purpose : Emits packet to invite a PC. @@ -774,6 +845,10 @@ void CLuaClientEntityPairActions::Register() SOL_REGISTER("rangedAttack", CLuaClientEntityPairActions::rangedAttack); SOL_REGISTER("useItem", CLuaClientEntityPairActions::useItem); SOL_REGISTER("trigger", CLuaClientEntityPairActions::trigger); + SOL_REGISTER("guildBuy", CLuaClientEntityPairActions::guildBuy); + SOL_REGISTER("guildSell", CLuaClientEntityPairActions::guildSell); + SOL_REGISTER("guildBuyList", CLuaClientEntityPairActions::guildBuyList); + SOL_REGISTER("guildSellList", CLuaClientEntityPairActions::guildSellList); SOL_REGISTER("inviteToParty", CLuaClientEntityPairActions::inviteToParty); SOL_REGISTER("formAlliance", CLuaClientEntityPairActions::formAlliance); SOL_REGISTER("acceptPartyInvite", CLuaClientEntityPairActions::acceptPartyInvite); diff --git a/src/test/lua/helpers/lua_client_entity_pair_actions.h b/src/test/lua/helpers/lua_client_entity_pair_actions.h index e7ec85197d1..aae2f44debb 100644 --- a/src/test/lua/helpers/lua_client_entity_pair_actions.h +++ b/src/test/lua/helpers/lua_client_entity_pair_actions.h @@ -58,6 +58,11 @@ class CLuaClientEntityPairActions void engage(CLuaBaseEntity* mob) const; void skillchain(CLuaBaseEntity* target, sol::variadic_args weaponskillIds) const; + void guildBuy(uint16 itemId, uint8 quantity) const; + void guildSell(uint16 itemId, uint8 quantity) const; + auto guildBuyList() const -> sol::table; + auto guildSellList() const -> sol::table; + void moveItem(uint8 srcContainer, uint8 srcSlot, uint8 dstContainer, uint32 quantity, sol::optional dstSlot) const; void sortContainer(uint8 container) const; void dropItem(uint8 container, uint8 slot, uint32 quantity) const; diff --git a/src/test/lua/helpers/lua_client_entity_pair_packets.cpp b/src/test/lua/helpers/lua_client_entity_pair_packets.cpp index dfd3edc501b..075bdd35240 100644 --- a/src/test/lua/helpers/lua_client_entity_pair_packets.cpp +++ b/src/test/lua/helpers/lua_client_entity_pair_packets.cpp @@ -33,6 +33,7 @@ #include "map/map_networking.h" #include "map/packets/c2s/0x00a_login.h" #include "map/packets/s2c/0x028_battle2.h" +#include "map/packets/s2c/0x083_guild_buylist.h" #include "packets/c2s/0x011_zone_transition.h" #include "test_char.h" #include "test_common.h" @@ -230,6 +231,40 @@ auto CLuaClientEntityPairPackets::actionPackets() const -> sol::table return table; } +/************************************************************************ + * Function: guildList() + * Purpose : Decode a received guild buy/sell list packet into { [itemNo] = { count, max, price } } + ************************************************************************/ + +auto CLuaClientEntityPairPackets::guildList(uint16 packetId) const -> sol::table +{ + const auto testChar = parent_->testChar(); + auto table = lua.create_table(); + + for (auto&& pkt : testChar->entity()->getPacketList()) + { + if (pkt->getType() != packetId) + { + continue; + } + + const auto& body = pkt->ref(sizeof(GP_SERV_HEADER)); + for (uint8 i = 0; i < body.Count; ++i) + { + const auto& item = body.List[i]; + + auto row = lua.create_table(); + row["count"] = item.Count; + row["max"] = item.Max; + row["price"] = item.Price; + + table[item.ItemNo] = row; + } + } + + return table; +} + /************************************************************************ * Function: clear() * Purpose : Clear all packets from the player's packet list diff --git a/src/test/lua/helpers/lua_client_entity_pair_packets.h b/src/test/lua/helpers/lua_client_entity_pair_packets.h index ab478e97886..ad033cb433c 100644 --- a/src/test/lua/helpers/lua_client_entity_pair_packets.h +++ b/src/test/lua/helpers/lua_client_entity_pair_packets.h @@ -46,6 +46,7 @@ class CLuaClientEntityPairPackets void parseIncoming(); auto getIncoming() const -> sol::table; auto actionPackets() const -> sol::table; + auto guildList(uint16 packetId) const -> sol::table; void clear() const; static void Register();