diff --git a/CHANGELOG.md b/CHANGELOG.md index a2df2e025e..e3b281b909 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ Feature #8580: Sort characters in the save loading menu Feature #8597: Lua: Add more built-in event handlers Feature #8629: Expose path grid data to Lua + Feature #8654: Allow lua world.createRecord to create NPC records 0.49.0 ------ diff --git a/CMakeLists.txt b/CMakeLists.txt index 57ebeefcfd..5991c57c4c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 87) +set(OPENMW_LUA_API_REVISION 88) set(OPENMW_POSTPROCESSING_API_REVISION 3) set(OPENMW_VERSION_COMMITHASH "") diff --git a/apps/openmw/mwlua/types/actor.hpp b/apps/openmw/mwlua/types/actor.hpp index 425e44451b..bd098d99d5 100644 --- a/apps/openmw/mwlua/types/actor.hpp +++ b/apps/openmw/mwlua/types/actor.hpp @@ -16,6 +16,7 @@ #include "../context.hpp" +#include "servicesoffered.hpp" namespace MWLua { @@ -25,15 +26,6 @@ namespace MWLua record["servicesOffered"] = sol::readonly_property([context](const T& rec) -> sol::table { sol::state_view lua = context.sol(); sol::table providedServices(lua, sol::create); - constexpr std::array, 19> serviceNames = { { { ESM::NPC::Spells, - "Spells" }, - { ESM::NPC::Spellmaking, "Spellmaking" }, { ESM::NPC::Enchanting, "Enchanting" }, - { ESM::NPC::Training, "Training" }, { ESM::NPC::Repair, "Repair" }, { ESM::NPC::AllItems, "Barter" }, - { ESM::NPC::Weapon, "Weapon" }, { ESM::NPC::Armor, "Armor" }, { ESM::NPC::Clothing, "Clothing" }, - { ESM::NPC::Books, "Books" }, { ESM::NPC::Ingredients, "Ingredients" }, { ESM::NPC::Picks, "Picks" }, - { ESM::NPC::Probes, "Probes" }, { ESM::NPC::Lights, "Lights" }, { ESM::NPC::Apparatus, "Apparatus" }, - { ESM::NPC::RepairItem, "RepairItem" }, { ESM::NPC::Misc, "Misc" }, { ESM::NPC::Potions, "Potions" }, - { ESM::NPC::MagicItems, "MagicItems" } } }; int services = rec.mAiData.mServices; if constexpr (std::is_same_v) @@ -42,10 +34,11 @@ namespace MWLua services = MWBase::Environment::get().getESMStore()->get().find(rec.mClass)->mData.mServices; } - for (const auto& [flag, name] : serviceNames) + for (const auto& [flag, name] : MWLua::ServiceNames) { providedServices[name] = (services & flag) != 0; } + providedServices["Travel"] = !rec.getTransport().empty(); return LuaUtil::makeReadOnly(providedServices); }); diff --git a/apps/openmw/mwlua/types/npc.cpp b/apps/openmw/mwlua/types/npc.cpp index 380a2d1e9b..e847357bd9 100644 --- a/apps/openmw/mwlua/types/npc.cpp +++ b/apps/openmw/mwlua/types/npc.cpp @@ -2,6 +2,7 @@ #include "actor.hpp" #include "modelproperty.hpp" +#include "servicesoffered.hpp" #include #include @@ -44,6 +45,126 @@ namespace return faction->mRanks.size(); } + ESM::NPC tableToNPC(const sol::table& rec) + { + ESM::NPC npc; + + // Start from template if provided + if (rec["template"] != sol::nil) + npc = LuaUtil::cast(rec["template"]); + else + npc.blank(); + + npc.mId = {}; + + // Basic fields + if (rec["name"] != sol::nil) + npc.mName = rec["name"]; + if (rec["model"] != sol::nil) + npc.mModel = Misc::ResourceHelpers::meshPathForESM3(rec["model"].get()); + if (rec["mwscript"] != sol::nil) + npc.mScript = ESM::RefId::deserializeText(rec["mwscript"].get()); + if (rec["race"] != sol::nil) + npc.mRace = ESM::RefId::deserializeText(rec["race"].get()); + if (rec["class"] != sol::nil) + npc.mClass = ESM::RefId::deserializeText(rec["class"].get()); + if (rec["head"] != sol::nil) + npc.mHead = ESM::RefId::deserializeText(rec["head"].get()); + if (rec["hair"] != sol::nil) + npc.mHair = ESM::RefId::deserializeText(rec["hair"].get()); + if (rec["primaryFaction"] != sol::nil) + { + auto factionStr = rec["primaryFaction"].get(); + ESM::RefId factionId = ESM::RefId::deserializeText(factionStr); + + const auto& factionStore = MWBase::Environment::get().getESMStore()->get(); + if (!factionStore.search(factionId)) + throw std::runtime_error("Invalid faction '" + std::string(factionStr) + "' in primaryFaction"); + + npc.mFaction = factionId; + } + if (rec["isMale"] != sol::nil) + { + bool male = rec["isMale"]; + if (male) + npc.mFlags &= ~ESM::NPC::Female; + else + npc.mFlags |= ESM::NPC::Female; + } + + if (rec["isEssential"] != sol::nil) + { + bool essential = rec["isEssential"]; + if (essential) + npc.mFlags |= ESM::NPC::Essential; + else + npc.mFlags &= ~ESM::NPC::Essential; + } + + if (rec["isAutocalc"] != sol::nil) + { + bool autoCalc = rec["isAutocalc"]; + if (autoCalc) + npc.mFlags |= ESM::NPC::Autocalc; + else + npc.mFlags &= ~ESM::NPC::Autocalc; + } + + if (rec["isRespawning"] != sol::nil) + { + bool respawn = rec["isRespawning"]; + if (respawn) + npc.mFlags |= ESM::NPC::Respawn; + else + npc.mFlags &= ~ESM::NPC::Respawn; + } + + if (rec["baseDisposition"] != sol::nil) + npc.mNpdt.mDisposition = rec["baseDisposition"].get(); + + if (rec["baseGold"] != sol::nil) + npc.mNpdt.mGold = rec["baseGold"].get(); + + if (rec["bloodType"] != sol::nil) + npc.mBloodType = rec["bloodType"].get(); + + if (rec["primaryFactionRank"] != sol::nil) + { + if (!npc.mFaction.empty()) + { + const ESM::Faction* faction + = MWBase::Environment::get().getESMStore()->get().find(npc.mFaction); + + int luaValue = rec["primaryFactionRank"]; + int rank = LuaUtil::fromLuaIndex(luaValue); + + int maxRank = static_cast(getValidRanksCount(faction)); + + if (rank < 0 || rank >= maxRank) + throw std::runtime_error("primaryFactionRank: Requested rank " + std::to_string(rank) + + " is out of bounds for faction " + npc.mFaction.toDebugString()); + + npc.mNpdt.mRank = rank; + } + } + + if (rec["servicesOffered"] != sol::nil) + { + const sol::table services = rec["servicesOffered"]; + int flags = 0; + + for (const auto& [mask, key] : MWLua::ServiceNames) + { + sol::object value = services[key]; + if (value != sol::nil && value.as()) + flags |= mask; + } + + npc.mAiData.mServices = flags; + } + + return npc; + } ESM::RefId parseFactionId(std::string_view faction) { @@ -95,9 +216,18 @@ namespace MWLua = sol::readonly_property([](const ESM::NPC& rec) -> int { return (int)rec.mNpdt.mDisposition; }); record["head"] = sol::readonly_property([](const ESM::NPC& rec) -> std::string { return rec.mHead.serializeText(); }); + record["primaryFaction"] = sol::readonly_property( + [](const ESM::NPC& rec) -> sol::optional { return LuaUtil::serializeRefId(rec.mFaction); }); + record["primaryFactionRank"] = sol::readonly_property([](const ESM::NPC& rec, sol::this_state s) -> int { + if (rec.mFaction.empty()) + return 0; + return LuaUtil::toLuaIndex(rec.mNpdt.mRank); + }); addModelProperty(record); record["isEssential"] = sol::readonly_property([](const ESM::NPC& rec) -> bool { return rec.mFlags & ESM::NPC::Essential; }); + record["isAutocalc"] + = sol::readonly_property([](const ESM::NPC& rec) -> bool { return rec.mFlags & ESM::NPC::Autocalc; }); record["isMale"] = sol::readonly_property([](const ESM::NPC& rec) -> bool { return rec.isMale(); }); record["isRespawning"] = sol::readonly_property([](const ESM::NPC& rec) -> bool { return rec.mFlags & ESM::NPC::Respawn; }); @@ -152,6 +282,7 @@ namespace MWLua stats.setBaseDisposition(stats.getBaseDisposition() + value); }; + npc["createRecordDraft"] = tableToNPC; npc["getFactionRank"] = [](const Object& actor, std::string_view faction) -> size_t { const MWWorld::Ptr ptr = actor.ptr(); ESM::RefId factionId = parseFactionId(faction); diff --git a/apps/openmw/mwlua/types/servicesoffered.hpp b/apps/openmw/mwlua/types/servicesoffered.hpp new file mode 100644 index 0000000000..94695c44fb --- /dev/null +++ b/apps/openmw/mwlua/types/servicesoffered.hpp @@ -0,0 +1,21 @@ +#ifndef MWLUA_SERVICESOFFERED_HPP +#define MWLUA_SERVICESOFFERED_HPP + +#include +#include +#include + +namespace MWLua +{ + + inline constexpr std::array, 19> ServiceNames + = { { { ESM::NPC::Spells, "Spells" }, { ESM::NPC::Spellmaking, "Spellmaking" }, + { ESM::NPC::Enchanting, "Enchanting" }, { ESM::NPC::Training, "Training" }, { ESM::NPC::Repair, "Repair" }, + { ESM::NPC::AllItems, "Barter" }, { ESM::NPC::Weapon, "Weapon" }, { ESM::NPC::Armor, "Armor" }, + { ESM::NPC::Clothing, "Clothing" }, { ESM::NPC::Books, "Books" }, { ESM::NPC::Ingredients, "Ingredients" }, + { ESM::NPC::Picks, "Picks" }, { ESM::NPC::Probes, "Probes" }, { ESM::NPC::Lights, "Lights" }, + { ESM::NPC::Apparatus, "Apparatus" }, { ESM::NPC::RepairItem, "RepairItem" }, { ESM::NPC::Misc, "Misc" }, + { ESM::NPC::Potions, "Potions" }, { ESM::NPC::MagicItems, "MagicItems" } } }; +} + +#endif diff --git a/apps/openmw/mwlua/worldbindings.cpp b/apps/openmw/mwlua/worldbindings.cpp index d98c769e65..5539d8d77c 100644 --- a/apps/openmw/mwlua/worldbindings.cpp +++ b/apps/openmw/mwlua/worldbindings.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -188,6 +189,14 @@ namespace MWLua checkGameInitialized(lua); return MWBase::Environment::get().getESMStore()->insert(potion); }, + [lua = context.mLua](const ESM::NPC& npc) -> const ESM::NPC* { + checkGameInitialized(lua); + if (npc.mId.empty()) + return MWBase::Environment::get().getESMStore()->insert(npc); + ESM::NPC copy = npc; + copy.mId = {}; + return MWBase::Environment::get().getESMStore()->insert(copy); + }, [lua = context.mLua](const ESM::Weapon& weapon) -> const ESM::Weapon* { checkGameInitialized(lua); return MWBase::Environment::get().getESMStore()->insert(weapon); diff --git a/apps/openmw/mwworld/esmstore.cpp b/apps/openmw/mwworld/esmstore.cpp index 7262805f81..369731a019 100644 --- a/apps/openmw/mwworld/esmstore.cpp +++ b/apps/openmw/mwworld/esmstore.cpp @@ -726,8 +726,6 @@ namespace MWWorld switch (type) { case ESM::REC_ALCH: - case ESM::REC_MISC: - case ESM::REC_ACTI: case ESM::REC_ARMO: case ESM::REC_BOOK: case ESM::REC_CLAS: @@ -735,14 +733,16 @@ namespace MWWorld case ESM::REC_ENCH: case ESM::REC_SPEL: case ESM::REC_WEAP: - case ESM::REC_LEVI: - case ESM::REC_LEVC: - case ESM::REC_LIGH: mStoreImp->mRecNameToStore[type]->read(reader); return true; case ESM::REC_NPC_: case ESM::REC_CREA: case ESM::REC_CONT: + case ESM::REC_MISC: + case ESM::REC_ACTI: + case ESM::REC_LEVI: + case ESM::REC_LEVC: + case ESM::REC_LIGH: mStoreImp->mRecNameToStore[type]->read(reader, true); return true; diff --git a/apps/openmw/mwworld/store.cpp b/apps/openmw/mwworld/store.cpp index e4e67c2f3d..12120b998d 100644 --- a/apps/openmw/mwworld/store.cpp +++ b/apps/openmw/mwworld/store.cpp @@ -269,9 +269,12 @@ namespace MWWorld list.push_back((*it)->mId); } } + template T* TypedDynamicStore::insert(const T& item, bool overrideOnly) { + if constexpr (std::is_same_v) + overrideOnly = overrideOnly && !item.mId.template is(); if (overrideOnly) { auto it = mStatic.find(item.mId); diff --git a/files/lua_api/openmw/types.lua b/files/lua_api/openmw/types.lua index 6d51987ac7..aa5b70c4e0 100644 --- a/files/lua_api/openmw/types.lua +++ b/files/lua_api/openmw/types.lua @@ -861,6 +861,9 @@ -- @field #boolean canWalk whether the creature can walk -- @field #boolean canUseWeapons whether the creature can use weapons and shields -- @field #boolean isBiped whether the creature is a biped +-- @field #boolean isAutocalc If true, the actors stats will be automatically calculated based on level and class. +-- @field #string primaryFaction Faction ID of the NPCs default faction. Nil if no faction +-- @field #number primaryFactionRank Faction rank of the NPCs default faction. Nil if no faction -- @field #boolean isEssential whether the creature is essential -- @field #boolean isRespawning whether the creature respawns after death -- @field #number bloodType integer representing the blood type of the Creature. Used to generate the correct blood vfx. @@ -875,6 +878,13 @@ -- @field #Actor baseType @{#Actor} -- @field [parent=#NPC] #NpcStats stats +--- +-- Creates an @{#NpcRecord} without adding it to the world database. +-- Use @{openmw_world#(world).createRecord} to add the record to the world. +-- @function [parent=#NPC] createRecordDraft +-- @param #NpcRecord npc A Lua table with the fields of an NpcRecord, with an optional field `template` that accepts an @{#NpcRecord} as a base. +-- @return #NpcRecord A strongly typed NPC record. + --- -- A read-only list of all @{#NpcRecord}s in the world database, may be indexed by recordId. -- Implements [iterables#List](iterables.html#List) of #NpcRecord.