From 5ef93fa7b3ecd31b5efac8dbe123ab16e4531f9f Mon Sep 17 00:00:00 2001 From: sruon Date: Mon, 4 May 2026 04:19:05 -0600 Subject: [PATCH] Route synthesis through SynthTransaction --- .../tests/systems/crafting/transaction.lua | 145 +++++ src/map/ai/states/synth_state.cpp | 3 - src/map/entities/charentity.cpp | 7 +- src/map/entities/charentity.h | 70 ++- src/map/items.h | 1 + src/map/items/CMakeLists.txt | 4 + src/map/items/craft_state.cpp | 102 +++ src/map/items/craft_state.h | 88 +++ src/map/items/transactions/synth.cpp | 258 ++++++++ src/map/items/transactions/synth.h | 86 +++ src/map/packets/c2s/0x029_item_move.cpp | 4 +- src/map/packets/c2s/0x096_combine_ask.cpp | 28 +- src/map/packets/c2s/0x10a_bazaar_itemset.cpp | 4 +- src/map/packets/s2c/0x06f_combine_ans.cpp | 35 +- src/map/packets/s2c/0x06f_combine_ans.h | 4 +- src/map/packets/s2c/0x070_combine_inf.cpp | 23 +- src/map/packets/s2c/0x070_combine_inf.h | 4 +- src/map/trade_container.cpp | 11 - src/map/trade_container.h | 16 - src/map/utils/charutils.cpp | 28 +- src/map/utils/synthutils.cpp | 594 ++++++------------ src/map/utils/synthutils.h | 3 +- src/map/zone_entities.cpp | 4 +- 23 files changed, 1045 insertions(+), 477 deletions(-) create mode 100644 scripts/tests/systems/crafting/transaction.lua create mode 100644 src/map/items/craft_state.cpp create mode 100644 src/map/items/craft_state.h create mode 100644 src/map/items/transactions/synth.cpp create mode 100644 src/map/items/transactions/synth.h diff --git a/scripts/tests/systems/crafting/transaction.lua b/scripts/tests/systems/crafting/transaction.lua new file mode 100644 index 00000000000..500b74e9526 --- /dev/null +++ b/scripts/tests/systems/crafting/transaction.lua @@ -0,0 +1,145 @@ +describe('SynthTransaction', function() + ---@type CClientEntityPair + local player + + local crystal = xi.item.WIND_CRYSTAL + local ingredient = xi.item.ARROWWOOD_LOG + local resultItem = xi.item.PIECE_OF_ARROWWOOD_LUMBER + + before_each(function() + player = xi.test.world:spawnPlayer( + { + zone = xi.zone.SOUTHERN_SAN_DORIA, + }) + player:setSkillLevel(xi.skill.WOODWORKING, 200) + end) + + it('crystal and ingredient are InTransaction during the animation', function() + player:addItem(crystal) + player:addItem(ingredient) + + local crystalItem = player:findItem(crystal) + local ingItem = player:findItem(ingredient) + assert(crystalItem and ingItem, 'expected crystal and ingredient in inventory') + assert(crystalItem:state() == xi.itemState.FREE) + assert(ingItem:state() == xi.itemState.FREE) + + player.actions:craft(crystal, { ingredient }) + + local midSynth = player:findItem(ingredient) + assert(midSynth, 'ingredient should still be in inventory mid-synth') + assert(midSynth:state() == xi.itemState.IN_TRANSACTION, + 'expected IN_TRANSACTION mid-synth, got ' .. tostring(midSynth:state())) + end) + + it('successful synth consumes ingredients and yields the result', function() + player:addItem(crystal) + player:addItem(ingredient) + + player.actions:craft(crystal, { ingredient }) + xi.test.world:skipTime(17) + xi.test.world:skipTime(15) + + player.assert.no:hasItem(crystal) + player.assert.no:hasItem(ingredient) + player.assert:hasItem(resultItem) + -- Exactly one result delivered (no double-commit / pendingResult leak). + assert(player:getItemCount(resultItem) == 1, 'expected exactly 1 result, got ' .. tostring(player:getItemCount(resultItem))) + end) + + it('stack ingredient consumes exactly N from one inventory slot', function() + local stackQty = 3 + player:addItem(crystal) + player:addItem(ingredient, stackQty) + + player.actions:craft(crystal, { ingredient }) + xi.test.world:skipTime(17) + xi.test.world:skipTime(15) + + assert(player:getItemCount(ingredient) == stackQty - 1, + string.format('expected %d remaining, got %d', stackQty - 1, player:getItemCount(ingredient))) + end) + + it('NO_LOSS recipe fail preserves ingredient', function() + -- Recipe 3049: Light Crystal + Broken Lu Shang's -> Lu Shang's. Wood 70, NO_LOSS. + local lightCrystal = xi.item.LIGHT_CRYSTAL + local brokenRod = xi.item.BROKEN_LU_SHANGS_FISHING_ROD + local luShangs = xi.item.LU_SHANGS_FISHING_ROD + + player:setSkillLevel(xi.skill.WOODWORKING, 550) + + local sawFail = false + for i = 1, 20 do + player:addItem(lightCrystal) + player:addItem(brokenRod) + player.actions:craft(lightCrystal, { brokenRod }) + xi.test.world:skipTime(17) + xi.test.world:skipTime(15) + + player.assert.no:hasItem(lightCrystal) + + if player:hasItem(luShangs) then + assert(not player:hasItem(brokenRod), 'success should consume ingredient') + player:delContainerItems(xi.inv.INVENTORY) + else + assert(player:hasItem(brokenRod), 'NO_LOSS fail must preserve ingredient') + sawFail = true + break + end + end + + assert(sawFail, 'expected at least one fail in 20 attempts at min skill') + end) + + it('bad recipe leaves items Free and unconsumed', function() + player:addItem(crystal) + player:addItem(xi.item.CHUNK_OF_IRON_ORE) + + player.actions:craft(crystal, { xi.item.CHUNK_OF_IRON_ORE }) + + local crystalItem = player:findItem(crystal) + local ore = player:findItem(xi.item.CHUNK_OF_IRON_ORE) + assert(crystalItem, 'crystal should not be consumed on bad recipe') + assert(ore, 'ingredient should not be consumed on bad recipe') + assert(crystalItem:state() == xi.itemState.FREE) + assert(ore:state() == xi.itemState.FREE) + end) + + it('moving an InTransaction ingredient is rejected', function() + player:addItem(crystal) + player:addItem(ingredient) + + local ingItem = player:findItem(ingredient, xi.inventoryLocation.INVENTORY) + assert(ingItem, 'expected ingredient in inventory') + local srcSlot = ingItem:getSlotID() + + player.actions:craft(crystal, { ingredient }) + assert(ingItem:state() == xi.itemState.IN_TRANSACTION) + + player.actions:moveItem(xi.inventoryLocation.INVENTORY, srcSlot, xi.inventoryLocation.MOGSAFE, 1) + + local stillInInv = player:findItem(ingredient, xi.inventoryLocation.INVENTORY) + assert(stillInInv, 'ingredient should still be in inventory') + assert(stillInInv:getSlotID() == srcSlot, 'ingredient slot must not change') + assert(stillInInv:state() == xi.itemState.IN_TRANSACTION, 'state must still be IN_TRANSACTION') + + local inMogSafe = player:findItem(ingredient, xi.inventoryLocation.MOGSAFE) + assert(not inMogSafe, 'ingredient must not have moved to mog safe') + end) + + it('zoning mid-synth consumes claimed ingredients', function() + player:addItem(crystal) + player:addItem(ingredient) + + player.actions:craft(crystal, { ingredient }) + + local midSynth = player:findItem(ingredient) + assert(midSynth and midSynth:state() == xi.itemState.IN_TRANSACTION) + + player:gotoZone(xi.zone.NORTHERN_SAN_DORIA) + xi.test.world:skipTime(2) + + player.assert.no:hasItem(crystal) + player.assert.no:hasItem(ingredient) + end) +end) diff --git a/src/map/ai/states/synth_state.cpp b/src/map/ai/states/synth_state.cpp index bebed5578f9..99721ba51ec 100644 --- a/src/map/ai/states/synth_state.cpp +++ b/src/map/ai/states/synth_state.cpp @@ -24,7 +24,6 @@ #include "entities/battleentity.h" #include "ai/ai_container.h" -#include "trade_container.h" #include "utils/synthutils.h" CSynthState::CSynthState(CCharEntity* PChar, SKILLTYPE skill) @@ -68,8 +67,6 @@ bool CSynthState::Update(timer::time_point tick) if (m_PEntity->isDead()) { synthutils::doSynthCriticalFail(m_PEntity); - - m_PEntity->CraftContainer->Clean(); // Clean to reset m_ItemCount to 0 return true; } diff --git a/src/map/entities/charentity.cpp b/src/map/entities/charentity.cpp index e57aeeb1c0e..37fee2f3bd0 100644 --- a/src/map/entities/charentity.cpp +++ b/src/map/entities/charentity.cpp @@ -79,6 +79,7 @@ #include "items/item_furnishing.h" #include "items/item_usable.h" #include "items/item_weapon.h" +#include "items/transactions/synth.h" #include "job_points.h" #include "latent_effect_container.h" #include "linkshell.h" @@ -130,7 +131,6 @@ CCharEntity::CCharEntity() TradeContainer = new CTradeContainer(); Container = new CTradeContainer(); UContainer = new CUContainer(); - CraftContainer = new CTradeContainer(); m_Inventory = std::make_unique(LOC_INVENTORY); m_Mogsafe = std::make_unique(LOC_MOGSAFE); @@ -370,10 +370,11 @@ CCharEntity::~CCharEntity() charutils::WriteHistory(this); + this->clearTransactions(); + destroy(TradeContainer); destroy(Container); destroy(UContainer); - destroy(CraftContainer); destroy(PLatentEffectContainer); PGuildShop = nullptr; @@ -560,7 +561,7 @@ bool CCharEntity::hasAutoTargetEnabled() const auto CCharEntity::isCrafting() const -> bool { - return animation == ANIMATION_SYNTH || (CraftContainer && CraftContainer->getItemsCount() > 0); + return animation == ANIMATION_SYNTH || this->activeTransaction(); } auto CCharEntity::isFishing() const -> bool diff --git a/src/map/entities/charentity.h b/src/map/entities/charentity.h index 0569e24ec87..9c5fd723960 100644 --- a/src/map/entities/charentity.h +++ b/src/map/entities/charentity.h @@ -27,6 +27,8 @@ #include "gmcall_container.h" #include "inventory_sync_state.h" #include "item_container.h" +#include "items/craft_state.h" +#include "items/transaction.h" #include "map_session.h" #include "monstrosity.h" @@ -38,6 +40,7 @@ #include #include #include +#include #include #include #include @@ -497,7 +500,69 @@ class CCharEntity : public CBattleEntity CTradeContainer* TradeContainer; // Container used specifically for trading. CTradeContainer* Container; // Universal container for exchange, synthesis, store, etc. CUContainer* UContainer; // Container used for universal actions -- used for trading at least despite the dedicated trading container above - CTradeContainer* CraftContainer; // Container used for crafting actions. + + auto craftState() -> CCraftState& + { + return craftState_; + } + + auto craftState() const -> const CCraftState& + { + return craftState_; + } + + template + auto activeTransaction() const -> T* + { + for (const auto& transaction : transactions_) + { + if (auto* typed = dynamic_cast(transaction.get()); typed != nullptr && typed->isOpen()) + { + return typed; + } + } + return nullptr; + } + + // Only one transaction of each type may be active at a time. Aborts + // on null input or duplicate type. + template + auto addTransaction(std::unique_ptr transaction) -> T* + { + if (!transaction) + { + ShowErrorFmt("CCharEntity::addTransaction: null transaction of type {}", typeid(T).name()); + std::abort(); + } + + if (this->activeTransaction()) + { + ShowErrorFmt("CCharEntity::addTransaction: a transaction of type {} is already active", typeid(T).name()); + std::abort(); + } + + this->transactions_.push_back(std::move(transaction)); + return static_cast(this->transactions_.back().get()); + } + + void removeTransaction(Transaction* transaction) + { + if (!transaction) + { + return; + } + + std::erase_if(transactions_, + [transaction](const auto& slot) + { + return slot.get() == transaction; + }); + } + + void clearTransactions() + { + transactions_.clear(); + } // TODO: All member instances of EntityID_t should be Maybe to allow for them not to be set, // : instead of checking for entityId.id != 0, etc. @@ -694,6 +759,9 @@ class CCharEntity : public CBattleEntity void TrackArrowUsageForScavenge(CItemWeapon* PAmmo); private: + CCraftState craftState_{}; + std::vector> transactions_; + std::array equipped_{}; // Lazily initialized AMAN data diff --git a/src/map/items.h b/src/map/items.h index b1afb3324b3..09acad78355 100644 --- a/src/map/items.h +++ b/src/map/items.h @@ -147,5 +147,6 @@ enum ITEMID : uint16 MAZE_TABULA_M03 = 28674, MAZE_TABULA_R01 = 28704, MAZE_TABULA_R03 = 28706, + MANGLED_MESS = 29695, GIL = 65535, }; diff --git a/src/map/items/CMakeLists.txt b/src/map/items/CMakeLists.txt index 63807f1a284..5ad83b71601 100644 --- a/src/map/items/CMakeLists.txt +++ b/src/map/items/CMakeLists.txt @@ -89,9 +89,13 @@ set(ITEM_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/item.cpp ${CMAKE_CURRENT_SOURCE_DIR}/item.h ${CMAKE_CURRENT_SOURCE_DIR}/item_access.h + ${CMAKE_CURRENT_SOURCE_DIR}/craft_state.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/craft_state.h ${CMAKE_CURRENT_SOURCE_DIR}/transaction.cpp ${CMAKE_CURRENT_SOURCE_DIR}/transaction.h ${CMAKE_CURRENT_SOURCE_DIR}/transactions/item_use.cpp ${CMAKE_CURRENT_SOURCE_DIR}/transactions/item_use.h + ${CMAKE_CURRENT_SOURCE_DIR}/transactions/synth.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/transactions/synth.h PARENT_SCOPE ) diff --git a/src/map/items/craft_state.cpp b/src/map/items/craft_state.cpp new file mode 100644 index 00000000000..b74784bc065 --- /dev/null +++ b/src/map/items/craft_state.cpp @@ -0,0 +1,102 @@ +/* +=========================================================================== + + Copyright (c) 2026 LandSandBoat Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#include "craft_state.h" + +void CCraftState::populate(const Init& data) +{ + recipeId_ = data.recipeId; + craftMode_ = data.craftMode; + crystalItemId_ = data.crystalItemId; + element_ = data.element; + results_ = data.results; + skillRequired_ = data.skillRequired; + ingredientItemIds_ = data.ingredientItemIds; + + failingSkill_ = 0; + result_ = 0; + ingredientBroken_ = {}; +} + +void CCraftState::setFailingSkill(uint8 s) +{ + failingSkill_ = s; +} + +void CCraftState::setResult(uint8 r) +{ + result_ = r; +} + +void CCraftState::markBroken(uint8 idx) +{ + ingredientBroken_[idx] = true; +} + +auto CCraftState::recipeId() const -> uint32 +{ + return recipeId_; +} + +auto CCraftState::craftMode() const -> CRAFT_TYPE +{ + return craftMode_; +} + +auto CCraftState::crystalItemId() const -> uint16 +{ + return crystalItemId_; +} + +auto CCraftState::resultTier(uint8 tier) const -> const Result& +{ + return results_[tier]; +} + +auto CCraftState::skillRequired(uint8 idx) const -> uint8 +{ + return skillRequired_[idx]; +} + +auto CCraftState::element() const -> uint8 +{ + return element_; +} + +auto CCraftState::failingSkill() const -> uint8 +{ + return failingSkill_; +} + +auto CCraftState::result() const -> uint8 +{ + return result_; +} + +auto CCraftState::isBroken(uint8 idx) const -> bool +{ + return ingredientBroken_[idx]; +} + +auto CCraftState::ingredientItemId(uint8 idx) const -> uint16 +{ + return ingredientItemIds_[idx]; +} diff --git a/src/map/items/craft_state.h b/src/map/items/craft_state.h new file mode 100644 index 00000000000..e00e1104b92 --- /dev/null +++ b/src/map/items/craft_state.h @@ -0,0 +1,88 @@ +/* +=========================================================================== + + Copyright (c) 2026 LandSandBoat Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#pragma once + +#include "common/cbasetypes.h" + +#include + +inline constexpr size_t SynthMaxIngredients = 8; + +enum CRAFT_TYPE +{ + CRAFT_SYNTHESIS = 0, + CRAFT_DESYNTHESIS = 1, + CRAFT_SYNTHESIS_NO_LOSS = 2, +}; + +// Runtime state for synthutils to track synthesis over multiple steps +// Item ownership and mutations exclusively in SynthTransaction! +class CCraftState +{ +public: + struct Result + { + uint16 itemId{}; + uint8 qty{}; + }; + + struct Init + { + uint32 recipeId{}; + CRAFT_TYPE craftMode{}; + uint16 crystalItemId{}; + uint8 element{}; + std::array results{}; + std::array skillRequired{}; + std::array ingredientItemIds{}; + }; + + // Sets recipe data and resets runtime fields. + void populate(const Init& data); + + void setFailingSkill(uint8 s); + void setResult(uint8 r); + void markBroken(uint8 idx); + + auto recipeId() const -> uint32; + auto craftMode() const -> CRAFT_TYPE; + auto crystalItemId() const -> uint16; + auto resultTier(uint8 tier) const -> const Result&; + auto skillRequired(uint8 idx) const -> uint8; + auto element() const -> uint8; + auto failingSkill() const -> uint8; + auto result() const -> uint8; + auto isBroken(uint8 idx) const -> bool; + auto ingredientItemId(uint8 idx) const -> uint16; + +private: + uint32 recipeId_{}; + CRAFT_TYPE craftMode_{}; + uint16 crystalItemId_{}; + std::array results_{}; + std::array skillRequired_{}; + std::array ingredientItemIds_{}; + uint8 element_{}; + uint8 failingSkill_{}; + uint8 result_{}; + std::array ingredientBroken_{}; +}; diff --git a/src/map/items/transactions/synth.cpp b/src/map/items/transactions/synth.cpp new file mode 100644 index 00000000000..f16d8ead727 --- /dev/null +++ b/src/map/items/transactions/synth.cpp @@ -0,0 +1,258 @@ +/* +=========================================================================== + + Copyright (c) 2026 LandSandBoat Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#include "synth.h" + +#include "common/database.h" +#include "common/logging.h" + +#include "entities/charentity.h" +#include "enums/item_flag.h" +#include "enums/item_lockflg.h" +#include "item_container.h" +#include "items/item.h" +#include "packets/s2c/0x01d_item_same.h" +#include "packets/s2c/0x01f_item_list.h" +#include "packets/s2c/0x020_item_attr.h" +#include "utils/charutils.h" + +SynthTransaction::SynthTransaction(xi::Badge, CCharEntity* player) +: player_(player) +{ +} + +SynthTransaction::~SynthTransaction() +{ + this->rollbackIfOpen(); +} + +auto SynthTransaction::start(CCharEntity* player, const SynthOffer& offer) -> std::unique_ptr +{ + if (!player) + { + ShowWarningFmt("SynthTransaction::start: null player"); + return nullptr; + } + + const auto* container = player->getStorage(LOC_INVENTORY); + if (!container || offer.crystal.invSlot == 0xFF) + { + ShowWarningFmt("SynthTransaction::start: {} has no inventory or invalid crystal slot ({})", + player->getName(), + offer.crystal.invSlot); + return nullptr; + } + + auto transaction = std::unique_ptr(new SynthTransaction(xi::Badge{}, player)); + + auto* crystalItem = container->GetItem(offer.crystal.invSlot); + if (!crystalItem || !transaction->claim(crystalItem)) + { + ShowWarningFmt("SynthTransaction::start: {} could not claim crystal {} at slot {}", + player->getName(), + offer.crystal.itemId, + offer.crystal.invSlot); + return nullptr; + } + + transaction->slots_[0] = Slot{ crystalItem, offer.crystal.itemId, offer.crystal.invSlot, false }; + + for (size_t i = 0; i < offer.ingredients.size(); ++i) + { + const auto& ing = offer.ingredients[i]; + if (ing.invSlot == 0xFF) + { + continue; + } + + if (ing.invSlot == offer.crystal.invSlot) + { + ShowWarningFmt("SynthTransaction::start: {} ingredient entry {} aliases crystal slot {}", + player->getName(), + i, + ing.invSlot); + transaction->releaseAllClaims(); + return nullptr; + } + + auto* item = container->GetItem(ing.invSlot); + if (!item) + { + continue; + } + + if (!transaction->holds(item) && !transaction->claim(item)) + { + ShowWarningFmt("SynthTransaction::start: {} could not claim ingredient {} at slot {} (entry {})", + player->getName(), + ing.itemId, + ing.invSlot, + i); + // If we can't start the transaction, release all already locked items to owner. + transaction->releaseAllClaims(); + return nullptr; + } + + transaction->slots_[i + 1] = Slot{ .item = item, .itemId = ing.itemId, .invSlot = ing.invSlot, .saved = false }; + } + + return transaction; +} + +auto SynthTransaction::holds(const CItem* item) const -> bool +{ + if (!item) + { + return false; + } + + for (const auto& s : this->slots_) + { + if (s.item == item) + { + return true; + } + } + + return false; +} + +// Crystal is always consumed/rendered early +void SynthTransaction::consumeCrystal() +{ + auto& slot = this->slots_[0]; + if (!slot.item) + { + return; + } + + exitTx(slot.item); + slot.item->setSubType(ITEM_UNLOCKED); + charutils::UpdateItem(this->player_, LOC_INVENTORY, slot.invSlot, -1); + slot.item = nullptr; +} + +void SynthTransaction::markSaved(const uint8 ingredientIdx) +{ + this->slots_[ingredientIdx + 1].saved = true; +} + +void SynthTransaction::setResultDelivery(const CCraftState::Result result) +{ + pendingResult_ = result; +} + +// Synthesis is complete: consume all ingredients not explicitly saved and deliver result +auto SynthTransaction::doCommit() -> bool +{ + std::array consumePerSlot{}; + for (const auto& s : this->slots_) + { + if (s.item && !s.saved && s.invSlot < consumePerSlot.size()) + { + consumePerSlot[s.invSlot] += 1; + } + } + + // Release all items so subsequent UpdateItem can modify them + this->releaseAllClaims(); + + for (size_t s = 0; s < consumePerSlot.size(); ++s) + { + const uint8 toConsume = consumePerSlot[s]; + if (toConsume > 0) + { + charutils::UpdateItem(this->player_, LOC_INVENTORY, static_cast(s), -static_cast(toConsume)); + } + } + + if (pendingResult_) + { + const uint8 resultSlot = charutils::AddItem(this->player_, LOC_INVENTORY, pendingResult_->itemId, pendingResult_->qty); + if (resultSlot != ERROR_SLOTID) + { + CItem* PItem = this->player_->getStorage(LOC_INVENTORY)->GetItem(resultSlot); + if (PItem && PItem->hasFlag(ItemFlag::Inscribable) && this->slots_[0].itemId > 0x1080) + { + PItem->setSignature(this->player_->name); + db::preparedStmt("UPDATE char_inventory SET signature = ? WHERE charid = ? AND location = 0 AND slot = ? LIMIT 1", + this->player_->name, + this->player_->id, + resultSlot); + } + this->player_->pushPacket(PItem, LOC_INVENTORY, resultSlot); + } + + this->player_->pushPacket(this->player_); + } + + return true; +} + +// Synth rollbacks LOSE EVERYTHING on purpose. +// Drop any pending result so an accidental rollback never delivers an item. +void SynthTransaction::doRollback() +{ + this->pendingResult_.reset(); + std::ignore = doCommit(); +} + +// Lock the item and notify client +auto SynthTransaction::claim(CItem* item) const -> bool +{ + if (!item || !enterTx(item)) + { + return false; + } + + this->player_->pushPacket(item, ItemLockFlg::NoSelect); + return true; +} + +void SynthTransaction::releaseAllClaims() +{ + for (size_t i = 0; i < this->slots_.size(); ++i) + { + auto* item = this->slots_[i].item; + if (!item) + { + continue; + } + + bool seen = false; + for (size_t j = 0; j < i; ++j) + { + if (this->slots_[j].item == item) + { + seen = true; + break; + } + } + + if (!seen) + { + exitTx(item); + item->setSubType(ITEM_UNLOCKED); + } + + this->slots_[i].item = nullptr; + } +} diff --git a/src/map/items/transactions/synth.h b/src/map/items/transactions/synth.h new file mode 100644 index 00000000000..7ed5a1fdd88 --- /dev/null +++ b/src/map/items/transactions/synth.h @@ -0,0 +1,86 @@ +/* +=========================================================================== + + Copyright (c) 2026 LandSandBoat Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#pragma once + +#include "common/cbasetypes.h" +#include "common/types/badge.h" +#include "items/craft_state.h" +#include "items/transaction.h" + +#include +#include +#include + +class CCharEntity; +class CItem; + +inline constexpr size_t MaxSlots = SynthMaxIngredients + 1; // crystal + 8 ingredients + +struct SynthIngredient +{ + uint16 itemId{}; + uint8 invSlot{ 0xFF }; +}; + +struct SynthOffer +{ + SynthIngredient crystal{}; + std::array ingredients{}; +}; + +class SynthTransaction : public Transaction +{ +public: + struct Slot + { + CItem* item{ nullptr }; + uint16 itemId{ 0 }; + uint8 invSlot{ 0xFF }; + bool saved{ false }; + }; + + static auto start(CCharEntity* player, const SynthOffer& offer) -> std::unique_ptr; + + SynthTransaction(xi::Badge, CCharEntity* player); + ~SynthTransaction() override; + DISALLOW_COPY_AND_MOVE(SynthTransaction); + + auto holds(const CItem* item) const -> bool override; + + void consumeCrystal(); + void markSaved(uint8 ingredientIdx); + + void setResultDelivery(CCraftState::Result result); + +protected: + auto doCommit() -> bool override; + void doRollback() override; + +private: + auto claim(CItem* item) const -> bool; + void releaseAllClaims(); + + CCharEntity* player_{}; + std::array slots_{}; // [0] crystal, [1..8] ingredients + + std::optional pendingResult_; +}; diff --git a/src/map/packets/c2s/0x029_item_move.cpp b/src/map/packets/c2s/0x029_item_move.cpp index 87d952d0b00..04280def351 100644 --- a/src/map/packets/c2s/0x029_item_move.cpp +++ b/src/map/packets/c2s/0x029_item_move.cpp @@ -111,8 +111,7 @@ const auto isValidMovement = [](const CCharEntity* PChar, const CONTAINER_ID fro { const CItem* PItem = PChar->getStorage(from)->GetItem(itemIndex); - // Always disallowed to move locked items or Gil. - if (!PItem || PItem->isSubType(ITEM_LOCKED) || PItem->getID() == ITEMID::GIL) + if (!PItem || PItem->isSubType(ITEM_LOCKED) || PItem->isBusy() || PItem->getID() == ITEMID::GIL) { return false; } @@ -193,6 +192,7 @@ void GP_CLI_COMMAND_ITEM_MOVE::process(MapSession* PSession, CCharEntity* PChar) if (!PItem2 || PItem2->getID() != PItem->getID() || PItem2->isSubType(ITEM_LOCKED) || + PItem2->isBusy() || PItem2->getReserve() > 0) { ShowWarning("GP_CLI_COMMAND_ITEM_MOVE: Trying to unite items with invalid item %i at location %u slot %u", diff --git a/src/map/packets/c2s/0x096_combine_ask.cpp b/src/map/packets/c2s/0x096_combine_ask.cpp index a92c0294f18..8250b6b0d5b 100644 --- a/src/map/packets/c2s/0x096_combine_ask.cpp +++ b/src/map/packets/c2s/0x096_combine_ask.cpp @@ -24,9 +24,9 @@ #include "entities/charentity.h" #include "enums/msg_std.h" #include "items.h" +#include "items/transactions/synth.h" #include "packets/s2c/0x022_item_trade_res.h" #include "packets/s2c/0x029_battle_message.h" -#include "trade_container.h" #include "universal_container.h" #include "utils/jailutils.h" #include "utils/synthutils.h" @@ -126,8 +126,6 @@ void GP_CLI_COMMAND_COMBINE_ASK::process(MapSession* PSession, CCharEntity* PCha } // End temporary additions - PChar->CraftContainer->Clean(); - const auto* PItem = PChar->getStorage(LOC_INVENTORY)->GetItem(this->CrystalIdx); if (!PItem || this->Crystal != PItem->getID() || @@ -139,22 +137,22 @@ void GP_CLI_COMMAND_COMBINE_ASK::process(MapSession* PSession, CCharEntity* PCha return; } - if (PItem->isSubType(ITEM_LOCKED) || PItem->getReserve() > 0) + if (PItem->isBusy() || PItem->isSubType(ITEM_LOCKED)) { - ShowWarningFmt("GP_CLI_COMMAND_COMBINE_ASK: {} trying to use invalid crystal (locked/reserved)", PChar->getName()); + ShowWarningFmt("GP_CLI_COMMAND_COMBINE_ASK: {} trying to use unavailable crystal", PChar->getName()); PChar->pushPacket(PChar, PChar, 0, 0, MsgBasic::CannotUseInArea); return; } - uint16 itemId = this->Crystal; - uint8 invSlotId = this->CrystalIdx; - PChar->CraftContainer->setItem(0, itemId, invSlotId, 0); + SynthOffer offer{ + .crystal = { this->Crystal, this->CrystalIdx }, + }; std::vector slotQty(MAX_CONTAINER_SIZE); for (int32 slotId = 0; slotId < this->Items; ++slotId) { - itemId = this->ItemNo[slotId]; - invSlotId = this->TableNo[slotId]; + const uint16 itemId = this->ItemNo[slotId]; + const uint8 invSlotId = this->TableNo[slotId]; slotQty[invSlotId]++; @@ -165,15 +163,15 @@ void GP_CLI_COMMAND_COMBINE_ASK::process(MapSession* PSession, CCharEntity* PCha continue; } - if (PSlotItem->isSubType(ITEM_LOCKED) || - slotQty[invSlotId] > (PSlotItem->getQuantity() - PSlotItem->getReserve())) + if (PSlotItem->isBusy() || PSlotItem->isSubType(ITEM_LOCKED) || + slotQty[invSlotId] > PSlotItem->getQuantity()) { - ShowWarningFmt("GP_CLI_COMMAND_COMBINE_ASK: {} trying to use invalid ingredient (locked/reserved)", PChar->getName()); + ShowWarningFmt("GP_CLI_COMMAND_COMBINE_ASK: {} trying to use unavailable ingredient", PChar->getName()); continue; } - PChar->CraftContainer->setItem(slotId + 1, itemId, invSlotId, 1); + offer.ingredients[slotId] = { itemId, invSlotId }; } - synthutils::startSynth(PChar); + synthutils::startSynth(PChar, offer); } diff --git a/src/map/packets/c2s/0x10a_bazaar_itemset.cpp b/src/map/packets/c2s/0x10a_bazaar_itemset.cpp index b58eb4bd4f5..f05c9ecd952 100644 --- a/src/map/packets/c2s/0x10a_bazaar_itemset.cpp +++ b/src/map/packets/c2s/0x10a_bazaar_itemset.cpp @@ -47,9 +47,9 @@ void GP_CLI_COMMAND_BAZAAR_ITEMSET::process(MapSession* PSession, CCharEntity* P return; } - if (PItem->getReserve() > 0) + if (PItem->getReserve() > 0 || PItem->isBusy()) { - ShowError("Player %s trying to bazaar a RESERVED item! [Item: %i | Slot ID: %i] ", PChar->getName(), PItem->getID(), this->ItemIndex); + ShowError("Player %s trying to bazaar a busy/reserved item! [Item: %i | Slot ID: %i] ", PChar->getName(), PItem->getID(), this->ItemIndex); return; } diff --git a/src/map/packets/s2c/0x06f_combine_ans.cpp b/src/map/packets/s2c/0x06f_combine_ans.cpp index d4eb70f86fc..aeb3cefba41 100644 --- a/src/map/packets/s2c/0x06f_combine_ans.cpp +++ b/src/map/packets/s2c/0x06f_combine_ans.cpp @@ -22,20 +22,28 @@ #include "0x06f_combine_ans.h" #include "entities/charentity.h" -#include "trade_container.h" +#include "items/craft_state.h" +#include "items/transactions/synth.h" -GP_SERV_COMMAND_COMBINE_ANS::GP_SERV_COMMAND_COMBINE_ANS(const CCharEntity* PChar, const SynthesisResult result, const uint16 itemId, const uint8 quantity) +GP_SERV_COMMAND_COMBINE_ANS::GP_SERV_COMMAND_COMBINE_ANS(const CCharEntity* PChar, const SynthesisResult result, const CCraftState::Result item) { auto& packet = this->data(); packet.Result = result; - if (itemId != 0) + if (item.itemId != 0) { - packet.Count = quantity; - packet.ItemNo = itemId; + packet.Count = item.qty; + packet.ItemNo = item.itemId; } + if (!PChar->activeTransaction()) + { + return; + } + + const auto& craftState = PChar->craftState(); + for (uint8 i = 0; i < 4; i++) { uint8 skillValue = 0; @@ -46,24 +54,23 @@ GP_SERV_COMMAND_COMBINE_ANS::GP_SERV_COMMAND_COMBINE_ANS(const CCharEntity* PCha continue; } - if (PChar->CraftContainer->getQuantity(skillID - 40) > skillValue) + const uint8 required = craftState.skillRequired(skillID - SKILL_WOODWORKING); + if (required > skillValue) { - skillValue = PChar->CraftContainer->getQuantity(skillID - 40); + skillValue = required; packet.UpKind[i] = skillID; } } } - packet.CrystalNo = PChar->CraftContainer->getItemID(0); + packet.CrystalNo = craftState.crystalItemId(); - for (uint8 slotID = 1; slotID <= 8; ++slotID) // recipe materials + for (uint8 idx = 0; idx < SynthMaxIngredients; ++idx) { - const uint16 slotItemID = PChar->CraftContainer->getItemID(slotID); - packet.MaterialNo[slotID - 1] = slotItemID; - - if (PChar->CraftContainer->getQuantity(slotID) == 0) + packet.MaterialNo[idx] = craftState.ingredientItemId(idx); + if (craftState.isBroken(idx)) { - packet.BreakNo[slotID - 1] = slotItemID; + packet.BreakNo[idx] = craftState.ingredientItemId(idx); } } } diff --git a/src/map/packets/s2c/0x06f_combine_ans.h b/src/map/packets/s2c/0x06f_combine_ans.h index 7373661589c..dc028973b99 100644 --- a/src/map/packets/s2c/0x06f_combine_ans.h +++ b/src/map/packets/s2c/0x06f_combine_ans.h @@ -25,6 +25,8 @@ #include "base.h" +#include "items/craft_state.h" + enum class SynthesisResult : uint8_t; class CCharEntity; @@ -48,5 +50,5 @@ class GP_SERV_COMMAND_COMBINE_ANS final : public GP_SERV_PACKETdata(); @@ -34,21 +35,21 @@ GP_SERV_COMMAND_COMBINE_INF::GP_SERV_COMMAND_COMBINE_INF(const CCharEntity* PCha packet.UniqueNo = PChar->id; packet.ActIndex = PChar->targid; - if (itemId != 0) + if (item.itemId != 0) { - packet.Count = quantity; - packet.ItemNo = itemId; + packet.Count = item.qty; + packet.ItemNo = item.itemId; } - if (result == SynthesisResult::Failed) + if (result == SynthesisResult::Failed && PChar->activeTransaction()) { - uint8 count = 0; - for (uint8 slotID = 1; slotID <= 8; ++slotID) + const auto& craftState = PChar->craftState(); + uint8 count = 0; + for (uint8 idx = 0; idx < SynthMaxIngredients; ++idx) { - if (PChar->CraftContainer->getQuantity(slotID) == 0) + if (craftState.isBroken(idx)) { - const uint16 failedItemID = PChar->CraftContainer->getItemID(slotID); - packet.BreakNo[count] = failedItemID; + packet.BreakNo[count] = craftState.ingredientItemId(idx); count++; } } diff --git a/src/map/packets/s2c/0x070_combine_inf.h b/src/map/packets/s2c/0x070_combine_inf.h index faa35c9a1d1..ddf15f9bda5 100644 --- a/src/map/packets/s2c/0x070_combine_inf.h +++ b/src/map/packets/s2c/0x070_combine_inf.h @@ -25,6 +25,8 @@ #include "base.h" +#include "items/craft_state.h" + enum class SynthesisResult : uint8_t; class CCharEntity; @@ -47,5 +49,5 @@ class GP_SERV_COMMAND_COMBINE_INF final : public GP_SERV_PACKETisBusy() && !force) + { + ShowWarningFmt("UpdateItem: refusing to mutate busy item {} in state {} (loc={}, slot={}, char={})", + ItemID, + magic_enum::enum_name(PItem->state()), + LocationID, + slotID, + PChar->getName()); + return 0; + } + uint32 newQuantity = PItem->getQuantity() + quantity; if (newQuantity > PItem->getStackSize()) @@ -2919,6 +2931,15 @@ void AddItemToRecycleBin(CCharEntity* PChar, uint32 container, uint8 slotID, uin return; } + if (PSrcItem->isBusy()) + { + ShowWarningFmt("AddItemToRecycleBin: refusing to move busy item {} (state={}, char={})", + PSrcItem->getID(), + magic_enum::enum_name(PSrcItem->state()), + PChar->getName()); + return; + } + const uint16 itemID = PSrcItem->getID(); const auto itemName = PSrcItem->getName(); @@ -7326,7 +7347,7 @@ void SendToZone(CCharEntity* PChar, uint16 zoneId) } // If player somehow gets zoned, force crit fail their synth - if (PChar->CraftContainer && PChar->CraftContainer->getItemsCount() > 0) + if (PChar->activeTransaction()) { charutils::forceSynthCritFail("SendToZone", PChar); } @@ -7993,11 +8014,6 @@ void forceSynthCritFail(const std::string& sourceFunction, CCharEntity* PChar) ShowWarning("%s: Force crit-failing %s synthesis!", sourceFunction, PChar->getName()); synthutils::doSynthCriticalFail(PChar); - - PChar->CraftContainer->Clean(); // Clean to reset m_ItemCount to 0 - PChar->animation = ANIMATION_NONE; - PChar->updatemask |= UPDATE_HP; - PChar->pushPacket(PChar); } void removeCharFromZone(CCharEntity* PChar) diff --git a/src/map/utils/synthutils.cpp b/src/map/utils/synthutils.cpp index c7cc29d6a95..92e74387367 100644 --- a/src/map/utils/synthutils.cpp +++ b/src/map/utils/synthutils.cpp @@ -21,35 +21,25 @@ #include "synthutils.h" +#include "charutils.h" #include "common/database.h" #include "common/logging.h" #include "common/timer.h" #include "common/utils.h" -#include "common/vana_time.h" - #include "entities/battleentity.h" - -#include "packets/char_status.h" -#include "packets/s2c/0x01d_item_same.h" -#include "packets/s2c/0x01f_item_list.h" -#include "packets/s2c/0x020_item_attr.h" -#include "packets/s2c/0x062_clistatus2.h" - -#include "item_container.h" -#include "items.h" -#include "roe.h" -#include "trade_container.h" - -#include "charutils.h" -#include "enums/item_lockflg.h" #include "enums/key_items.h" #include "enums/synthesis_effect.h" #include "enums/synthesis_result.h" +#include "items.h" +#include "items/transactions/synth.h" #include "itemutils.h" +#include "packets/char_status.h" #include "packets/s2c/0x029_battle_message.h" #include "packets/s2c/0x030_effect.h" +#include "packets/s2c/0x062_clistatus2.h" #include "packets/s2c/0x06f_combine_ans.h" #include "packets/s2c/0x070_combine_inf.h" +#include "roe.h" #include "zone.h" #include "zoneutils.h" @@ -181,28 +171,75 @@ struct SynthRecipe return out; } - static auto ingredientKey(uint16 crystal, uint16 ingredient1, uint16 ingredient2, uint16 ingredient3, uint16 ingredient4, uint16 ingredient5, uint16 ingredient6, uint16 ingredient7, uint16 ingredient8) + static auto ingredientKey(uint16 crystal, const std::array& ingredients) -> std::string { return fmt::format("{}-{}-{}-{}-{}-{}-{}-{}-{}", crystalString(crystal), - ingredient1, - ingredient2, - ingredient3, - ingredient4, - ingredient5, - ingredient6, - ingredient7, - ingredient8); + ingredients[0], + ingredients[1], + ingredients[2], + ingredients[3], + ingredients[4], + ingredients[5], + ingredients[6], + ingredients[7]); } auto key() const { - return ingredientKey(Crystal, Ingredient1, Ingredient2, Ingredient3, Ingredient4, Ingredient5, Ingredient6, Ingredient7, Ingredient8); + return ingredientKey(Crystal, + { Ingredient1, Ingredient2, Ingredient3, Ingredient4, Ingredient5, Ingredient6, Ingredient7, Ingredient8 }); } }; std::unordered_map synthRecipes; +struct CrystalProps +{ + uint8 element; + SynthesisEffect effect; +}; + +auto crystalProps(uint16 itemId) -> CrystalProps +{ + switch (itemId) + { + case FIRE_CRYSTAL: + case INFERNO_CRYSTAL: + case PYRE_CRYSTAL: + return { ELEMENT_FIRE, SynthesisEffect::Fire }; + case ICE_CRYSTAL: + case GLACIER_CRYSTAL: + case FROST_CRYSTAL: + return { ELEMENT_ICE, SynthesisEffect::Ice }; + case WIND_CRYSTAL: + case CYCLONE_CRYSTAL: + case VORTEX_CRYSTAL: + return { ELEMENT_WIND, SynthesisEffect::Wind }; + case EARTH_CRYSTAL: + case TERRA_CRYSTAL: + case GEO_CRYSTAL: + return { ELEMENT_EARTH, SynthesisEffect::Earth }; + case LIGHTNING_CRYSTAL: + case PLASMA_CRYSTAL: + case BOLT_CRYSTAL: + return { ELEMENT_LIGHTNING, SynthesisEffect::Lightning }; + case WATER_CRYSTAL: + case TORRENT_CRYSTAL: + case FLUID_CRYSTAL: + return { ELEMENT_WATER, SynthesisEffect::Water }; + case LIGHT_CRYSTAL: + case AURORA_CRYSTAL: + case GLIMMER_CRYSTAL: + return { ELEMENT_LIGHT, SynthesisEffect::Light }; + case DARK_CRYSTAL: + case TWILIGHT_CRYSTAL: + case SHADOW_CRYSTAL: + return { ELEMENT_DARK, SynthesisEffect::Dark }; + } + return { 0, SynthesisEffect::None }; +} + void LoadSynthRecipes() { TracyZoneScoped; @@ -303,71 +340,81 @@ void LoadSynthRecipes() * In the fields itemID and slotID of 10-14 cells, we write the results of the synthesis * ********************************************************************************************************************************/ // Used in: startSynth -auto isRightRecipe(CCharEntity* PChar) -> bool +auto resolveRecipe(CCharEntity* PChar, const SynthOffer& offer) -> bool { TracyZoneScoped; - const auto crystal = PChar->CraftContainer->getItemID(0); - const auto ingredient1 = PChar->CraftContainer->getItemID(1); - const auto ingredient2 = PChar->CraftContainer->getItemID(2); - const auto ingredient3 = PChar->CraftContainer->getItemID(3); - const auto ingredient4 = PChar->CraftContainer->getItemID(4); - const auto ingredient5 = PChar->CraftContainer->getItemID(5); - const auto ingredient6 = PChar->CraftContainer->getItemID(6); - const auto ingredient7 = PChar->CraftContainer->getItemID(7); - const auto ingredient8 = PChar->CraftContainer->getItemID(8); + std::array ingredientIds{}; + for (size_t i = 0; i < ingredientIds.size(); ++i) + { + ingredientIds[i] = offer.ingredients[i].itemId; + } - const auto possibleRecipeKey = SynthRecipe::ingredientKey(crystal, ingredient1, ingredient2, ingredient3, ingredient4, ingredient5, ingredient6, ingredient7, ingredient8); + const auto possibleRecipeKey = SynthRecipe::ingredientKey(offer.crystal.itemId, ingredientIds); - if (synthRecipes.contains(possibleRecipeKey)) + auto it = synthRecipes.find(possibleRecipeKey); + if (it == synthRecipes.end()) { - const auto& recipe = synthRecipes[possibleRecipeKey]; + PChar->pushPacket(PChar, SynthesisResult::CancelBadRecipe); + return false; + } - if (!luautils::IsContentEnabled(recipe.ContentTag)) - { - PChar->pushPacket(PChar, SynthesisResult::CancelBadRecipe); - return false; - } + const auto& recipe = it->second; - // Check if recipe result is rare and player already owns a copy. - const CItem* PItem = xi::items::lookup(recipe.Result); - if (PItem && PItem->hasFlag(ItemFlag::Rare) && charutils::HasItem(PChar, recipe.Result)) - { - PChar->pushPacket(PChar, SynthesisResult::CancelRareItem); - return false; - } + if (!luautils::IsContentEnabled(recipe.ContentTag)) + { + PChar->pushPacket(PChar, SynthesisResult::CancelBadRecipe); + return false; + } - if (recipe.RequiredKeyItem == KeyItem::NONE || charutils::hasKeyItem(PChar, recipe.RequiredKeyItem)) - { - // in the ninth cell write the id of the recipe - PChar->CraftContainer->setItem(9, recipe.ID, 0xFF, 0); - PChar->CraftContainer->setItem(10 + 1, recipe.Result, recipe.ResultQty, 0); // RESULT_SUCCESS - PChar->CraftContainer->setItem(10 + 2, recipe.ResultHQ1, recipe.ResultHQ1Qty, 0); // RESULT_HQ - PChar->CraftContainer->setItem(10 + 3, recipe.ResultHQ2, recipe.ResultHQ2Qty, 0); // RESULT_HQ2 - PChar->CraftContainer->setItem(10 + 4, recipe.ResultHQ3, recipe.ResultHQ3Qty, 0); // RESULT_HQ3 - PChar->CraftContainer->setCraftType(recipe.Desynth); // Store synth type (regular, desynth or "no material loss") - - for (uint8 skillID = SKILL_WOODWORKING; skillID <= SKILL_COOKING; ++skillID) // range for all 8 synth skills - { - uint16 skillValue = recipe.getSkillValue(static_cast(skillID)); - uint16 currentSkill = PChar->RealSkills.skill[skillID]; + const CItem* PItem = xi::items::lookup(recipe.Result); + if (PItem && PItem->hasFlag(ItemFlag::Rare) && charutils::HasItem(PChar, recipe.Result)) + { + PChar->pushPacket(PChar, SynthesisResult::CancelRareItem); + return false; + } + + if (recipe.RequiredKeyItem != KeyItem::NONE && !charutils::hasKeyItem(PChar, recipe.RequiredKeyItem)) + { + PChar->pushPacket(PChar, SynthesisResult::CancelBadRecipe); + return false; + } - // skill write in the quantity field of cells 9-16 - PChar->CraftContainer->setQuantity(skillID - 40, skillValue); + CCraftState::Init data{ + .recipeId = recipe.ID, + .craftMode = static_cast(recipe.Desynth), + .crystalItemId = offer.crystal.itemId, + .element = crystalProps(offer.crystal.itemId).element, + .results = { { + {}, + { recipe.Result, recipe.ResultQty }, + { recipe.ResultHQ1, recipe.ResultHQ1Qty }, + { recipe.ResultHQ2, recipe.ResultHQ2Qty }, + { recipe.ResultHQ3, recipe.ResultHQ3Qty }, + } }, + }; + + for (size_t i = 0; i < offer.ingredients.size(); ++i) + { + data.ingredientItemIds[i] = offer.ingredients[i].itemId; + } - if (currentSkill < (skillValue * 10 - 150)) // Check player skill against recipe level. Range must be 14 or less. - { - PChar->pushPacket(PChar, SynthesisResult::CancelSkillTooLow); - return false; - } - } - return true; + for (uint8 skillID = SKILL_WOODWORKING; skillID <= SKILL_COOKING; ++skillID) + { + const uint16 skillValue = recipe.getSkillValue(static_cast(skillID)); + const uint16 currentSkill = PChar->RealSkills.skill[skillID]; + + data.skillRequired[skillID - SKILL_WOODWORKING] = static_cast(skillValue); + + if (currentSkill < (skillValue * 10 - 150)) + { + PChar->pushPacket(PChar, SynthesisResult::CancelSkillTooLow); + return false; } } - // Otherwise, fall through to failure - PChar->pushPacket(PChar, SynthesisResult::CancelBadRecipe); - return false; + PChar->craftState().populate(data); + return true; } // Used in: LOCAL handleSynthResult @@ -404,7 +451,7 @@ auto getSynthDifficulty(CCharEntity* PChar, uint8 skillID) -> int16 } uint8 charSkill = PChar->RealSkills.skill[skillID] / 10; // Player skill level is truncated before synth difficulty is calculated - int16 difficulty = PChar->CraftContainer->getQuantity(skillID - 40) - charSkill - PChar->getMod(ModID); + int16 difficulty = PChar->craftState().skillRequired(skillID - SKILL_WOODWORKING) - charSkill - PChar->getMod(ModID); return difficulty; } @@ -461,7 +508,7 @@ auto calculateSynthResult(CCharEntity* PChar) -> uint8 for (skillID = SKILL_WOODWORKING; skillID <= SKILL_COOKING; ++skillID) { // Skip current iteration if skill isn't involved. - if (PChar->CraftContainer->getQuantity(skillID - 40) == 0) + if (PChar->craftState().skillRequired(skillID - SKILL_WOODWORKING) == 0) { continue; } @@ -524,8 +571,7 @@ auto calculateSynthResult(CCharEntity* PChar) -> uint8 if (xirand::GetRandomNumber(0.0f, 100.f) > successRate) // Synthesis broke. This is not a mistake, the break check HAS to be done per craft skill involved. { // Keep the skill because of which the synthesis failed. - // Use the slotID of the crystal cell, because it was removed at the beginning of the synthesis. - PChar->CraftContainer->setInvSlotID(0, skillID); + PChar->craftState().setFailingSkill(skillID); synthResult = SYNTHESIS_FAIL; break; @@ -615,7 +661,7 @@ auto calculateDesynthResult(CCharEntity* PChar) -> uint8 for (skillID = SKILL_WOODWORKING; skillID <= SKILL_COOKING; ++skillID) { // Skip current iteration if skill isn't involved. - if (PChar->CraftContainer->getQuantity(skillID - 40) == 0) + if (PChar->craftState().skillRequired(skillID - SKILL_WOODWORKING) == 0) { continue; } @@ -648,8 +694,7 @@ auto calculateDesynthResult(CCharEntity* PChar) -> uint8 if (xirand::GetRandomNumber(0.0f, 100.f) > successRate) // Synthesis broke. This is not a mistake, the break check HAS to be done per craft skill involved. { // Keep the skill because of which the synthesis failed. - // Use the slotID of the crystal cell, because it was removed at the beginning of the synthesis. - PChar->CraftContainer->setInvSlotID(0, skillID); + PChar->craftState().setFailingSkill(skillID); synthResult = SYNTHESIS_FAIL; break; @@ -710,7 +755,7 @@ auto handleSynthResult(CCharEntity* PChar) -> uint8 { // Calculate synthesis result based on synthesis type. uint8 synthResult = SYNTHESIS_FAIL; - if (PChar->CraftContainer->getCraftType() == CRAFT_DESYNTHESIS) + if (PChar->craftState().craftMode() == CRAFT_DESYNTHESIS) { synthResult = calculateDesynthResult(PChar); } @@ -719,10 +764,8 @@ auto handleSynthResult(CCharEntity* PChar) -> uint8 synthResult = calculateSynthResult(PChar); } - // Store result in the quantity field of the crystal cell. - PChar->CraftContainer->setQuantity(0, synthResult); + PChar->craftState().setResult(synthResult); - // Return result. switch (synthResult) { case SYNTHESIS_FAIL: @@ -732,11 +775,7 @@ auto handleSynthResult(CCharEntity* PChar) -> uint8 synthResult = RESULT_SUCCESS; break; case SYNTHESIS_HQ: - synthResult = RESULT_HQ; - break; case SYNTHESIS_HQ2: - synthResult = RESULT_HQ; - break; case SYNTHESIS_HQ3: synthResult = RESULT_HQ; break; @@ -748,18 +787,13 @@ auto handleSynthResult(CCharEntity* PChar) -> uint8 // Used in: LOCAL handleSynthFail void handleMaterialLoss(CCharEntity* PChar) { - uint8 currentCraft = PChar->CraftContainer->getInvSlotID(0); + auto& craftState = PChar->craftState(); + auto& synthTransaction = *PChar->activeTransaction(); - // Loop variables - uint8 invSlotID = PChar->CraftContainer->getInvSlotID(1); - uint8 nextSlotID = 0; - uint8 lostCount = 0; - uint8 totalCount = 0; - uint8 random = 0; + uint8 currentCraft = craftState.failingSkill(); - // Synth material loss modifiers. TODO: Audit usage of this modifiers. int16 breakGlobalReduction = PChar->getMod(Mod::SYNTH_MATERIAL_LOSS); - int16 breakElementalReduction = PChar->getMod((Mod)((int32)Mod::SYNTH_MATERIAL_LOSS_FIRE + PChar->CraftContainer->getType())); + int16 breakElementalReduction = PChar->getMod((Mod)((int32)Mod::SYNTH_MATERIAL_LOSS_FIRE + craftState.element())); int16 breakTypeReduction = PChar->getMod((Mod)((int32)Mod::SYNTH_MATERIAL_LOSS_WOODWORKING + currentCraft - SKILL_WOODWORKING)); int16 synthDifficulty = getSynthDifficulty(PChar, currentCraft); @@ -772,51 +806,21 @@ void handleMaterialLoss(CCharEntity* PChar) // Clamp note: https://wiki-ffo-jp.translate.goog/html/36626.html?_x_tr_sl=ja&_x_tr_tl=en&_x_tr_hl=en&_x_tr_pto=sc int16 breakChance = std::clamp(50 - breakGlobalReduction - breakElementalReduction - breakTypeReduction + 5 * synthDifficulty, 20, 100); - // Loop through craft container items. - for (uint8 slotID = 1; slotID <= 8; ++slotID) + for (uint8 idx = 0; idx < SynthMaxIngredients; ++idx) { - if (slotID != 8) + if (craftState.ingredientItemId(idx) == 0) { - nextSlotID = PChar->CraftContainer->getInvSlotID(slotID + 1); + continue; } - random = 1 + xirand::GetRandomNumber(100); - + const uint8 random = 1 + xirand::GetRandomNumber(100); if (random <= breakChance) { - PChar->CraftContainer->setQuantity(slotID, 0); - lostCount++; + craftState.markBroken(idx); } - totalCount++; - - if (invSlotID != nextSlotID) - { - CItem* PItem = PChar->getStorage(LOC_INVENTORY)->GetItem(invSlotID); - - if (PItem != nullptr) - { - PItem->setSubType(ITEM_UNLOCKED); - PItem->setReserve(PItem->getReserve() - totalCount); - totalCount = 0; - - if (lostCount > 0) - { - charutils::UpdateItem(PChar, LOC_INVENTORY, invSlotID, -(int32)lostCount); - lostCount = 0; - } - else - { - PChar->pushPacket(PItem, ItemLockFlg::Normal); - } - } - invSlotID = nextSlotID; - } - - nextSlotID = 0; - - if (invSlotID == 0xFF) + else { - break; + synthTransaction.markSaved(idx); } } } @@ -824,73 +828,24 @@ void handleMaterialLoss(CCharEntity* PChar) // Used in: sendSynthDone void handleSynthSuccess(CCharEntity* PChar) { - uint8 m_synthResult = PChar->CraftContainer->getQuantity(0); - uint16 itemID = PChar->CraftContainer->getItemID(10 + m_synthResult); - uint8 quantity = PChar->CraftContainer->getInvSlotID(10 + m_synthResult); // unfortunately, the quantity field is taken - - uint8 invSlotID = 0; - uint8 nextSlotID = 0; - uint8 removeCount = 0; + auto& craftState = PChar->craftState(); + auto& synthTransaction = *PChar->activeTransaction(); + const auto& result = craftState.resultTier(craftState.result()); - invSlotID = PChar->CraftContainer->getInvSlotID(1); - - for (uint8 slotID = 1; slotID <= 8; ++slotID) - { - nextSlotID = (slotID != 8 ? PChar->CraftContainer->getInvSlotID(slotID + 1) : 0); - removeCount++; - - if (invSlotID != nextSlotID) - { - if (invSlotID != 0xFF) - { - auto* PItem = PChar->getStorage(LOC_INVENTORY)->GetItem(invSlotID); - if (PItem != nullptr) - { - PItem->setSubType(ITEM_UNLOCKED); - PItem->setReserve(PItem->getReserve() - removeCount); - charutils::UpdateItem(PChar, LOC_INVENTORY, invSlotID, -(int32)removeCount); - } - } - invSlotID = nextSlotID; - nextSlotID = 0; - removeCount = 0; - } - } - - // TODO: switch to the new AddItem function so as not to update the signature - - invSlotID = charutils::AddItem(PChar, LOC_INVENTORY, itemID, quantity); - - CItem* PItem = PChar->getStorage(LOC_INVENTORY)->GetItem(invSlotID); - - if (PItem != nullptr) - { - if (PItem->hasFlag(ItemFlag::Inscribable) && (PChar->CraftContainer->getItemID(0) > 0x1080)) - { - PItem->setSignature(PChar->name); - - db::preparedStmt("UPDATE char_inventory SET signature = ? WHERE charid = ? AND location = 0 AND slot = ? LIMIT 1", - PChar->name, - PChar->id, - invSlotID); - } - PChar->pushPacket(PItem, LOC_INVENTORY, invSlotID); - } - - PChar->pushPacket(PChar); + synthTransaction.setResultDelivery(result); // Use appropiate message (Regular or desynthesis) - const auto message = PChar->CraftContainer->getCraftType() == CRAFT_DESYNTHESIS ? SynthesisResult::SuccessDesynth : SynthesisResult::Success; + const auto message = craftState.craftMode() == CRAFT_DESYNTHESIS ? SynthesisResult::SuccessDesynth : SynthesisResult::Success; - PChar->loc.zone->PushPacket(PChar, CHAR_INRANGE, std::make_unique(PChar, message, itemID, quantity)); - PChar->pushPacket(PChar, message, itemID, quantity); + PChar->loc.zone->PushPacket(PChar, CHAR_INRANGE, std::make_unique(PChar, message, result)); + PChar->pushPacket(PChar, message, result); // Calculate what craft this recipe "belongs" to based on highest skill required uint32 skillType = 0; uint32 highestSkill = 0; for (uint8 skillID = SKILL_WOODWORKING; skillID <= SKILL_COOKING; ++skillID) { - uint8 skillRequired = PChar->CraftContainer->getQuantity(skillID - 40); + uint8 skillRequired = craftState.skillRequired(skillID - SKILL_WOODWORKING); if (skillRequired > highestSkill) { skillType = skillID; @@ -898,7 +853,7 @@ void handleSynthSuccess(CCharEntity* PChar) } } - RoeDatagram roeItemId = RoeDatagram("itemid", itemID); + RoeDatagram roeItemId = RoeDatagram("itemid", result.itemId); RoeDatagram roeSkillType = RoeDatagram("skillType", skillType); RoeDatagramList roeSynthResult({ roeItemId, roeSkillType }); @@ -908,45 +863,21 @@ void handleSynthSuccess(CCharEntity* PChar) // Used in: sendSynthDone void handleSynthFail(CCharEntity* PChar) { - // Break material calculations. - if (PChar->CraftContainer->getCraftType() != CRAFT_SYNTHESIS_NO_LOSS) // If it's a synth where no materials can be lost, skip break calculations. + auto& craftState = PChar->craftState(); + auto& synthTransaction = *PChar->activeTransaction(); + + if (craftState.craftMode() != CRAFT_SYNTHESIS_NO_LOSS) { handleMaterialLoss(PChar); } else { - // Recipe cannot lose ingredients, unlock everything. - uint8 invSlotID = PChar->CraftContainer->getInvSlotID(1); - uint8 nextSlotID = 0; - uint8 totalCount = 0; - - for (uint8 slotID = 1; slotID <= 8; ++slotID) + // No-loss recipe: every claimed ingredient survives intact. + for (uint8 idx = 0; idx < SynthMaxIngredients; ++idx) { - if (slotID != 8) - { - nextSlotID = PChar->CraftContainer->getInvSlotID(slotID + 1); - } - - totalCount++; - - if (invSlotID != nextSlotID) - { - if (auto* PItem = PChar->getStorage(LOC_INVENTORY)->GetItem(invSlotID)) - { - PItem->setSubType(ITEM_UNLOCKED); - PItem->setReserve(PItem->getReserve() - totalCount); - PChar->pushPacket(PItem, ItemLockFlg::Normal); - } - - invSlotID = nextSlotID; - totalCount = 0; - } - - nextSlotID = 0; - - if (invSlotID == 0xFF) + if (craftState.ingredientItemId(idx) != 0) { - break; + synthTransaction.markSaved(idx); } } } @@ -962,7 +893,7 @@ void handleSynthFail(CCharEntity* PChar) PChar->loc.zone->PushPacket(PChar, CHAR_INRANGE, std::make_unique(PChar, SynthesisResult::Failed)); } - PChar->pushPacket(PChar, SynthesisResult::Failed, 29695); + PChar->pushPacket(PChar, SynthesisResult::Failed, CCraftState::Result{ MANGLED_MESS, 0 }); } // Used in: sendSynthDone @@ -975,7 +906,7 @@ void doSynthSkillUp(CCharEntity* PChar) //------------------------------ // We don't Skill Up if the recipe doesn't involve the currently checked skill. - if (PChar->CraftContainer->getQuantity(skillID - 40) == 0) + if (PChar->craftState().skillRequired(skillID - SKILL_WOODWORKING) == 0) { continue; // Break current loop iteration. } @@ -992,7 +923,7 @@ void doSynthSkillUp(CCharEntity* PChar) // We don't Skill Up if the recipe isn't difficult enough. // Era -> Char lvl must be bellow recipe level. Retail -> Char level myst be bellow recipe level + 10. // Char level does NOT count the effects of image support/gear. - int16 baseDiff = PChar->CraftContainer->getQuantity(skillID - 40) - charSkill / 10; + int16 baseDiff = PChar->craftState().skillRequired(skillID - SKILL_WOODWORKING) - charSkill / 10; int8 minDiff = settings::get("map.CRAFT_MODERN_SYSTEM") ? -11 : 0; if (baseDiff <= minDiff) { @@ -1000,7 +931,7 @@ void doSynthSkillUp(CCharEntity* PChar) } // We don't Skill Up if the synth breaks outside the [-5, 0) interval - if (PChar->CraftContainer->getQuantity(0) == SYNTHESIS_FAIL && (baseDiff > 5 || baseDiff <= 0)) + if (PChar->craftState().result() == SYNTHESIS_FAIL && (baseDiff > 5 || baseDiff <= 0)) { continue; // Break current loop iteration. } @@ -1037,12 +968,12 @@ void doSynthSkillUp(CCharEntity* PChar) // Chance penalties. uint8 penalty = 1; - if (PChar->CraftContainer->getCraftType() == CRAFT_DESYNTHESIS) // If it's a desynth, lower skill up rate + if (PChar->craftState().craftMode() == CRAFT_DESYNTHESIS) // If it's a desynth, lower skill up rate { penalty += 1; } - if (PChar->CraftContainer->getQuantity(0) == SYNTHESIS_FAIL) // If synth breaks, lower skill up rate + if (PChar->craftState().result() == SYNTHESIS_FAIL) // If synth breaks, lower skill up rate { penalty += 1; } @@ -1185,129 +1116,35 @@ void doSynthSkillUp(CCharEntity* PChar) /************************ * Public functions * ************************/ -void startSynth(CCharEntity* PChar) +void startSynth(CCharEntity* PChar, const SynthOffer& offer) { PChar->m_LastSynthTime = timer::now(); - if (!isRightRecipe(PChar)) + if (!resolveRecipe(PChar, offer)) { - PChar->CraftContainer->Clean(); - return; } - // Set animation and element based on crystal element. - auto effect = SynthesisEffect::None; - uint8 element = 0; - - switch (PChar->CraftContainer->getItemID(0)) - { - case FIRE_CRYSTAL: - case INFERNO_CRYSTAL: - case PYRE_CRYSTAL: - effect = SynthesisEffect::Fire; - element = ELEMENT_FIRE; - break; - - case ICE_CRYSTAL: - case GLACIER_CRYSTAL: - case FROST_CRYSTAL: - effect = SynthesisEffect::Ice; - element = ELEMENT_ICE; - break; - - case WIND_CRYSTAL: - case CYCLONE_CRYSTAL: - case VORTEX_CRYSTAL: - effect = SynthesisEffect::Wind; - element = ELEMENT_WIND; - break; - - case EARTH_CRYSTAL: - case TERRA_CRYSTAL: - case GEO_CRYSTAL: - effect = SynthesisEffect::Earth; - element = ELEMENT_EARTH; - break; - - case LIGHTNING_CRYSTAL: - case PLASMA_CRYSTAL: - case BOLT_CRYSTAL: - effect = SynthesisEffect::Lightning; - element = ELEMENT_LIGHTNING; - break; - - case WATER_CRYSTAL: - case TORRENT_CRYSTAL: - case FLUID_CRYSTAL: - effect = SynthesisEffect::Water; - element = ELEMENT_WATER; - break; - - case LIGHT_CRYSTAL: - case AURORA_CRYSTAL: - case GLIMMER_CRYSTAL: - effect = SynthesisEffect::Light; - element = ELEMENT_LIGHT; - break; - - case DARK_CRYSTAL: - case TWILIGHT_CRYSTAL: - case SHADOW_CRYSTAL: - effect = SynthesisEffect::Dark; - element = ELEMENT_DARK; - break; - } - - PChar->CraftContainer->setType(element); - - // Reserve the items after we know we have the right recipe - for (uint8 container_slotID = 0; container_slotID <= 8; ++container_slotID) + auto synthTransaction = SynthTransaction::start(PChar, offer); + if (!synthTransaction) { - const auto slotid = PChar->CraftContainer->getInvSlotID(container_slotID); - if (slotid != 0xFF) - { - if (CItem* PItem = PChar->getStorage(LOC_INVENTORY)->GetItem(slotid); PItem != nullptr) - { - PItem->setReserve(PItem->getReserve() + 1); - } - } + ShowWarningFmt("startSynth: failed to claim ingredients for {}", PChar->getName()); + PChar->pushPacket(PChar, SynthesisResult::CancelBadRecipe); + return; } - // remove crystal - if (auto* PItem = PChar->getStorage(LOC_INVENTORY)->GetItem(PChar->CraftContainer->getInvSlotID(0)); PItem != nullptr) - { - PItem->setReserve(PItem->getReserve() - 1); - } + const auto effect = crystalProps(offer.crystal.itemId).effect; - charutils::UpdateItem(PChar, LOC_INVENTORY, PChar->CraftContainer->getInvSlotID(0), -1); + PChar->addTransaction(std::move(synthTransaction))->consumeCrystal(); uint8 result = handleSynthResult(PChar); - uint8 invSlotID = 0; - uint8 tempSlotID = 0; - - for (uint8 slotID = 1; slotID <= 8; ++slotID) - { - tempSlotID = PChar->CraftContainer->getInvSlotID(slotID); - if ((tempSlotID != 0xFF) && (tempSlotID != invSlotID)) - { - invSlotID = tempSlotID; - - if (CItem* PCraftItem = PChar->getStorage(LOC_INVENTORY)->GetItem(invSlotID); PCraftItem != nullptr) - { - PCraftItem->setSubType(ITEM_LOCKED); - PChar->pushPacket(PCraftItem, ItemLockFlg::NoSelect); - } - } - } - // Calculate what craft this recipe "belongs" to based on highest skill required uint32 skillType = 0; uint32 highestSkill = 0; for (uint8 skillID = SKILL_WOODWORKING; skillID <= SKILL_COOKING; ++skillID) { - if (const uint8 skillRequired = PChar->CraftContainer->getQuantity(skillID - 40); skillRequired > highestSkill) + if (const uint8 skillRequired = PChar->craftState().skillRequired(skillID - SKILL_WOODWORKING); skillRequired > highestSkill) { skillType = skillID; highestSkill = skillRequired; @@ -1324,9 +1161,17 @@ void startSynth(CCharEntity* PChar) void sendSynthDone(CCharEntity* PChar) { - // Handle synthesis result. - uint8 m_synthResult = PChar->CraftContainer->getQuantity(0); - if (m_synthResult == SYNTHESIS_FAIL) + // forceSynthCritFail already ran, the transaction is gone -- clear the ANIMATION_SYNTH flag and bail. + auto* synthTransaction = PChar->activeTransaction(); + if (!synthTransaction) + { + PChar->animation = ANIMATION_NONE; + PChar->updatemask |= UPDATE_HP; + PChar->pushPacket(PChar); + return; + } + + if (PChar->craftState().result() == SYNTHESIS_FAIL) { handleSynthFail(PChar); } @@ -1335,11 +1180,11 @@ void sendSynthDone(CCharEntity* PChar) handleSynthSuccess(PChar); } - // Handle skill up calculations. doSynthSkillUp(PChar); - // Handle craft container and others. - PChar->CraftContainer->Clean(); + std::ignore = synthTransaction->commit(); + PChar->removeTransaction(synthTransaction); + PChar->animation = ANIMATION_NONE; PChar->updatemask |= UPDATE_HP; PChar->pushPacket(PChar); @@ -1347,52 +1192,18 @@ void sendSynthDone(CCharEntity* PChar) void doSynthCriticalFail(CCharEntity* PChar) { - // Loop variables - uint8 invSlotID = PChar->CraftContainer->getInvSlotID(1); - uint8 nextSlotID = 0; - uint8 lostCount = 0; - uint8 totalCount = 0; - - // Loop through craft container items. - for (uint8 slotID = 1; slotID <= 8; ++slotID) + auto* synthTransaction = PChar->activeTransaction(); + if (!synthTransaction) { - if (slotID != 8) - { - nextSlotID = PChar->CraftContainer->getInvSlotID(slotID + 1); - } - - PChar->CraftContainer->setQuantity(slotID, 0); - lostCount++; - totalCount++; - - if (invSlotID != nextSlotID) - { - CItem* PItem = PChar->getStorage(LOC_INVENTORY)->GetItem(invSlotID); - - if (PItem != nullptr) - { - PItem->setSubType(ITEM_UNLOCKED); - PItem->setReserve(PItem->getReserve() - totalCount); - totalCount = 0; - - if (lostCount > 0) - { - charutils::UpdateItem(PChar, LOC_INVENTORY, invSlotID, -(int32)lostCount); - lostCount = 0; - } - else - { - PChar->pushPacket(PItem, ItemLockFlg::Normal); - } - } - invSlotID = nextSlotID; - } - - nextSlotID = 0; + return; + } - if (invSlotID == 0xFF) + auto& craftState = PChar->craftState(); + for (uint8 idx = 0; idx < SynthMaxIngredients; ++idx) + { + if (craftState.ingredientItemId(idx) != 0) { - break; + craftState.markBroken(idx); } } @@ -1407,7 +1218,14 @@ void doSynthCriticalFail(CCharEntity* PChar) PChar->loc.zone->PushPacket(PChar, CHAR_INRANGE, std::make_unique(PChar, SynthesisResult::InterruptedCritical)); } - PChar->pushPacket(PChar, SynthesisResult::InterruptedCritical, 29695); + PChar->pushPacket(PChar, SynthesisResult::InterruptedCritical, CCraftState::Result{ MANGLED_MESS, 0 }); + + std::ignore = synthTransaction->commit(); + PChar->removeTransaction(synthTransaction); + + PChar->animation = ANIMATION_NONE; + PChar->updatemask |= UPDATE_HP; + PChar->pushPacket(PChar); } } // namespace synthutils diff --git a/src/map/utils/synthutils.h b/src/map/utils/synthutils.h index b1fe04062df..8de8cbb7070 100644 --- a/src/map/utils/synthutils.h +++ b/src/map/utils/synthutils.h @@ -30,6 +30,7 @@ ************************************************************************/ class CCharEntity; +struct SynthOffer; namespace synthutils { @@ -56,7 +57,7 @@ enum SYNTHESIS_RESULT }; void LoadSynthRecipes(); -void startSynth(CCharEntity* PChar); +void startSynth(CCharEntity* PChar, const SynthOffer& offer); void sendSynthDone(CCharEntity* PChar); void doSynthCriticalFail(CCharEntity* PChar); diff --git a/src/map/zone_entities.cpp b/src/map/zone_entities.cpp index 70d4a7a820b..4f64f3c1d10 100644 --- a/src/map/zone_entities.cpp +++ b/src/map/zone_entities.cpp @@ -48,6 +48,7 @@ #include "battlefield.h" #include "enums/weather.h" +#include "items/transactions/synth.h" #include "packets/s2c/0x05f_music.h" #include "utils/battleutils.h" #include "utils/charutils.h" @@ -564,14 +565,13 @@ void CZoneEntities::DecreaseZoneCounter(CCharEntity* PChar) } // Duplicated from charUtils, it is theoretically possible through d/c magic to hit this block and not sendToZone - if (PChar->CraftContainer && PChar->CraftContainer->getItemsCount() > 0) + if (PChar->activeTransaction()) { charutils::forceSynthCritFail("DecreaseZoneCounter", PChar); } if (PChar->animation == ANIMATION_SYNTH) { - PChar->CraftContainer->setQuantity(0, synthutils::SYNTHESIS_FAIL); synthutils::sendSynthDone(PChar); }