1
0
Fork 0
mirror of https://github.com/OpenMW/openmw.git synced 2025-10-16 18:46:36 +00:00

Merge branch 'journal-lua-api' into 'master'

Add Lua API for the player journal text data

Closes #7966

See merge request OpenMW/openmw!4203
This commit is contained in:
Alexei Kotov 2025-08-13 14:58:48 +03:00
commit cb0316e6be
10 changed files with 349 additions and 6 deletions

View file

@ -82,7 +82,7 @@ message(STATUS "Configuring OpenMW...")
set(OPENMW_VERSION_MAJOR 0)
set(OPENMW_VERSION_MINOR 50)
set(OPENMW_VERSION_RELEASE 0)
set(OPENMW_LUA_API_REVISION 90)
set(OPENMW_LUA_API_REVISION 91)
set(OPENMW_POSTPROCESSING_API_REVISION 3)
set(OPENMW_VERSION_COMMITHASH "")

View file

@ -47,7 +47,7 @@ namespace MWBase
virtual void clear() = 0;
virtual ~Journal() {}
virtual ~Journal() = default;
virtual MWDialogue::Quest& getOrStartQuest(const ESM::RefId& id) = 0;
///< Gets the quest requested. Creates it and inserts it in quests if it is not yet started.
@ -79,6 +79,8 @@ namespace MWBase
virtual TEntryIter end() const = 0;
///< Iterator pointing past the end of the main journal.
virtual const TEntryContainer& getEntries() const = 0;
virtual TQuestIter questBegin() const = 0;
///< Iterator pointing to the first quest (sorted by topic ID)
@ -93,6 +95,8 @@ namespace MWBase
virtual TTopicIter topicEnd() const = 0;
///< Iterator pointing past the last topic.
virtual const TTopicContainer& getTopics() const = 0;
virtual int countSavedGameRecords() const = 0;
virtual void write(ESM::ESMWriter& writer, Loading::Listener& progress) const = 0;

View file

@ -55,6 +55,8 @@ namespace MWDialogue
TEntryIter end() const override;
///< Iterator pointing past the end of the main journal.
const TEntryContainer& getEntries() const override { return mJournal; }
TQuestIter questBegin() const override;
///< Iterator pointing to the first quest (sorted by topic ID)
@ -69,6 +71,8 @@ namespace MWDialogue
TTopicIter topicEnd() const override;
///< Iterator pointing past the last topic.
const TTopicContainer& getTopics() const override { return mTopics; }
int countSavedGameRecords() const override;
void write(ESM::ESMWriter& writer, Loading::Listener& progress) const override;

View file

@ -14,8 +14,6 @@ namespace MWDialogue
{
}
Topic::~Topic() {}
bool Topic::addEntry(const JournalEntry& entry)
{
if (entry.mTopic != mTopic)

View file

@ -31,7 +31,7 @@ namespace MWDialogue
Topic(const ESM::RefId& topic);
virtual ~Topic();
virtual ~Topic() = default;
virtual bool addEntry(const JournalEntry& entry);
///< Add entry
@ -53,6 +53,10 @@ namespace MWDialogue
TEntryIter end() const;
///< Iterator pointing past the end of the journal for this topic.
std::size_t size() const { return mEntries.size(); }
const Entry& operator[](std::size_t i) const { return mEntries[i]; }
};
}

View file

