Add MVP reconnect snapshots

This commit is contained in:
2026-05-18 15:23:01 -07:00
parent 3b772da73c
commit 0b9a8b7b30
7 changed files with 208 additions and 49 deletions
+3 -1
View File
@@ -725,7 +725,9 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe
- [x] Add basic latency testing. Added an MVP latency test plan with clean LAN, - [x] Add basic latency testing. Added an MVP latency test plan with clean LAN,
mild WAN, and rough WAN Unreal packet-simulation profiles plus a Windows mild WAN, and rough WAN Unreal packet-simulation profiles plus a Windows
helper script for the test console commands. helper script for the test console commands.
- [ ] Add disconnect/reconnect handling. - [x] Add disconnect/reconnect handling. Added MVP player reconnect snapshots:
logout saves transform, survival, care history, and inventory for the player,
and restart/spawn restores the matching snapshot when the same player returns.
## 0.1.M Persistence MVP ## 0.1.M Persistence MVP
+8 -5
View File
@@ -271,11 +271,14 @@ Initial latency testing should cover:
MVP behavior: MVP behavior:
- disconnect removes the player's pawn from active control; - disconnect removes the player's pawn from active control and captures a
- player survival/inventory can be preserved by the current persistence player reconnect snapshot during `AAgrarianGameGameMode::Logout`;
mechanism if available; - the reconnect snapshot preserves transform, survival, care history, and
- reconnect may respawn the player at the last saved state or MVP spawn point, inventory through `UAgrarianPersistenceSubsystem::SavePlayerSnapshot`;
depending on persistence maturity; - reconnect/spawn restores the matching snapshot in
`AAgrarianGameGameMode::RestartPlayer` through
`UAgrarianPersistenceSubsystem::RestorePlayerSnapshot`;
- if no matching snapshot exists, the player uses the normal MVP spawn point;
- server should not crash or leak active interaction/build state when a player - server should not crash or leak active interaction/build state when a player
disconnects mid-action. disconnects mid-action.
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Validate MVP disconnect/reconnect snapshot handling."""
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[1]
def read(relative_path: str) -> str:
path = ROOT / relative_path
if not path.exists():
raise AssertionError(f"Missing required file: {relative_path}")
return path.read_text(encoding="utf-8")
def require(content: str, needle: str, context: str) -> None:
if needle not in content:
raise AssertionError(f"Missing {needle!r} in {context}")
def main() -> int:
errors: list[str] = []
checks = {
"Source/AgrarianGame/AgrarianPersistenceSubsystem.h": [
"SavePlayerSnapshot",
"RestorePlayerSnapshot",
"CapturePlayerIntoSave",
"RestorePlayerFromSave",
],
"Source/AgrarianGame/AgrarianPersistenceSubsystem.cpp": [
"bool UAgrarianPersistenceSubsystem::SavePlayerSnapshot",
"bool UAgrarianPersistenceSubsystem::RestorePlayerSnapshot",
"SaveGame->Players.RemoveAll",
"SavedPlayer.Transform = Character->GetActorTransform()",
"SavedPlayer.Survival = SurvivalComponent->Survival",
"SavedPlayer.CareHistory = SurvivalComponent->CareHistory",
"SavedPlayer.Inventory = InventoryComponent->Items",
"Character->SetActorTransform",
"InventoryComponent->RestoreSavedItems",
],
"Source/AgrarianGame/AgrarianGameGameMode.h": [
"virtual void RestartPlayer(AController* NewPlayer) override;",
"virtual void Logout(AController* Exiting) override;",
],
"Source/AgrarianGame/AgrarianGameGameMode.cpp": [
"RestorePlayerSnapshot(AgrarianCharacter)",
"SavePlayerSnapshot(AgrarianCharacter)",
"Super::RestartPlayer(NewPlayer)",
"Super::Logout(Exiting)",
],
"Docs/MultiplayerNetworkingDesign.md": [
"player reconnect snapshot",
"SavePlayerSnapshot",
"RestorePlayerSnapshot",
"normal MVP spawn point",
],
"AGRARIAN_DEVELOPMENT_ROADMAP.md": [
"[x] Add disconnect/reconnect handling.",
"MVP player reconnect snapshots",
],
}
for relative_path, needles in checks.items():
try:
content = read(relative_path)
for needle in needles:
require(content, needle, relative_path)
except AssertionError as exc:
errors.append(str(exc))
if errors:
for error in errors:
print(f"ERROR: {error}", file=sys.stderr)
return 1
print("PASS: MVP disconnect/reconnect snapshot handling is wired and documented.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,11 +1,37 @@
// Copyright Epic Games, Inc. All Rights Reserved. // Copyright Epic Games, Inc. All Rights Reserved.
#include "AgrarianGameGameMode.h" #include "AgrarianGameGameMode.h"
#include "AgrarianGameCharacter.h"
#include "AgrarianDebugHUD.h" #include "AgrarianDebugHUD.h"
#include "AgrarianGameState.h" #include "AgrarianGameState.h"
#include "AgrarianPersistenceSubsystem.h"
AAgrarianGameGameMode::AAgrarianGameGameMode() AAgrarianGameGameMode::AAgrarianGameGameMode()
{ {
GameStateClass = AAgrarianGameState::StaticClass(); GameStateClass = AAgrarianGameState::StaticClass();
HUDClass = AAgrarianDebugHUD::StaticClass(); HUDClass = AAgrarianDebugHUD::StaticClass();
} }
void AAgrarianGameGameMode::RestartPlayer(AController* NewPlayer)
{
Super::RestartPlayer(NewPlayer);
AAgrarianGameCharacter* AgrarianCharacter = NewPlayer ? Cast<AAgrarianGameCharacter>(NewPlayer->GetPawn()) : nullptr;
UAgrarianPersistenceSubsystem* Persistence = GetGameInstance() ? GetGameInstance()->GetSubsystem<UAgrarianPersistenceSubsystem>() : nullptr;
if (AgrarianCharacter && Persistence && Persistence->RestorePlayerSnapshot(AgrarianCharacter))
{
UE_LOG(LogTemp, Log, TEXT("Agrarian restored reconnect snapshot for %s."), *AgrarianCharacter->GetName());
}
}
void AAgrarianGameGameMode::Logout(AController* Exiting)
{
AAgrarianGameCharacter* AgrarianCharacter = Exiting ? Cast<AAgrarianGameCharacter>(Exiting->GetPawn()) : nullptr;
UAgrarianPersistenceSubsystem* Persistence = GetGameInstance() ? GetGameInstance()->GetSubsystem<UAgrarianPersistenceSubsystem>() : nullptr;
if (AgrarianCharacter && Persistence)
{
Persistence->SavePlayerSnapshot(AgrarianCharacter);
}
Super::Logout(Exiting);
}
@@ -18,6 +18,9 @@ public:
/** Constructor */ /** Constructor */
AAgrarianGameGameMode(); AAgrarianGameGameMode();
virtual void RestartPlayer(AController* NewPlayer) override;
virtual void Logout(AController* Exiting) override;
}; };
@@ -173,24 +173,7 @@ int32 UAgrarianPersistenceSubsystem::CapturePlayers(UAgrarianSaveGame* SaveGame)
SaveGame->Players.Reset(); SaveGame->Players.Reset();
for (const AAgrarianGameCharacter* Character : Players) for (const AAgrarianGameCharacter* Character : Players)
{ {
const UAgrarianSurvivalComponent* SurvivalComponent = Character ? Character->GetSurvivalComponent() : nullptr; CapturePlayerIntoSave(Character, SaveGame);
if (!Character || !SurvivalComponent)
{
continue;
}
FAgrarianSavedPlayer SavedPlayer;
SavedPlayer.PlayerId = GetPlayerPersistenceId(Character);
SavedPlayer.Transform = Character->GetActorTransform();
SavedPlayer.Survival = SurvivalComponent->Survival;
SavedPlayer.CareHistory = SurvivalComponent->CareHistory;
if (const UAgrarianInventoryComponent* InventoryComponent = Character->GetInventoryComponent())
{
SavedPlayer.Inventory = InventoryComponent->Items;
}
SaveGame->Players.Add(SavedPlayer);
} }
return SaveGame->Players.Num(); return SaveGame->Players.Num();
@@ -209,38 +192,32 @@ int32 UAgrarianPersistenceSubsystem::RestorePlayers(const UAgrarianSaveGame* Sav
int32 RestoredCount = 0; int32 RestoredCount = 0;
for (AAgrarianGameCharacter* Character : Players) for (AAgrarianGameCharacter* Character : Players)
{ {
UAgrarianSurvivalComponent* SurvivalComponent = Character ? Character->GetSurvivalComponent() : nullptr; if (RestorePlayerFromSave(Character, SaveGame))
if (!Character || !SurvivalComponent)
{ {
continue; RestoredCount++;
} }
const FString PlayerId = GetPlayerPersistenceId(Character);
const FAgrarianSavedPlayer* SavedPlayer = SaveGame->Players.FindByPredicate(
[&PlayerId](const FAgrarianSavedPlayer& Candidate)
{
return Candidate.PlayerId == PlayerId;
});
if (!SavedPlayer)
{
continue;
}
Character->SetActorTransform(SavedPlayer->Transform, false, nullptr, ETeleportType::TeleportPhysics);
SurvivalComponent->ApplySavedState(SavedPlayer->Survival, SavedPlayer->CareHistory);
if (UAgrarianInventoryComponent* InventoryComponent = Character->GetInventoryComponent())
{
InventoryComponent->RestoreSavedItems(SavedPlayer->Inventory);
}
RestoredCount++;
} }
return RestoredCount; return RestoredCount;
} }
bool UAgrarianPersistenceSubsystem::SavePlayerSnapshot(const AAgrarianGameCharacter* Character) const
{
UAgrarianSaveGame* SaveGame = LoadOrCreateSave();
if (!SaveGame || !CapturePlayerIntoSave(Character, SaveGame))
{
return false;
}
return WriteSave(SaveGame);
}
bool UAgrarianPersistenceSubsystem::RestorePlayerSnapshot(AAgrarianGameCharacter* Character) const
{
const UAgrarianSaveGame* SaveGame = LoadOrCreateSave();
return SaveGame ? RestorePlayerFromSave(Character, SaveGame) : false;
}
int32 UAgrarianPersistenceSubsystem::CaptureResourceNodes(UAgrarianSaveGame* SaveGame) const int32 UAgrarianPersistenceSubsystem::CaptureResourceNodes(UAgrarianSaveGame* SaveGame) const
{ {
if (!SaveGame) if (!SaveGame)
@@ -339,6 +316,64 @@ bool UAgrarianPersistenceSubsystem::LoadCurrentWorld(int32& RestoredPlayerCount,
return bRestoredWorldState; return bRestoredWorldState;
} }
bool UAgrarianPersistenceSubsystem::CapturePlayerIntoSave(const AAgrarianGameCharacter* Character, UAgrarianSaveGame* SaveGame) const
{
const UAgrarianSurvivalComponent* SurvivalComponent = Character ? Character->GetSurvivalComponent() : nullptr;
if (!Character || !SurvivalComponent || !SaveGame)
{
return false;
}
FAgrarianSavedPlayer SavedPlayer;
SavedPlayer.PlayerId = GetPlayerPersistenceId(Character);
SavedPlayer.Transform = Character->GetActorTransform();
SavedPlayer.Survival = SurvivalComponent->Survival;
SavedPlayer.CareHistory = SurvivalComponent->CareHistory;
if (const UAgrarianInventoryComponent* InventoryComponent = Character->GetInventoryComponent())
{
SavedPlayer.Inventory = InventoryComponent->Items;
}
SaveGame->Players.RemoveAll([&SavedPlayer](const FAgrarianSavedPlayer& Candidate)
{
return Candidate.PlayerId == SavedPlayer.PlayerId;
});
SaveGame->Players.Add(SavedPlayer);
return true;
}
bool UAgrarianPersistenceSubsystem::RestorePlayerFromSave(AAgrarianGameCharacter* Character, const UAgrarianSaveGame* SaveGame) const
{
UAgrarianSurvivalComponent* SurvivalComponent = Character ? Character->GetSurvivalComponent() : nullptr;
if (!Character || !SurvivalComponent || !SaveGame)
{
return false;
}
const FString PlayerId = GetPlayerPersistenceId(Character);
const FAgrarianSavedPlayer* SavedPlayer = SaveGame->Players.FindByPredicate(
[&PlayerId](const FAgrarianSavedPlayer& Candidate)
{
return Candidate.PlayerId == PlayerId;
});
if (!SavedPlayer)
{
return false;
}
Character->SetActorTransform(SavedPlayer->Transform, false, nullptr, ETeleportType::TeleportPhysics);
SurvivalComponent->ApplySavedState(SavedPlayer->Survival, SavedPlayer->CareHistory);
if (UAgrarianInventoryComponent* InventoryComponent = Character->GetInventoryComponent())
{
InventoryComponent->RestoreSavedItems(SavedPlayer->Inventory);
}
return true;
}
void UAgrarianPersistenceSubsystem::FindPersistentComponents(TArray<UAgrarianPersistentActorComponent*>& OutComponents) const void UAgrarianPersistenceSubsystem::FindPersistentComponents(TArray<UAgrarianPersistentActorComponent*>& OutComponents) const
{ {
OutComponents.Reset(); OutComponents.Reset();
@@ -59,6 +59,12 @@ public:
UFUNCTION(BlueprintCallable, Category = "Agrarian|Persistence") UFUNCTION(BlueprintCallable, Category = "Agrarian|Persistence")
int32 RestorePlayers(const UAgrarianSaveGame* SaveGame) const; int32 RestorePlayers(const UAgrarianSaveGame* SaveGame) const;
UFUNCTION(BlueprintCallable, Category = "Agrarian|Persistence")
bool SavePlayerSnapshot(const AAgrarianGameCharacter* Character) const;
UFUNCTION(BlueprintCallable, Category = "Agrarian|Persistence")
bool RestorePlayerSnapshot(AAgrarianGameCharacter* Character) const;
UFUNCTION(BlueprintCallable, Category = "Agrarian|Persistence") UFUNCTION(BlueprintCallable, Category = "Agrarian|Persistence")
int32 CaptureResourceNodes(UAgrarianSaveGame* SaveGame) const; int32 CaptureResourceNodes(UAgrarianSaveGame* SaveGame) const;
@@ -72,6 +78,8 @@ public:
bool LoadCurrentWorld(int32& RestoredPlayerCount, int32& RestoredWorldActorCount, bool bClearExistingActors = true) const; bool LoadCurrentWorld(int32& RestoredPlayerCount, int32& RestoredWorldActorCount, bool bClearExistingActors = true) const;
protected: protected:
bool CapturePlayerIntoSave(const AAgrarianGameCharacter* Character, UAgrarianSaveGame* SaveGame) const;
bool RestorePlayerFromSave(AAgrarianGameCharacter* Character, const UAgrarianSaveGame* SaveGame) const;
void FindPersistentComponents(TArray<UAgrarianPersistentActorComponent*>& OutComponents) const; void FindPersistentComponents(TArray<UAgrarianPersistentActorComponent*>& OutComponents) const;
void FindAgrarianPlayers(TArray<AAgrarianGameCharacter*>& OutPlayers) const; void FindAgrarianPlayers(TArray<AAgrarianGameCharacter*>& OutPlayers) const;
void FindResourceNodes(TArray<AAgrarianResourceNode*>& OutResourceNodes) const; void FindResourceNodes(TArray<AAgrarianResourceNode*>& OutResourceNodes) const;