Hold proxy in hidden position when no remote

Add a temporary in-cell holding fallback for the single placed proxy actor when no valid same-cell remote player is available. Introduces kProxyHiddenHoldingPosition, ProxyLifecycleState, RemotePlayerSelection, MoveProxyToHoldingPosition and RestoreProxyForRemotePlayer, plus selection/state-tracking and throttled logs. The controller now moves the proxy to the hidden position on disconnect, disappearance, or cell-mismatch and snaps it back when a valid same-cell remote appears. Documentation and dev log updated to describe the lifecycle and rationale (docs/architecture.md, docs/dev-log.md, protocol/player-sync.md).
This commit is contained in:
2026-06-02 13:59:32 +12:00
parent d36140c76e
commit a0563a6849
4 changed files with 233 additions and 31 deletions
+174 -30
View File
@@ -24,10 +24,16 @@ namespace
constexpr RE::TESFormID kProxyFallbackLocalFormId = 0x0020A2;
constexpr auto kProxyOffsetX = 150.0F;
constexpr float kProxyPositionLerpAlpha = 0.15F;
// Temporary single-proxy lifecycle fallback. Keep the placed proxy inside the
// loaded test cell instead of disabling a persistent reference before that path
// is validated in-game.
constexpr RE::NiPoint3 kProxyHiddenHoldingPosition{ -100000.0F, -100000.0F, -100000.0F };
constexpr auto kSafetyOffsetMovementInterval = 200ms;
constexpr auto kWarningLogInterval = 5s;
constexpr auto kNotInTestCellLogInterval = 30s;
using RemotePlayer = F4T::RemotePlayerState::RemotePlayerState;
enum class ProxyMovementMode
{
kSafetyOffsetTest,
@@ -41,6 +47,22 @@ namespace
kFallbackFormId
};
enum class ProxyLifecycleState
{
kNoRemotePlayer,
kRepresentingRemotePlayer,
kRemotePlayerDisconnected,
kRemotePlayerLeftCell,
kProxyHiddenOrIdle
};
struct RemotePlayerSelection
{
std::optional<RemotePlayer> selectedPlayer;
std::optional<std::uint32_t> disappearedPlayerId;
bool selectedPlayerChanged{ false };
};
constexpr auto kProxyMovementMode = ProxyMovementMode::kRemotePlayerStateTest;
RE::ObjectRefHandle g_proxyHandle;
@@ -51,13 +73,14 @@ namespace
bool g_proxySmoothingEnabledLogged = false;
bool g_firstSmoothedMovementLogged = false;
auto g_lastSafetyOffsetMovementTime = std::chrono::steady_clock::time_point{};
ProxyLifecycleState g_proxyLifecycleState = ProxyLifecycleState::kNoRemotePlayer;
std::optional<ProxyLifecycleState> g_proxyHoldReason;
std::optional<std::uint32_t> g_proxyHoldPlayerId;
std::optional<std::uint32_t> g_representedPlayerId;
std::optional<std::uint32_t> g_lastObservedSneakPlayerId;
std::optional<bool> g_lastObservedSneakState;
std::unordered_map<std::string, std::chrono::steady_clock::time_point> g_lastWarningLogTimes;
using RemotePlayer = F4T::RemotePlayerState::RemotePlayerState;
std::string GetLocalPlayerLogMessage(std::string_view a_message)
{
return F4T::Networking::GetLocalPlayerLogPrefix() + " " + std::string(a_message);
@@ -107,6 +130,91 @@ namespace
}
}
void ResetRemoteMovementStateTracking();
bool IsProxyHeldFor(ProxyLifecycleState a_reason, std::optional<std::uint32_t> a_playerId)
{
return g_proxyLifecycleState == ProxyLifecycleState::kProxyHiddenOrIdle &&
g_proxyHoldReason == a_reason &&
g_proxyHoldPlayerId == a_playerId;
}
void LogProxyStillHeld(ProxyLifecycleState a_reason, std::optional<std::uint32_t> a_playerId)
{
switch (a_reason) {
case ProxyLifecycleState::kNoRemotePlayer:
LogThrottledInfo(
"proxy_held_no_remote_player",
"No suitable remote player is available; proxy actor is already at the holding position.");
break;
case ProxyLifecycleState::kRemotePlayerDisconnected:
if (a_playerId) {
LogThrottledInfo(
"proxy_held_disconnected_" + std::to_string(*a_playerId),
std::format(
"Remote player {} is still absent from state; proxy actor remains at the holding position.",
*a_playerId));
}
break;
case ProxyLifecycleState::kRemotePlayerLeftCell:
if (a_playerId) {
LogThrottledInfo(
"proxy_held_left_cell_" + std::to_string(*a_playerId),
std::format(
"Remote player {} is still outside the local test cell; proxy actor remains at the holding position.",
*a_playerId));
}
break;
default:
break;
}
}
void MoveProxyToHoldingPosition(
RE::Actor& a_proxy,
ProxyLifecycleState a_reason,
std::optional<std::uint32_t> a_playerId = std::nullopt)
{
if (IsProxyHeldFor(a_reason, a_playerId)) {
LogProxyStillHeld(a_reason, a_playerId);
return;
}
a_proxy.SetPosition(kProxyHiddenHoldingPosition, true);
g_proxyLifecycleState = ProxyLifecycleState::kProxyHiddenOrIdle;
g_proxyHoldReason = a_reason;
g_proxyHoldPlayerId = a_playerId;
g_remoteStateMovedLogged = false;
g_firstSmoothedMovementLogged = false;
ResetRemoteMovementStateTracking();
switch (a_reason) {
case ProxyLifecycleState::kNoRemotePlayer:
LogInfoWithLocalPlayerPrefix(std::format(
"No suitable remote player is available; moving proxy actor {} to holding position.",
kProxyEditorId));
break;
case ProxyLifecycleState::kRemotePlayerDisconnected:
if (a_playerId) {
LogInfoWithLocalPlayerPrefix(std::format(
"Remote player {} disappeared; moving proxy actor {} to holding position.",
*a_playerId,
kProxyEditorId));
}
break;
case ProxyLifecycleState::kRemotePlayerLeftCell:
if (a_playerId) {
LogInfoWithLocalPlayerPrefix(std::format(
"Remote player {} is not in the same cell; moving proxy actor {} to holding position.",
*a_playerId,
kProxyEditorId));
}
break;
default:
break;
}
}
float Lerp(float a_current, float a_target, float a_alpha)
{
return a_current + ((a_target - a_current) * a_alpha);
@@ -413,8 +521,10 @@ namespace
return true;
}
std::optional<RemotePlayer> SelectRemotePlayerToRepresent(std::vector<RemotePlayer> a_remotePlayers)
RemotePlayerSelection SelectRemotePlayerToRepresent(std::vector<RemotePlayer> a_remotePlayers)
{
RemotePlayerSelection selection;
if (g_representedPlayerId) {
const auto representedPlayerStillExists = std::ranges::any_of(
a_remotePlayers,
@@ -422,9 +532,7 @@ namespace
return a_remotePlayer.playerId == *g_representedPlayerId;
});
if (!representedPlayerStillExists) {
LogInfoWithLocalPlayerPrefix(std::format(
"Represented remote player {} disconnected or disappeared from state; leaving proxy actor idle at its last position. TODO: hide or disable the proxy actor after disconnect.",
*g_representedPlayerId));
selection.disappearedPlayerId = g_representedPlayerId;
g_representedPlayerId.reset();
g_remoteStateMovedLogged = false;
g_firstSmoothedMovementLogged = false;
@@ -433,10 +541,7 @@ namespace
}
if (a_remotePlayers.empty()) {
LogThrottledInfo(
"no_remote_players",
"No suitable remote player is available for proxy movement; leaving proxy actor idle.");
return std::nullopt;
return selection;
}
auto selectedPlayer = std::ranges::min_element(
@@ -445,7 +550,7 @@ namespace
return a_left.playerId < a_right.playerId;
});
if (selectedPlayer == a_remotePlayers.end()) {
return std::nullopt;
return selection;
}
if (a_remotePlayers.size() > 1) {
@@ -457,6 +562,7 @@ namespace
}
if (g_representedPlayerId != selectedPlayer->playerId) {
selection.selectedPlayerChanged = true;
g_representedPlayerId = selectedPlayer->playerId;
g_remoteStateMovedLogged = false;
g_firstSmoothedMovementLogged = false;
@@ -471,13 +577,10 @@ namespace
kProxyPositionLerpAlpha));
g_proxySmoothingEnabledLogged = true;
}
LogInfoWithLocalPlayerPrefix(std::format(
"Proxy actor {} is representing remote player {}.",
kProxyEditorId,
selectedPlayer->playerId));
}
return *selectedPlayer;
selection.selectedPlayer = *selectedPlayer;
return selection;
}
void ApplyRemoteMovementStateToProxy(RE::Actor& a_proxy, const RemotePlayer& a_remotePlayer)
@@ -527,6 +630,28 @@ namespace
}
}
void RestoreProxyForRemotePlayer(RE::Actor& a_proxy, const RemotePlayer& a_remotePlayer)
{
RE::NiPoint3 targetPosition{};
targetPosition.x = a_remotePlayer.x;
targetPosition.y = a_remotePlayer.y;
targetPosition.z = a_remotePlayer.z;
a_proxy.SetPosition(targetPosition, true);
a_proxy.SetHeading(a_remotePlayer.angleZ);
g_proxyLifecycleState = ProxyLifecycleState::kRepresentingRemotePlayer;
g_proxyHoldReason.reset();
g_proxyHoldPlayerId.reset();
LogInfoWithLocalPlayerPrefix(std::format(
"Proxy actor {} resumed representing remote player {} at X={:.2f}, Y={:.2f}, Z={:.2f}.",
kProxyEditorId,
a_remotePlayer.playerId,
a_remotePlayer.x,
a_remotePlayer.y,
a_remotePlayer.z));
}
void MoveProxyToRemotePlayer(RE::Actor& a_proxy, const RemotePlayer& a_remotePlayer)
{
// TODO: Later animation/behavior milestones can read movement state from
@@ -614,28 +739,47 @@ namespace F4T::ProxyActorController
return;
}
auto selectedRemotePlayer = SelectRemotePlayerToRepresent(F4T::RemotePlayerState::GetRemotePlayerSnapshot());
if (!selectedRemotePlayer) {
return;
}
auto* proxy = ResolveProxy(*parentCell, *player);
if (!proxy) {
return;
}
if (!IsRemotePlayerInSameLocation(*selectedRemotePlayer, *player, *parentCell)) {
LogThrottledInfo(
"proxy_idle_cell_mismatch_" + std::to_string(selectedRemotePlayer->playerId),
std::format(
"Proxy actor {} is idle because represented remote player {} is not in the same cell. TODO: hide or disable the proxy actor after cell leave.",
kProxyEditorId,
selectedRemotePlayer->playerId));
const auto selection = SelectRemotePlayerToRepresent(F4T::RemotePlayerState::GetRemotePlayerSnapshot());
if (selection.disappearedPlayerId) {
MoveProxyToHoldingPosition(
*proxy,
ProxyLifecycleState::kRemotePlayerDisconnected,
selection.disappearedPlayerId);
}
if (!selection.selectedPlayer) {
if (!selection.disappearedPlayerId) {
if (g_proxyLifecycleState == ProxyLifecycleState::kProxyHiddenOrIdle) {
LogProxyStillHeld(
g_proxyHoldReason.value_or(ProxyLifecycleState::kNoRemotePlayer),
g_proxyHoldPlayerId);
} else {
MoveProxyToHoldingPosition(*proxy, ProxyLifecycleState::kNoRemotePlayer);
}
}
return;
}
ApplyRemoteMovementStateToProxy(*proxy, *selectedRemotePlayer);
MoveProxyToRemotePlayer(*proxy, *selectedRemotePlayer);
if (!IsRemotePlayerInSameLocation(*selection.selectedPlayer, *player, *parentCell)) {
MoveProxyToHoldingPosition(
*proxy,
ProxyLifecycleState::kRemotePlayerLeftCell,
selection.selectedPlayer->playerId);
return;
}
if (selection.selectedPlayerChanged || g_proxyLifecycleState != ProxyLifecycleState::kRepresentingRemotePlayer) {
RestoreProxyForRemotePlayer(*proxy, *selection.selectedPlayer);
return;
}
ApplyRemoteMovementStateToProxy(*proxy, *selection.selectedPlayer);
MoveProxyToRemotePlayer(*proxy, *selection.selectedPlayer);
}
void UpdateSafetyTest()