@ -2,6 +2,7 @@
#include <components/esm3/loadbsgn.hpp>
#include <components/esm3/loadfact.hpp>
#include <components/lua/util.hpp>
#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<MWLua::Quest> : std::false_type
{
};
template <>
struct is_automagical<MWBase::Journal> : std::false_type
{
};
template <>
struct is_automagical<MWLua::Topics> : std::false_type
{
};
template <>
struct is_automagical<MWLua::JournalEntries> : std::false_type
{
};
template <>
struct is_automagical<MWDialogue::StampedJournalEntry> : std::false_type
{
};
template <>
struct is_automagical<MWDialogue::Topic> : std::false_type
{
};
template <>
struct is_automagical<MWLua::TopicEntries> : std::false_type
{
};
template <>
struct is_automagical<MWDialogue::Entry> : std::false_type
{
};
}
namespace
@ -62,6 +101,14 @@ namespace
return ESM::RefId();
return id;
}
const MWDialogue::Topic& getTopicDataOrThrow(const ESM::RefId& topicId, const MWBase::Journal* journal)
{
const auto it = journal->getTopics().find(topicId);
if (it == journal->topicEnd())
throw std::runtime_error("Topic " + topicId.toDebugString() + " could not be found in the journal");
return it->second;
}
}
namespace MWLua
@ -78,17 +125,176 @@ 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<MWBase::Journal>("MWDialogue_Journal");
journalBindingsClass[sol::meta_function::to_string] = [](const MWBase::Journal& store) {
const size_t numberOfTopics = store.getTopics().size();
const size_t numberOfJournalEntries = store.getEntries().size();
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>("MWDialogue_Journal_Topics");
topicsBindingsClass[sol::meta_function::to_string] = [journal](const MWLua::Topics& topicEntriesStore) {
const size_t numberOfTopics = journal->getTopics().size();
return "{MWDialogue_Journal_Topics: " + std::to_string(numberOfTopics) + " topics}";
};
topicsBindingsClass[sol::meta_function::index]
= [journal](
const MWLua::Topics& topicEntriesStore, std::string_view givenTopicId) -> const MWDialogue::Topic* {
const auto it = journal->getTopics().find(ESM::RefId::deserializeText(givenTopicId));
if (it == journal->topicEnd())
return nullptr;
return &it->second;
};
topicsBindingsClass[sol::meta_function::length]
= [journal](const MWLua::Topics&) -> size_t { return journal->getTopics().size(); };
topicsBindingsClass[sol::meta_function::pairs] = [journal](const MWLua::Topics&) {
MWBase::Journal::TTopicIter iterator = journal->topicBegin();
return sol::as_function(
[iterator, journal]() mutable -> std::pair<sol::optional<std::string>, 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>("MWDialogue_Journal_Topic");
topicBindingsClass[sol::meta_function::to_string] = [](const MWDialogue::Topic& topic) {
return "MWDialogue_Journal_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<MWLua::TopicEntries>("MWDialogue_Journal_Topic_WrittenEntries");
topicEntriesBindingsClass[sol::meta_function::to_string] = [journal](const MWLua::TopicEntries& topicEntries) {
const MWDialogue::Topic& topic = getTopicDataOrThrow(topicEntries.mTopicId, journal);
return "MWDialogue_Journal_Topic_WrittenEntries for \"" + std::string{ topic.getName() }
+ "\": " + std::to_string(topic.size()) + " elements";
};
topicEntriesBindingsClass[sol::meta_function::length] = [journal](const MWLua::TopicEntries& topicEntries) {
const MWDialogue::Topic& topic = getTopicDataOrThrow(topicEntries.mTopicId, journal);
return topic.size();
};
topicEntriesBindingsClass[sol::meta_function::index]
= [journal](const MWLua::TopicEntries& topicEntries, size_t index) -> const MWDialogue::Entry* {
const MWDialogue::Topic& topic = getTopicDataOrThrow(topicEntries.mTopicId, journal);
if (index == 0 || index > topic.size())
return nullptr;
index = LuaUtil::fromLuaIndex(index);
return &topic[index];
};
topicEntriesBindingsClass[sol::meta_function::ipairs] = lua["ipairsForArray"].template get<sol::function>();
topicEntriesBindingsClass[sol::meta_function::pairs] = lua["ipairsForArray"].template get<sol::function>();
}
void addJournalClassTopicEntryBindings(sol::state_view& lua, const MWBase::Journal* journal)
{
auto topicEntryBindingsClass = lua.new_usertype<MWDialogue::Entry>("MWDialogue_Journal_Topic_WrittenEntry");
topicEntryBindingsClass[sol::meta_function::to_string] = [](const MWDialogue::Entry& topicEntry) {
return "MWDialogue_Journal_Topic_WrittenEntry: " + 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<MWLua::JournalEntries>("MWDialogue_Journal_WrittenEntries");
journalEntriesBindingsClass[sol::meta_function::to_string] = [journal](const MWLua::JournalEntries&) {
const size_t numberOfEntries = journal->getEntries().size();
return "{MWDialogue_Journal_WrittenEntries: " + std::to_string(numberOfEntries) + " journal text entries}";
};
journalEntriesBindingsClass[sol::meta_function::length]
= [journal](const MWLua::JournalEntries&) { return journal->getEntries().size(); };
journalEntriesBindingsClass[sol::meta_function::index]
= [journal](const MWLua::JournalEntries&, size_t index) -> const MWDialogue::StampedJournalEntry* {
if (index == 0 || index > journal->getEntries().size())
return nullptr;
index = LuaUtil::fromLuaIndex(index);
return &journal->getEntries()[index];
};
journalEntriesBindingsClass[sol::meta_function::ipairs] = lua["ipairsForArray"].template get<sol::function>();
journalEntriesBindingsClass[sol::meta_function::pairs] = lua["ipairsForArray"].template get<sol::function>();
}
void addJournalClassJournalEntryBindings(sol::state_view& lua, const MWBase::Journal* journal)
{
auto journalEntryBindingsClass
= lua.new_usertype<MWDialogue::StampedJournalEntry>("MWDialogue_Journal_WrittenEntry");
journalEntryBindingsClass[sol::meta_function::to_string]
= [](const MWDialogue::StampedJournalEntry& journalEntry) {
return "MWDialogue_Journal_WrittenEntry: " + 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<const GObject*>(&player) != nullptr
|| dynamic_cast<const SelfObject*>(&player) != nullptr;
return Quests{ .mMutable = allowChanges };
};
sol::state_view lua = context.sol();
sol::usertype<Quests> quests = lua.new_usertype<Quests>("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<Quest> {

View file

@ -1224,6 +1224,79 @@
-- @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 "a1_1_findspymaster" in vanilla MW
-- local firstQuestName = types.Player.journal(player).journalTextEntries[1].questId
-- @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 -- First NPC topic line entry in the "Background" topic
-- 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 questId 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.

View file

@ -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")

View file

@ -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)

View file

@ -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,