diff --git a/data-global/lib/core/echo_warden.lua b/data-global/lib/core/echo_warden.lua new file mode 100644 index 000000000..2bc150f35 --- /dev/null +++ b/data-global/lib/core/echo_warden.lua @@ -0,0 +1,261 @@ +-- ============================================================================ +-- Echo Raids / Echo Wardens - shared library (pure Lua) +-- IMPORTANT: lives in data/libs/functions/ (NOT data/global/lib/, which does +-- not exist in this fork). Registered from data/libs/functions/load.lua so it +-- loads before every revscriptsys data/scripts file at boot. +-- +-- Verified APIs only: +-- Monster:setForgeStack/getForgeStack (C++; applyStacks buffs HP, icon "forge") +-- Creature:setMaxHealth/setHealth/getMaxHealth/setIcon (C++) +-- Monster:setStorageValue/getStorageValue (Lua fallback in monster.lua; +-- getStorageValue returns -1 when unset, so compare with == 1) +-- MonsterType:BestiaryStars/raceId/name/isRewardBoss/bossRace +-- Player:addMinorCharmEchoes/sendLeaderMonsterKilledBanner/kv():scoped() (boolean round-trips) +-- Game.createMonster(name,pos,extended,force,master,spawnEffect) (returns nil on fail) +-- Game.getSpectators(pos, multifloor, onlyPlayer, minX,maxX,minY,maxY) +-- addEvent, Tile:hasFlag/getGround, Position:sendMagicEffect +-- ============================================================================ + +EchoWarden = EchoWarden or {} + +-- ----------------------------- TUNABLES ------------------------------------- +EchoWarden.PORTAL_ITEM_ID = 54133 +EchoWarden.PORTAL_ATTR_KIND = "echoKind" -- item:setCustomAttribute key +EchoWarden.PORTAL_TTL_MS = 120000 -- portal self-removes after 2 min (counted from when it appears) +EchoWarden.PORTAL_DELAY_MS = 30000 -- portal appears 30s AFTER the kill (not instantly) + +EchoWarden.SPAWN_CHANCE_NUM = 100 -- 100 / 200000 = 0.05% +EchoWarden.SPAWN_CHANCE_DEN = 200000 + +EchoWarden.WARDEN_ADDS_MIN, EchoWarden.WARDEN_ADDS_MAX = 7, 12 -- cantidad de minions por raid + +EchoWarden.WARDEN_HP_MULT = 4.0 +EchoWarden.WARDEN_ATK_MULT = 1.5 -- only used if optional C++ applied +EchoWarden.USE_CPP_ATK = true -- set true only if applyEchoWarden compiled + +EchoWarden.AURA_RANGE = 5 +EchoWarden.AURA_MS = 2000 + +EchoWarden.SCATTER_RADIUS = 3 -- (unused now: spawns land on the portal tile) +EchoWarden.SPAWN_EFFECT = CONST_ME_NONE -- monsters are born silently (no teleport flash) +EchoWarden.SPAWN_STEP_MS = 400 -- delay between each monster (born one after another) + +EchoWarden.STORAGE_IS_WARDEN = 54133 -- 1 = this monster is THE echo warden +EchoWarden.STORAGE_IS_SPAWNED = 54134 -- 1 = spawned by an echo raid (never re-triggers) +EchoWarden.KV_SCOPE = "echo_warden" -- player:kv():scoped(...) for first-kill + +-- first-kill reward (MINOR CHARM ECHOES) by bestiary difficulty (stars). 0-star fall back to [0]. +EchoWarden.ECHOES_BY_STARS = { [0] = 1, [1] = 10, [2] = 15, [3] = 20, [4] = 25, [5] = 30 } + +-- runtime map: wardenCreatureId -> baseKindName (aura loop + reward lookup) +EchoWarden.activeWardens = EchoWarden.activeWardens or {} +EchoWarden.activeMinions = EchoWarden.activeMinions or {} +EchoWarden.GLOW_REFRESH_MS = 400 +EchoWarden._glowLoop = false + +function EchoWarden.pickTile(center, radius) + radius = radius or EchoWarden.SCATTER_RADIUS + for _ = 1, 12 do + local dx = math.random(-radius, radius) + local dy = math.random(-radius, radius) + local p = Position(center.x + dx, center.y + dy, center.z) + local tile = Tile(p) + if tile and tile:getGround() and not tile:hasFlag(TILESTATE_BLOCKSOLID) and not tile:hasFlag(TILESTATE_PROTECTIONZONE) and not tile:hasFlag(TILESTATE_FLOORCHANGE) and not tile:hasFlag(TILESTATE_TELEPORT) then + return p + end + end + return center +end + +function EchoWarden.markSpawned(m) + if m and m.setStorageValue then + m:setStorageValue(EchoWarden.STORAGE_IS_SPAWNED, 1) + end +end + +EchoWarden.STORAGE_IS_MINION = 54135 +EchoWarden.MINION_HP_MULT = 1.5 +function EchoWarden.makeMinion(m) + if not m then + return + end + if m:getStorageValue(EchoWarden.STORAGE_IS_MINION) ~= 1 then + m:setStorageValue(EchoWarden.STORAGE_IS_MINION, 1) + m:setMaxHealth(math.floor(m:getMaxHealth() * EchoWarden.MINION_HP_MULT)) + m:setHealth(m:getMaxHealth()) + EchoWarden.markSpawned(m) -- nunca re-dispara un portal + end + EchoWarden.activeMinions[m:getId()] = true + EchoWarden.sendCreatureGlow(m, EchoWarden.STATE_MINION) -- glow nativo de MINION (state 1) + EchoWarden.ensureGlowLoop() -- re-envío continuo (persiste al moverse) +end + +EchoWarden.STATE_LEADER = 0 +EchoWarden.STATE_MINION = 1 +EchoWarden.GLOW_RANGE = 11 -- tiles a la redonda para mandar el glow a los spectators + +---@param creature Creature +---@param state number 0=leader, 1=minion +function EchoWarden.sendCreatureGlow(creature, state) + if not creature then + return + end + local cid = creature:getId() + local pos = creature:getPosition() + if not pos then + return + end + local r = EchoWarden.GLOW_RANGE + for _, spec in ipairs(Game.getSpectators(pos, false, true, r, r, r, r)) do + local msg = NetworkMessage() + msg:addByte(0x8B) + msg:addU32(cid) + msg:addByte(0x0f) + msg:addByte(1) + msg:addByte(state) -- 0=leader, 1=minion + msg:sendToPlayer(spec) + end +end + +function EchoWarden.glowRefresh() + local any = false + for id in pairs(EchoWarden.activeWardens) do + local w = Monster(id) + if w and not w:isRemoved() and w:getHealth() > 0 then + EchoWarden.sendCreatureGlow(w, EchoWarden.STATE_LEADER) + any = true + else + EchoWarden.activeWardens[id] = nil + end + end + for id in pairs(EchoWarden.activeMinions) do + local m = Monster(id) + if m and not m:isRemoved() and m:getHealth() > 0 then + EchoWarden.sendCreatureGlow(m, EchoWarden.STATE_MINION) + any = true + else + EchoWarden.activeMinions[id] = nil + end + end + if any then + addEvent(EchoWarden.glowRefresh, EchoWarden.GLOW_REFRESH_MS) + else + EchoWarden._glowLoop = false + end +end + +function EchoWarden.ensureGlowLoop() + if not EchoWarden._glowLoop then + EchoWarden._glowLoop = true + EchoWarden.glowRefresh() + end +end + +function EchoWarden.makeWarden(w, kindName) + if not w then + return + end + if EchoWarden.USE_CPP_ATK and w.applyEchoWarden then + w:applyEchoWarden(EchoWarden.WARDEN_HP_MULT, EchoWarden.WARDEN_ATK_MULT) + w:removeIcon("warden") + else + w:setMaxHealth(math.floor(w:getMaxHealth() * EchoWarden.WARDEN_HP_MULT)) + w:setHealth(w:getMaxHealth()) + end + w:setStorageValue(EchoWarden.STORAGE_IS_WARDEN, 1) + w:setStorageValue(EchoWarden.STORAGE_IS_SPAWNED, 1) + EchoWarden.activeWardens[w:getId()] = kindName + EchoWarden.sendCreatureGlow(w, EchoWarden.STATE_LEADER) + EchoWarden.ensureGlowLoop() +end + +function EchoWarden.aura(wardenId, kindName) + local w = Monster(wardenId) + if not w or w:isRemoved() or w:getHealth() <= 0 then + EchoWarden.activeWardens[wardenId] = nil + return + end + local p = w:getPosition() + local r = EchoWarden.AURA_RANGE + for _, c in ipairs(Game.getSpectators(p, false, false, r, r, r, r)) do + local m = c:getMonster() + if m and m:getId() ~= wardenId and m:getName() == kindName and m:getStorageValue(EchoWarden.STORAGE_IS_WARDEN) ~= 1 then + EchoWarden.makeMinion(m) + end + end + addEvent(EchoWarden.aura, EchoWarden.AURA_MS, wardenId, kindName) +end + +function EchoWarden.trickleSpawn(kindName, cx, cy, cz, remaining, stepMs) + if remaining <= 0 then + return + end + local m = Game.createMonster(kindName, Position(cx, cy, cz), false, true, nil, EchoWarden.SPAWN_EFFECT) + if m then + EchoWarden.makeMinion(m) + end + addEvent(EchoWarden.trickleSpawn, stepMs, kindName, cx, cy, cz, remaining - 1, stepMs) +end + +function EchoWarden.spawnPortal(px, py, pz, kindName) + local pos = Position(px, py, pz) + local tile = Tile(pos) + if not tile or not tile:getGround() then + return + end + local portal = Game.createItem(EchoWarden.PORTAL_ITEM_ID, 1, pos) + if not portal then + return + end + portal:setCustomAttribute(EchoWarden.PORTAL_ATTR_KIND, kindName) + pos:sendMagicEffect(CONST_ME_TELEPORT) + -- failsafe cleanup if nobody steps on it + addEvent(function(qx, qy, qz) + local t = Tile(Position(qx, qy, qz)) + if t then + local it = t:getItemById(EchoWarden.PORTAL_ITEM_ID) + if it then + it:remove() + end + end + end, EchoWarden.PORTAL_TTL_MS, px, py, pz) +end + +function EchoWarden.runRaid(kindName, center) + if not kindName or kindName == "" then + return + end + local cx, cy, cz = center.x, center.y, center.z + local step = EchoWarden.SPAWN_STEP_MS + + local w = Game.createMonster(kindName, Position(cx, cy, cz), false, true, nil, EchoWarden.SPAWN_EFFECT) + if w then + EchoWarden.makeWarden(w, kindName) + addEvent(EchoWarden.aura, EchoWarden.AURA_MS, w:getId(), kindName) + end + + local adds = math.random(EchoWarden.WARDEN_ADDS_MIN, EchoWarden.WARDEN_ADDS_MAX) + EchoWarden.trickleSpawn(kindName, cx, cy, cz, adds, step) +end + +function EchoWarden.grantReward(player, baseKind) + if not player or not baseKind or baseKind == "" then + return false + end + local kv = player:kv():scoped(EchoWarden.KV_SCOPE) + if kv:get(baseKind) then + return false + end + + local mt = MonsterType(baseKind) + local stars = (mt and mt:BestiaryStars()) or 0 + local amount = EchoWarden.ECHOES_BY_STARS[stars] or EchoWarden.ECHOES_BY_STARS[0] + player:addMinorCharmEchoes(amount) + kv:set(baseKind, true) + + local raceId = (mt and mt:raceId()) or 0 + if raceId > 0 then + player:sendLeaderMonsterKilledBanner(raceId, amount) + end + return true +end diff --git a/data-global/lib/core/load.lua b/data-global/lib/core/load.lua index bf9b37a52..2988fa676 100644 --- a/data-global/lib/core/load.lua +++ b/data-global/lib/core/load.lua @@ -1,3 +1,4 @@ dofile(DATA_DIRECTORY .. "/lib/core/storages.lua") dofile(DATA_DIRECTORY .. "/lib/core/constants.lua") dofile(DATA_DIRECTORY .. "/lib/core/quests.lua") +dofile(DATA_DIRECTORY .. "/lib/core/echo_warden.lua") diff --git a/data-global/scripts/custom/echo_warden/echo_warden_portal.lua b/data-global/scripts/custom/echo_warden/echo_warden_portal.lua new file mode 100644 index 000000000..60cc68700 --- /dev/null +++ b/data-global/scripts/custom/echo_warden/echo_warden_portal.lua @@ -0,0 +1,31 @@ +-- ============================================================================ +-- Echo Warden Portal - onStepIn for item 54133 +-- Reads stored echoKind, removes the portal, runs the 3-outcome echo raid. +-- Only players trigger it. +-- ============================================================================ + +local echoPortal = MoveEvent() + +function echoPortal.onStepIn(creature, item, position, fromPosition) + local player = creature and creature:getPlayer() + if not player then + return true + end + + local kind = item:getCustomAttribute(EchoWarden.PORTAL_ATTR_KIND) + if type(kind) ~= "string" or kind == "" then + item:remove() + return true + end + + local center = Position(position.x, position.y, position.z) + item:remove() + center:sendMagicEffect(CONST_ME_AGONY) + + EchoWarden.runRaid(kind, center) + return true +end + +echoPortal:type("stepin") +echoPortal:id(54133) +echoPortal:register() diff --git a/data-global/scripts/custom/echo_warden/echo_warden_trigger.lua b/data-global/scripts/custom/echo_warden/echo_warden_trigger.lua new file mode 100644 index 000000000..b4ea154d3 --- /dev/null +++ b/data-global/scripts/custom/echo_warden/echo_warden_trigger.lua @@ -0,0 +1,117 @@ +-- ============================================================================ +-- Echo Warden Trigger - onDeath for every common/uncommon monster +-- (a) 0.05% chance to spawn echo warden portal (item 54133) at corpse tile, +-- appearing PORTAL_DELAY_MS (30s) AFTER the kill (not instantly). +-- (b) on tagged Echo Warden death -> first-kill charm reward per damager +-- Echo-raid-spawned creatures (STORAGE_IS_SPAWNED == 1) never re-trigger. +-- Registered to all 1-2 star monsters via GlobalEvent onStartup. +-- ============================================================================ + +local echoWardenTrigger = CreatureEvent("EchoWardenTrigger") + +-- summon-aware list of players who damaged the creature +local function damagersOf(creature) + local players, seen = {}, {} + local dmap = creature:getDamageMap() + if not dmap then + return players + end + for cid, _ in pairs(dmap) do + if cid and cid > 0 then + local d = Creature(cid) + if d then + local pl + if d:isPlayer() then + pl = d:getPlayer() + else + local master = d:getMaster() + if master and master:isPlayer() then + pl = master:getPlayer() + end + end + if pl and not seen[pl:getId()] then + seen[pl:getId()] = true + players[#players + 1] = pl + end + end + end + end + return players +end + +function echoWardenTrigger.onDeath(creature, corpse, killer, mostDamageKiller, lastHitUnjustified, mostDamageUnjustified) + if not creature or not creature:isMonster() then + return true + end + + -- (b) tagged Echo Warden -> first-kill reward per damager, then stop. + if creature:getStorageValue(EchoWarden.STORAGE_IS_WARDEN) == 1 then + local baseKind = EchoWarden.activeWardens[creature:getId()] or creature:getName() + EchoWarden.activeWardens[creature:getId()] = nil + for _, player in ipairs(damagersOf(creature)) do + EchoWarden.grantReward(player, baseKind) + end + return true + end + + -- echo-raid-spawned creatures (warden + minions) never re-trigger. + if creature:getStorageValue(EchoWarden.STORAGE_IS_SPAWNED) == 1 then + return true + end + + local mType = creature:getType() + if not mType then + return true + end + + -- Guards: never spawn from summon / reward boss / bosstiary boss / forge / out-of-range stars. + if creature:getMaster() ~= nil or mType:isRewardBoss() then + return true + end + local br = mType.bossRace and mType:bossRace() + if br and br ~= "" then + return true + end + if creature.getForgeStack and creature:getForgeStack() > 0 then + return true + end + local stars = mType:BestiaryStars() or 0 + if stars < 1 or stars > 2 then -- SOLO common/uncommon (1-2 estrellas) disparan Echo Raids (como Tibia RL) + return true + end + + -- (a) portal spawn roll + if math.random(1, EchoWarden.SPAWN_CHANCE_DEN) > EchoWarden.SPAWN_CHANCE_NUM then + return true + end + + local kindName = creature:getName() + local pos = creature:getPosition() + addEvent(EchoWarden.spawnPortal, EchoWarden.PORTAL_DELAY_MS, pos.x, pos.y, pos.z, kindName) + + return true +end + +echoWardenTrigger:register() + +-- Register the death event to ALL common/uncommon monsters at boot. +local echoWardenStartup = GlobalEvent("EchoWardenTriggerStartup") + +function echoWardenStartup.onStartup() + local count = 0 + for stars = 1, 2 do -- SOLO common/uncommon (1-2 estrellas), como Tibia RL + local monsters = Game.getMonstersByBestiaryStars(stars) + if monsters then + for _, mType in ipairs(monsters) do + if mType:name() then + mType:registerEvent("EchoWardenTrigger") + count = count + 1 + end + end + end + end + logger.info("[EchoWarden] Registered death trigger to {} monsters (common/uncommon 1-2)", count) + return true +end + +echoWardenStartup:register() diff --git a/data-global/scripts/custom/echo_warden/ondroploot_echo_warden.lua b/data-global/scripts/custom/echo_warden/ondroploot_echo_warden.lua new file mode 100644 index 000000000..2886adf3d --- /dev/null +++ b/data-global/scripts/custom/echo_warden/ondroploot_echo_warden.lua @@ -0,0 +1,32 @@ +-- ============================================================================ +-- Echo Warden loot: ALWAYS 1 random basic scroll (53751-53774) + suffix. +-- The scroll is added unconditionally; the "(Echo Warden kill)" suffix is +-- best-effort cosmetic (creature.cpp only prints it when the most-damage +-- killer is a player receiving the loot message). +-- corpse is a Container userdata (events.cpp:1239), so corpse:addItem works; +-- there is NO Container:addLoot in this fork -- do not use it. +-- ============================================================================ + +local BASIC_SCROLL_MIN, BASIC_SCROLL_MAX = 53751, 53774 + +local callback = EventCallback("MonsterOnDropLootEchoWarden") + +function callback.monsterOnDropLoot(monster, corpse) + if not monster or not corpse then + return + end + if monster:getStorageValue(EchoWarden.STORAGE_IS_WARDEN) ~= 1 then + return + end + + -- exactly one random basic imbuement scroll + local scrollId = math.random(BASIC_SCROLL_MIN, BASIC_SCROLL_MAX) + corpse:addItem(scrollId, 1) + + -- loot message suffix: C++ wraps as "Loot of X: items (Echo Warden kill)" + local existing = corpse:getAttribute(ITEM_ATTRIBUTE_LOOTMESSAGE_SUFFIX) or "" + local suffix = string.len(existing) > 0 and (existing .. " Echo Warden kill") or "Echo Warden kill" + corpse:setAttribute(ITEM_ATTRIBUTE_LOOTMESSAGE_SUFFIX, suffix) +end + +callback:register() diff --git a/data/XML/imbuements.xml b/data/XML/imbuements.xml index dc712cbd6..b1e8563a7 100644 --- a/data/XML/imbuements.xml +++ b/data/XML/imbuements.xml @@ -23,7 +23,7 @@ - + @@ -41,7 +41,7 @@ - + @@ -59,7 +59,7 @@ - + @@ -77,7 +77,7 @@ - + @@ -95,7 +95,7 @@ - + @@ -113,7 +113,7 @@ - + @@ -131,7 +131,7 @@ - + @@ -149,7 +149,7 @@ - + @@ -167,7 +167,7 @@ - + @@ -185,7 +185,7 @@ - + @@ -203,7 +203,7 @@ - + @@ -221,7 +221,7 @@ - + @@ -239,7 +239,7 @@ - + @@ -257,7 +257,7 @@ - + @@ -275,7 +275,7 @@ - + @@ -293,7 +293,7 @@ - + @@ -311,7 +311,7 @@ - + @@ -329,7 +329,7 @@ - + @@ -347,7 +347,7 @@ - + @@ -365,7 +365,7 @@ - + @@ -383,7 +383,7 @@ - + @@ -401,7 +401,7 @@ - + @@ -419,7 +419,7 @@ - + @@ -437,7 +437,7 @@ - + diff --git a/data/XML/mounts.xml b/data/XML/mounts.xml index af7293507..f948a255c 100644 --- a/data/XML/mounts.xml +++ b/data/XML/mounts.xml @@ -276,4 +276,6 @@ + + \ No newline at end of file diff --git a/data/items/appearances.dat b/data/items/appearances.dat index 07db48e8e..ad2b48757 100644 Binary files a/data/items/appearances.dat and b/data/items/appearances.dat differ diff --git a/data/items/items.xml b/data/items/items.xml index d44c6db32..236c20484 100644 --- a/data/items/items.xml +++ b/data/items/items.xml @@ -87477,4 +87477,753 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/data/items/proficiencies.json b/data/items/proficiencies.json index 49ec7edc6..6725f22b6 100644 --- a/data/items/proficiencies.json +++ b/data/items/proficiencies.json @@ -27086,5 +27086,2667 @@ "Name": "Crypt 1H Rod", "ProficiencyId": 463, "Version": 5 + }, + { + "Levels": [ + { + "Perks": [ + { + "ElementId": 64, + "Type": 31, + "Value": 0.05 + }, + { + "Type": 0, + "Value": 1 + } + ] + }, + { + "Perks": [ + { + "ElementId": 64, + "Type": 9, + "Value": 0.015 + }, + { + "Type": 0, + "Value": 1 + } + ] + }, + { + "Perks": [ + { + "ElementId": 64, + "Type": 31, + "Value": 0.1 + }, + { + "Type": 24, + "Value": 1 + } + ] + }, + { + "Perks": [ + { + "ElementId": 64, + "Type": 9, + "Value": 0.03 + }, + { + "Type": 0, + "Value": 2 + } + ] + }, + { + "Perks": [ + { + "ElementId": 64, + "Type": 13, + "Value": 0.1 + }, + { + "Type": 15, + "Value": 2.0 + } + ] + } + ], + "Name": "Throw - Snowball with Shards", + "ProficiencyId": 474, + "Version": 1 + }, + { + "Levels": [ + { + "Perks": [ + { + "Type": 17, + "Value": 0.04 + } + ] + }, + { + "Perks": [ + { + "BestiaryId": 10, + "Type": 6, + "Value": 0.03 + }, + { + "Type": 12, + "Value": 0.05 + }, + { + "SkillId": 6, + "Type": 3, + "Value": 2 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 2, + "SpellId": 106, + "Type": 5, + "Value": 0.06 + }, + { + "ElementId": 16, + "MissileId": 71, + "Multiplier": 2.0, + "Probability": 0.01, + "Type": 32, + "Value": 0 + }, + { + "AugmentType": 2, + "SpellId": 316, + "Type": 5, + "Value": 0.05 + } + ] + }, + { + "Perks": [ + { + "Type": 17, + "Value": 0.03 + }, + { + "Type": 0, + "Value": 1 + }, + { + "Type": 8, + "Value": 0.01 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 17, + "SpellId": 106, + "Type": 5, + "Value": 0.03 + }, + { + "Type": 12, + "Value": 0.12 + }, + { + "AugmentType": 17, + "SpellId": 316, + "Type": 5, + "Value": 0.025 + } + ] + }, + { + "Perks": [ + { + "Type": 16, + "Value": 0.02 + }, + { + "Type": 0, + "Value": 1 + }, + { + "ElementId": 1, + "Type": 31, + "Value": 0.09 + } + ] + }, + { + "Perks": [ + { + "Type": 29, + "Value": 0.05 + } + ] + } + ], + "Name": "Moonsilver 1H Axe", + "ProficiencyId": 475, + "Version": 4 + }, + { + "Levels": [ + { + "Perks": [ + { + "Type": 17, + "Value": 0.04 + } + ] + }, + { + "Perks": [ + { + "BestiaryId": 10, + "Type": 6, + "Value": 0.03 + }, + { + "Type": 12, + "Value": 0.05 + }, + { + "SkillId": 6, + "Type": 3, + "Value": 2 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 2, + "SpellId": 106, + "Type": 5, + "Value": 0.06 + }, + { + "ElementId": 16, + "MissileId": 71, + "Multiplier": 2.0, + "Probability": 0.01, + "Type": 32, + "Value": 0 + }, + { + "AugmentType": 2, + "SpellId": 316, + "Type": 5, + "Value": 0.05 + } + ] + }, + { + "Perks": [ + { + "Type": 17, + "Value": 0.03 + }, + { + "Type": 0, + "Value": 1 + }, + { + "Type": 8, + "Value": 0.01 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 17, + "SpellId": 106, + "Type": 5, + "Value": 0.03 + }, + { + "Type": 12, + "Value": 0.12 + }, + { + "AugmentType": 17, + "SpellId": 316, + "Type": 5, + "Value": 0.025 + } + ] + }, + { + "Perks": [ + { + "Type": 16, + "Value": 0.02 + }, + { + "Type": 0, + "Value": 1 + }, + { + "ElementId": 1, + "Type": 31, + "Value": 0.09 + } + ] + }, + { + "Perks": [ + { + "Type": 29, + "Value": 0.05 + } + ] + } + ], + "Name": "Moonsilver 1H Club", + "ProficiencyId": 476, + "Version": 4 + }, + { + "Levels": [ + { + "Perks": [ + { + "Type": 16, + "Value": 0.025 + } + ] + }, + { + "Perks": [ + { + "BestiaryId": 10, + "Type": 6, + "Value": 0.03 + }, + { + "SkillId": 1, + "Type": 3, + "Value": 1 + }, + { + "Type": 12, + "Value": 0.05 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 2, + "SpellId": 43, + "Type": 5, + "Value": 0.06 + }, + { + "ElementId": 64, + "MissileId": 73, + "Multiplier": 2.0, + "Probability": 0.01, + "Type": 32, + "Value": 0 + }, + { + "AugmentType": 2, + "SpellId": 318, + "Type": 5, + "Value": 0.05 + } + ] + }, + { + "Perks": [ + { + "Type": 16, + "Value": 0.025 + }, + { + "SkillId": 1, + "Type": 3, + "Value": 2 + }, + { + "Type": 8, + "Value": 0.01 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 17, + "SpellId": 43, + "Type": 5, + "Value": 0.015 + }, + { + "AugmentType": 3, + "SpellId": 5, + "Type": 5, + "Value": 0.06 + }, + { + "AugmentType": 17, + "SpellId": 318, + "Type": 5, + "Value": 0.01 + } + ] + }, + { + "Perks": [ + { + "ElementId": 64, + "Type": 31, + "Value": 0.04 + }, + { + "Type": 12, + "Value": 0.12 + }, + { + "ElementId": 16, + "Type": 31, + "Value": 0.04 + } + ] + }, + { + "Perks": [ + { + "Type": 29, + "Value": 0.075 + } + ] + } + ], + "Name": "Moonsilver 1H Rod", + "ProficiencyId": 477, + "Version": 4 + }, + { + "Levels": [ + { + "Perks": [ + { + "Type": 17, + "Value": 0.04 + } + ] + }, + { + "Perks": [ + { + "BestiaryId": 10, + "Type": 6, + "Value": 0.03 + }, + { + "Type": 12, + "Value": 0.05 + }, + { + "SkillId": 6, + "Type": 3, + "Value": 2 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 2, + "SpellId": 106, + "Type": 5, + "Value": 0.06 + }, + { + "ElementId": 16, + "MissileId": 73, + "Multiplier": 2.0, + "Probability": 0.01, + "Type": 32, + "Value": 0 + }, + { + "AugmentType": 2, + "SpellId": 316, + "Type": 5, + "Value": 0.05 + } + ] + }, + { + "Perks": [ + { + "Type": 17, + "Value": 0.03 + }, + { + "Type": 0, + "Value": 1 + }, + { + "Type": 8, + "Value": 0.01 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 17, + "SpellId": 106, + "Type": 5, + "Value": 0.03 + }, + { + "Type": 12, + "Value": 0.12 + }, + { + "AugmentType": 17, + "SpellId": 316, + "Type": 5, + "Value": 0.025 + } + ] + }, + { + "Perks": [ + { + "Type": 16, + "Value": 0.02 + }, + { + "Type": 0, + "Value": 1 + }, + { + "ElementId": 1, + "Type": 31, + "Value": 0.09 + } + ] + }, + { + "Perks": [ + { + "Type": 29, + "Value": 0.05 + } + ] + } + ], + "Name": "Moonsilver 1H Sword", + "ProficiencyId": 478, + "Version": 6 + }, + { + "Levels": [ + { + "Perks": [ + { + "Type": 12, + "Value": 0.1 + } + ] + }, + { + "Perks": [ + { + "BestiaryId": 10, + "Type": 6, + "Value": 0.03 + }, + { + "SkillId": 1, + "Type": 3, + "Value": 1 + }, + { + "Type": 17, + "Value": 0.03 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 2, + "SpellId": 240, + "Type": 5, + "Value": 0.04 + }, + { + "ElementId": 256, + "MissileId": 75, + "Multiplier": 2.0, + "Probability": 0.01, + "Type": 32, + "Value": 0 + }, + { + "AugmentType": 2, + "SpellId": 310, + "Type": 5, + "Value": 0.05 + } + ] + }, + { + "Perks": [ + { + "Type": 16, + "Value": 0.025 + }, + { + "SkillId": 1, + "Type": 3, + "Value": 2 + }, + { + "Type": 8, + "Value": 0.01 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 17, + "SpellId": 240, + "Type": 5, + "Value": 0.01 + }, + { + "AugmentType": 17, + "SpellId": 21, + "Type": 5, + "Value": 0.025 + }, + { + "AugmentType": 17, + "SpellId": 310, + "Type": 5, + "Value": 0.015 + } + ] + }, + { + "Perks": [ + { + "ElementId": 8, + "Type": 31, + "Value": 0.04 + }, + { + "Type": 12, + "Value": 0.12 + }, + { + "ElementId": 256, + "Type": 31, + "Value": 0.04 + } + ] + }, + { + "Perks": [ + { + "Type": 29, + "Value": 0.075 + } + ] + } + ], + "Name": "Moonsilver 1H Wand", + "ProficiencyId": 479, + "Version": 4 + }, + { + "Levels": [ + { + "Perks": [ + { + "Type": 17, + "Value": 0.04 + } + ] + }, + { + "Perks": [ + { + "BestiaryId": 10, + "Type": 6, + "Value": 0.03 + }, + { + "Type": 12, + "Value": 0.05 + }, + { + "SkillId": 10, + "Type": 3, + "Value": 1 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 2, + "SpellId": 106, + "Type": 5, + "Value": 0.06 + }, + { + "ElementId": 8, + "MissileId": 71, + "Multiplier": 2.0, + "Probability": 0.01, + "Type": 32, + "Value": 0 + }, + { + "Type": 11, + "Value": 0.015 + } + ] + }, + { + "Perks": [ + { + "Type": 17, + "Value": 0.03 + }, + { + "Type": 0, + "Value": 1 + }, + { + "Type": 8, + "Value": 0.01 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 17, + "SpellId": 106, + "Type": 5, + "Value": 0.03 + }, + { + "Type": 12, + "Value": 0.12 + }, + { + "SkillId": 10, + "Type": 25, + "Value": 0.1 + } + ] + }, + { + "Perks": [ + { + "Type": 16, + "Value": 0.02 + }, + { + "Type": 0, + "Value": 1 + }, + { + "ElementId": 1, + "Type": 31, + "Value": 0.09 + } + ] + }, + { + "Perks": [ + { + "Type": 29, + "Value": 0.05 + } + ] + } + ], + "Name": "Moonsilver 2H Axe", + "ProficiencyId": 480, + "Version": 4 + }, + { + "Levels": [ + { + "Perks": [ + { + "Type": 12, + "Value": 0.08 + } + ] + }, + { + "Perks": [ + { + "BestiaryId": 10, + "Type": 6, + "Value": 0.03 + }, + { + "SkillId": 7, + "Type": 3, + "Value": 1 + }, + { + "Type": 17, + "Value": 0.03 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 2, + "SpellId": 303, + "Type": 5, + "Value": 0.04 + }, + { + "ElementId": 128, + "MissileId": 74, + "Multiplier": 2.0, + "Probability": 0.01, + "Type": 32, + "Value": 0 + }, + { + "AugmentType": 2, + "SpellId": 302, + "Type": 5, + "Value": 0.04 + } + ] + }, + { + "Perks": [ + { + "Type": 30, + "Value": 0.06 + }, + { + "Type": 0, + "Value": 2 + }, + { + "Type": 16, + "Value": 0.02 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 17, + "SpellId": 303, + "Type": 5, + "Value": 0.01 + }, + { + "SkillId": 7, + "Type": 26, + "Value": 0.12 + }, + { + "AugmentType": 17, + "SpellId": 302, + "Type": 5, + "Value": 0.01 + } + ] + }, + { + "Perks": [ + { + "ElementId": 1, + "Type": 31, + "Value": 0.09 + }, + { + "Type": 12, + "Value": 0.1 + }, + { + "ElementId": 128, + "Type": 31, + "Value": 0.04 + } + ] + }, + { + "Perks": [ + { + "Type": 29, + "Value": 0.05 + } + ] + } + ], + "Name": "Moonsilver 2H Bow", + "ProficiencyId": 481, + "Version": 7 + }, + { + "Levels": [ + { + "Perks": [ + { + "Type": 17, + "Value": 0.04 + } + ] + }, + { + "Perks": [ + { + "BestiaryId": 10, + "Type": 6, + "Value": 0.03 + }, + { + "Type": 12, + "Value": 0.05 + }, + { + "SkillId": 9, + "Type": 3, + "Value": 1 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 2, + "SpellId": 106, + "Type": 5, + "Value": 0.06 + }, + { + "ElementId": 64, + "MissileId": 73, + "Multiplier": 2.0, + "Probability": 0.01, + "Type": 32, + "Value": 0 + }, + { + "Type": 11, + "Value": 0.015 + } + ] + }, + { + "Perks": [ + { + "Type": 17, + "Value": 0.03 + }, + { + "Type": 0, + "Value": 1 + }, + { + "Type": 8, + "Value": 0.01 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 17, + "SpellId": 106, + "Type": 5, + "Value": 0.03 + }, + { + "Type": 12, + "Value": 0.12 + }, + { + "SkillId": 9, + "Type": 25, + "Value": 0.1 + } + ] + }, + { + "Perks": [ + { + "Type": 16, + "Value": 0.02 + }, + { + "Type": 0, + "Value": 1 + }, + { + "ElementId": 1, + "Type": 31, + "Value": 0.09 + } + ] + }, + { + "Perks": [ + { + "Type": 29, + "Value": 0.05 + } + ] + } + ], + "Name": "Moonsilver 2H Club", + "ProficiencyId": 482, + "Version": 3 + }, + { + "Levels": [ + { + "Perks": [ + { + "Type": 23, + "Value": 0.02 + } + ] + }, + { + "Perks": [ + { + "Type": 7, + "Value": 0.03 + }, + { + "SkillId": 7, + "Type": 3, + "Value": 1 + }, + { + "Type": 17, + "Value": 0.03 + } + ] + }, + { + "Perks": [ + { + "ElementId": 1, + "Type": 13, + "Value": 0.12 + }, + { + "ElementId": 128, + "MissileId": 74, + "Multiplier": 2.0, + "Probability": 0.01, + "Type": 32, + "Value": 0 + }, + { + "ElementId": 128, + "Type": 13, + "Value": 0.1 + } + ] + }, + { + "Perks": [ + { + "Type": 30, + "Value": 0.06 + }, + { + "Type": 0, + "Value": 2 + }, + { + "Type": 16, + "Value": 0.02 + } + ] + }, + { + "Perks": [ + { + "ElementId": 1, + "Type": 9, + "Value": 0.02 + }, + { + "SkillId": 7, + "Type": 26, + "Value": 0.12 + }, + { + "ElementId": 128, + "Type": 9, + "Value": 0.015 + } + ] + }, + { + "Perks": [ + { + "ElementId": 1, + "Type": 31, + "Value": 0.09 + }, + { + "Type": 12, + "Value": 0.12 + }, + { + "ElementId": 128, + "Type": 31, + "Value": 0.04 + } + ] + }, + { + "Perks": [ + { + "Type": 29, + "Value": 0.05 + } + ] + } + ], + "Name": "Moonsilver 2H Crossbow", + "ProficiencyId": 483, + "Version": 5 + }, + { + "Levels": [ + { + "Perks": [ + { + "Type": 12, + "Value": 0.1 + } + ] + }, + { + "Perks": [ + { + "BestiaryId": 10, + "Type": 6, + "Value": 0.03 + }, + { + "SkillId": 1, + "Type": 3, + "Value": 2 + }, + { + "Type": 12, + "Value": 0.05 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 14, + "SpellId": 301, + "Type": 5, + "Value": 0.1 + }, + { + "ElementId": 32, + "MissileId": 72, + "Multiplier": 2.0, + "Probability": 0.01, + "Type": 32, + "Value": 0 + }, + { + "AugmentType": 2, + "SpellId": 301, + "Type": 5, + "Value": 0.075 + } + ] + }, + { + "Perks": [ + { + "Type": 17, + "Value": 0.03 + }, + { + "Type": 0, + "Value": 1 + }, + { + "Type": 8, + "Value": 0.01 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 15, + "SpellId": 301, + "Type": 5, + "Value": 0.075 + }, + { + "Type": 12, + "Value": 0.08 + }, + { + "AugmentType": 17, + "SpellId": 301, + "Type": 5, + "Value": 0.035 + } + ] + }, + { + "Perks": [ + { + "Type": 16, + "Value": 0.02 + }, + { + "Type": 0, + "Value": 1 + }, + { + "Type": 17, + "Value": 0.06 + } + ] + }, + { + "Perks": [ + { + "Type": 29, + "Value": 0.05 + } + ] + } + ], + "Name": "Moonsilver 2H Fist", + "ProficiencyId": 484, + "Version": 5 + }, + { + "Levels": [ + { + "Perks": [ + { + "Type": 17, + "Value": 0.04 + } + ] + }, + { + "Perks": [ + { + "BestiaryId": 10, + "Type": 6, + "Value": 0.03 + }, + { + "Type": 12, + "Value": 0.05 + }, + { + "SkillId": 8, + "Type": 3, + "Value": 1 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 2, + "SpellId": 106, + "Type": 5, + "Value": 0.06 + }, + { + "ElementId": 8, + "MissileId": 71, + "Multiplier": 2.0, + "Probability": 0.01, + "Type": 32, + "Value": 0 + }, + { + "Type": 11, + "Value": 0.015 + } + ] + }, + { + "Perks": [ + { + "Type": 17, + "Value": 0.03 + }, + { + "Type": 0, + "Value": 1 + }, + { + "Type": 8, + "Value": 0.01 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 17, + "SpellId": 106, + "Type": 5, + "Value": 0.03 + }, + { + "Type": 12, + "Value": 0.12 + }, + { + "SkillId": 8, + "Type": 25, + "Value": 0.1 + } + ] + }, + { + "Perks": [ + { + "Type": 16, + "Value": 0.02 + }, + { + "Type": 0, + "Value": 1 + }, + { + "ElementId": 1, + "Type": 31, + "Value": 0.09 + } + ] + }, + { + "Perks": [ + { + "Type": 29, + "Value": 0.05 + } + ] + } + ], + "Name": "Moonsilver 2H Sword", + "ProficiencyId": 485, + "Version": 4 + }, + { + "Levels": [ + { + "Perks": [ + { + "Type": 17, + "Value": 0.04 + } + ] + }, + { + "Perks": [ + { + "BestiaryId": 10, + "Type": 6, + "Value": 0.03 + }, + { + "Type": 12, + "Value": 0.05 + }, + { + "SkillId": 6, + "Type": 3, + "Value": 2 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 2, + "SpellId": 106, + "Type": 5, + "Value": 0.09 + }, + { + "ElementId": 16, + "MissileId": 71, + "Multiplier": 3.0, + "Probability": 0.01, + "Type": 32, + "Value": 0 + }, + { + "AugmentType": 2, + "SpellId": 316, + "Type": 5, + "Value": 0.075 + } + ] + }, + { + "Perks": [ + { + "Type": 17, + "Value": 0.03 + }, + { + "Type": 0, + "Value": 1 + }, + { + "Type": 8, + "Value": 0.01 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 17, + "SpellId": 106, + "Type": 5, + "Value": 0.03 + }, + { + "Type": 12, + "Value": 0.12 + }, + { + "AugmentType": 17, + "SpellId": 316, + "Type": 5, + "Value": 0.025 + } + ] + }, + { + "Perks": [ + { + "Type": 16, + "Value": 0.02 + }, + { + "Type": 0, + "Value": 1 + }, + { + "ElementId": 1, + "Type": 31, + "Value": 0.09 + } + ] + }, + { + "Perks": [ + { + "Type": 29, + "Value": 0.075 + } + ] + } + ], + "Name": "Stellar Moonsilver 1H Axe", + "ProficiencyId": 486, + "Version": 5 + }, + { + "Levels": [ + { + "Perks": [ + { + "Type": 17, + "Value": 0.04 + } + ] + }, + { + "Perks": [ + { + "BestiaryId": 10, + "Type": 6, + "Value": 0.03 + }, + { + "Type": 12, + "Value": 0.05 + }, + { + "SkillId": 6, + "Type": 3, + "Value": 2 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 2, + "SpellId": 106, + "Type": 5, + "Value": 0.09 + }, + { + "ElementId": 16, + "MissileId": 71, + "Multiplier": 3.0, + "Probability": 0.01, + "Type": 32, + "Value": 0 + }, + { + "AugmentType": 2, + "SpellId": 316, + "Type": 5, + "Value": 0.075 + } + ] + }, + { + "Perks": [ + { + "Type": 17, + "Value": 0.03 + }, + { + "Type": 0, + "Value": 1 + }, + { + "Type": 8, + "Value": 0.01 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 17, + "SpellId": 106, + "Type": 5, + "Value": 0.03 + }, + { + "Type": 12, + "Value": 0.12 + }, + { + "AugmentType": 17, + "SpellId": 316, + "Type": 5, + "Value": 0.025 + } + ] + }, + { + "Perks": [ + { + "Type": 16, + "Value": 0.02 + }, + { + "Type": 0, + "Value": 1 + }, + { + "ElementId": 1, + "Type": 31, + "Value": 0.09 + } + ] + }, + { + "Perks": [ + { + "Type": 29, + "Value": 0.075 + } + ] + } + ], + "Name": "Stellar Moonsilver 1H Club", + "ProficiencyId": 487, + "Version": 5 + }, + { + "Levels": [ + { + "Perks": [ + { + "Type": 16, + "Value": 0.025 + } + ] + }, + { + "Perks": [ + { + "BestiaryId": 10, + "Type": 6, + "Value": 0.03 + }, + { + "SkillId": 1, + "Type": 3, + "Value": 1 + }, + { + "Type": 12, + "Value": 0.05 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 2, + "SpellId": 43, + "Type": 5, + "Value": 0.09 + }, + { + "ElementId": 64, + "MissileId": 80, + "Multiplier": 3.0, + "Probability": 0.01, + "Type": 32, + "Value": 0 + }, + { + "AugmentType": 2, + "SpellId": 318, + "Type": 5, + "Value": 0.075 + } + ] + }, + { + "Perks": [ + { + "Type": 16, + "Value": 0.025 + }, + { + "SkillId": 1, + "Type": 3, + "Value": 2 + }, + { + "Type": 8, + "Value": 0.01 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 17, + "SpellId": 43, + "Type": 5, + "Value": 0.015 + }, + { + "AugmentType": 3, + "SpellId": 5, + "Type": 5, + "Value": 0.06 + }, + { + "AugmentType": 17, + "SpellId": 318, + "Type": 5, + "Value": 0.01 + } + ] + }, + { + "Perks": [ + { + "ElementId": 64, + "Type": 31, + "Value": 0.04 + }, + { + "Type": 12, + "Value": 0.12 + }, + { + "ElementId": 16, + "Type": 31, + "Value": 0.04 + } + ] + }, + { + "Perks": [ + { + "Type": 29, + "Value": 0.1125 + } + ] + } + ], + "Name": "Stellar Moonsilver 1H Rod", + "ProficiencyId": 488, + "Version": 5 + }, + { + "Levels": [ + { + "Perks": [ + { + "Type": 17, + "Value": 0.04 + } + ] + }, + { + "Perks": [ + { + "BestiaryId": 10, + "Type": 6, + "Value": 0.03 + }, + { + "Type": 12, + "Value": 0.05 + }, + { + "SkillId": 6, + "Type": 3, + "Value": 2 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 2, + "SpellId": 106, + "Type": 5, + "Value": 0.09 + }, + { + "ElementId": 16, + "MissileId": 71, + "Multiplier": 3.0, + "Probability": 0.01, + "Type": 32, + "Value": 0 + }, + { + "AugmentType": 2, + "SpellId": 316, + "Type": 5, + "Value": 0.075 + } + ] + }, + { + "Perks": [ + { + "Type": 17, + "Value": 0.03 + }, + { + "Type": 0, + "Value": 1 + }, + { + "Type": 8, + "Value": 0.01 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 17, + "SpellId": 106, + "Type": 5, + "Value": 0.03 + }, + { + "Type": 12, + "Value": 0.12 + }, + { + "AugmentType": 17, + "SpellId": 316, + "Type": 5, + "Value": 0.025 + } + ] + }, + { + "Perks": [ + { + "Type": 16, + "Value": 0.02 + }, + { + "Type": 0, + "Value": 1 + }, + { + "ElementId": 1, + "Type": 31, + "Value": 0.09 + } + ] + }, + { + "Perks": [ + { + "Type": 29, + "Value": 0.075 + } + ] + } + ], + "Name": "Stellar Moonsilver 1H Sword", + "ProficiencyId": 489, + "Version": 6 + }, + { + "Levels": [ + { + "Perks": [ + { + "Type": 12, + "Value": 0.1 + } + ] + }, + { + "Perks": [ + { + "BestiaryId": 10, + "Type": 6, + "Value": 0.03 + }, + { + "SkillId": 1, + "Type": 3, + "Value": 1 + }, + { + "Type": 17, + "Value": 0.03 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 2, + "SpellId": 240, + "Type": 5, + "Value": 0.06 + }, + { + "ElementId": 256, + "MissileId": 82, + "Multiplier": 3.0, + "Probability": 0.01, + "Type": 32, + "Value": 0 + }, + { + "AugmentType": 2, + "SpellId": 310, + "Type": 5, + "Value": 0.075 + } + ] + }, + { + "Perks": [ + { + "Type": 16, + "Value": 0.025 + }, + { + "SkillId": 1, + "Type": 3, + "Value": 2 + }, + { + "Type": 8, + "Value": 0.01 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 17, + "SpellId": 240, + "Type": 5, + "Value": 0.01 + }, + { + "AugmentType": 17, + "SpellId": 21, + "Type": 5, + "Value": 0.025 + }, + { + "AugmentType": 17, + "SpellId": 310, + "Type": 5, + "Value": 0.015 + } + ] + }, + { + "Perks": [ + { + "ElementId": 8, + "Type": 31, + "Value": 0.04 + }, + { + "Type": 12, + "Value": 0.12 + }, + { + "ElementId": 256, + "Type": 31, + "Value": 0.04 + } + ] + }, + { + "Perks": [ + { + "Type": 29, + "Value": 0.1125 + } + ] + } + ], + "Name": "Stellar Moonsilver 1H Wand", + "ProficiencyId": 490, + "Version": 5 + }, + { + "Levels": [ + { + "Perks": [ + { + "Type": 17, + "Value": 0.04 + } + ] + }, + { + "Perks": [ + { + "BestiaryId": 10, + "Type": 6, + "Value": 0.03 + }, + { + "Type": 12, + "Value": 0.05 + }, + { + "SkillId": 10, + "Type": 3, + "Value": 1 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 2, + "SpellId": 106, + "Type": 5, + "Value": 0.09 + }, + { + "ElementId": 8, + "MissileId": 79, + "Multiplier": 3.0, + "Probability": 0.01, + "Type": 32, + "Value": 0 + }, + { + "Type": 11, + "Value": 0.0275 + } + ] + }, + { + "Perks": [ + { + "Type": 17, + "Value": 0.03 + }, + { + "Type": 0, + "Value": 1 + }, + { + "Type": 8, + "Value": 0.01 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 17, + "SpellId": 106, + "Type": 5, + "Value": 0.03 + }, + { + "Type": 12, + "Value": 0.12 + }, + { + "SkillId": 10, + "Type": 25, + "Value": 0.1 + } + ] + }, + { + "Perks": [ + { + "Type": 16, + "Value": 0.02 + }, + { + "Type": 0, + "Value": 1 + }, + { + "ElementId": 1, + "Type": 31, + "Value": 0.09 + } + ] + }, + { + "Perks": [ + { + "Type": 29, + "Value": 0.075 + } + ] + } + ], + "Name": "Stellar Moonsilver 2H Axe", + "ProficiencyId": 491, + "Version": 5 + }, + { + "Levels": [ + { + "Perks": [ + { + "Type": 12, + "Value": 0.08 + } + ] + }, + { + "Perks": [ + { + "BestiaryId": 10, + "Type": 6, + "Value": 0.03 + }, + { + "SkillId": 7, + "Type": 3, + "Value": 1 + }, + { + "Type": 17, + "Value": 0.03 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 2, + "SpellId": 303, + "Type": 5, + "Value": 0.06 + }, + { + "ElementId": 128, + "MissileId": 81, + "Multiplier": 3.0, + "Probability": 0.01, + "Type": 32, + "Value": 0 + }, + { + "AugmentType": 2, + "SpellId": 302, + "Type": 5, + "Value": 0.06 + } + ] + }, + { + "Perks": [ + { + "Type": 30, + "Value": 0.06 + }, + { + "Type": 0, + "Value": 2 + }, + { + "Type": 16, + "Value": 0.02 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 17, + "SpellId": 303, + "Type": 5, + "Value": 0.01 + }, + { + "SkillId": 7, + "Type": 26, + "Value": 0.12 + }, + { + "AugmentType": 17, + "SpellId": 302, + "Type": 5, + "Value": 0.01 + } + ] + }, + { + "Perks": [ + { + "ElementId": 1, + "Type": 31, + "Value": 0.09 + }, + { + "Type": 12, + "Value": 0.1 + }, + { + "ElementId": 128, + "Type": 31, + "Value": 0.04 + } + ] + }, + { + "Perks": [ + { + "Type": 29, + "Value": 0.075 + } + ] + } + ], + "Name": "Stellar Moonsilver 2H Bow", + "ProficiencyId": 492, + "Version": 8 + }, + { + "Levels": [ + { + "Perks": [ + { + "Type": 17, + "Value": 0.04 + } + ] + }, + { + "Perks": [ + { + "BestiaryId": 10, + "Type": 6, + "Value": 0.03 + }, + { + "Type": 12, + "Value": 0.05 + }, + { + "SkillId": 9, + "Type": 3, + "Value": 1 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 2, + "SpellId": 106, + "Type": 5, + "Value": 0.09 + }, + { + "ElementId": 64, + "MissileId": 73, + "Multiplier": 3.0, + "Probability": 0.01, + "Type": 32, + "Value": 0 + }, + { + "Type": 11, + "Value": 0.0275 + } + ] + }, + { + "Perks": [ + { + "Type": 17, + "Value": 0.03 + }, + { + "Type": 0, + "Value": 1 + }, + { + "Type": 8, + "Value": 0.01 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 17, + "SpellId": 106, + "Type": 5, + "Value": 0.03 + }, + { + "Type": 12, + "Value": 0.12 + }, + { + "SkillId": 9, + "Type": 25, + "Value": 0.1 + } + ] + }, + { + "Perks": [ + { + "Type": 16, + "Value": 0.02 + }, + { + "Type": 0, + "Value": 1 + }, + { + "ElementId": 1, + "Type": 31, + "Value": 0.09 + } + ] + }, + { + "Perks": [ + { + "Type": 29, + "Value": 0.075 + } + ] + } + ], + "Name": "Stellar Moonsilver 2H Club", + "ProficiencyId": 493, + "Version": 4 + }, + { + "Levels": [ + { + "Perks": [ + { + "Type": 23, + "Value": 0.02 + } + ] + }, + { + "Perks": [ + { + "Type": 7, + "Value": 0.03 + }, + { + "SkillId": 7, + "Type": 3, + "Value": 1 + }, + { + "Type": 17, + "Value": 0.03 + } + ] + }, + { + "Perks": [ + { + "ElementId": 1, + "Type": 13, + "Value": 0.18 + }, + { + "ElementId": 128, + "MissileId": 81, + "Multiplier": 3.0, + "Probability": 0.01, + "Type": 32, + "Value": 0 + }, + { + "ElementId": 128, + "Type": 13, + "Value": 0.15 + } + ] + }, + { + "Perks": [ + { + "Type": 30, + "Value": 0.06 + }, + { + "Type": 0, + "Value": 2 + }, + { + "Type": 16, + "Value": 0.02 + } + ] + }, + { + "Perks": [ + { + "ElementId": 1, + "Type": 9, + "Value": 0.02 + }, + { + "SkillId": 7, + "Type": 26, + "Value": 0.12 + }, + { + "ElementId": 128, + "Type": 9, + "Value": 0.015 + } + ] + }, + { + "Perks": [ + { + "ElementId": 1, + "Type": 31, + "Value": 0.09 + }, + { + "Type": 12, + "Value": 0.12 + }, + { + "ElementId": 128, + "Type": 31, + "Value": 0.04 + } + ] + }, + { + "Perks": [ + { + "Type": 29, + "Value": 0.075 + } + ] + } + ], + "Name": "Stellar Moonsilver 2H Crossbow", + "ProficiencyId": 494, + "Version": 6 + }, + { + "Levels": [ + { + "Perks": [ + { + "Type": 12, + "Value": 0.1 + } + ] + }, + { + "Perks": [ + { + "BestiaryId": 10, + "Type": 6, + "Value": 0.03 + }, + { + "SkillId": 1, + "Type": 3, + "Value": 2 + }, + { + "Type": 12, + "Value": 0.05 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 14, + "SpellId": 301, + "Type": 5, + "Value": 0.15 + }, + { + "ElementId": 32, + "MissileId": 79, + "Multiplier": 3.0, + "Probability": 0.01, + "Type": 32, + "Value": 0 + }, + { + "AugmentType": 2, + "SpellId": 301, + "Type": 5, + "Value": 0.1125 + } + ] + }, + { + "Perks": [ + { + "Type": 17, + "Value": 0.03 + }, + { + "Type": 0, + "Value": 1 + }, + { + "Type": 8, + "Value": 0.01 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 15, + "SpellId": 301, + "Type": 5, + "Value": 0.075 + }, + { + "Type": 12, + "Value": 0.08 + }, + { + "AugmentType": 17, + "SpellId": 301, + "Type": 5, + "Value": 0.035 + } + ] + }, + { + "Perks": [ + { + "Type": 16, + "Value": 0.02 + }, + { + "Type": 0, + "Value": 1 + }, + { + "Type": 17, + "Value": 0.06 + } + ] + }, + { + "Perks": [ + { + "Type": 29, + "Value": 0.075 + } + ] + } + ], + "Name": "Stellar Moonsilver 2H Fist", + "ProficiencyId": 495, + "Version": 6 + }, + { + "Levels": [ + { + "Perks": [ + { + "Type": 17, + "Value": 0.04 + } + ] + }, + { + "Perks": [ + { + "BestiaryId": 10, + "Type": 6, + "Value": 0.03 + }, + { + "Type": 12, + "Value": 0.05 + }, + { + "SkillId": 8, + "Type": 3, + "Value": 1 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 2, + "SpellId": 106, + "Type": 5, + "Value": 0.09 + }, + { + "ElementId": 8, + "MissileId": 77, + "Multiplier": 3.0, + "Probability": 0.01, + "Type": 32, + "Value": 0 + }, + { + "Type": 11, + "Value": 0.0275 + } + ] + }, + { + "Perks": [ + { + "Type": 17, + "Value": 0.03 + }, + { + "Type": 0, + "Value": 1 + }, + { + "Type": 8, + "Value": 0.01 + } + ] + }, + { + "Perks": [ + { + "AugmentType": 17, + "SpellId": 106, + "Type": 5, + "Value": 0.03 + }, + { + "Type": 12, + "Value": 0.12 + }, + { + "SkillId": 8, + "Type": 25, + "Value": 0.1 + } + ] + }, + { + "Perks": [ + { + "Type": 16, + "Value": 0.02 + }, + { + "Type": 0, + "Value": 1 + }, + { + "ElementId": 1, + "Type": 31, + "Value": 0.09 + } + ] + }, + { + "Perks": [ + { + "Type": 29, + "Value": 0.075 + } + ] + } + ], + "Name": "Stellar Moonsilver 2H Sword", + "ProficiencyId": 496, + "Version": 4 } -] \ No newline at end of file +] diff --git a/data/scripts/actions/items/imbuement_scrolls.lua b/data/scripts/actions/items/imbuement_scrolls.lua index 597b4218a..942f7c60c 100644 --- a/data/scripts/actions/items/imbuement_scrolls.lua +++ b/data/scripts/actions/items/imbuement_scrolls.lua @@ -9,5 +9,78 @@ function imbuementScrolls.onUse(player, item, fromPosition, target, toPosition, return true end -imbuementScrolls:id(51444, 51445, 51446, 51447, 51448, 51449, 51450, 51451, 51452, 51453, 51454, 51455, 51456, 51457, 51458, 51459, 51460, 51461, 51462, 51463, 51464, 51465, 51466, 51467, 51724, 51725, 51726, 51727, 51728, 51729, 51730, 51731, 51732, 51733, 51734, 51735, 51736, 51737, 51738, 51739, 51740, 51741, 51742, 51743, 51744, 51745, 51746, 51747) +imbuementScrolls:id( + 51444, + 51445, + 51446, + 51447, + 51448, + 51449, + 51450, + 51451, + 51452, + 51453, + 51454, + 51455, + 51456, + 51457, + 51458, + 51459, + 51460, + 51461, + 51462, + 51463, + 51464, + 51465, + 51466, + 51467, + 51724, + 51725, + 51726, + 51727, + 51728, + 51729, + 51730, + 51731, + 51732, + 51733, + 51734, + 51735, + 51736, + 51737, + 51738, + 51739, + 51740, + 51741, + 51742, + 51743, + 51744, + 51745, + 51746, + 51747, + 53751, + 53752, + 53753, + 53754, + 53755, + 53756, + 53757, + 53758, + 53759, + 53760, + 53761, + 53762, + 53763, + 53764, + 53765, + 53766, + 53767, + 53768, + 53769, + 53770, + 53771, + 53772, + 53773, + 53774 +) imbuementScrolls:register() diff --git a/data/scripts/talkactions/god/boss_difficulty_test.lua b/data/scripts/talkactions/god/boss_difficulty_test.lua new file mode 100644 index 000000000..53625617d --- /dev/null +++ b/data/scripts/talkactions/god/boss_difficulty_test.lua @@ -0,0 +1,40 @@ +-- Boss Difficulty Selection window — S2C opcode 0x2F. +-- Field -> controller (RE'd open handler 0x1405ed8d3): +-- f1->+0x40 = lowestDifficulty | f2(u8)->+0x44 = SPINNER GATE (f2=0 -> spinner editable!) | +-- f3->+0x48 = raceId | f4->+0x4c = selectedDifficulty (value shown in spinner) | +-- f5->+0x50 = highestDifficultyOfGroup | f6->+0x54 = personalHighestDifficulty | f7->+0x58 = badLuck/1000 +-- +-- LIVE-confirmed: f2=0 makes the "Select Difficulty" spinner appear with arrows; f4 = the shown value. +-- The server NO LONGER auto-closes on the client's 0xC2 (that was making the window vanish); it now +-- just logs the 0xC2 body so we can wire Cancel/Start properly next. +-- +-- Usage: /bossdiff [selected=f4] [lowest=f1] [group=f5] [personal=f6] +-- default: /bossdiff 100 -> boss 100, spinner ON, selected 1, range lowest 1 .. group/personal 25 + +local talkaction = TalkAction("/bossdiff") + +function talkaction.onSay(player, words, param) + local a = {} + for tok in string.gmatch(param or "", "%S+") do + a[#a + 1] = tonumber(tok) + end + + local raceId = a[1] or 100 + local selected = a[2] or 0 -- f4 = selectedDifficulty (spinner start value) + local lowest = a[3] or 0 -- f1 = lowestDifficulty (spinner min = 0) + local group = a[4] or 25 -- f5 = highestDifficultyOfGroup (max = 25) + local personal = a[5] or 25 -- f6 = personalHighestDifficulty (max = 25) + local spinnerGate = 0 -- f2 = 0 -> spinner editable (difficulty 0..25) + + -- numbers = { f1=lowest, f2=gate, f3=raceId, f4=selected, f5=group, f6=personal, f7=badluck } + local numbers = { lowest, spinnerGate, raceId, selected, group, personal, 2000 } + local banners = { "Yvara", "Gryllan", "Frost Walker", "Drift Reaper", "Eradrel" } + + player:sendBossDifficultySelection(0, numbers, banners, {}, {}) + player:sendTextMessage(MESSAGE_EVENT_ADVANCE, string.format("[bossdiff] boss=%d selected=%d lowest=%d group=%d personal=%d", raceId, selected, lowest, group, personal)) + return true +end + +talkaction:separator(" ") +talkaction:groupType("god") +talkaction:register() diff --git a/src/core.hpp b/src/core.hpp index 41c20bd7d..e37b8773b 100644 --- a/src/core.hpp +++ b/src/core.hpp @@ -18,8 +18,8 @@ #pragma once static constexpr auto SOFTWARE_NAME = "Crystal Server"; -static constexpr auto SOFTWARE_VERSION = "4.1.8"; -static constexpr auto GAME_UPDATE = "Winter Update 2025"; +static constexpr auto SOFTWARE_VERSION = "4.1.9"; +static constexpr auto GAME_UPDATE = "Summer Update 2026"; static constexpr auto AUTHENTICATOR_DIGITS = 6U; static constexpr auto AUTHENTICATOR_PERIOD = 30U; diff --git a/src/creatures/monsters/monster.cpp b/src/creatures/monsters/monster.cpp index 71791246a..ce72000e2 100644 --- a/src/creatures/monsters/monster.cpp +++ b/src/creatures/monsters/monster.cpp @@ -2668,9 +2668,39 @@ float Monster::getAttackMultiplier() const { if (auto stacks = getForgeStack(); stacks > 0) { multiplier *= (1.35 + (stacks - 1) * 0.1); } + if (echoWarden) { + multiplier *= echoAtkMult; + } return multiplier; } +bool Monster::applyEchoWarden(float hpMult, float atkMult) { + if (echoWarden) { + return false; + } + if (!mType) { + return false; + } + // Same protections as the forge/soulpit guards: never on summon/boss/fiendish/soulpit. + // (C: also excluded `dark`, but this fork has no dark-monster layer.) + if (isSummon() || mType->isBoss() || forgeStack > 0 || soulPit) { + return false; + } + + echoWarden = true; + echoAtkMult = atkMult; + + healthMax = static_cast(std::ceil(static_cast(healthMax) * hpMult)); + health = healthMax; + runAwayHealth = static_cast(std::ceil(static_cast(runAwayHealth) * hpMult)); + + // Intense glow, distinct "warden" key (NOT "forge"); NO rename, classification stays NORMAL. + setIcon("warden", CreatureIcon(CreatureIconModifications_t::Fiendish, 0)); + g_game().updateCreatureIcon(static_self_cast()); + g_game().sendUpdateCreature(static_self_cast()); + return true; +} + float Monster::getDefenseMultiplier() const { float multiplier = mType->getDefenseMultiplier(); return multiplier; diff --git a/src/creatures/monsters/monster.hpp b/src/creatures/monsters/monster.hpp index d84b53968..dbdb45f1b 100644 --- a/src/creatures/monsters/monster.hpp +++ b/src/creatures/monsters/monster.hpp @@ -231,6 +231,8 @@ class Monster final : public Creature { float getDefenseMultiplier() const; + bool applyEchoWarden(float hpMult, float atkMult); + bool isDead() const override; void setDead(bool isDead); @@ -316,6 +318,9 @@ class Monster final : public Creature { bool soulPit = false; + bool echoWarden = false; + float echoAtkMult = 1.0f; + bool m_isDead = false; bool m_isImmune = false; diff --git a/src/creatures/players/player.cpp b/src/creatures/players/player.cpp index 04fbea05b..7db947532 100644 --- a/src/creatures/players/player.cpp +++ b/src/creatures/players/player.cpp @@ -8502,6 +8502,12 @@ void Player::sendScreenshotAndBannerWeeklyTaskSpecificFinished(uint16_t raceId) } } +void Player::sendScreenshotAndBannerLeaderMonsterKilled(uint16_t raceId, uint32_t charmPoints) const { + if (client) { + client->sendScreenshotAndBannerLeaderMonsterKilled(raceId, charmPoints); + } +} + void Player::checkSpellUnlocksOnAdvance(uint32_t oldLevel, uint32_t newLevel, uint32_t oldMagLevel, uint32_t newMagLevel) const { if (!client) { return; @@ -12742,6 +12748,121 @@ EquippedWeaponProficiencyBonuses &Player::getEquippedWeaponProficiency() { return equippedWeaponProficiency; } +void Player::tryProcWeaponProficiencyHomingMissile(const std::shared_ptr &target) { + if (!target || target->isRemoved() || target->getHealth() <= 0) { + return; + } + + // Equipped weapon (ignore ammo so distance weapons resolve to the bow itself). + const auto &weapon = getWeapon(true); + if (!weapon) { + return; + } + const uint16_t itemId = weapon->getID(); + + // Player's unlocked/active perk slots for this weapon. + auto profIt = weaponProficiencies.find(itemId); + if (profIt == weaponProficiencies.end()) { + return; + } + const WeaponProficiencyData &playerProficiencyData = profIt->second; + + // The weapon's proficiency tree (the perk catalogue). Nullptr-guarded (also logs internally). + const WeaponProficiencyStruct* proficiencyData = g_proficiencies().getProficiencyByItemId(itemId); + if (!proficiencyData) { + return; + } + + for (const auto &lvl : proficiencyData->proficiencyDataLevel) { + for (const auto &perk : lvl.proficiencyDataPerks) { + if (perk.perkType != PROFICIENCY_PERK_ON_HIT_HOMING_MISSILE) { + continue; + } + + // Active only if the player has unlocked this exact slot (level + position). + const bool isActive = std::any_of( + playerProficiencyData.activePerks.begin(), playerProficiencyData.activePerks.end(), + [&](const WeaponProficiencyPerk &p) { + return p.proficiencyLevel == lvl.proficiencyLevel && p.perkPosition == perk.positionSlot; + } + ); + if (!isActive) { + continue; + } + + if (perk.probability <= 0.0f || perk.multiplier <= 0.0f) { + continue; + } + + // Roll Probability (e.g. 0.01 = 1%). + if (!boolean_random(static_cast(perk.probability))) { + continue; + } + + // Map ElementId (stored in perk.damageType) -> CombatType + the homing-missile target hit effect + // observed in the 15.25 client. These raw effect ids (176-181) are sent verbatim over the wire; + // the official client renders them as the element-specific homing-missile impact. + CombatType_t combatType = COMBAT_NONE; + uint16_t targetEffect = CONST_ME_NONE; + switch (perk.damageType) { + case PROFICIENCY_DAMAGETYPE_PHYSICAL: + combatType = COMBAT_PHYSICALDAMAGE; + targetEffect = CONST_ME_DRAWBLOOD; + break; + case PROFICIENCY_DAMAGETYPE_FIRE: + combatType = COMBAT_FIREDAMAGE; + targetEffect = 177; + break; + case PROFICIENCY_DAMAGETYPE_EARTH: + combatType = COMBAT_EARTHDAMAGE; + targetEffect = 178; + break; + case PROFICIENCY_DAMAGETYPE_ENERGY: + combatType = COMBAT_ENERGYDAMAGE; + targetEffect = 179; + break; + case PROFICIENCY_DAMAGETYPE_ICE: + combatType = COMBAT_ICEDAMAGE; + targetEffect = 176; + break; + case PROFICIENCY_DAMAGETYPE_HOLY: + combatType = COMBAT_HOLYDAMAGE; + targetEffect = 181; + break; + case PROFICIENCY_DAMAGETYPE_DEATH: + combatType = COMBAT_DEATHDAMAGE; + targetEffect = 180; + break; + default: + continue; // healing / unsupported element -> skip + } + + // Damage = Multiplier * level (data-driven; Multiplier lives in proficiencies.json). + const int32_t dmg = static_cast(perk.multiplier * static_cast(getLevel())); + if (dmg <= 0) { + continue; + } + + // (b) shoot/distance effect caster -> target. missileId is the raw 15.25 shoot id from the json + // (e.g. 74 normal / 81 Stellar for holy); sent verbatim, the official client renders it. + g_game().addDistanceEffect(getPosition(), target->getPosition(), perk.missileId, static_self_cast()); + + // (c) magic hit effect on the target tile. + g_game().addMagicEffect(target->getPosition(), targetEffect); + + // (a) element-typed damage (negative value = damage). + CombatDamage damage; + damage.origin = ORIGIN_SPELL; + damage.primary.type = combatType; + damage.primary.value = -dmg; + g_game().combatChangeHealth(static_self_cast(), target, damage); + + // One proc per hit: stop after the first active homing-missile perk fires. + return; + } + } +} + void Player::addWeaponProficiencyExperience(const std::shared_ptr &mType, const ForgeClassifications_t classification, const bool bossSoulpit) { uint32_t addProficiencyExperience = 0; const auto weaponProficiencyRate = std::max(0.0f, g_configManager().getFloat(RATE_WEAPON_PROFICIENCY)); @@ -12865,6 +12986,287 @@ void Player::sendWeaponProficiencyInfo(const uint16_t itemId) const { } } +void Player::sendBossDifficultySelection(uint8_t selectedDifficulty, const std::vector &numbers, const std::vector &banners, const std::vector &redMods, const std::vector &greenMods) const { + if (client) { + client->sendBossDifficultySelection(selectedDifficulty, numbers, banners, redMods, greenMods); + } +} + +void Player::sendWeaponProficiencyReshapeOffers(const uint16_t itemId) const { + if (client) { + client->sendWeaponProficiencyReshapeOffers(itemId); + } +} + +uint8_t Player::getWeaponProficiencyVocationRegion(const uint16_t itemId) const { + // 15.25 (sommerrelease26): the client shaping catalogue is laid out in 50-wide vocation regions + // (0 = knight 1-50, 1 = paladin 51-100, 2 = sorcerer 101-150, 3 = druid 151-200, 4 = monk 201-250). + // Derive the region from the WEAPON so a paladin bow always offers paladin augments. Wands (sorcerer) + // and rods (druid) share WEAPON_WAND, so disambiguate by the holder's CIP vocation. + const ItemType &itemType = Item::items[itemId]; + switch (itemType.weaponType) { + case WEAPON_SWORD: + case WEAPON_CLUB: + case WEAPON_AXE: + return 0; // knight + case WEAPON_DISTANCE: + case WEAPON_AMMO: + case WEAPON_MISSILE: + return 1; // paladin + case WEAPON_FIST: + return 4; // monk + case WEAPON_WAND: { + return getPlayerVocationEnum() == Vocation_t::VOCATION_DRUID_CIP ? 3 : 2; + } + default: { + const uint16_t cip = getPlayerVocationEnum(); + return cip > Vocation_t::VOCATION_NONE ? static_cast(cip - 1) : 0; + } + } +} + +WeaponProficiencyPerkType_t Player::rollWeaponProficiencyPerk(const uint16_t itemId) const { + // 15.25 (sommerrelease26): roll one valid shapeable perk from the union of the weapon's per-vocation spell + // augments (region*50 + UNIVERSAL offsets) and the GENERAL vocation-agnostic pool. Both contain only valid + // catalogue indices, so the client never renders the "Attack Damage" fallback. + const uint8_t region = getWeaponProficiencyVocationRegion(itemId); + constexpr int32_t augmentPoolSize = static_cast(sizeof(WEAPON_PROFICIENCY_UNIVERSAL_SHAPEABLE_PERKS) / sizeof(WEAPON_PROFICIENCY_UNIVERSAL_SHAPEABLE_PERKS[0])); + constexpr int32_t generalPoolSize = static_cast(sizeof(WEAPON_PROFICIENCY_GENERAL_SHAPEABLE_PERKS) / sizeof(WEAPON_PROFICIENCY_GENERAL_SHAPEABLE_PERKS[0])); + const int32_t pick = uniform_random(0, augmentPoolSize + generalPoolSize - 1); + if (pick < augmentPoolSize) { + return static_cast(region * 50 + static_cast(WEAPON_PROFICIENCY_UNIVERSAL_SHAPEABLE_PERKS[pick])); + } + return WEAPON_PROFICIENCY_GENERAL_SHAPEABLE_PERKS[pick - augmentPoolSize]; +} + +void Player::modifyWeaponProficiencySlot(const uint16_t itemId, const uint8_t proficiencyLevel, const uint8_t perkPosition) { + static constexpr uint64_t MODIFY_DUST_COST = 250; + auto it = weaponProficiencies.find(itemId); + if (it == weaponProficiencies.end()) { + sendTextMessage(MESSAGE_FAILURE, "You have no proficiency progress on this weapon."); + return; + } + const auto &tile = getTile(); + if (!tile || !tile->hasFlag(TILESTATE_PROTECTIONZONE)) { + sendTextMessage(MESSAGE_FAILURE, "You can only modify proficiency slots inside a protection zone."); + return; + } + if (getForgeDusts() < MODIFY_DUST_COST) { + sendTextMessage(MESSAGE_FAILURE, "You do not have enough dust to modify this slot."); + return; + } + removeForgeDusts(MODIFY_DUST_COST); + auto &proficiency = it->second; + const auto perkType = rollWeaponProficiencyPerk(itemId); + const uint8_t value = 1; + // Store 1-based; the wire request is 0-based. + const uint8_t storedLevel = static_cast(proficiencyLevel + 1); + const uint8_t storedPosition = static_cast(perkPosition + 1); + bool replaced = false; + for (auto &slot : proficiency.modifiedSlots) { + if (slot.proficiencyLevel == storedLevel && slot.perkPosition == storedPosition) { + slot.perkType = perkType; + slot.value = value; + replaced = true; + break; + } + } + if (!replaced) { + proficiency.modifiedSlots.push_back({ storedLevel, storedPosition, perkType, value }); + } + applyEquippedWeaponProficiency(itemId); + sendWeaponProficiencyInfo(itemId); +} + +void Player::refineWeaponProficiencySlot(const uint16_t itemId, const uint8_t proficiencyLevel, const uint8_t perkPosition) { + static constexpr uint64_t REFINE_DUST_COST = 200; + static constexpr uint8_t MAX_RANK = 10; + auto it = weaponProficiencies.find(itemId); + if (it == weaponProficiencies.end()) { + sendTextMessage(MESSAGE_FAILURE, "You have no proficiency progress on this weapon."); + return; + } + const auto &tile = getTile(); + if (!tile || !tile->hasFlag(TILESTATE_PROTECTIONZONE)) { + sendTextMessage(MESSAGE_FAILURE, "You can only refine proficiency slots inside a protection zone."); + return; + } + const uint8_t storedLevel = static_cast(proficiencyLevel + 1); + const uint8_t storedPosition = static_cast(perkPosition + 1); + auto &proficiency = it->second; + WeaponProficiencyModifiedSlot* slot = nullptr; + for (auto &candidate : proficiency.modifiedSlots) { + if (candidate.proficiencyLevel == storedLevel && candidate.perkPosition == storedPosition) { + slot = &candidate; + break; + } + } + if (!slot) { + sendTextMessage(MESSAGE_FAILURE, "That slot has not been modified yet."); + return; + } + if (slot->value >= MAX_RANK) { + sendTextMessage(MESSAGE_FAILURE, "This perk is already at its maximum rank."); + return; + } + if (getForgeDusts() < REFINE_DUST_COST) { + sendTextMessage(MESSAGE_FAILURE, fmt::format("You need {} dust to refine this perk (you currently have {}).", REFINE_DUST_COST, getForgeDusts())); + return; + } + removeForgeDusts(REFINE_DUST_COST); + ++slot->value; + applyEquippedWeaponProficiency(itemId); + sendWeaponProficiencyInfo(itemId); +} + +void Player::maximiseWeaponProficiencySlot(const uint16_t itemId, const uint8_t proficiencyLevel, const uint8_t perkPosition) { + // TODO(15.25): official client charges 1 special tradeable item; not in data yet, so just set to max. + static constexpr uint8_t MAX_RANK = 10; + auto it = weaponProficiencies.find(itemId); + if (it == weaponProficiencies.end()) { + sendTextMessage(MESSAGE_FAILURE, "You have no proficiency progress on this weapon."); + return; + } + const auto &tile = getTile(); + if (!tile || !tile->hasFlag(TILESTATE_PROTECTIONZONE)) { + sendTextMessage(MESSAGE_FAILURE, "You can only shape proficiency slots inside a protection zone."); + return; + } + const uint8_t storedLevel = static_cast(proficiencyLevel + 1); + const uint8_t storedPosition = static_cast(perkPosition + 1); + auto &proficiency = it->second; + WeaponProficiencyModifiedSlot* slot = nullptr; + for (auto &candidate : proficiency.modifiedSlots) { + if (candidate.proficiencyLevel == storedLevel && candidate.perkPosition == storedPosition) { + slot = &candidate; + break; + } + } + if (!slot) { + sendTextMessage(MESSAGE_FAILURE, "That slot has not been modified yet."); + return; + } + if (slot->value >= MAX_RANK) { + sendTextMessage(MESSAGE_FAILURE, "This perk is already at its maximum rank."); + return; + } + slot->value = MAX_RANK; + applyEquippedWeaponProficiency(itemId); + sendWeaponProficiencyInfo(itemId); +} + +void Player::clearWeaponProficiencySlot(const uint16_t itemId, const uint8_t proficiencyLevel, const uint8_t perkPosition) { + auto it = weaponProficiencies.find(itemId); + if (it == weaponProficiencies.end()) { + sendTextMessage(MESSAGE_FAILURE, "You have no proficiency progress on this weapon."); + return; + } + const auto &tile = getTile(); + if (!tile || !tile->hasFlag(TILESTATE_PROTECTIONZONE)) { + sendTextMessage(MESSAGE_FAILURE, "You can only shape proficiency slots inside a protection zone."); + return; + } + const uint8_t storedLevel = static_cast(proficiencyLevel + 1); + const uint8_t storedPosition = static_cast(perkPosition + 1); + auto &proficiency = it->second; + const auto removed = std::erase_if(proficiency.modifiedSlots, [storedLevel, storedPosition](const WeaponProficiencyModifiedSlot &s) { + return s.proficiencyLevel == storedLevel && s.perkPosition == storedPosition; + }); + if (removed == 0) { + sendTextMessage(MESSAGE_FAILURE, "That slot has not been modified."); + return; + } + applyEquippedWeaponProficiency(itemId); + sendWeaponProficiencyInfo(itemId); +} + +void Player::reshapeWeaponProficiencySlot(const uint16_t itemId, const uint8_t proficiencyLevel, const uint8_t perkPosition) { + static constexpr uint8_t RESHAPE_OFFER_COUNT = 3; + static constexpr uint64_t RESHAPE_DUST_COST = 250; + auto it = weaponProficiencies.find(itemId); + if (it == weaponProficiencies.end()) { + sendTextMessage(MESSAGE_FAILURE, "You have no proficiency progress on this weapon."); + return; + } + const auto &tile = getTile(); + if (!tile || !tile->hasFlag(TILESTATE_PROTECTIONZONE)) { + sendTextMessage(MESSAGE_FAILURE, "You can only shape proficiency slots inside a protection zone."); + return; + } + const uint8_t storedLevel = static_cast(proficiencyLevel + 1); + const uint8_t storedPosition = static_cast(perkPosition + 1); + auto &proficiency = it->second; + WeaponProficiencyModifiedSlot* slot = nullptr; + for (auto &candidate : proficiency.modifiedSlots) { + if (candidate.proficiencyLevel == storedLevel && candidate.perkPosition == storedPosition) { + slot = &candidate; + break; + } + } + if (!slot) { + sendTextMessage(MESSAGE_FAILURE, "That slot has not been modified yet."); + return; + } + if (getForgeDusts() < RESHAPE_DUST_COST) { + sendTextMessage(MESSAGE_FAILURE, fmt::format("You need {} dust to reshape this perk (you currently have {}).", RESHAPE_DUST_COST, getForgeDusts())); + return; + } + removeForgeDusts(RESHAPE_DUST_COST); + (void)slot; + std::vector offers; + offers.reserve(RESHAPE_OFFER_COUNT); + for (int32_t guard = 0; offers.size() < RESHAPE_OFFER_COUNT && guard < 200; ++guard) { + const auto candidate = rollWeaponProficiencyPerk(itemId); + bool duplicate = false; + for (const auto existing : offers) { + if (existing == candidate) { + duplicate = true; + break; + } + } + if (!duplicate) { + offers.push_back(candidate); + } + } + proficiency.pendingReshapeLevel = storedLevel; + proficiency.pendingReshapePosition = storedPosition; + proficiency.pendingReshapeOffers = offers; + sendWeaponProficiencyReshapeOffers(itemId); +} + +void Player::pickReshapeWeaponProficiencyOffer(const uint16_t itemId, const uint8_t offerIndex) { + // 15.25: the client sends the 0-based index of the clicked offer (left=0/middle=1/right=2). + auto it = weaponProficiencies.find(itemId); + if (it == weaponProficiencies.end()) { + return; + } + auto &proficiency = it->second; + if (proficiency.pendingReshapeOffers.empty()) { + return; + } + if (static_cast(offerIndex) >= proficiency.pendingReshapeOffers.size()) { + return; + } + const auto chosen = proficiency.pendingReshapeOffers[offerIndex]; + WeaponProficiencyModifiedSlot* slot = nullptr; + for (auto &candidate : proficiency.modifiedSlots) { + if (candidate.proficiencyLevel == proficiency.pendingReshapeLevel && candidate.perkPosition == proficiency.pendingReshapePosition) { + slot = &candidate; + break; + } + } + if (!slot) { + proficiency.pendingReshapeOffers.clear(); + return; + } + slot->perkType = chosen; + proficiency.pendingReshapeOffers.clear(); + proficiency.pendingReshapeLevel = 0; + proficiency.pendingReshapePosition = 0; + applyEquippedWeaponProficiency(itemId); + sendWeaponProficiencyInfo(itemId); +} + void Player::resetAllWeaponProficiencyPerks(const uint16_t itemId) { auto it = weaponProficiencies.find(itemId); if (it == weaponProficiencies.end()) { @@ -13148,6 +13550,72 @@ void Player::applyEquippedWeaponProficiency(const uint16_t itemId) { } } + // 15.25 (sommerrelease26) SHAPE: apply the dust-modified slots on top of the tree perks. modSlot.perkType is + // a CLIENT CATALOGUE INDEX (1-323) from rollWeaponProficiencyPerk() -- NOT a server perk enum -- so decode the + // index range here into the SAME aggregate fields the tree-perk switch above uses (already-wired hooks pick + // them up). Magnitude is linear between the catalogue's rank-0 and rank-10 figures. See PORT.md §8.2. + for (const auto &modSlot : playerProficiencyData.modifiedSlots) { + const int32_t idx = static_cast(modSlot.perkType); + const float rankFraction = static_cast(modSlot.value) / 10.0f; + const auto lerp = [rankFraction](float rank0, float rank10) { + return rank0 + (rank10 - rank0) * rankFraction; + }; + + // --- bestiary damage: 251-271 = 250 + bestiaryId (0.50% -> 2.50% vs that race) --- + if (idx >= 251 && idx <= 271) { + equippedWeaponProficiency.bestiaryRacePercentDamageGain += lerp(0.5f, 2.5f) / 100.0f; + equippedWeaponProficiency.bestiaryId = static_cast(idx - 250); // single-slot: last decoded wins + continue; + } + + switch (idx) { + // --- leech / on-hit / on-kill / alpha / omega: 281-288 --- + case 281: // mana leech 1.00% -> 8.00% (stored as fraction * 10000 bp) + equippedWeaponProficiency.manaLeech += static_cast((lerp(1.0f, 8.0f) / 100.0f) * 10000.0f); + break; + case 282: // life leech 1.00% -> 16.00% + equippedWeaponProficiency.lifeLeech += static_cast((lerp(1.0f, 16.0f) / 100.0f) * 10000.0f); + break; + case 283: // +2 -> +12 mana on hit + equippedWeaponProficiency.manaGainOnHit += static_cast(lerp(2.0f, 12.0f)); + break; + case 284: // +5 -> +25 HP on hit + equippedWeaponProficiency.lifeGainOnHit += static_cast(lerp(5.0f, 25.0f)); + break; + case 285: // +4 -> +24 mana on kill + equippedWeaponProficiency.manaGainOnKill += static_cast(lerp(4.0f, 24.0f)); + break; + case 286: // +10 -> +50 HP on kill + equippedWeaponProficiency.lifeGainOnKill += static_cast(lerp(10.0f, 50.0f)); + break; + case 287: // alpha strike 2.00% -> 10.00% (vs targets above 95% HP) + equippedWeaponProficiency.alphaStrikeExtraDamage += lerp(2.0f, 10.0f) / 100.0f; + break; + case 288: // omega strike 1.00% -> 4.00% (vs targets below 30% HP) + equippedWeaponProficiency.omegaStrikeExtraDamage += lerp(1.0f, 4.0f) / 100.0f; + break; + + // --- universals: 321 armor pen, 322 elemental pierce, 323 powerful foe --- + case 321: // armor penetration 5% -> 15% + equippedWeaponProficiency.armorPenetration += lerp(5.0f, 15.0f) / 100.0f; + break; + case 322: // elemental pierce 5% -> 15% (no element sub-id -> all elements; curve estimated) + for (int element = 0; element < COMBAT_COUNT; ++element) { + equippedWeaponProficiency.elementalPierce[element] += lerp(5.0f, 15.0f) / 100.0f; + } + break; + case 323: // powerful foe (boss / sinister-embraced) 1% -> 5% (curve estimated) + equippedWeaponProficiency.damageGainBossAndSinisterEmbraced += lerp(1.0f, 5.0f) / 100.0f; + break; + + default: + // 1-250 (per-vocation spell augments) and 291-317 (skill%) intentionally NOT applied yet (§8.3: + // augments need the authoritative (region, spellIndex) -> spellId map; skill% needs its real curve + // + the combat.cpp auto-attack sign fix). Decoded but parked until live data. + break; + } + } + sendStats(); sendSkills(); } diff --git a/src/creatures/players/player.hpp b/src/creatures/players/player.hpp index db78f7f8c..3035cadea 100644 --- a/src/creatures/players/player.hpp +++ b/src/creatures/players/player.hpp @@ -148,9 +148,24 @@ struct WeaponProficiencyPerk { uint8_t perkPosition = 0; }; +// 15.25 (sommerrelease26) SHAPE: a perk slot can be "modified" with dust to roll a custom effect that +// overrides the slot's tree perk. One entry per modified slot, keyed by (level, position). perkType is a +// CLIENT CATALOGUE INDEX (1-323), value is the rank 0-10. +struct WeaponProficiencyModifiedSlot { + uint8_t proficiencyLevel = 0; + uint8_t perkPosition = 0; + WeaponProficiencyPerkType_t perkType = PROFICIENCY_PERK_ATTACK_DAMAGE; + uint8_t value = 0; +}; + struct WeaponProficiencyData { uint32_t experience = 0; std::vector activePerks; + std::vector modifiedSlots; + // 15.25 (sommerrelease26): transient Reshape state (offer -> pick). Not persisted. + uint8_t pendingReshapeLevel = 0; + uint8_t pendingReshapePosition = 0; + std::vector pendingReshapeOffers; }; struct WeaponProficiencyAugment { @@ -1131,11 +1146,26 @@ class Player final : public Creature, public Cylinder, public Bankable { // Weapon Proficiency EquippedWeaponProficiencyBonuses &getEquippedWeaponProficiency(); + + // Weapon-proficiency Type-32 "Homing Missile": on a weapon hit, rolls each active Type-32 perk's probability + // and on success fires an element-typed homing missile at the target. + void tryProcWeaponProficiencyHomingMissile(const std::shared_ptr &target); void sendWeaponProficiencyInfo(const uint16_t itemId) const; + void sendBossDifficultySelection(uint8_t selectedDifficulty, const std::vector &numbers, const std::vector &banners, const std::vector &redMods, const std::vector &greenMods) const; void resetAllWeaponProficiencyPerks(const uint16_t itemId); void applyEquippedWeaponProficiency(const uint16_t itemId); void removeEquippedWeaponProficiency(const uint16_t itemId); void sendWeaponProficiencyExperience(const uint16_t itemId, const uint32_t addProficiencyExperience); + // 15.25 (sommerrelease26) SHAPE handlers + helpers (see CLIENT_15.25_e2a4a1_PORT.md §7.5 / §8). + uint8_t getWeaponProficiencyVocationRegion(const uint16_t itemId) const; + WeaponProficiencyPerkType_t rollWeaponProficiencyPerk(const uint16_t itemId) const; + void modifyWeaponProficiencySlot(const uint16_t itemId, const uint8_t proficiencyLevel, const uint8_t perkPosition); + void refineWeaponProficiencySlot(const uint16_t itemId, const uint8_t proficiencyLevel, const uint8_t perkPosition); + void maximiseWeaponProficiencySlot(const uint16_t itemId, const uint8_t proficiencyLevel, const uint8_t perkPosition); + void clearWeaponProficiencySlot(const uint16_t itemId, const uint8_t proficiencyLevel, const uint8_t perkPosition); + void reshapeWeaponProficiencySlot(const uint16_t itemId, const uint8_t proficiencyLevel, const uint8_t perkPosition); + void pickReshapeWeaponProficiencyOffer(const uint16_t itemId, const uint8_t offerIndex); + void sendWeaponProficiencyReshapeOffers(const uint16_t itemId) const; std::unordered_map weaponProficiencies; @@ -1191,6 +1221,7 @@ class Player final : public Creature, public Cylinder, public Bankable { void checkSpellUnlocksOnAdvance(uint32_t oldLevel, uint32_t newLevel, uint32_t oldMagLevel, uint32_t newMagLevel) const; void sendScreenshotAndBannerBountyTaskFinished(uint16_t raceId) const; void sendScreenshotAndBannerWeeklyTaskSpecificFinished(uint16_t raceId) const; + void sendScreenshotAndBannerLeaderMonsterKilled(uint16_t raceId, uint32_t charmPoints) const; void onThink(uint32_t interval) override; diff --git a/src/creatures/players/proficiencies/proficiencies.cpp b/src/creatures/players/proficiencies/proficiencies.cpp index f7f732ad0..803ff5d72 100644 --- a/src/creatures/players/proficiencies/proficiencies.cpp +++ b/src/creatures/players/proficiencies/proficiencies.cpp @@ -63,7 +63,9 @@ bool Proficiencies::loadFromJson(bool /* reloading */) { const auto &perkJson = perksArray[perkIdx]; const uint8_t positionSlot = static_cast(perkIdx + 1); const WeaponProficiencyPerkType_t perkType = static_cast(perkJson.at("Type").get()); - const float perkValue = perkJson.at("Value").get(); + // Some perks carry no "Value" (e.g. the on-hit elemental proc, Type 32, which uses + // ElementId/MissileId/Multiplier/Probability instead). Default to 0 so they still load. + const float perkValue = perkJson.value("Value", 0.0f); ProficiencyPerk perk(positionSlot, perkType, perkValue); @@ -99,6 +101,19 @@ bool Proficiencies::loadFromJson(bool /* reloading */) { perk.damageType = perkJson.at("DamageType").get(); } + // Type 32 (PROFICIENCY_PERK_ON_HIT_HOMING_MISSILE) fields. + if (perkJson.contains("MissileId")) { + perk.missileId = perkJson.at("MissileId").get(); + } + + if (perkJson.contains("Multiplier")) { + perk.multiplier = perkJson.value("Multiplier", 0.0f); + } + + if (perkJson.contains("Probability")) { + perk.probability = perkJson.value("Probability", 0.0f); + } + levelStruct.proficiencyDataPerks.emplace_back(std::move(perk)); } diff --git a/src/creatures/players/proficiencies/proficiencies.hpp b/src/creatures/players/proficiencies/proficiencies.hpp index 4410716f0..3c6c371cc 100644 --- a/src/creatures/players/proficiencies/proficiencies.hpp +++ b/src/creatures/players/proficiencies/proficiencies.hpp @@ -38,6 +38,12 @@ struct ProficiencyPerk { uint8_t bestiaryId = 0; int32_t damageType = 0; uint8_t range = 0; + // Type 32 (PROFICIENCY_PERK_ON_HIT_HOMING_MISSILE): on weapon hit, roll `probability`; on success fire a + // homing missile (`missileId` shoot effect) dealing `multiplier` * playerLevel damage of the combat type + // derived from `damageType` (parsed from ElementId). + uint16_t missileId = 0; + float multiplier = 0.0f; + float probability = 0.0f; // std::string bestiaryName = ""; }; diff --git a/src/creatures/players/proficiencies/proficiencies_definitions.hpp b/src/creatures/players/proficiencies/proficiencies_definitions.hpp index 7ed5dca50..4258b225d 100644 --- a/src/creatures/players/proficiencies/proficiencies_definitions.hpp +++ b/src/creatures/players/proficiencies/proficiencies_definitions.hpp @@ -50,6 +50,90 @@ enum WeaponProficiencyPerkType_t : uint16_t { PROFICIENCY_PERK_OMEGA_STRIKE_EXTRA_DAMAGE = 29, PROFICIENCY_PERK_ARMOR_PENETRATION = 30, PROFICIENCY_PERK_ELEMENTAL_PIERCE = 31, + PROFICIENCY_PERK_ON_HIT_HOMING_MISSILE = 32, +}; + +// 15.25 (sommerrelease26) SHAPE system: the perkType sent in a modified slot / reshape offer (0xC4/0xBB) is an +// INDEX into the client's shaping catalogue (1-323), NOT this server enum. UNIVERSAL = the per-vocation spell +// augment OFFSETS (added to region*50 in rollWeaponProficiencyPerk): {1-5 crit-chance, 11-15 crit-extra, +// 21-25 base-damage}. GENERAL = the vocation-agnostic 251-323 block (bestiary / leech-on-hit-on-kill / +// alpha-omega / skill% / armor-pen / elemental-pierce / powerful-foe). See CLIENT_15.25_e2a4a1_PORT.md §8.1. +inline constexpr WeaponProficiencyPerkType_t WEAPON_PROFICIENCY_UNIVERSAL_SHAPEABLE_PERKS[] = { + static_cast(1), + static_cast(2), + static_cast(3), + static_cast(4), + static_cast(5), + static_cast(11), + static_cast(12), + static_cast(13), + static_cast(14), + static_cast(15), + static_cast(21), + static_cast(22), + static_cast(23), + static_cast(24), + static_cast(25), +}; + +// 15.25 (sommerrelease26): the GENERAL (vocation-agnostic) shapeable catalogue indices in the 251-323 block. +// 251-271 = bestiary damage (250 + bestiaryId, 21 races) +// 281-288 = mana/life leech, mana/life on-hit, mana/life on-kill, alpha strike, omega strike +// 291-297 = skill% of auto-attacks (290 + skillSlot 1-7); 301-307 = skill% of spells; 311-317 = skill% of healing (313 omitted) +// 321-323 = armor penetration, elemental pierce, powerful foe +inline constexpr WeaponProficiencyPerkType_t WEAPON_PROFICIENCY_GENERAL_SHAPEABLE_PERKS[] = { + static_cast(251), + static_cast(252), + static_cast(253), + static_cast(254), + static_cast(255), + static_cast(256), + static_cast(257), + static_cast(258), + static_cast(259), + static_cast(260), + static_cast(261), + static_cast(262), + static_cast(263), + static_cast(264), + static_cast(265), + static_cast(266), + static_cast(267), + static_cast(268), + static_cast(269), + static_cast(270), + static_cast(271), + static_cast(281), + static_cast(282), + static_cast(283), + static_cast(284), + static_cast(285), + static_cast(286), + static_cast(287), + static_cast(288), + static_cast(291), + static_cast(292), + static_cast(293), + static_cast(294), + static_cast(295), + static_cast(296), + static_cast(297), + static_cast(301), + static_cast(302), + static_cast(303), + static_cast(304), + static_cast(305), + static_cast(306), + static_cast(307), + static_cast(311), + static_cast(312), + static_cast(314), + static_cast(315), + static_cast(316), + static_cast(317), + static_cast(321), + static_cast(322), + static_cast(323), }; enum WeaponProficiencyPerkSkills_t : int8_t { diff --git a/src/io/functions/iologindata_load_player.cpp b/src/io/functions/iologindata_load_player.cpp index c7219567f..06b50cecc 100644 --- a/src/io/functions/iologindata_load_player.cpp +++ b/src/io/functions/iologindata_load_player.cpp @@ -1280,6 +1280,41 @@ void IOLoginDataLoad::loadPlayerWeaponProficiency(const std::shared_ptr player->weaponProficiencies[itemId] = std::move(data); } + + // 15.25 (sommerrelease26) SHAPE: trailing modified-slots section. Old blobs end above, so a failed read + // simply means the player has no modified slots. See PORT.md §7.5. + uint16_t modifiedWeaponCount; + if (stream.read(modifiedWeaponCount)) { + for (uint16_t i = 0; i < modifiedWeaponCount; ++i) { + uint16_t itemId; + if (!stream.read(itemId)) { + break; + } + + uint8_t slotCount; + if (!stream.read(slotCount)) { + break; + } + + auto &data = player->weaponProficiencies[itemId]; + for (uint8_t j = 0; j < slotCount; ++j) { + uint8_t level; + uint8_t pos; + uint16_t perkTypeRaw; + uint8_t value; + if (!stream.read(level) || !stream.read(pos) || !stream.read(perkTypeRaw) || !stream.read(value)) { + break; + } + + WeaponProficiencyModifiedSlot slot {}; + slot.proficiencyLevel = level; + slot.perkPosition = pos; + slot.perkType = static_cast(perkTypeRaw); + slot.value = value; + data.modifiedSlots.push_back(slot); + } + } + } } void IOLoginDataLoad::loadPlayerExivaRestrictions(const std::shared_ptr &player) { diff --git a/src/io/functions/iologindata_save_player.cpp b/src/io/functions/iologindata_save_player.cpp index 9879a62a2..ce029f052 100644 --- a/src/io/functions/iologindata_save_player.cpp +++ b/src/io/functions/iologindata_save_player.cpp @@ -355,6 +355,29 @@ bool IOLoginDataSave::savePlayerFirst(const std::shared_ptr &player) { } } + // 15.25 (sommerrelease26) SHAPE: trailing modified-slots section. Appended AFTER the main loop so old blobs + // (which lack it) still load — the loader treats a missing section as "no modified slots". See PORT.md §7.5. + uint16_t modifiedWeaponCount = 0; + for (const auto &entry : player->weaponProficiencies) { + if (!entry.second.modifiedSlots.empty()) { + ++modifiedWeaponCount; + } + } + propWeaponProficiency.write(modifiedWeaponCount); + for (const auto &[itemId, proficiency] : player->weaponProficiencies) { + if (proficiency.modifiedSlots.empty()) { + continue; + } + propWeaponProficiency.write(itemId); + propWeaponProficiency.write(static_cast(proficiency.modifiedSlots.size())); + for (const auto &slot : proficiency.modifiedSlots) { + propWeaponProficiency.write(slot.proficiencyLevel); + propWeaponProficiency.write(slot.perkPosition); + propWeaponProficiency.write(static_cast(slot.perkType)); + propWeaponProficiency.write(slot.value); + } + } + size_t proficiencySize; const char* proficiencyData = propWeaponProficiency.getStream(proficiencySize); diff --git a/src/items/weapons/weapons.cpp b/src/items/weapons/weapons.cpp index 13837da2e..ee48bd60d 100644 --- a/src/items/weapons/weapons.cpp +++ b/src/items/weapons/weapons.cpp @@ -383,6 +383,11 @@ void Weapon::internalUseWeapon(const std::shared_ptr &player, const std: } } + // Weapon-proficiency Type-32 "Homing Missile": on-hit weapon proc. Fires once per resolved hit against a + // creature (auto-attack of any weapon type, including each cleaved target). Rolls the perk's Probability and + // on success launches an element-typed homing missile; calls combatChangeHealth directly, so no re-entrancy. + player->tryProcWeaponProficiencyHomingMissile(target); + onUsedWeapon(player, item, target->getTile()); } diff --git a/src/lua/functions/core/game/lua_enums.cpp b/src/lua/functions/core/game/lua_enums.cpp index 6e6f5894b..775096b7f 100644 --- a/src/lua/functions/core/game/lua_enums.cpp +++ b/src/lua/functions/core/game/lua_enums.cpp @@ -772,6 +772,20 @@ void LuaEnums::initConstAniEnums(lua_State* L) { registerEnum(L, CONST_ANI_TERRASTORMARROW); registerEnum(L, CONST_ANI_FROSTSTORMARROW); registerEnum(L, CONST_ANI_THUNDERSTORMARROW); + registerEnum(L, CONST_ANI_HOMING_PHYSICAL); + registerEnum(L, CONST_ANI_HOMING_FIRE); + registerEnum(L, CONST_ANI_HOMING_EARTH); + registerEnum(L, CONST_ANI_HOMING_ENERGY); + registerEnum(L, CONST_ANI_HOMING_ICE); + registerEnum(L, CONST_ANI_HOMING_HOLY); + registerEnum(L, CONST_ANI_HOMING_DEATH); + registerEnum(L, CONST_ANI_HOMING_STELLAR_PHYSICAL); + registerEnum(L, CONST_ANI_HOMING_STELLAR_FIRE); + registerEnum(L, CONST_ANI_HOMING_STELLAR_EARTH); + registerEnum(L, CONST_ANI_HOMING_STELLAR_ENERGY); + registerEnum(L, CONST_ANI_HOMING_STELLAR_ICE); + registerEnum(L, CONST_ANI_HOMING_STELLAR_HOLY); + registerEnum(L, CONST_ANI_HOMING_STELLAR_DEATH); registerEnum(L, CONST_ANI_WEAPONTYPE); } diff --git a/src/lua/functions/creatures/monster/monster_functions.cpp b/src/lua/functions/creatures/monster/monster_functions.cpp index 1d5bd886a..4e6c50984 100644 --- a/src/lua/functions/creatures/monster/monster_functions.cpp +++ b/src/lua/functions/creatures/monster/monster_functions.cpp @@ -91,6 +91,8 @@ void MonsterFunctions::init(lua_State* L) { Lua::registerMethod(L, "Monster", "walkTo", MonsterFunctions::luaMonsterWalkTo); + Lua::registerMethod(L, "Monster", "applyEchoWarden", MonsterFunctions::luaMonsterApplyEchoWarden); + CharmFunctions::init(L); LootFunctions::init(L); MonsterSpellFunctions::init(L); @@ -914,3 +916,18 @@ int MonsterFunctions::luaMonsterWalkTo(lua_State* L) { Lua::pushBoolean(L, true); return 1; } + +int MonsterFunctions::luaMonsterApplyEchoWarden(lua_State* L) { + // monster:applyEchoWarden(hpMult[, atkMult = 1.0]) + const auto &monster = Lua::getUserdataShared(L, 1); + if (!monster) { + Lua::reportErrorFunc(Lua::getErrorDesc(LUA_ERROR_MONSTER_NOT_FOUND)); + Lua::pushBoolean(L, false); + return 1; + } + + const float hpMult = Lua::getNumber(L, 2, 1.0f); + const float atkMult = Lua::getNumber(L, 3, 1.0f); + Lua::pushBoolean(L, monster->applyEchoWarden(hpMult, atkMult)); + return 1; +} diff --git a/src/lua/functions/creatures/monster/monster_functions.hpp b/src/lua/functions/creatures/monster/monster_functions.hpp index 6a001c487..f0577c4a2 100644 --- a/src/lua/functions/creatures/monster/monster_functions.hpp +++ b/src/lua/functions/creatures/monster/monster_functions.hpp @@ -96,5 +96,7 @@ class MonsterFunctions { static int luaMonsterWalkTo(lua_State* L); + static int luaMonsterApplyEchoWarden(lua_State* L); + friend class CreatureFunctions; }; diff --git a/src/lua/functions/creatures/player/player_functions.cpp b/src/lua/functions/creatures/player/player_functions.cpp index 5cf2b1bd6..fb1c0b7b2 100644 --- a/src/lua/functions/creatures/player/player_functions.cpp +++ b/src/lua/functions/creatures/player/player_functions.cpp @@ -485,6 +485,7 @@ void PlayerFunctions::init(lua_State* L) { Lua::registerMethod(L, "Player", "sendBannerType", PlayerFunctions::luaPlayersendBannerType); Lua::registerMethod(L, "Player", "sendQuestProgress", PlayerFunctions::luaPlayerSendQuestStatusUpdate); + Lua::registerMethod(L, "Player", "sendLeaderMonsterKilledBanner", PlayerFunctions::luaPlayerSendLeaderMonsterKilledBanner); Lua::registerMethod(L, "Player", "sendIconBakragore", PlayerFunctions::luaPlayerSendIconBakragore); Lua::registerMethod(L, "Player", "removeIconBakragore", PlayerFunctions::luaPlayerRemoveIconBakragore); @@ -511,6 +512,7 @@ void PlayerFunctions::init(lua_State* L) { Lua::registerMethod(L, "Player", "applyImbuementScrollToItem", PlayerFunctions::luaPlayerApplyImbuementScrollToItem); Lua::registerMethod(L, "Player", "onClearAllImbuementsOnEtcher", PlayerFunctions::luaPlayerOnClearAllImbuementsOnEtcher); Lua::registerMethod(L, "Player", "sendWeaponProficiencyExperience", PlayerFunctions::luaPlayerSendWeaponProficiencyExperience); + Lua::registerMethod(L, "Player", "sendBossDifficultySelection", PlayerFunctions::luaPlayerSendBossDifficultySelection); // OTCR Features Lua::registerMethod(L, "Player", "getMapShader", PlayerFunctions::luaPlayerGetMapShader); @@ -5452,6 +5454,19 @@ int PlayerFunctions::luaPlayerSendQuestStatusUpdate(lua_State* L) { return 0; } +int PlayerFunctions::luaPlayerSendLeaderMonsterKilledBanner(lua_State* L) { + // player:sendLeaderMonsterKilledBanner(raceId, charmPoints) -> "Echo Warden Killed" banner (shows the creature) + const auto &player = Lua::getUserdataShared(L, 1); + if (!player) { + lua_pushnil(L); + return 1; + } + + player->sendScreenshotAndBannerLeaderMonsterKilled(Lua::getNumber(L, 2), Lua::getNumber(L, 3)); + Lua::pushBoolean(L, true); + return 1; +} + int PlayerFunctions::luaPlayerSendIconBakragore(lua_State* L) { // player:sendIconBakragore() const auto &player = Lua::getUserdataShared(L, 1); @@ -5782,6 +5797,60 @@ int PlayerFunctions::luaPlayerSendWeaponProficiencyExperience(lua_State* L) { return 1; } +int PlayerFunctions::luaPlayerSendBossDifficultySelection(lua_State* L) { + // player:sendBossDifficultySelection(selectedDifficulty, numbers{}, banners{}, redMods{}, greenMods{}) + const auto &player = Lua::getUserdataShared(L, 1); + if (!player) { + Lua::reportErrorFunc(Lua::getErrorDesc(LUA_ERROR_PLAYER_NOT_FOUND)); + Lua::pushBoolean(L, false); + return 1; + } + + const uint8_t selectedDifficulty = Lua::getNumber(L, 2, 0); + + auto readNumbers = [L](int idx, std::vector &out) { + if (!lua_istable(L, idx)) { + return; + } + for (int i = 1;; ++i) { + lua_rawgeti(L, idx, i); + if (lua_isnil(L, -1)) { + lua_pop(L, 1); + break; + } + out.push_back(Lua::getNumber(L, -1, 0)); + lua_pop(L, 1); + } + }; + auto readStrings = [L](int idx, std::vector &out) { + if (!lua_istable(L, idx)) { + return; + } + for (int i = 1;; ++i) { + lua_rawgeti(L, idx, i); + if (lua_isnil(L, -1)) { + lua_pop(L, 1); + break; + } + out.push_back(Lua::getString(L, -1, "")); + lua_pop(L, 1); + } + }; + + std::vector numbers; + std::vector banners; + std::vector redMods; + std::vector greenMods; + readNumbers(3, numbers); + readStrings(4, banners); + readStrings(5, redMods); + readStrings(6, greenMods); + + player->sendBossDifficultySelection(selectedDifficulty, numbers, banners, redMods, greenMods); + Lua::pushBoolean(L, true); + return 1; +} + int PlayerFunctions::luaPlayerGetMapShader(lua_State* L) { // player:getMapShader() const auto &player = Lua::getUserdataShared(L, 1); diff --git a/src/lua/functions/creatures/player/player_functions.hpp b/src/lua/functions/creatures/player/player_functions.hpp index c43141be1..b41cc637d 100644 --- a/src/lua/functions/creatures/player/player_functions.hpp +++ b/src/lua/functions/creatures/player/player_functions.hpp @@ -459,6 +459,7 @@ class PlayerFunctions { static int luaPlayersendBannerType(lua_State* L); static int luaPlayerSendQuestStatusUpdate(lua_State* L); + static int luaPlayerSendLeaderMonsterKilledBanner(lua_State* L); static int luaPlayerSendIconBakragore(lua_State* L); static int luaPlayerRemoveIconBakragore(lua_State* L); @@ -485,6 +486,7 @@ class PlayerFunctions { static int luaPlayerApplyImbuementScrollToItem(lua_State* L); static int luaPlayerOnClearAllImbuementsOnEtcher(lua_State* L); static int luaPlayerSendWeaponProficiencyExperience(lua_State* L); + static int luaPlayerSendBossDifficultySelection(lua_State* L); static int luaPlayerGetMapShader(lua_State* L); static int luaPlayerSetMapShader(lua_State* L); diff --git a/src/server/network/protocol/protocolgame.cpp b/src/server/network/protocol/protocolgame.cpp index 9748ad987..963b976c8 100644 --- a/src/server/network/protocol/protocolgame.cpp +++ b/src/server/network/protocol/protocolgame.cpp @@ -1438,6 +1438,9 @@ void ProtocolGame::parsePacketFromDispatcher(NetworkMessage &msg, uint8_t recvby case 0xB3: parseWeaponProficiency(msg); break; + case 0xC2: + parseBossDifficultySelection(msg); + break; case 0xBA: parseSoulSeals(msg); break; @@ -1617,7 +1620,9 @@ void ProtocolGame::GetTileDescription(const std::shared_ptr &tile, Network int32_t count; std::shared_ptr ground = tile->getGround(); - if (ground) { + // 15.25+ clients reject a zero client-id appearance on a map tile ("field has more than + // one zero id appearance"); skip any tile thing whose ItemType resolves to id 0. + if (ground && Item::items[ground->getID()].id != 0) { AddItem(msg, ground); count = 1; } else { @@ -1627,6 +1632,9 @@ void ProtocolGame::GetTileDescription(const std::shared_ptr &tile, Network const TileItemVector* items = tile->getItemList(); if (items) { for (auto it = items->getBeginTopItem(), end = items->getEndTopItem(); it != end; ++it) { + if (Item::items[(*it)->getID()].id == 0) { + continue; // skip zero client-id appearance (15.25+ map parser rejects it) + } AddItem(msg, *it); count++; @@ -1671,6 +1679,9 @@ void ProtocolGame::GetTileDescription(const std::shared_ptr &tile, Network if (items) { for (auto it = items->getBeginDownItem(), end = items->getEndDownItem(); it != end; ++it) { + if (Item::items[(*it)->getID()].id == 0) { + continue; // skip zero client-id appearance (15.25+ map parser rejects it) + } AddItem(msg, *it); if (++count == 10) { @@ -2655,72 +2666,78 @@ void ProtocolGame::parseBestiarysendMonsterData(NetworkMessage &msg) { newmsg.add(mtype->info.bestiaryToUnlock); newmsg.addByte(mtype->info.bestiaryStars); - newmsg.addByte(mtype->info.bestiaryOccurrence); - std::vector lootList = mtype->info.lootItems; - newmsg.addByte(lootList.size()); - for (const LootBlock &loot : lootList) { - int8_t difficult = g_iobestiary().calculateDifficult(loot.chance); - bool shouldAddItem = false; - - switch (currentLevel) { - case 1: - shouldAddItem = false; - break; - case 2: - if (difficult < 2) { - shouldAddItem = true; - } - break; - case 3: - if (difficult < 3) { + // 15.25 (sommerrelease26): in the e2a4a1 client everything past the stars byte is gated on + // currentLevel != 0, and the occurrence byte is now followed by an extra (unconfirmed) byte. + if (currentLevel != 0) { + newmsg.addByte(mtype->info.bestiaryOccurrence); + newmsg.addByte(0); + + std::vector lootList = mtype->info.lootItems; + newmsg.addByte(lootList.size()); + for (const LootBlock &loot : lootList) { + int8_t difficult = g_iobestiary().calculateDifficult(loot.chance); + bool shouldAddItem = false; + + switch (currentLevel) { + case 1: + shouldAddItem = false; + break; + case 2: + if (difficult < 2) { + shouldAddItem = true; + } + break; + case 3: + if (difficult < 3) { + shouldAddItem = true; + } + break; + case 4: shouldAddItem = true; - } - break; - case 4: - shouldAddItem = true; - break; - } + break; + } - newmsg.add(g_configManager().getBoolean(SHOW_LOOTS_IN_BESTIARY) || shouldAddItem == true ? loot.id : 0); - newmsg.addByte(difficult); - newmsg.addByte(0); // 1 if special event - 0 if regular loot (?) - if (g_configManager().getBoolean(SHOW_LOOTS_IN_BESTIARY) || shouldAddItem == true) { - newmsg.addString(loot.name); - newmsg.addByte(loot.countmax > 0 ? 0x1 : 0x0); + newmsg.add(g_configManager().getBoolean(SHOW_LOOTS_IN_BESTIARY) || shouldAddItem == true ? loot.id : 0); + newmsg.addByte(difficult); + newmsg.addByte(0); // 1 if special event - 0 if regular loot (?) + if (g_configManager().getBoolean(SHOW_LOOTS_IN_BESTIARY) || shouldAddItem == true) { + newmsg.addString(loot.name); + newmsg.addByte(loot.countmax > 0 ? 0x1 : 0x0); + } } - } - if (currentLevel > 1) { - newmsg.add(mtype->info.bestiaryCharmsPoints); - int8_t attackmode = 0; - if (!mtype->info.isHostile) { - attackmode = 2; - } else if (mtype->info.targetDistance) { - attackmode = 1; + if (currentLevel > 1) { + newmsg.add(mtype->info.bestiaryCharmsPoints); + int8_t attackmode = 0; + if (!mtype->info.isHostile) { + attackmode = 2; + } else if (mtype->info.targetDistance) { + attackmode = 1; + } + + newmsg.addByte(attackmode); + newmsg.addByte(0x02); + newmsg.add(mtype->info.healthMax); + newmsg.add(mtype->info.experience); + newmsg.add(mtype->getBaseSpeed()); + newmsg.add(mtype->info.armor); + newmsg.addDouble(mtype->info.mitigation); } - newmsg.addByte(attackmode); - newmsg.addByte(0x02); - newmsg.add(mtype->info.healthMax); - newmsg.add(mtype->info.experience); - newmsg.add(mtype->getBaseSpeed()); - newmsg.add(mtype->info.armor); - newmsg.addDouble(mtype->info.mitigation); - } + if (currentLevel > 2) { + std::map elements = g_iobestiary().getMonsterElements(mtype); - if (currentLevel > 2) { - std::map elements = g_iobestiary().getMonsterElements(mtype); + newmsg.addByte(elements.size()); + for (auto &element : elements) { + newmsg.addByte(element.first); + newmsg.add(element.second); + } - newmsg.addByte(elements.size()); - for (auto &element : elements) { - newmsg.addByte(element.first); - newmsg.add(element.second); + newmsg.add(1); + newmsg.addString(mtype->info.bestiaryLocations); } - - newmsg.add(1); - newmsg.addString(mtype->info.bestiaryLocations); - } + } // end 15.25 (sommerrelease26) currentLevel != 0 gate writeToOutputBuffer(newmsg); } @@ -3296,10 +3313,14 @@ void ProtocolGame::parseBestiarySendCreatures(NetworkMessage &msg) { } } + // 15.25 (sommerrelease26): the e2a4a1 client reads a new per-creature byte right after the + // race id (before progress). It appears to be a "known/unlocked" flag, so derive it from the + // kill progress (0 keeps the silhouette, 1 reveals the entry). The progress block also gained + // an extra trailing byte (sent as 0 until its meaning is confirmed). + newmsg.addByte(progress > 0 ? 1 : 0); + newmsg.addByte(progress); if (progress > 0) { - newmsg.addByte(progress); newmsg.addByte(occurrence); - } else { newmsg.addByte(0); } @@ -3542,30 +3563,22 @@ void ProtocolGame::parseSendResourceBalance() { void ProtocolGame::parseSendResourceBalance(NetworkMessage &msg) { // Portable forge open (client UI): empty 0xED requests the forge item list (0x87). + // Do NOT prime m_lastForgeOpenTime here: leaving it stale makes the follow-up + // forge-resource request trigger a full re-open (sendOpenForge), which is what + // arms the window buttons. This mirrors the working path (use-item / /openforge). if (!msg.canRead(1)) { - m_lastForgeOpenTime = OTSYS_TIME(); + // Empty 0xED (portable-forge button): open the exaltation forge. sendOpenForge(); return; } const auto resourceType = static_cast(msg.getByte()); - switch (resourceType) { - case RESOURCE_FORGE_DUST: - case RESOURCE_FORGE_SLIVER: - case RESOURCE_FORGE_CORES: { - const uint32_t now = OTSYS_TIME(); - if (now - m_lastForgeOpenTime > 1000) { - m_lastForgeOpenTime = now; - sendOpenForge(); - } else { - sendResourceBalance(resourceType); - } - break; - } - default: - sendResourceBalance(resourceType); - break; - } + // 15.25 (sommerrelease26): answer with the requested resource balance only. The Weapon + // Proficiency "Modify" panel polls the forge dust via 0xED (with a resource byte); the old + // portable-forge re-open hack popped the exaltation forge and never delivered the dust to + // the proficiency panel ("Not enough dust available"). Sending the balance fixes both the + // stray forge window and the missing dust. + sendResourceBalance(resourceType); } void ProtocolGame::sendResourceBalance(Resource_t resourceType) { @@ -3600,6 +3613,12 @@ void ProtocolGame::sendResourceBalance(Resource_t resourceType) { value = coreCount; break; } + case RESOURCE_FORGE_DUST_LIMIT: + // 15.25 (sommerrelease26): the client polls 0x49 for the forge dust cap (shown as the + // "/limit" next to current dust, and used by the Increase Dust Limit preview). The 0xEE + // parser reads this in the default branch as u64, which is what sendResourceBalance emits. + value = player->getForgeDustLevel(); + break; case RESOURCE_LESSER_GEMS: value = player->getItemTypeCount(player->getVocation()->getWheelGemId(WheelGemQuality_t::Lesser)); break; @@ -3846,6 +3865,7 @@ void ProtocolGame::addCreatureIcon(NetworkMessage &msg, const std::shared_ptr(icon.category)); msg.add(icon.count); + msg.addByte(0); // 15.25 (sommerrelease26): new trailing per-icon byte } } @@ -6432,32 +6452,10 @@ void ProtocolGame::sendForgingData() { msg.add(price); } - // (conversion) (left column top) Cost to make 1 bottom item - 20 - msg.addByte(static_cast(g_configManager().getNumber(FORGE_COST_ONE_SLIVER))); - // (conversion) (left column bottom) How many items to make - 3 - msg.addByte(static_cast(g_configManager().getNumber(FORGE_SLIVER_AMOUNT))); - // (conversion) (middle column top) Cost to make 1 - 50 - msg.addByte(static_cast(g_configManager().getNumber(FORGE_CORE_COST))); - // (conversion) (right column top) Current stored dust limit minus this number = cost to increase stored dust limit - 75 - msg.addByte(75); - // (conversion) (right column bottom) Starting stored dust limit - msg.add(player->getForgeDustLevel()); - // (conversion) (right column bottom) Max stored dust limit - 325 - msg.add(g_configManager().getNumber(FORGE_MAX_DUST)); - // (normal fusion) dust cost - 100 - msg.addByte(static_cast(g_configManager().getNumber(FORGE_FUSION_DUST_COST))); - // (convergence fusion) dust cost - 130 - msg.addByte(static_cast(g_configManager().getNumber(FORGE_CONVERGENCE_FUSION_DUST_COST))); - // (normal transfer) dust cost - 100 - msg.addByte(static_cast(g_configManager().getNumber(FORGE_TRANSFER_DUST_COST))); - // (convergence transfer) dust cost - 160 - msg.addByte(static_cast(g_configManager().getNumber(FORGE_CONVERGENCE_TRANSFER_DUST_COST))); - // (fusion) Base success rate - 50 - msg.addByte(static_cast(g_configManager().getNumber(FORGE_BASE_SUCCESS_RATE))); - // (fusion) Bonus success rate - 15 - msg.addByte(static_cast(g_configManager().getNumber(FORGE_BONUS_SUCCESS_RATE))); - // (fusion) Tier loss chance after reduction - 50 - msg.addByte(static_cast(g_configManager().getNumber(FORGE_TIER_LOSS_REDUCTION))); + // 15.25 (sommerrelease26): the 13.30 forge-config block (slot costs, dust limits, + // success rates) was dropped by the new client, which reads a single trailing byte + // here instead. Send 0 to keep alignment (forge config UI may show defaults). + msg.addByte(0); // Update player resources parseSendResourceBalance(); @@ -6697,7 +6695,7 @@ void ProtocolGame::sendOpenForge() { msg.addByte(convergenceTransferCount); msg.setBufferPosition(dustLevelPosition); - msg.add(player->getForgeDustLevel()); // Player dust limit + msg.addByte(static_cast(std::min(player->getForgeDustLevel(), 0xFF))); // Player dust limit writeToOutputBuffer(msg); // Update forging informations sendForgingData(); @@ -7428,6 +7426,7 @@ void ProtocolGame::sendCancelTarget() { NetworkMessage msg; msg.addByte(0xA3); msg.add(0x00); + msg.add(0x00); // 15.25 (sommerrelease26): client reads a 2nd u32 here now writeToOutputBuffer(msg); } @@ -7747,6 +7746,12 @@ void ProtocolGame::sendAddTileItem(const Position &pos, uint32_t stackpos, const return; } + // Don't send a zero client-id appearance on a tile — the 15.25+ client rejects it + // ("field has more than one zero id appearance"); it can't render anyway. + if (!item || Item::items[item->getID()].id == 0) { + return; + } + NetworkMessage msg; msg.addByte(0x6A); msg.addPosition(pos); @@ -7760,6 +7765,11 @@ void ProtocolGame::sendUpdateTileItem(const Position &pos, uint32_t stackpos, co return; } + // Don't send a zero client-id appearance on a tile — the 15.25+ client rejects it. + if (!item || Item::items[item->getID()].id == 0) { + return; + } + NetworkMessage msg; msg.addByte(0x6B); msg.addPosition(pos); @@ -8928,6 +8938,9 @@ void ProtocolGame::AddCreature(NetworkMessage &msg, const std::shared_ptr(creature->getStepSpeed()); addCreatureIcon(msg, creature); + // 15.25 (sommerrelease26): the second creature-icon list (count2) is inline here in + // AddCreature only — sendCreatureIcon (0x8B) sends each list as its own typed message. + msg.addByte(0); msg.addByte(player->getSkullClient(creature)); msg.addByte(player->getPartyShield(otherPlayer)); @@ -10851,6 +10864,22 @@ void ProtocolGame::sendScreenshotAndBannerWeeklyTaskSpecificFinished(uint16_t ra writeToOutputBuffer(msg); } +void ProtocolGame::sendScreenshotAndBannerLeaderMonsterKilled(uint16_t raceId, uint32_t charmPoints) { + if (oldProtocol) { + return; + } + + // Client GameEventTypeLeaderMonsterKilled (banner subtype 0x0e): creature race id (uint16) + charm + // points (uint32). The client resolves the race id to the creature and renders the + // "Echo Warden Killed / You have received N Charm Points" banner with the creature shown. + NetworkMessage msg; + msg.addByte(0x75); + msg.addByte(SCREENSHOT_AND_BANNER_TYPE_LEADER_MONSTER); + msg.add(raceId); + msg.add(charmPoints); + writeToOutputBuffer(msg); +} + void ProtocolGame::sendOutfitWindowCustomOTCR(NetworkMessage &msg) { if (!isOTCR) { return; @@ -11309,6 +11338,64 @@ void ProtocolGame::parseWeaponProficiency(NetworkMessage &msg) { } player->applyEquippedWeaponProficiency(itemId); + + } else if (type == WEAPON_PROFICIENCY_MODIFY_SLOT) { + const uint16_t itemId = msg.get(); + const uint8_t proficiencyLevel = msg.getByte(); + const uint8_t perkPosition = msg.getByte(); + while (msg.canRead(1)) { // trailing flag byte (observed 0x49) — purpose unknown; drain it. + msg.getByte(); + } + player->modifyWeaponProficiencySlot(itemId, proficiencyLevel, perkPosition); + + } else if (type == WEAPON_PROFICIENCY_REFINE_SLOT) { + const uint16_t itemId = msg.get(); + const uint8_t proficiencyLevel = msg.getByte(); + const uint8_t perkPosition = msg.getByte(); + while (msg.canRead(1)) { // trailing flag byte (observed 0xCF) — drain it. + msg.getByte(); + } + player->refineWeaponProficiencySlot(itemId, proficiencyLevel, perkPosition); + + } else if (type == WEAPON_PROFICIENCY_MAXIMISE_SLOT) { + const uint16_t itemId = msg.get(); + const uint8_t proficiencyLevel = msg.getByte(); + const uint8_t perkPosition = msg.getByte(); + while (msg.canRead(1)) { // trailing flag byte (observed 0x90) — drain it. + msg.getByte(); + } + player->maximiseWeaponProficiencySlot(itemId, proficiencyLevel, perkPosition); + + } else if (type == WEAPON_PROFICIENCY_RESHAPE_SLOT) { + const uint16_t itemId = msg.get(); + const uint8_t proficiencyLevel = msg.getByte(); + const uint8_t perkPosition = msg.getByte(); + while (msg.canRead(1)) { // trailing flag byte (observed 0x46 / 0x60) — drain it. + msg.getByte(); + } + player->reshapeWeaponProficiencySlot(itemId, proficiencyLevel, perkPosition); + + } else if (type == WEAPON_PROFICIENCY_PICK_OFFER) { + // Payload CONFIRMED live (D8 0C 00 00 00 00): itemId(u16) + slot level + slot pos + chosen offer index + flag. + const uint16_t itemId = msg.canRead(2) ? msg.get() : 0; + const uint8_t proficiencyLevel = msg.canRead(1) ? msg.getByte() : 0; // echoed slot (unused) + const uint8_t perkPosition = msg.canRead(1) ? msg.getByte() : 0; + const uint8_t offerIndex = msg.canRead(1) ? msg.getByte() : 0; + (void)proficiencyLevel; + (void)perkPosition; + while (msg.canRead(1)) { + msg.getByte(); + } + player->pickReshapeWeaponProficiencyOffer(itemId, offerIndex); + + } else if (type == WEAPON_PROFICIENCY_CLEAR_SLOT) { + const uint16_t itemId = msg.get(); + const uint8_t proficiencyLevel = msg.getByte(); + const uint8_t perkPosition = msg.getByte(); + while (msg.canRead(1)) { // trailing flag byte (observed 0xF2) — drain it. + msg.getByte(); + } + player->clearWeaponProficiencySlot(itemId, proficiencyLevel, perkPosition); } } @@ -11343,10 +11430,128 @@ void ProtocolGame::sendWeaponProficiencyInfo(const uint16_t itemId) { msg.addByte(perk.proficiencyLevel - 1); msg.addByte(perk.perkPosition - 1); } + // 15.25 (sommerrelease26) SHAPE: a SECOND list right after the active perks carries the modified slots: + // [level(0-based), position(0-based), perkType:u16 (client catalogue index), value]. The client renders + // the rolled perk from the catalogue index. See PORT.md §7.4. + msg.addByte(static_cast(proficiency.modifiedSlots.size())); + for (const auto &slot : proficiency.modifiedSlots) { + msg.addByte(slot.proficiencyLevel - 1); + msg.addByte(slot.perkPosition - 1); + msg.add(static_cast(slot.perkType)); + msg.addByte(slot.value); + } writeToOutputBuffer(msg); } } +void ProtocolGame::sendWeaponProficiencyReshapeOffers(const uint16_t itemId) { + if (oldProtocol || !player) { + return; + } + + auto it = player->weaponProficiencies.find(itemId); + if (it == player->weaponProficiencies.end()) { + return; + } + const auto &offers = it->second.pendingReshapeOffers; + + // 15.25 (sommerrelease26): Reshape offers = opcode 0xBB (Ghidra-confirmed FUN_140601bf0). NOT 0xC7 (that is + // CyclopediaCurrentHouseData). Wire: u16 itemId · byte curLevel · byte curPos · byte count · count×{u16 perkType, byte value}. + // Each offer uses the same perkType+rank encoding as a 0xC4 modified slot (no name string). See PORT.md §7.4. + uint8_t rank = 1; + for (const auto &slot : it->second.modifiedSlots) { + if (slot.proficiencyLevel == it->second.pendingReshapeLevel && slot.perkPosition == it->second.pendingReshapePosition) { + rank = std::max(1, slot.value); + break; + } + } + + NetworkMessage msg; + msg.addByte(0xBB); + msg.add(itemId); + msg.addByte(it->second.pendingReshapeLevel - 1); // current slot (0-based on the wire, like active perks) + msg.addByte(it->second.pendingReshapePosition - 1); + msg.addByte(static_cast(offers.size())); + for (const auto perkType : offers) { + msg.add(static_cast(perkType)); + msg.addByte(rank); + } + + writeToOutputBuffer(msg); +} + +void ProtocolGame::sendBossDifficultySelection(uint8_t selectedDifficulty, const std::vector &numbers, const std::vector &banners, const std::vector &redMods, const std::vector &greenMods) { + if (oldProtocol || !player) { + return; + } + + NetworkMessage msg; + msg.addByte(0x2F); + msg.addByte(selectedDifficulty); + + msg.add(numbers.size() > 0 ? numbers[0] : 0); // f1 + msg.addByte(numbers.size() > 1 ? static_cast(numbers[1]) : 0); // f2 (u8) + msg.add(numbers.size() > 2 ? static_cast(numbers[2]) : 0); // f3 = raceId + msg.add(numbers.size() > 3 ? static_cast(numbers[3]) : 0); // f4 + msg.add(numbers.size() > 4 ? static_cast(numbers[4]) : 0); // f5 = Highest in Group + msg.add(numbers.size() > 5 ? static_cast(numbers[5]) : 0); // f6 = Personal Highest + msg.add(numbers.size() > 6 ? numbers[6] : 0); // f7 = Bad Luck % (value/1000) + + for (size_t i = 0; i < 5; ++i) { + msg.addString(i < banners.size() ? banners[i] : std::string()); + } + + msg.add(0); // Update.f1 + msg.addByte(static_cast(redMods.size())); + for (const auto &s : redMods) { + msg.addString(s); + } + msg.addByte(static_cast(greenMods.size())); + for (const auto &s : greenMods) { + msg.addString(s); + } + + g_logger().info("[BossDiffSel] 0x2F sel={} raceId(f3)={} banners={}", selectedDifficulty, numbers.size() > 2 ? numbers[2] : 0, banners.size()); + writeToOutputBuffer(msg); +} + +void ProtocolGame::parseBossDifficultySelection(NetworkMessage &msg) { + if (oldProtocol || !player) { + return; + } + + const uint32_t selectionContext = msg.canRead(4) ? msg.get() : 0; // always 1 so far + (void)selectionContext; + const uint8_t action = msg.canRead(1) ? msg.getByte() : 0xFF; + uint16_t difficulty = 0; + if ((action == 0x00 || action == 0x02) && msg.canRead(2)) { + difficulty = msg.get(); + } + while (msg.canRead(1)) { // drain trailing byte(s) + msg.getByte(); + } + + if (action == 0x02) { + // spinner preview — the window stays open, nothing to do server-side (selection is client-side) + g_logger().info("[BossDiffSel] select difficulty={}", difficulty); + return; + } + if (action == 0x00) { + // Start Fight — difficulty range is 0..25. Clamp/validate, then fire the callback. + if (difficulty > 25) { + difficulty = 25; + } + g_logger().info("[BossDiffSel] START FIGHT difficulty={} (0..25)", difficulty); + } else { + g_logger().info("[BossDiffSel] cancel/other action={}", action); + } + // Start (after handling) or Cancel -> close the dialog + NetworkMessage out; + out.addByte(0x2F); + out.addByte(0x01); // discriminator 1 = CLOSE + writeToOutputBuffer(out); +} + void ProtocolGame::parseExivaRestrictions(NetworkMessage &msg) { if (!player || (g_game().getWorldType() == WORLDTYPE_OPTIONAL && !g_configManager().getBoolean(EXIVA_RESTRICTIONS_ONLY_OPTIONAL_WORLDS))) { return; diff --git a/src/server/network/protocol/protocolgame.hpp b/src/server/network/protocol/protocolgame.hpp index d4a845299..47f3f2f53 100644 --- a/src/server/network/protocol/protocolgame.hpp +++ b/src/server/network/protocol/protocolgame.hpp @@ -570,6 +570,9 @@ class ProtocolGame final : public Protocol { void parseWeaponProficiency(NetworkMessage &msg); void sendWeaponProficiencyExperience(const uint16_t itemId, const uint32_t experience); void sendWeaponProficiencyInfo(const uint16_t itemId); + void sendWeaponProficiencyReshapeOffers(const uint16_t itemId); + void sendBossDifficultySelection(uint8_t selectedDifficulty, const std::vector &numbers, const std::vector &banners, const std::vector &redMods, const std::vector &greenMods); + void parseBossDifficultySelection(NetworkMessage &msg); friend class Player; friend class PlayerWheel; @@ -624,6 +627,7 @@ class ProtocolGame final : public Protocol { void sendScreenshotAndBannerUnlockedSpell(uint16_t spellId); void sendScreenshotAndBannerBountyTaskFinished(uint16_t raceId); void sendScreenshotAndBannerWeeklyTaskSpecificFinished(uint16_t raceId); + void sendScreenshotAndBannerLeaderMonsterKilled(uint16_t raceId, uint32_t charmPoints); void sendDisableLoginMusic(); diff --git a/src/server/server_definitions.hpp b/src/server/server_definitions.hpp index 5b45e50f1..b6e89cbc0 100644 --- a/src/server/server_definitions.hpp +++ b/src/server/server_definitions.hpp @@ -72,6 +72,7 @@ enum Resource_t : uint8_t { RESOURCE_FORGE_DUST = 0x46, RESOURCE_FORGE_SLIVER = 0x47, RESOURCE_FORGE_CORES = 0x48, + RESOURCE_FORGE_DUST_LIMIT = 0x49, // 15.25 (sommerrelease26): forge dust cap, polled next to current dust RESOURCE_LESSER_GEMS = 0x51, RESOURCE_REGULAR_GEMS = 0x52, RESOURCE_GREATER_GEMS = 0x53, @@ -163,5 +164,11 @@ enum WeaponProficiency_t : uint8_t { WEAPON_PROFICIENCY_ITEM_INFO = 0, WEAPON_PROFICIENCY_LIST_INFO = 1, WEAPON_PROFICIENCY_RESET_PERKS = 2, - WEAPON_PROFICIENCY_APPLY_PERKS = 3 + WEAPON_PROFICIENCY_APPLY_PERKS = 3, + WEAPON_PROFICIENCY_MODIFY_SLOT = 4, // 15.25 sommerrelease26: dust-roll a custom effect onto a perk slot + WEAPON_PROFICIENCY_REFINE_SLOT = 5, // 15.25: raise a modified slot's rank by one (Refine button) + WEAPON_PROFICIENCY_MAXIMISE_SLOT = 6, // 15.25: push a modified slot to max rank (Maximise button) + WEAPON_PROFICIENCY_RESHAPE_SLOT = 7, // 15.25: re-roll — server offers 3 perks to pick from (Reshape) + WEAPON_PROFICIENCY_PICK_OFFER = 8, // 15.25: pick one of the reshape offers (replace the slot's perk) + WEAPON_PROFICIENCY_CLEAR_SLOT = 9 // 15.25: free — remove the modification, restore the tree perk (Clear) }; diff --git a/src/utils/tools.cpp b/src/utils/tools.cpp index 1bdcc8a6b..bafca094a 100644 --- a/src/utils/tools.cpp +++ b/src/utils/tools.cpp @@ -871,6 +871,21 @@ ShootTypeNames shootTypeNames = { { "terrastormarrow", CONST_ANI_TERRASTORMARROW }, { "froststormarrow", CONST_ANI_FROSTSTORMARROW }, { "thunderstormarrow", CONST_ANI_THUNDERSTORMARROW }, + // 15.25 client homing-missile effects (proficiency Type-32 perk). ids 69/76 are placeholders. + { "homingphysical", CONST_ANI_HOMING_PHYSICAL }, + { "homingfire", CONST_ANI_HOMING_FIRE }, + { "homingearth", CONST_ANI_HOMING_EARTH }, + { "homingenergy", CONST_ANI_HOMING_ENERGY }, + { "homingice", CONST_ANI_HOMING_ICE }, + { "homingholy", CONST_ANI_HOMING_HOLY }, + { "homingdeath", CONST_ANI_HOMING_DEATH }, + { "homingstellarphysical", CONST_ANI_HOMING_STELLAR_PHYSICAL }, + { "homingstellarfire", CONST_ANI_HOMING_STELLAR_FIRE }, + { "homingstellarearth", CONST_ANI_HOMING_STELLAR_EARTH }, + { "homingstellarenergy", CONST_ANI_HOMING_STELLAR_ENERGY }, + { "homingstellarice", CONST_ANI_HOMING_STELLAR_ICE }, + { "homingstellarholy", CONST_ANI_HOMING_STELLAR_HOLY }, + { "homingstellardeath", CONST_ANI_HOMING_STELLAR_DEATH }, }; CombatTypeNames combatTypeNames = { diff --git a/src/utils/utils_definitions.hpp b/src/utils/utils_definitions.hpp index 41381abd2..57b9a1f62 100644 --- a/src/utils/utils_definitions.hpp +++ b/src/utils/utils_definitions.hpp @@ -347,7 +347,24 @@ enum ShootType_t : uint8_t { CONST_ANI_FROSTSTORMARROW = 67, CONST_ANI_THUNDERSTORMARROW = 68, - CONST_ANI_LAST = CONST_ANI_THUNDERSTORMARROW, + // New 15.25 client distance effects — homing missiles (proficiency Type-32 ON_HIT_HOMING_MISSILE). + // ids 69 and 76 lead each block of 7; semantics unconfirmed (never fired by current data) — placeholder names. + CONST_ANI_HOMING_PHYSICAL = 69, // unknown/untyped homing variant — placeholder + CONST_ANI_HOMING_FIRE = 70, + CONST_ANI_HOMING_EARTH = 71, + CONST_ANI_HOMING_ENERGY = 72, + CONST_ANI_HOMING_ICE = 73, + CONST_ANI_HOMING_HOLY = 74, + CONST_ANI_HOMING_DEATH = 75, + CONST_ANI_HOMING_STELLAR_PHYSICAL = 76, // unknown/untyped stellar homing variant — placeholder + CONST_ANI_HOMING_STELLAR_FIRE = 77, + CONST_ANI_HOMING_STELLAR_EARTH = 78, + CONST_ANI_HOMING_STELLAR_ENERGY = 79, + CONST_ANI_HOMING_STELLAR_ICE = 80, + CONST_ANI_HOMING_STELLAR_HOLY = 81, + CONST_ANI_HOMING_STELLAR_DEATH = 82, + + CONST_ANI_LAST = CONST_ANI_HOMING_STELLAR_DEATH, // for internal use, don't send to client CONST_ANI_WEAPONTYPE = 0xFE, // 254 @@ -849,7 +866,8 @@ enum ScreenshotAndBanner_t : uint8_t { SCREENSHOT_AND_BANNER_TYPE_PROFICIENCY = 10, SCREENSHOT_AND_BANNER_TYPE_BOUNTY_TASK = 11, // client GameEventTypeBountyTaskFinished (payload: uint16) SCREENSHOT_AND_BANNER_TYPE_WEEKLY_TASK_SPECIFIC = 12, // client GameEventTypeWeeklyTaskSpecificCreatureFinished (payload: uint16) - SCREENSHOT_AND_BANNER_TYPE_SPELL = 13 // client GameEventTypeSpellUnlocked (payload: uint32 spellId) -> banner "New Spell Unlocked" + SCREENSHOT_AND_BANNER_TYPE_SPELL = 13, // client GameEventTypeSpellUnlocked (payload: uint32 spellId) -> banner "New Spell Unlocked" + SCREENSHOT_AND_BANNER_TYPE_LEADER_MONSTER = 14 // client GameEventTypeLeaderMonsterKilled (payload: uint16 raceId + uint32 charmPoints) -> banner "Echo Warden Killed" }; enum Banner_t : uint8_t {