diff --git a/Code/client/PluginAPI.cpp b/Code/client/PluginAPI.cpp index 77d5b4128..3e360acf5 100644 --- a/Code/client/PluginAPI.cpp +++ b/Code/client/PluginAPI.cpp @@ -17,3 +17,9 @@ extern "C" __declspec(dllexport) SkyrimTogetherAPI::ChannelHandle SkyrimTogether { return PluginService::Get().RegisterScopedChannel(apName, aScope, aOnMessage, aOnPeersChanged); } + +// Also outside the V1 vtable, for the same reason. +extern "C" __declspec(dllexport) uint32_t SkyrimTogetherGetPartyLeaderId() +{ + return PluginService::Get().GetPartyLeaderId(); +} diff --git a/Code/client/Services/Generic/PluginService.cpp b/Code/client/Services/Generic/PluginService.cpp index 170940b9d..d5dd658f2 100644 --- a/Code/client/Services/Generic/PluginService.cpp +++ b/Code/client/Services/Generic/PluginService.cpp @@ -68,6 +68,12 @@ bool PluginService::IsInParty() const noexcept return m_inParty; } +uint32_t PluginService::GetPartyLeaderId() const noexcept +{ + std::lock_guard cLock(m_lock); + return m_partyLeaderId; +} + SendResult PluginService::Queue(ChannelHandle aChannel, uint32_t aTargetPlayerId, const char* apBytes, size_t aLength) noexcept { if (aLength > kMaxMessageSize || (aLength != 0 && !apBytes)) @@ -138,6 +144,7 @@ void PluginService::Update() noexcept const uint32_t cLocalId = cOnline ? world.GetTransport().GetLocalPlayerId() : 0; const auto& party = world.GetPartyService(); const bool cInParty = cOnline && party.IsInParty(); + const uint32_t cLeaderId = cInParty ? party.GetLeaderPlayerId() : 0; Vector members, nearby; if (cInParty) for (uint32_t id : party.GetPartyMembers()) @@ -162,6 +169,7 @@ void PluginService::Update() noexcept std::lock_guard cLock(m_lock); m_localPlayerId = cLocalId; m_inParty = cInParty; + m_partyLeaderId = cLeaderId; m_members = members; m_nearby = nearby; for (Channel& channel : m_channels) diff --git a/Code/client/Services/PluginService.h b/Code/client/Services/PluginService.h index ed53ca6b7..bd025aeb2 100644 --- a/Code/client/Services/PluginService.h +++ b/Code/client/Services/PluginService.h @@ -26,6 +26,8 @@ struct PluginService final : SkyrimTogetherAPI::IVSkyrimTogether1 SkyrimTogetherAPI::SendResult SendTo(SkyrimTogetherAPI::ChannelHandle aChannel, uint32_t aPlayerId, const char* apBytes, size_t aLength) noexcept override; uint32_t GetLocalPlayerId() const noexcept override; bool IsInParty() const noexcept override; + // Not part of the V1 vtable, exported as SkyrimTogetherGetPartyLeaderId. 0 while not in a party. + [[nodiscard]] uint32_t GetPartyLeaderId() const noexcept; /** * @brief Hands a relayed Mod Message to the plugin on its channel, if any. Runs during the World update. @@ -55,4 +57,5 @@ struct PluginService final : SkyrimTogetherAPI::IVSkyrimTogether1 Vector m_nearby; uint32_t m_localPlayerId{0}; bool m_inParty{false}; + uint32_t m_partyLeaderId{0}; }; diff --git a/Code/plugin_api/README.md b/Code/plugin_api/README.md index aac65ad87..f60dfc841 100644 --- a/Code/plugin_api/README.md +++ b/Code/plugin_api/README.md @@ -36,6 +36,23 @@ send means the target is not in the current nearby snapshot (the existing result The server independently checks every recipient: party equality, or matching interior/worldspace and loaded-grid range. Both client and server must run the new protocol build. +## Party leader + +The party leader hosts the world: NPCs, scenes and quest progression run on the leader's client. Plugins +that move NPCs or start scenes, or that need one authoritative client, can look up +`SkyrimTogetherGetPartyLeaderId` using `GetPartyLeaderId_t` from the header: + +```cpp +auto getLeader = reinterpret_cast( + GetProcAddress(GetModuleHandleW(nullptr), SkyrimTogetherAPI::kGetPartyLeaderIdName)); +const uint32_t leader = getLeader ? getLeader() : 0; +const bool isLeader = leader != 0 && leader == api->GetLocalPlayerId(); +``` + +It returns 0 while not in a party, and is absent on older executables, so plugins need a fallback (for +example: act alone when not in a party). It reads the same locked snapshot as the other getters, so a +leadership change shows up after the next World update. + ## Lifetime and resynchronization The registration callback reports audience joins/leaves. Nearby membership follows loaded remote player diff --git a/Code/plugin_api/include/SkyrimTogetherAPI.h b/Code/plugin_api/include/SkyrimTogetherAPI.h index 80134b603..f8644993a 100644 --- a/Code/plugin_api/include/SkyrimTogetherAPI.h +++ b/Code/plugin_api/include/SkyrimTogetherAPI.h @@ -83,4 +83,10 @@ using RequestPluginAPI_t = void* (*)(InterfaceVersion aVersion); // Callbacks run in the World update context; queue engine work through SKSE AddTask. using RegisterScopedChannel_t = ChannelHandle (*)(const char*, ChannelScope, MessageCallback, PartyChangedCallback); constexpr const char* kRegisterScopedChannelName = "SkyrimTogetherRegisterScopedChannel"; +// Optional extension, independent of the V1 vtable. Older executables return no symbol. +// The player ID of the party leader, who hosts the world's NPCs and quest scenes; 0 while not in a party. +// Compare with GetLocalPlayerId() to know whether this client leads. Callable from any thread (locked snapshot, +// refreshed every World update), so it can briefly lag a leadership change. +using GetPartyLeaderId_t = uint32_t (*)(); +constexpr const char* kGetPartyLeaderIdName = "SkyrimTogetherGetPartyLeaderId"; } // namespace SkyrimTogetherAPI