diff --git a/apps/openmw/mwlua/types/player.cpp b/apps/openmw/mwlua/types/player.cpp index e8e0eaebb5..9ad9a6be14 100644 --- a/apps/openmw/mwlua/types/player.cpp +++ b/apps/openmw/mwlua/types/player.cpp @@ -2,6 +2,7 @@ #include #include +#include #include "../birthsignbindings.hpp" #include "../luamanagerimp.hpp" @@ -28,6 +29,16 @@ namespace MWLua ESM::RefId mQuestId; bool mMutable = false; }; + struct Topics + { + }; + struct JournalEntries + { + }; + struct TopicEntries + { + ESM::RefId mTopicId; + }; } namespace sol @@ -40,6 +51,34 @@ namespace sol struct is_automagical : std::false_type { }; + template <> + struct is_automagical : std::false_type + { + }; + template <> + struct is_automagical : std::false_type + { + }; + template <> + struct is_automagical : std::false_type + { + }; + template <> + struct is_automagical : std::false_type + { + }; + template <> + struct is_automagical : std::false_type + { + }; + template <> + struct is_automagical : std::false_type + { + }; + template <> + struct is_automagical : std::false_type + { + }; } namespace @@ -62,6 +101,18 @@ namespace return ESM::RefId(); return id; } + + const MWDialogue::Topic& getTopicDataOrThrow(const ESM::RefId& topicId, const MWBase::Journal* journal) + { + const MWBase::Journal::TTopicIter iterToFoundTopic = std::find_if(journal->topicBegin(), journal->topicEnd(), + [&topicId](const auto& topicKeyAndValue) { return topicKeyAndValue.first == topicId; }); + if (iterToFoundTopic == journal->topicEnd()) + { + throw std::runtime_error( + "Topic id: \"" + topicId.serializeText() + "\" expected to be present in player journal data"); + } + return iterToFoundTopic->second; + } } namespace MWLua @@ -78,17 +129,195 @@ namespace MWLua throw std::runtime_error("The argument must be a NPC!"); } + void addJournalClassBindings(sol::state_view& lua, const MWBase::Journal* journal) + { + auto journalBindingsClass = lua.new_usertype("MWDialogue_Journal"); + journalBindingsClass[sol::meta_function::to_string] = [](const MWBase::Journal& store) { + const size_t numberOfTopics = std::distance(store.topicBegin(), store.topicEnd()); + const size_t numberOfJournalEntries = std::distance(store.begin(), store.end()); + return "{MWDialogue_Journal: " + std::to_string(numberOfTopics) + " topic entries, " + + std::to_string(numberOfJournalEntries) + " journal entries}"; + }; + journalBindingsClass["topics"] + = sol::readonly_property([](const MWBase::Journal& store) { return MWLua::Topics{}; }); + journalBindingsClass["journalTextEntries"] + = sol::readonly_property([](const MWBase::Journal& store) { return MWLua::JournalEntries{}; }); + } + + void addJournalClassTopicsListBindings(sol::state_view& lua, const MWBase::Journal* journal) + { + auto topicsBindingsClass = lua.new_usertype("MWLua::Topics"); + topicsBindingsClass[sol::meta_function::to_string] = [journal](const MWLua::Topics& topicEntriesStore) { + const size_t numberOfTopics = std::distance(journal->topicBegin(), journal->topicEnd()); + return "{MWDialogue_Journal: " + std::to_string(numberOfTopics) + " topics}"; + }; + topicsBindingsClass[sol::meta_function::index] + = [journal]( + const MWLua::Topics& topicEntriesStore, std::string_view givenTopicId) -> const MWDialogue::Topic* { + const MWBase::Journal::TTopicIter iterToFoundTopic + = std::find_if(journal->topicBegin(), journal->topicEnd(), + [&givenTopicId](const auto& topicKeyAndValue) { return topicKeyAndValue.first == givenTopicId; }); + + return (iterToFoundTopic != journal->topicEnd()) ? &(iterToFoundTopic->second) : nullptr; + }; + topicsBindingsClass[sol::meta_function::length] = [journal](const MWLua::Topics&) -> size_t { + return std::distance(journal->topicBegin(), journal->topicEnd()); + }; + topicsBindingsClass[sol::meta_function::pairs] = [journal](const MWLua::Topics&) { + MWBase::Journal::TTopicIter iterator = journal->topicBegin(); + return sol::as_function( + [iterator, journal]() mutable -> std::pair, const MWDialogue::Topic*> { + if (iterator != journal->topicEnd()) + { + return { iterator->first.serializeText(), &((iterator++)->second) }; + } + return { sol::nullopt, nullptr }; + }); + }; + } + + void addJournalClassTopicBindings(sol::state_view& lua, const MWBase::Journal* journal) + { + auto topicBindingsClass = lua.new_usertype("MWDialogue_Topic"); + topicBindingsClass[sol::meta_function::to_string] = [](const MWDialogue::Topic& topic) { + return "MWDialogue_Topic: \"" + std::string{ topic.getName() } + "\""; + }; + topicBindingsClass["id"] + = sol::readonly_property([](const MWDialogue::Topic& topic) { return topic.getTopic().serializeText(); }); + topicBindingsClass["name"] + = sol::readonly_property([](const MWDialogue::Topic& topic) { return topic.getName(); }); + topicBindingsClass["entries"] = sol::readonly_property( + [](const MWDialogue::Topic& topic) { return MWLua::TopicEntries{ topic.getTopic() }; }); + } + + void addJournalClassTopicEntriesListBindings(sol::state_view& lua, const MWBase::Journal* journal) + { + auto topicEntriesBindingsClass = lua.new_usertype("MWDialogue_Topic_TextEntries"); + topicEntriesBindingsClass[sol::meta_function::to_string] = [journal](const MWLua::TopicEntries& topicEntries) { + const MWDialogue::Topic& topic = getTopicDataOrThrow(topicEntries.mTopicId, journal); + const size_t numberOfTopics = std::distance(topic.begin(), topic.end()); + return "MWDialogue_Topic \"" + std::string{ topic.getName() } + + "\" entries: " + std::to_string(numberOfTopics) + " elements"; + }; + topicEntriesBindingsClass[sol::meta_function::length] = [journal](const MWLua::TopicEntries& topicEntries) { + const MWDialogue::Topic& topic = getTopicDataOrThrow(topicEntries.mTopicId, journal); + return std::distance(topic.begin(), topic.end()); + }; + topicEntriesBindingsClass[sol::meta_function::index] + = [journal](const MWLua::TopicEntries& topicEntries, size_t index) -> const MWDialogue::Entry* { + if (index == 0) + { + return nullptr; + } + index = LuaUtil::fromLuaIndex(index); + const MWDialogue::Topic& topic = getTopicDataOrThrow(topicEntries.mTopicId, journal); + + MWDialogue::Topic::TEntryIter iter{ topic.begin() }; + while (index > 0 && iter != topic.end()) + { + ++iter; + --index; + } + return (iter != topic.end()) ? &(*iter) : nullptr; + }; + topicEntriesBindingsClass[sol::meta_function::ipairs] = lua["ipairsForArray"].template get(); + topicEntriesBindingsClass[sol::meta_function::pairs] = lua["ipairsForArray"].template get(); + } + + void addJournalClassTopicEntryBindings(sol::state_view& lua, const MWBase::Journal* journal) + { + auto topicEntryBindingsClass = lua.new_usertype("MWDialogue_Topic_Entry"); + topicEntryBindingsClass[sol::meta_function::to_string] = [](const MWDialogue::Entry& topicEntry) { + return "MWDialogue_Topic_Entry: " + topicEntry.mInfoId.toDebugString(); + }; + topicEntryBindingsClass["id"] = sol::readonly_property( + [](const MWDialogue::Entry& topicEntry) { return topicEntry.mInfoId.serializeText(); }); + topicEntryBindingsClass["text"] + = sol::readonly_property([](const MWDialogue::Entry& topicEntry) { return topicEntry.mText; }); + topicEntryBindingsClass["actor"] + = sol::readonly_property([](const MWDialogue::Entry& topicEntry) { return topicEntry.mActorName; }); + } + + void addJournalClassJournalEntriesListBindings(sol::state_view& lua, const MWBase::Journal* journal) + { + auto journalEntriesBindingsClass = lua.new_usertype("MWDialogue_Topic_TextEntries"); + journalEntriesBindingsClass[sol::meta_function::to_string] = [journal](const MWLua::JournalEntries&) { + const size_t numberOfEntries = std::distance(journal->begin(), journal->end()); + return "{MWDialogue_Journal: " + std::to_string(numberOfEntries) + " journal text entries}"; + }; + journalEntriesBindingsClass[sol::meta_function::length] + = [journal](const MWLua::JournalEntries&) { return std::distance(journal->begin(), journal->end()); }; + journalEntriesBindingsClass[sol::meta_function::index] + = [journal](const MWLua::JournalEntries&, size_t index) -> const MWDialogue::StampedJournalEntry* { + if (index == 0) + { + return nullptr; + } + index = LuaUtil::fromLuaIndex(index); + + MWBase::Journal::TEntryIter iter{ journal->begin() }; + while (index > 0 && iter != journal->end()) + { + ++iter; + --index; + } + return (iter != journal->end()) ? &(*iter) : nullptr; + }; + journalEntriesBindingsClass[sol::meta_function::ipairs] = lua["ipairsForArray"].template get(); + journalEntriesBindingsClass[sol::meta_function::pairs] = lua["ipairsForArray"].template get(); + } + + void addJournalClassJournalEntryBindings(sol::state_view& lua, const MWBase::Journal* journal) + { + auto journalEntryBindingsClass + = lua.new_usertype("MWDialogue_Topic_TextEntry"); + journalEntryBindingsClass[sol::meta_function::to_string] + = [](const MWDialogue::StampedJournalEntry& journalEntry) { + return "MWDialogue_Journal_TextEntry: " + journalEntry.mTopic.toDebugString(); + }; + journalEntryBindingsClass["id"] = sol::readonly_property( + [](const MWDialogue::StampedJournalEntry& journalEntry) { return journalEntry.mInfoId.serializeText(); }); + journalEntryBindingsClass["text"] = sol::readonly_property( + [](const MWDialogue::StampedJournalEntry& journalEntry) { return journalEntry.mText; }); + journalEntryBindingsClass["questId"] = sol::readonly_property( + [](const MWDialogue::StampedJournalEntry& journalEntry) { return journalEntry.mTopic.serializeText(); }); + journalEntryBindingsClass["day"] = sol::readonly_property( + [](const MWDialogue::StampedJournalEntry& journalEntry) { return journalEntry.mDay; }); + journalEntryBindingsClass["month"] = sol::readonly_property( + [](const MWDialogue::StampedJournalEntry& journalEntry) { return journalEntry.mMonth + 1; }); + journalEntryBindingsClass["dayOfMonth"] = sol::readonly_property( + [](const MWDialogue::StampedJournalEntry& journalEntry) { return journalEntry.mDayOfMonth; }); + } + + void addJournalEntryBindings(sol::table& playerBindings, sol::state_view lua, const MWBase::Journal* journal) + { + playerBindings["journal"] = [journal](const Object& player) -> const MWBase::Journal* { + verifyPlayer(player); + return journal; + }; + + addJournalClassBindings(lua, journal); + addJournalClassTopicsListBindings(lua, journal); + addJournalClassTopicBindings(lua, journal); + addJournalClassTopicEntriesListBindings(lua, journal); + addJournalClassTopicEntryBindings(lua, journal); + addJournalClassJournalEntriesListBindings(lua, journal); + addJournalClassJournalEntryBindings(lua, journal); + } + void addPlayerBindings(sol::table player, const Context& context) { MWBase::Journal* const journal = MWBase::Environment::get().getJournal(); + sol::state_view lua = context.sol(); + addJournalEntryBindings(player, lua, journal); + player["quests"] = [](const Object& player) { verifyPlayer(player); bool allowChanges = dynamic_cast(&player) != nullptr || dynamic_cast(&player) != nullptr; return Quests{ .mMutable = allowChanges }; }; - sol::state_view lua = context.sol(); sol::usertype quests = lua.new_usertype("Quests"); quests[sol::meta_function::to_string] = [](const Quests& quests) { return "Quests"; }; quests[sol::meta_function::index] = [](const Quests& quests, std::string_view questId) -> sol::optional { @@ -134,6 +363,12 @@ namespace MWLua quest["id"] = sol::readonly_property([](const Quest& q) -> std::string { return q.mQuestId.serializeText(); }); quest["started"] = sol::readonly_property( [journal](const Quest& q) { return journal->getQuestOrNull(q.mQuestId) != nullptr; }); + quest["name"] = sol::readonly_property([journal](const Quest& q) -> std::string_view { + const MWDialogue::Quest* quest = journal->getQuestOrNull(q.mQuestId); + if (quest == nullptr) + return ""; + return quest->getName(); + }); quest["finished"] = sol::property( [journal](const Quest& q) -> bool { const MWDialogue::Quest* quest = journal->getQuestOrNull(q.mQuestId); diff --git a/files/lua_api/openmw/types.lua b/files/lua_api/openmw/types.lua index aa5b70c4e0..abb01f900f 100644 --- a/files/lua_api/openmw/types.lua +++ b/files/lua_api/openmw/types.lua @@ -1224,6 +1224,78 @@ -- @usage -- Start a new quest, add it to the player's quest list but don't add any journal entries -- types.Player.quests(player)["ms_fargothring"].stage = 0 +--- +-- Returns @{#PlayerJournal}, which contains the read-only access to journal text data accumulated by the player. +-- Not the same as @{openmw_core#Dialogue.journal} which holds raw game records: with placeholders for dynamic variables and no player-specific info. +-- @function [parent=#Player] journal +-- @param openmw.core#GameObject player +-- @return #PlayerJournal +-- @usage -- Get text of the 1st journal entry player made +-- local entryText = types.Player.journal(player).journalTextEntries[1].text +-- @usage -- Get the number of "my trade" conversation topic lines the player journal accumulated +-- local num = #types.Player.journal(player).topics["my trade"].entries + +--- +-- A read-only list of player's accumulated journal (quest etc.) entries (@{#PlayerJournalTextEntry} elements), ordered from oldest entry to newest. +-- Implements [iterables#list-iterable](iterables.html#list-iterable) of #PlayerJournalTextEntry. +-- @field [parent=#PlayerJournal] #list<#PlayerJournalTextEntry> journalTextEntries +-- @usage -- The `firstQuestName` variable below is likely to be "Report to Caius Cosades" in vanilla MW +-- local firstQuestName = types.Player.journal(player).journalTextEntries[1].quest +-- @usage -- The number of journal entries accumulated in the player journal +-- local num = #types.Player.journal(player).journalTextEntries +-- @usage -- Print all journal entries accumulated in the player journal +-- for idx, journalEntry in pairs(types.Player.journal(player).journalTextEntries) do +-- print(idx, journalEntry.text) +-- end + +--- +-- A read-only table of player's accumulated @{#PlayerJournalTopic}s, indexed by the topic name. +-- Implements [iterables#Map](iterables.html#map-iterable) of #PlayerJournalTopic. +-- Topic name index doesn't have to be lowercase. +-- @field [parent=#PlayerJournal] #map<#string, #PlayerJournalTopic> topics +-- @usage local record = types.Player.journal(player).topics["my trade"] +-- @usage local record = types.Player.journal(player).topics["Vivec"] + +--- +-- @type PlayerJournalTopic +-- @field #string id Topic id. It's a lowercase version of name. +-- @field #string name Topic name. Same as id, but with upper cases preserved. + +--- +-- A read-only list of player's accumulated conversation lines (@{#PlayerJournalTopicEntry}) for this topic. +-- Implements [iterables#list-iterable](iterables.html#list-iterable) of #PlayerJournalTopicEntry. +-- @field [parent=#PlayerJournalTopic] #list<#PlayerJournalTopicEntry> entries +-- @usage -- local firstBackgroundLine = types.Player.journal(player).topics["Background"].entries[1] +-- @usage -- The number of topic entries accumulated in the player journal for "Vivec" +-- local num = #types.Player.journal(player).topics["vivec"].entries +-- @usage -- Print all conversation lines accumulated in the player journal for "Balmora" +-- for idx, topicEntry in pairs(types.Player.journal(player).topics["balmora"].entries) do +-- print(idx, topicEntry.text) +-- end + +--- +-- @type PlayerJournalTopicEntry +-- @field #string text Text of this topic line. +-- @field #string actor Name of an NPC who is recorded in the player journal as an origin of this topic line. + +--- +-- Identifier for this topic line. Is unique only within the @{#PlayerJournalTopic} it belongs to. +-- Has a counterpart in raw data game dialogue records at @{openmw_core#DialogueRecordInfo} held by @{openmw_core#Dialogue.topic} +-- @field [parent=#PlayerJournalTopicEntry] #string id + +--- +-- @type PlayerJournalTextEntry +-- @field #string text Text of this journal entry. +-- @field #string quest Quest id this journal entry is associated with. Can be nil if there is no quest associated with this entry or if journal quest sorting functionality is not available in game. +-- @field #number day Number of the day this journal entry was written at. +-- @field #number month Number of the month this journal entry was written at. +-- @field #number dayOfMonth Number of the day in the month this journal entry was written at. + +--- +-- Identifier for this journal entry line. Is unique only within the @{#PlayerJournalTextEntry} it belongs to. +-- Has a counterpart in raw data game dialogue records at @{openmw_core#DialogueRecordInfo} held by @{openmw_core#Dialogue.journal} +-- @field [parent=#PlayerJournalTextEntry] #string id + --- -- @type PlayerQuest -- @field #string id The quest id. diff --git a/scripts/data/integration_tests/test_lua_api/global.lua b/scripts/data/integration_tests/test_lua_api/global.lua index 3a85e224d1..2bdf765c91 100644 --- a/scripts/data/integration_tests/test_lua_api/global.lua +++ b/scripts/data/integration_tests/test_lua_api/global.lua @@ -137,6 +137,11 @@ testing.registerGlobalTest('record stores', function() testRecordStore(core.sound, "sound") testRecordStore(core.factions, "factions") + testRecordStore(core.dialogue.greeting, "dialogue greeting") + testRecordStore(core.dialogue.topic, "dialogue topic") + testRecordStore(core.dialogue.journal, "dialogue journal") + testRecordStore(core.dialogue.persuasion, "dialogue persuasion") + testRecordStore(core.dialogue.voice, "dialogue voice") testRecordStore(types.NPC.classes, "classes") testRecordStore(types.NPC.races, "races") diff --git a/scripts/data/morrowind_tests/global_issues.lua b/scripts/data/morrowind_tests/global_issues.lua index a3400da87c..a55b1858bb 100644 --- a/scripts/data/morrowind_tests/global_issues.lua +++ b/scripts/data/morrowind_tests/global_issues.lua @@ -38,3 +38,8 @@ testing.registerGlobalTest('[issues] Should keep reference to an object moved in end testing.expectThat(types.Container.inventory(barrel):find('ring_keley'), isFargothRing) end) + +testing.registerGlobalTest('[regression] Player quest status should update and its journal entries should be accessible', function() + coroutine.yield() + testing.runLocalTest(world.players[1], 'Player quest status should update and its journal entries should be accessible') +end) diff --git a/scripts/data/morrowind_tests/player.lua b/scripts/data/morrowind_tests/player.lua index fcb1126c1b..163c3dd9d9 100644 --- a/scripts/data/morrowind_tests/player.lua +++ b/scripts/data/morrowind_tests/player.lua @@ -1,4 +1,5 @@ local core = require('openmw.core') +local calendar = require('openmw_aux.calendar') local input = require('openmw.input') local self = require('openmw.self') local testing = require('testing_util') @@ -77,6 +78,49 @@ testing.registerLocalTest('Guard in Imperial Prison Ship should find path (#7241 end end) +testing.registerLocalTest('Player quest status should update and its journal entries should be accessible', + function() + testing.expectEqual(#types.Player.journal(self).topics, 0, 'Fresh player has more journal topics than zero') + testing.expectEqual(#types.Player.journal(self).journalTextEntries, 0, 'Fresh player has more journal text entries than zero') + testing.expectEqual(types.Player.quests(self)["ms_fargothring"].stage, 0, "Player's not started quest has an unexpected stage") + types.Player.quests(self)["ms_fargothring"]:addJournalEntry(10) + coroutine.yield() + testing.expectEqual(types.Player.quests(self)["ms_fargothring"].stage, 10, "Unexpected quest stage number") + testing.expectEqual(types.Player.quests(self)["ms_fargothring"].finished, false, "Quest should not have been finished yet") + testing.expectEqual(#types.Player.journal(self).journalTextEntries, 1, 'Unexpected number of entries in the player journal') + + local expectedJournalEntry = core.dialogue.journal.records["ms_fargothring"].infos[#core.dialogue.journal.records["ms_fargothring"].infos-1] + testing.expectEqual( + types.Player.journal(self).journalTextEntries[1].id, + expectedJournalEntry.id, 'Quest journal entries ids differ') + testing.expectEqual( + types.Player.journal(self).journalTextEntries[1].text, + expectedJournalEntry.text, 'Quest journal entries texts differ') + + local dateWhenStageShouldHaveBeenUpdated = calendar.formatGameTime('*t') + testing.expectEqual( + types.Player.journal(self).journalTextEntries[1].dayOfMonth, + dateWhenStageShouldHaveBeenUpdated.day, 'Unexpected journal update day (of month)') + testing.expectEqual( + types.Player.journal(self).journalTextEntries[1].month, + dateWhenStageShouldHaveBeenUpdated.month, 'Unexpected journal update month') + + types.Player.quests(self)["ms_fargothring"]:addJournalEntry(100) + types.Player.quests(self)["ms_fargothring"].finished = true + coroutine.yield() + testing.expectEqual(types.Player.quests(self)["ms_fargothring"].stage, 100, "Unexpected quest stage number") + testing.expectEqual(types.Player.quests(self)["ms_fargothring"].finished, true, "Quest should have been finished now") + testing.expectEqual(#types.Player.journal(self).journalTextEntries, 2, 'Unexpected number of entries in the player journal') + expectedJournalEntry = core.dialogue.journal.records["ms_fargothring"].infos[#core.dialogue.journal.records["ms_fargothring"].infos] + testing.expectEqual( + types.Player.journal(self).journalTextEntries[2].id, + expectedJournalEntry.id, 'Quest journal entries ids differ') + testing.expectEqual( + types.Player.journal(self).journalTextEntries[2].text, + expectedJournalEntry.text, + 'Quest journal entries texts differ') + end) + return { engineHandlers = { onFrame = testing.updateLocal,