diff --git a/apps/opencs/model/doc/loader.cpp b/apps/opencs/model/doc/loader.cpp index d3a3490f73..46dea447fe 100644 --- a/apps/opencs/model/doc/loader.cpp +++ b/apps/opencs/model/doc/loader.cpp @@ -123,6 +123,7 @@ void CSMDoc::Loader::load() } else { + document->getData().finishLoading(); done = true; } diff --git a/apps/opencs/model/world/collection.hpp b/apps/opencs/model/world/collection.hpp index 0ec7142d2a..e177291a7c 100644 --- a/apps/opencs/model/world/collection.hpp +++ b/apps/opencs/model/world/collection.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -17,6 +18,7 @@ #include "collectionbase.hpp" #include "columnbase.hpp" +#include "info.hpp" #include "land.hpp" #include "landtexture.hpp" #include "record.hpp" @@ -24,12 +26,29 @@ namespace CSMWorld { + inline std::pair parseInfoRefId(const ESM::RefId& infoId) + { + const auto separator = infoId.getRefIdString().find('#'); + if (separator == std::string::npos) + throw std::runtime_error("Invalid info id: " + infoId.getRefIdString()); + const std::string_view view(infoId.getRefIdString()); + return { view.substr(0, separator), view.substr(separator + 1) }; + } + template void setRecordId(const decltype(T::mId)& id, T& record) { record.mId = id; } + inline void setRecordId(const ESM::RefId& id, Info& record) + { + record.mId = id; + const auto [topicId, originalId] = parseInfoRefId(id); + record.mTopicId = ESM::RefId::stringRefId(topicId); + record.mOriginalId = ESM::RefId::stringRefId(originalId); + } + template auto getRecordId(const T& record) { @@ -85,6 +104,8 @@ namespace CSMWorld protected: const std::vector>>& getRecords() const; + void reorderRowsImp(const std::vector& indexOrder); + bool reorderRowsImp(int baseIndex, const std::vector& newOrder); ///< Reorder the rows [baseIndex, baseIndex+newOrder.size()) according to the indices /// given in \a newOrder (baseIndex+newOrder[0] specifies the new index of row baseIndex). @@ -191,6 +212,20 @@ namespace CSMWorld return mRecords; } + template + void Collection::reorderRowsImp(const std::vector& indexOrder) + { + assert(indexOrder.size() == mRecords.size()); + assert(std::unordered_set(indexOrder.begin(), indexOrder.end()).size() == indexOrder.size()); + std::vector>> orderedRecords; + for (const int index : indexOrder) + { + mIndex.at(mRecords[index]->get().mId) = static_cast(orderedRecords.size()); + orderedRecords.push_back(std::move(mRecords[index])); + } + mRecords = std::move(orderedRecords); + } + template bool Collection::reorderRowsImp(int baseIndex, const std::vector& newOrder) { diff --git a/apps/opencs/model/world/data.cpp b/apps/opencs/model/world/data.cpp index a6268c1a48..372b865009 100644 --- a/apps/opencs/model/world/data.cpp +++ b/apps/opencs/model/world/data.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -55,37 +56,42 @@ #include "resourcesmanager.hpp" #include "resourcetable.hpp" -namespace +namespace CSMWorld { - void removeDialogueInfos(const ESM::RefId& dialogueId, const CSMWorld::InfosByTopic& infosByTopic, - CSMWorld::InfoCollection& infoCollection) + namespace { - const auto topicInfos = infosByTopic.find(dialogueId); - - if (topicInfos == infosByTopic.end()) - return; - - std::vector erasedRecords; - - for (const ESM::RefId& id : topicInfos->second) + void removeDialogueInfos( + const ESM::RefId& dialogueId, InfoOrderByTopic& infoOrders, InfoCollection& infoCollection) { - const CSMWorld::Record& record = infoCollection.getRecord(id); + const auto topicInfoOrder = infoOrders.find(dialogueId); - if (record.mState == CSMWorld::RecordBase::State_ModifiedOnly) + if (topicInfoOrder == infoOrders.end()) + return; + + std::vector erasedRecords; + + for (const OrderedInfo& info : topicInfoOrder->second.getOrderedInfo()) { - erasedRecords.push_back(infoCollection.searchId(record.get().mId)); - continue; + const Record& record = infoCollection.getRecord(info.mId); + + if (record.mState == RecordBase::State_ModifiedOnly) + { + erasedRecords.push_back(infoCollection.searchId(info.mId)); + continue; + } + + auto deletedRecord = std::make_unique>(record); + deletedRecord->mState = RecordBase::State_Deleted; + infoCollection.setRecord(infoCollection.searchId(info.mId), std::move(deletedRecord)); } - auto deletedRecord = std::make_unique>(record); - deletedRecord->mState = CSMWorld::RecordBase::State_Deleted; - infoCollection.setRecord(infoCollection.searchId(record.get().mId), std::move(deletedRecord)); - } + while (!erasedRecords.empty()) + { + infoCollection.removeRows(erasedRecords.back(), 1); + erasedRecords.pop_back(); + } - while (!erasedRecords.empty()) - { - infoCollection.removeRows(erasedRecords.back(), 1); - erasedRecords.pop_back(); + infoOrders.erase(topicInfoOrder); } } } @@ -1289,11 +1295,11 @@ bool CSMWorld::Data::continueLoading(CSMDoc::Messages& messages) if (mJournals.tryDelete(record.mId)) { - removeDialogueInfos(record.mId, mJournalInfosByTopic, mJournalInfos); + removeDialogueInfos(record.mId, mJournalInfoOrder, mJournalInfos); } else if (mTopics.tryDelete(record.mId)) { - removeDialogueInfos(record.mId, mTopicInfosByTopic, mTopicInfos); + removeDialogueInfos(record.mId, mTopicInfoOrder, mTopicInfos); } else { @@ -1331,9 +1337,9 @@ bool CSMWorld::Data::continueLoading(CSMDoc::Messages& messages) } if (mDialogue->mType == ESM::Dialogue::Journal) - mJournalInfos.load(*mReader, mBase, *mDialogue, mJournalInfosByTopic); + mJournalInfos.load(*mReader, mBase, *mDialogue, mJournalInfoOrder); else - mTopicInfos.load(*mReader, mBase, *mDialogue, mTopicInfosByTopic); + mTopicInfos.load(*mReader, mBase, *mDialogue, mTopicInfoOrder); break; } @@ -1376,6 +1382,12 @@ bool CSMWorld::Data::continueLoading(CSMDoc::Messages& messages) return false; } +void CSMWorld::Data::finishLoading() +{ + mTopicInfos.sort(mTopicInfoOrder); + mJournalInfos.sort(mJournalInfoOrder); +} + bool CSMWorld::Data::hasId(const std::string& id) const { const ESM::RefId refId = ESM::RefId::stringRefId(id); diff --git a/apps/opencs/model/world/data.hpp b/apps/opencs/model/world/data.hpp index 42c5309723..16d7a28f53 100644 --- a/apps/opencs/model/world/data.hpp +++ b/apps/opencs/model/world/data.hpp @@ -15,6 +15,7 @@ #include #include +#include #include #include #include @@ -135,8 +136,8 @@ namespace CSMWorld std::vector> mReaders; - CSMWorld::InfosByTopic mJournalInfosByTopic; - CSMWorld::InfosByTopic mTopicInfosByTopic; + InfoOrderByTopic mJournalInfoOrder; + InfoOrderByTopic mTopicInfoOrder; // not implemented Data(const Data&); @@ -307,6 +308,8 @@ namespace CSMWorld bool continueLoading(CSMDoc::Messages& messages); ///< \return Finished? + void finishLoading(); + bool hasId(const std::string& id) const; std::vector getIds(bool listDeleted = true) const; diff --git a/apps/opencs/model/world/infocollection.cpp b/apps/opencs/model/world/infocollection.cpp index 5e5a017243..a038b1046b 100644 --- a/apps/opencs/model/world/infocollection.cpp +++ b/apps/opencs/model/world/infocollection.cpp @@ -1,91 +1,141 @@ #include "infocollection.hpp" +#include #include +#include #include #include -#include -#include +#include "components/debug/debuglog.hpp" +#include "components/esm3/infoorder.hpp" +#include "components/esm3/loaddial.hpp" +#include "components/esm3/loadinfo.hpp" #include "collection.hpp" #include "info.hpp" -bool CSMWorld::InfoCollection::load(const Info& record, bool base) +namespace CSMWorld { - const int index = searchId(record.mId); + namespace + { + ESM::RefId makeCompositeRefId(const ESM::RefId& topicId, const ESM::RefId& infoId) + { + return ESM::RefId::stringRefId(topicId.getRefIdString() + '#' + infoId.getRefIdString()); + } + + std::string_view getInfoTopicId(const ESM::RefId& infoId) + { + return parseInfoRefId(infoId).first; + } + } +} + +void CSMWorld::InfoCollection::load(const Info& value, bool base) +{ + const int index = searchId(value.mId); if (index == -1) { // new record - auto record2 = std::make_unique>(); - record2->mState = base ? RecordBase::State_BaseOnly : RecordBase::State_ModifiedOnly; - (base ? record2->mBase : record2->mModified) = record; + auto record = std::make_unique>(); + record->mState = base ? RecordBase::State_BaseOnly : RecordBase::State_ModifiedOnly; + (base ? record->mBase : record->mModified) = value; - appendRecord(std::move(record2)); - - return true; + insertRecord(std::move(record), getSize()); } else { // old record - auto record2 = std::make_unique>(getRecord(index)); + auto record = std::make_unique>(getRecord(index)); if (base) - record2->mBase = record; + record->mBase = value; else - record2->setModified(record); + record->setModified(value); - setRecord(index, std::move(record2)); - - return false; + setRecord(index, std::move(record)); } } void CSMWorld::InfoCollection::load( - ESM::ESMReader& reader, bool base, const ESM::Dialogue& dialogue, InfosByTopic& infosByTopic) + ESM::ESMReader& reader, bool base, const ESM::Dialogue& dialogue, InfoOrderByTopic& infoOrders) { Info info; bool isDeleted = false; info.load(reader, isDeleted); - const ESM::RefId id = ESM::RefId::stringRefId(dialogue.mId.getRefIdString() + "#" + info.mId.getRefIdString()); + + const ESM::RefId id = makeCompositeRefId(dialogue.mId, info.mId); if (isDeleted) { - int index = searchId(id); + const int index = searchId(id); if (index == -1) { - // deleting a record that does not exist - // ignore it for now - /// \todo report the problem to the user + Log(Debug::Warning) << "Trying to delete absent info \"" << info.mId << "\" from topic \"" << dialogue.mId + << "\""; + return; } - else if (base) - { - removeRows(index, 1); - } - else - { - auto record = std::make_unique>(getRecord(index)); - record->mState = RecordBase::State_Deleted; - setRecord(index, std::move(record)); - } - } - else - { - info.mTopicId = dialogue.mId; - info.mOriginalId = info.mId; - info.mId = id; - if (load(info, base)) - infosByTopic[dialogue.mId].push_back(info.mId); + if (base) + { + infoOrders.at(dialogue.mId).removeInfo(id); + removeRows(index, 1); + return; + } + + auto record = std::make_unique>(getRecord(index)); + record->mState = RecordBase::State_Deleted; + setRecord(index, std::move(record)); + + return; } + + info.mTopicId = dialogue.mId; + info.mOriginalId = info.mId; + info.mId = id; + + load(info, base); + + infoOrders[dialogue.mId].insertInfo(OrderedInfo(info), isDeleted); +} + +void CSMWorld::InfoCollection::sort(const InfoOrderByTopic& infoOrders) +{ + std::vector order; + order.reserve(getSize()); + for (const auto& [topicId, infoOrder] : infoOrders) + for (const OrderedInfo& info : infoOrder.getOrderedInfo()) + order.push_back(getIndex(makeCompositeRefId(topicId, info.mId))); + reorderRowsImp(order); } CSMWorld::InfosRecordPtrByTopic CSMWorld::InfoCollection::getInfosByTopic() const { InfosRecordPtrByTopic result; for (const std::unique_ptr>& record : getRecords()) - result[record->mBase.mTopicId].push_back(record.get()); + result[record->get().mTopicId].push_back(record.get()); return result; } + +int CSMWorld::InfoCollection::getAppendIndex(const ESM::RefId& id, UniversalId::Type /*type*/) const +{ + const auto lessByTopicId + = [](std::string_view lhs, const std::unique_ptr>& rhs) { return lhs < rhs->get().mTopicId; }; + const auto it = std::upper_bound(getRecords().begin(), getRecords().end(), getInfoTopicId(id), lessByTopicId); + return static_cast(it - getRecords().begin()); +} + +bool CSMWorld::InfoCollection::reorderRows(int baseIndex, const std::vector& newOrder) +{ + const int lastIndex = baseIndex + static_cast(newOrder.size()) - 1; + + if (lastIndex >= getSize()) + return false; + + if (getRecord(baseIndex).get().mTopicId != getRecord(lastIndex).get().mTopicId) + return false; + + return reorderRowsImp(baseIndex, newOrder); +} diff --git a/apps/opencs/model/world/infocollection.hpp b/apps/opencs/model/world/infocollection.hpp index 696b032440..3853954a08 100644 --- a/apps/opencs/model/world/infocollection.hpp +++ b/apps/opencs/model/world/infocollection.hpp @@ -1,6 +1,7 @@ #ifndef CSM_WOLRD_INFOCOLLECTION_H #define CSM_WOLRD_INFOCOLLECTION_H +#include #include #include #include @@ -12,22 +13,47 @@ namespace ESM { struct Dialogue; class ESMReader; + + template + class InfoOrder; } namespace CSMWorld { - using InfosByTopic = std::unordered_map>; using InfosRecordPtrByTopic = std::unordered_map*>>; + struct OrderedInfo + { + ESM::RefId mId; + ESM::RefId mNext; + ESM::RefId mPrev; + + explicit OrderedInfo(const Info& info) + : mId(info.mOriginalId) + , mNext(info.mNext) + , mPrev(info.mPrev) + { + } + }; + + using InfoOrder = ESM::InfoOrder; + using InfoOrderByTopic = std::map>; + class InfoCollection : public Collection { private: - bool load(const Info& record, bool base); + void load(const Info& value, bool base); public: - void load(ESM::ESMReader& reader, bool base, const ESM::Dialogue& dialogue, InfosByTopic& infosByTopic); + void load(ESM::ESMReader& reader, bool base, const ESM::Dialogue& dialogue, InfoOrderByTopic& infoOrder); + + void sort(const InfoOrderByTopic& infoOrders); InfosRecordPtrByTopic getInfosByTopic() const; + + int getAppendIndex(const ESM::RefId& id, UniversalId::Type type = UniversalId::Type_None) const override; + + bool reorderRows(int baseIndex, const std::vector& newOrder) override; }; } diff --git a/apps/opencs/view/world/infocreator.cpp b/apps/opencs/view/world/infocreator.cpp index c8f92630f3..f98fe5be5e 100644 --- a/apps/opencs/view/world/infocreator.cpp +++ b/apps/opencs/view/world/infocreator.cpp @@ -27,7 +27,7 @@ class QUndoStack; std::string CSVWorld::InfoCreator::getId() const { - std::string id = Misc::StringUtils::lowerCase(mTopic->text().toUtf8().constData()); + const std::string topic = mTopic->text().toStdString(); std::string unique = QUuid::createUuid().toByteArray().data(); @@ -35,7 +35,7 @@ std::string CSVWorld::InfoCreator::getId() const unique = unique.substr(1, unique.size() - 2); - return id + '#' + unique; + return topic + '#' + unique; } void CSVWorld::InfoCreator::configureCreateCommand(CSMWorld::CreateCommand& command) const diff --git a/apps/opencs_tests/model/world/testinfocollection.cpp b/apps/opencs_tests/model/world/testinfocollection.cpp index 9901266647..4aa277a22f 100644 --- a/apps/opencs_tests/model/world/testinfocollection.cpp +++ b/apps/opencs_tests/model/world/testinfocollection.cpp @@ -9,18 +9,69 @@ #include #include +#include #include -#include #include +#include namespace CSMWorld { + inline std::ostream& operator<<(std::ostream& stream, const Record* value) + { + return stream << "&Record{.mState=" << value->mState << ", .mId=" << value->get().mId << "}"; + } + namespace { using namespace ::testing; - std::unique_ptr saveDialogueWithInfos( - const ESM::Dialogue& dialogue, std::span infos) + struct DialInfoData + { + ESM::DialInfo mValue; + bool mDeleted = false; + + void save(ESM::ESMWriter& writer) const { mValue.save(writer, mDeleted); } + }; + + template + struct DialogueData + { + ESM::Dialogue mDialogue; + std::vector mInfos; + }; + + DialogueData generateDialogueWithInfos( + std::size_t infoCount, const ESM::RefId& dialogueId = ESM::RefId::stringRefId("dialogue")) + { + DialogueData result; + + result.mDialogue.blank(); + result.mDialogue.mId = dialogueId; + + for (std::size_t i = 0; i < infoCount; ++i) + { + ESM::DialInfo& info = result.mInfos.emplace_back(); + info.blank(); + info.mId = ESM::RefId::stringRefId("info" + std::to_string(i)); + } + + if (infoCount >= 2) + { + result.mInfos[0].mNext = result.mInfos[1].mId; + result.mInfos[infoCount - 1].mPrev = result.mInfos[infoCount - 2].mId; + } + + for (std::size_t i = 1; i < infoCount - 1; ++i) + { + result.mInfos[i].mPrev = result.mInfos[i - 1].mId; + result.mInfos[i].mNext = result.mInfos[i + 1].mId; + } + + return result; + } + + template + std::unique_ptr saveDialogueWithInfos(const ESM::Dialogue& dialogue, Infos&& infos) { auto stream = std::make_unique(); @@ -32,7 +83,7 @@ namespace CSMWorld dialogue.save(writer); writer.endRecord(ESM::REC_DIAL); - for (const ESM::DialInfo& info : infos) + for (const auto& info : infos) { writer.startRecord(ESM::REC_INFO); info.save(writer); @@ -43,7 +94,7 @@ namespace CSMWorld } void loadDialogueWithInfos(bool base, std::unique_ptr stream, InfoCollection& infoCollection, - InfosByTopic& infosByTopic) + InfoOrderByTopic& infoOrder) { ESM::ESMReader reader; reader.open(std::move(stream), "test"); @@ -59,64 +110,535 @@ namespace CSMWorld { ASSERT_EQ(reader.getRecName().toInt(), ESM::REC_INFO); reader.getRecHeader(); - infoCollection.load(reader, base, dialogue, infosByTopic); + infoCollection.load(reader, base, dialogue, infoOrder); } } - void saveAndLoadDialogueWithInfos(const ESM::Dialogue& dialogue, std::span infos, - bool base, InfoCollection& infoCollection, InfosByTopic& infosByTopic) + template + void saveAndLoadDialogueWithInfos(const ESM::Dialogue& dialogue, Infos&& infos, bool base, + InfoCollection& infoCollection, InfoOrderByTopic& infoOrder) { - loadDialogueWithInfos(base, saveDialogueWithInfos(dialogue, infos), infoCollection, infosByTopic); + loadDialogueWithInfos(base, saveDialogueWithInfos(dialogue, infos), infoCollection, infoOrder); + } + + template + void saveAndLoadDialogueWithInfos( + const DialogueData& data, bool base, InfoCollection& infoCollection, InfoOrderByTopic& infoOrder) + { + saveAndLoadDialogueWithInfos(data.mDialogue, data.mInfos, base, infoCollection, infoOrder); } TEST(CSMWorldInfoCollectionTest, loadShouldAddRecord) { ESM::Dialogue dialogue; dialogue.blank(); - dialogue.mId = ESM::RefId::stringRefId("dialogue1"); + dialogue.mId = ESM::RefId::stringRefId("dialogue"); ESM::DialInfo info; info.blank(); - info.mId = ESM::RefId::stringRefId("info1"); + info.mId = ESM::RefId::stringRefId("info0"); const bool base = true; - InfosByTopic infosByTopic; + InfoOrderByTopic infoOrder; InfoCollection collection; - saveAndLoadDialogueWithInfos(dialogue, std::array{ info }, base, collection, infosByTopic); + saveAndLoadDialogueWithInfos(dialogue, std::array{ info }, base, collection, infoOrder); EXPECT_EQ(collection.getSize(), 1); - ASSERT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue1#info1")), 0); + ASSERT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info0")), 0); const Record& record = collection.getRecord(0); ASSERT_EQ(record.mState, RecordBase::State_BaseOnly); EXPECT_EQ(record.mBase.mTopicId, dialogue.mId); EXPECT_EQ(record.mBase.mOriginalId, info.mId); - EXPECT_EQ(record.mBase.mId, ESM::RefId::stringRefId("dialogue1#info1")); + EXPECT_EQ(record.mBase.mId, ESM::RefId::stringRefId("dialogue#info0")); + } + + TEST(CSMWorldInfoCollectionTest, loadShouldAddRecordAndMarkModifiedOnlyWhenNotBase) + { + ESM::Dialogue dialogue; + dialogue.blank(); + dialogue.mId = ESM::RefId::stringRefId("dialogue"); + + ESM::DialInfo info; + info.blank(); + info.mId = ESM::RefId::stringRefId("info0"); + + const bool base = false; + InfoOrderByTopic infoOrder; + InfoCollection collection; + saveAndLoadDialogueWithInfos(dialogue, std::array{ info }, base, collection, infoOrder); + + EXPECT_EQ(collection.getSize(), 1); + ASSERT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info0")), 0); + const Record& record = collection.getRecord(0); + ASSERT_EQ(record.mState, RecordBase::State_ModifiedOnly); + EXPECT_EQ(record.mModified.mTopicId, dialogue.mId); + EXPECT_EQ(record.mModified.mOriginalId, info.mId); + EXPECT_EQ(record.mModified.mId, ESM::RefId::stringRefId("dialogue#info0")); } TEST(CSMWorldInfoCollectionTest, loadShouldUpdateRecord) { ESM::Dialogue dialogue; dialogue.blank(); - dialogue.mId = ESM::RefId::stringRefId("dialogue1"); + dialogue.mId = ESM::RefId::stringRefId("dialogue"); ESM::DialInfo info; info.blank(); - info.mId = ESM::RefId::stringRefId("info1"); + info.mId = ESM::RefId::stringRefId("info0"); const bool base = true; - InfosByTopic infosByTopic; + InfoOrderByTopic infoOrder; InfoCollection collection; - saveAndLoadDialogueWithInfos(dialogue, std::array{ info }, base, collection, infosByTopic); + saveAndLoadDialogueWithInfos(dialogue, std::array{ info }, base, collection, infoOrder); ESM::DialInfo updatedInfo = info; updatedInfo.mActor = ESM::RefId::stringRefId("newActor"); - saveAndLoadDialogueWithInfos(dialogue, std::array{ updatedInfo }, base, collection, infosByTopic); + saveAndLoadDialogueWithInfos(dialogue, std::array{ updatedInfo }, base, collection, infoOrder); - ASSERT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue1#info1")), 0); + ASSERT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info0")), 0); const Record& record = collection.getRecord(0); ASSERT_EQ(record.mState, RecordBase::State_BaseOnly); EXPECT_EQ(record.mBase.mActor, ESM::RefId::stringRefId("newActor")); } + + TEST(CSMWorldInfoCollectionTest, loadShouldUpdateRecordAndMarkModifiedWhenNotBase) + { + ESM::Dialogue dialogue; + dialogue.blank(); + dialogue.mId = ESM::RefId::stringRefId("dialogue"); + + ESM::DialInfo info; + info.blank(); + info.mId = ESM::RefId::stringRefId("info0"); + + const bool base = true; + InfoOrderByTopic infoOrder; + InfoCollection collection; + saveAndLoadDialogueWithInfos(dialogue, std::array{ info }, base, collection, infoOrder); + + ESM::DialInfo updatedInfo = info; + updatedInfo.mActor = ESM::RefId::stringRefId("newActor"); + + saveAndLoadDialogueWithInfos(dialogue, std::array{ updatedInfo }, false, collection, infoOrder); + + ASSERT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info0")), 0); + const Record& record = collection.getRecord(0); + ASSERT_EQ(record.mState, RecordBase::State_Modified); + EXPECT_EQ(record.mModified.mActor, ESM::RefId::stringRefId("newActor")); + } + + TEST(CSMWorldInfoCollectionTest, loadShouldSkipAbsentDeletedRecord) + { + ESM::Dialogue dialogue; + dialogue.blank(); + dialogue.mId = ESM::RefId::stringRefId("dialogue"); + + DialInfoData info; + info.mValue.blank(); + info.mValue.mId = ESM::RefId::stringRefId("info0"); + info.mDeleted = true; + + const bool base = true; + InfoOrderByTopic infoOrder; + InfoCollection collection; + saveAndLoadDialogueWithInfos(dialogue, std::array{ info }, base, collection, infoOrder); + + EXPECT_EQ(collection.getSize(), 0); + } + + TEST(CSMWorldInfoCollectionTest, loadShouldRemovePresentDeletedBaseRecord) + { + ESM::Dialogue dialogue; + dialogue.blank(); + dialogue.mId = ESM::RefId::stringRefId("dialogue"); + + DialInfoData info; + info.mValue.blank(); + info.mValue.mId = ESM::RefId::stringRefId("info0"); + + const bool base = true; + InfoOrderByTopic infoOrder; + InfoCollection collection; + + saveAndLoadDialogueWithInfos(dialogue, std::array{ info }, base, collection, infoOrder); + + info.mDeleted = true; + + saveAndLoadDialogueWithInfos(dialogue, std::array{ info }, base, collection, infoOrder); + + EXPECT_EQ(collection.getSize(), 0); + } + + TEST(CSMWorldInfoCollectionTest, loadShouldMarkAsDeletedNotBaseRecord) + { + ESM::Dialogue dialogue; + dialogue.blank(); + dialogue.mId = ESM::RefId::stringRefId("dialogue"); + + DialInfoData info; + info.mValue.blank(); + info.mValue.mId = ESM::RefId::stringRefId("info0"); + + const bool base = true; + InfoOrderByTopic infoOrder; + InfoCollection collection; + + saveAndLoadDialogueWithInfos(dialogue, std::array{ info }, base, collection, infoOrder); + + info.mDeleted = true; + + saveAndLoadDialogueWithInfos(dialogue, std::array{ info }, false, collection, infoOrder); + + EXPECT_EQ(collection.getSize(), 1); + EXPECT_EQ( + collection.getRecord(ESM::RefId::stringRefId("dialogue#info0")).mState, RecordBase::State_Deleted); + } + + TEST(CSMWorldInfoCollectionTest, sortShouldOrderRecordsBasedOnPrevAndNext) + { + const DialogueData data = generateDialogueWithInfos(3); + + const bool base = true; + InfoOrderByTopic infoOrder; + InfoCollection collection; + saveAndLoadDialogueWithInfos(data, base, collection, infoOrder); + + collection.sort(infoOrder); + + EXPECT_EQ(collection.getSize(), 3); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info0")), 0); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info1")), 1); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info2")), 2); + } + + TEST(CSMWorldInfoCollectionTest, sortShouldOrderRecordsBasedOnPrevAndNextWhenReversed) + { + DialogueData data = generateDialogueWithInfos(3); + + std::reverse(data.mInfos.begin(), data.mInfos.end()); + + const bool base = true; + InfoOrderByTopic infoOrder; + InfoCollection collection; + saveAndLoadDialogueWithInfos(data, base, collection, infoOrder); + + collection.sort(infoOrder); + + EXPECT_EQ(collection.getSize(), 3); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info0")), 0); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info1")), 1); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info2")), 2); + } + + TEST(CSMWorldInfoCollectionTest, sortShouldInsertNewRecordBasedOnPrev) + { + const bool base = true; + InfoOrderByTopic infoOrder; + InfoCollection collection; + + const DialogueData data = generateDialogueWithInfos(3); + + saveAndLoadDialogueWithInfos(data, base, collection, infoOrder); + + ESM::DialInfo newInfo; + newInfo.blank(); + newInfo.mId = ESM::RefId::stringRefId("newInfo"); + newInfo.mPrev = data.mInfos[1].mId; + newInfo.mNext = ESM::RefId::stringRefId("invalid"); + + saveAndLoadDialogueWithInfos(data.mDialogue, std::array{ newInfo }, base, collection, infoOrder); + + collection.sort(infoOrder); + + EXPECT_EQ(collection.getSize(), 4); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info0")), 0); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info1")), 1); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#newInfo")), 2); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info2")), 3); + } + + TEST(CSMWorldInfoCollectionTest, sortShouldInsertNewRecordBasedOnNextWhenPrevIsNotFound) + { + const bool base = true; + InfoOrderByTopic infoOrder; + InfoCollection collection; + + const DialogueData data = generateDialogueWithInfos(3); + + saveAndLoadDialogueWithInfos(data, base, collection, infoOrder); + + ESM::DialInfo newInfo; + newInfo.blank(); + newInfo.mId = ESM::RefId::stringRefId("newInfo"); + newInfo.mPrev = ESM::RefId::stringRefId("invalid"); + newInfo.mNext = data.mInfos[2].mId; + + saveAndLoadDialogueWithInfos(data.mDialogue, std::array{ newInfo }, base, collection, infoOrder); + + collection.sort(infoOrder); + + EXPECT_EQ(collection.getSize(), 4); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info0")), 0); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info1")), 1); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#newInfo")), 2); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info2")), 3); + } + + TEST(CSMWorldInfoCollectionTest, sortShouldInsertNewRecordToFrontWhenPrevIsEmpty) + { + const bool base = true; + InfoOrderByTopic infoOrder; + InfoCollection collection; + + const DialogueData data = generateDialogueWithInfos(3); + + saveAndLoadDialogueWithInfos(data, base, collection, infoOrder); + + ESM::DialInfo newInfo; + newInfo.blank(); + newInfo.mId = ESM::RefId::stringRefId("newInfo"); + newInfo.mNext = ESM::RefId::stringRefId("invalid"); + + saveAndLoadDialogueWithInfos(data.mDialogue, std::array{ newInfo }, base, collection, infoOrder); + + collection.sort(infoOrder); + + EXPECT_EQ(collection.getSize(), 4); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#newInfo")), 0); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info0")), 1); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info1")), 2); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info2")), 3); + } + + TEST(CSMWorldInfoCollectionTest, sortShouldInsertNewRecordToBackWhenNextIsEmpty) + { + const bool base = true; + InfoOrderByTopic infoOrder; + InfoCollection collection; + + const DialogueData data = generateDialogueWithInfos(3); + + saveAndLoadDialogueWithInfos(data, base, collection, infoOrder); + + ESM::DialInfo newInfo; + newInfo.blank(); + newInfo.mId = ESM::RefId::stringRefId("newInfo"); + newInfo.mPrev = ESM::RefId::stringRefId("invalid"); + + saveAndLoadDialogueWithInfos(data.mDialogue, std::array{ newInfo }, base, collection, infoOrder); + + collection.sort(infoOrder); + + EXPECT_EQ(collection.getSize(), 4); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info0")), 0); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info1")), 1); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info2")), 2); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#newInfo")), 3); + } + + TEST(CSMWorldInfoCollectionTest, sortShouldMoveBackwardUpdatedRecordBasedOnPrev) + { + const bool base = true; + InfoOrderByTopic infoOrder; + InfoCollection collection; + + const DialogueData data = generateDialogueWithInfos(3); + + saveAndLoadDialogueWithInfos(data, base, collection, infoOrder); + + ESM::DialInfo updatedInfo = data.mInfos[2]; + updatedInfo.mPrev = data.mInfos[0].mId; + updatedInfo.mNext = ESM::RefId::stringRefId("invalid"); + + saveAndLoadDialogueWithInfos(data.mDialogue, std::array{ updatedInfo }, base, collection, infoOrder); + + collection.sort(infoOrder); + + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info0")), 0); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info1")), 2); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info2")), 1); + } + + TEST(CSMWorldInfoCollectionTest, sortShouldMoveForwardUpdatedRecordBasedOnPrev) + { + const bool base = true; + InfoOrderByTopic infoOrder; + InfoCollection collection; + + const DialogueData data = generateDialogueWithInfos(3); + + saveAndLoadDialogueWithInfos(data, base, collection, infoOrder); + + ESM::DialInfo updatedInfo = data.mInfos[0]; + updatedInfo.mPrev = data.mInfos[1].mId; + updatedInfo.mNext = ESM::RefId::stringRefId("invalid"); + + saveAndLoadDialogueWithInfos(data.mDialogue, std::array{ updatedInfo }, base, collection, infoOrder); + + collection.sort(infoOrder); + + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info0")), 1); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info1")), 0); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info2")), 2); + } + + TEST(CSMWorldInfoCollectionTest, sortShouldMoveToFrontUpdatedRecordWhenPrevIsEmpty) + { + const bool base = true; + InfoOrderByTopic infoOrder; + InfoCollection collection; + + const DialogueData data = generateDialogueWithInfos(3); + + saveAndLoadDialogueWithInfos(data, base, collection, infoOrder); + + ESM::DialInfo updatedInfo = data.mInfos[2]; + updatedInfo.mPrev = ESM::RefId(); + updatedInfo.mNext = ESM::RefId::stringRefId("invalid"); + + saveAndLoadDialogueWithInfos(data.mDialogue, std::array{ updatedInfo }, base, collection, infoOrder); + + collection.sort(infoOrder); + + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info0")), 1); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info1")), 2); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info2")), 0); + } + + TEST(CSMWorldInfoCollectionTest, sortShouldMoveToBackUpdatedRecordWhenNextIsEmpty) + { + const bool base = true; + InfoOrderByTopic infoOrder; + InfoCollection collection; + + const DialogueData data = generateDialogueWithInfos(3); + + saveAndLoadDialogueWithInfos(data, base, collection, infoOrder); + + ESM::DialInfo updatedInfo = data.mInfos[0]; + updatedInfo.mPrev = ESM::RefId::stringRefId("invalid"); + updatedInfo.mNext = ESM::RefId(); + + saveAndLoadDialogueWithInfos(data.mDialogue, std::array{ updatedInfo }, base, collection, infoOrder); + + collection.sort(infoOrder); + + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info0")), 2); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info1")), 0); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue#info2")), 1); + } + + TEST(CSMWorldInfoCollectionTest, sortShouldProvideStableOrderByTopic) + { + const bool base = true; + InfoOrderByTopic infoOrder; + InfoCollection collection; + + saveAndLoadDialogueWithInfos( + generateDialogueWithInfos(2, ESM::RefId::stringRefId("dialogue2")), base, collection, infoOrder); + saveAndLoadDialogueWithInfos( + generateDialogueWithInfos(2, ESM::RefId::stringRefId("dialogue0")), base, collection, infoOrder); + saveAndLoadDialogueWithInfos( + generateDialogueWithInfos(2, ESM::RefId::stringRefId("dialogue1")), base, collection, infoOrder); + + collection.sort(infoOrder); + + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue0#info0")), 0); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue0#info1")), 1); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue1#info0")), 2); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue1#info1")), 3); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue2#info0")), 4); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue2#info1")), 5); + } + + TEST(CSMWorldInfoCollectionTest, getAppendIndexShouldReturnFirstIndexAfterInfoTopic) + { + const bool base = true; + InfoOrderByTopic infoOrder; + InfoCollection collection; + + saveAndLoadDialogueWithInfos( + generateDialogueWithInfos(2, ESM::RefId::stringRefId("dialogue0")), base, collection, infoOrder); + saveAndLoadDialogueWithInfos( + generateDialogueWithInfos(2, ESM::RefId::stringRefId("dialogue1")), base, collection, infoOrder); + + collection.sort(infoOrder); + + EXPECT_EQ(collection.getAppendIndex(ESM::RefId::stringRefId("dialogue0#info2")), 2); + } + + TEST(CSMWorldInfoCollectionTest, reorderRowsShouldFailWhenOutOfBounds) + { + const bool base = true; + InfoOrderByTopic infoOrder; + InfoCollection collection; + + saveAndLoadDialogueWithInfos( + generateDialogueWithInfos(2, ESM::RefId::stringRefId("dialogue0")), base, collection, infoOrder); + saveAndLoadDialogueWithInfos( + generateDialogueWithInfos(2, ESM::RefId::stringRefId("dialogue1")), base, collection, infoOrder); + + EXPECT_FALSE(collection.reorderRows(5, {})); + } + + TEST(CSMWorldInfoCollectionTest, reorderRowsShouldFailWhenAppliedToDifferentTopics) + { + const bool base = true; + InfoOrderByTopic infoOrder; + InfoCollection collection; + + saveAndLoadDialogueWithInfos( + generateDialogueWithInfos(2, ESM::RefId::stringRefId("dialogue0")), base, collection, infoOrder); + saveAndLoadDialogueWithInfos( + generateDialogueWithInfos(2, ESM::RefId::stringRefId("dialogue1")), base, collection, infoOrder); + + EXPECT_FALSE(collection.reorderRows(0, { 0, 1, 2 })); + } + + TEST(CSMWorldInfoCollectionTest, reorderRowsShouldSucceedWhenAppliedToOneTopic) + { + const bool base = true; + InfoOrderByTopic infoOrder; + InfoCollection collection; + + saveAndLoadDialogueWithInfos( + generateDialogueWithInfos(3, ESM::RefId::stringRefId("dialogue0")), base, collection, infoOrder); + saveAndLoadDialogueWithInfos( + generateDialogueWithInfos(3, ESM::RefId::stringRefId("dialogue1")), base, collection, infoOrder); + + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue0#info0")), 0); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue0#info1")), 1); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue0#info2")), 2); + + EXPECT_TRUE(collection.reorderRows(1, { 1, 0 })); + + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue0#info0")), 0); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue0#info1")), 2); + EXPECT_EQ(collection.searchId(ESM::RefId::stringRefId("dialogue0#info2")), 1); + } + + MATCHER_P(RecordPtrIdIs, v, "") + { + return v == arg->get().mId; + } + + TEST(CSMWorldInfoCollectionTest, getInfosByTopicShouldReturnRecordsGroupedByTopic) + { + const bool base = true; + InfoOrderByTopic infoOrder; + InfoCollection collection; + + saveAndLoadDialogueWithInfos( + generateDialogueWithInfos(2, ESM::RefId::stringRefId("d0")), base, collection, infoOrder); + saveAndLoadDialogueWithInfos( + generateDialogueWithInfos(2, ESM::RefId::stringRefId("d1")), base, collection, infoOrder); + + collection.sort(infoOrder); + + EXPECT_THAT(collection.getInfosByTopic(), + UnorderedElementsAre(Pair("d0", ElementsAre(RecordPtrIdIs("d0#info0"), RecordPtrIdIs("d0#info1"))), + Pair("d1", ElementsAre(RecordPtrIdIs("d1#info0"), RecordPtrIdIs("d1#info1"))))); + } } } diff --git a/components/CMakeLists.txt b/components/CMakeLists.txt index 5304086ee7..1576acc015 100644 --- a/components/CMakeLists.txt +++ b/components/CMakeLists.txt @@ -101,6 +101,7 @@ add_component_dir (esm3 inventorystate containerstate npcstate creaturestate dialoguestate statstate npcstats creaturestats weatherstate quickkeys fogstate spellstate activespells creaturelevliststate doorstate projectilestate debugprofile aisequence magiceffects custommarkerstate stolenitems transport animationstate controlsstate mappings readerscache + infoorder ) add_component_dir (esm3terrain diff --git a/components/esm3/infoorder.cpp b/components/esm3/infoorder.cpp new file mode 100644 index 0000000000..c77913e017 --- /dev/null +++ b/components/esm3/infoorder.cpp @@ -0,0 +1,87 @@ +#include "infoorder.hpp" + +namespace ESM +{ + const std::list* InfoOrder::findInfosByTopic(const ESM::RefId& refId) const + { + const auto it = mOrderByTopic.find(refId); + if (it == mOrderByTopic.end()) + return nullptr; + return &it->second; + } + + void InfoOrder::insertInfo(const OrderedInfo& info) + { + auto it = mInfoPositions.find(info.mId); + + if (it != mInfoPositions.end() && it->second->mPrev == info.mPrev) + { + it->second->mNext = info.mNext; + return; + } + + auto& infos = mOrderByTopic[info.mTopicId]; + + if (it == mInfoPositions.end()) + it = mInfoPositions.emplace(info.mId, infos.end()).first; + + std::list::iterator& position = it->second; + + const auto insertOrSplice = [&](std::list::const_iterator before) { + if (position == infos.end()) + position = infos.insert(before, info); + else + infos.splice(before, infos, position); + }; + + if (info.mPrev.empty()) + { + insertOrSplice(infos.begin()); + return; + } + + const auto prevIt = mInfoPositions.find(info.mPrev); + if (prevIt != mInfoPositions.end()) + { + insertOrSplice(std::next(prevIt->second)); + return; + } + + const auto nextIt = mInfoPositions.find(info.mNext); + if (nextIt != mInfoPositions.end()) + { + insertOrSplice(nextIt->second); + return; + } + + insertOrSplice(infos.end()); + } + + void InfoOrder::removeInfo(const ESM::RefId& infoRefId) + { + const auto it = mInfoPositions.find(infoRefId); + + if (it == mInfoPositions.end()) + return; + + const auto topicIt = mOrderByTopic.find(it->second->mTopicId); + + if (topicIt != mOrderByTopic.end()) + topicIt->second.erase(it->second); + + mInfoPositions.erase(it); + } + + void InfoOrder::removeTopic(const ESM::RefId& topicRefId) + { + const auto it = mOrderByTopic.find(topicRefId); + + if (it == mOrderByTopic.end()) + return; + + for (const OrderedInfo& info : it->second) + mInfoPositions.erase(info.mId); + + mOrderByTopic.erase(it); + } +} diff --git a/components/esm3/infoorder.hpp b/components/esm3/infoorder.hpp new file mode 100644 index 0000000000..77b80e3d20 --- /dev/null +++ b/components/esm3/infoorder.hpp @@ -0,0 +1,114 @@ +#ifndef OPENMW_COMPONENTS_ESM3_INFOORDER_H +#define OPENMW_COMPONENTS_ESM3_INFOORDER_H + +#include "components/esm/refid.hpp" + +#include +#include +#include +#include +#include + +namespace ESM +{ + template + class InfoOrder + { + public: + const std::list& getOrderedInfo() const { return mOrderedInfo; } + + template + void insertInfo(V&& value, bool deleted) + { + static_assert(std::is_same_v, T>); + + auto it = mInfoPositions.find(value.mId); + + if (it != mInfoPositions.end() && it->second.mPosition->mPrev == value.mPrev) + { + *it->second.mPosition = std::forward(value); + it->second.mDeleted = deleted; + return; + } + + if (it == mInfoPositions.end()) + it = mInfoPositions.emplace(value.mId, Item{ .mPosition = mOrderedInfo.end(), .mDeleted = deleted }) + .first; + + Item& item = it->second; + + const auto insertOrSplice = [&](typename std::list::const_iterator before) { + if (item.mPosition == mOrderedInfo.end()) + item.mPosition = mOrderedInfo.insert(before, std::forward(value)); + else + mOrderedInfo.splice(before, mOrderedInfo, item.mPosition); + }; + + if (value.mPrev.empty()) + { + insertOrSplice(mOrderedInfo.begin()); + return; + } + + const auto prevIt = mInfoPositions.find(value.mPrev); + if (prevIt != mInfoPositions.end()) + { + insertOrSplice(std::next(prevIt->second.mPosition)); + return; + } + + const auto nextIt = mInfoPositions.find(value.mNext); + if (nextIt != mInfoPositions.end()) + { + insertOrSplice(nextIt->second.mPosition); + return; + } + + insertOrSplice(mOrderedInfo.end()); + } + + void removeInfo(const RefId& infoRefId) + { + const auto it = mInfoPositions.find(infoRefId); + + if (it == mInfoPositions.end()) + return; + + mOrderedInfo.erase(it->second.mPosition); + mInfoPositions.erase(it); + } + + void removeDeleted() + { + for (auto it = mInfoPositions.begin(); it != mInfoPositions.end();) + { + if (!it->second.mDeleted) + { + ++it; + continue; + } + + mOrderedInfo.erase(it->second.mPosition); + it = mInfoPositions.erase(it); + } + } + + void extractOrderedInfo(std::list& info) + { + info = mOrderedInfo; + mInfoPositions.clear(); + } + + private: + struct Item + { + typename std::list::iterator mPosition; + bool mDeleted = false; + }; + + std::list mOrderedInfo; + std::unordered_map mInfoPositions; + }; +} + +#endif