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,
mild WAN, and rough WAN Unreal packet-simulation profiles plus a Windows
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
+8 -5
View File
@@ -271,11 +271,14 @@ Initial latency testing should cover:
MVP behavior:
- disconnect removes the player's pawn from active control;
- player survival/inventory can be preserved by the current persistence
mechanism if available;
- reconnect may respawn the player at the last saved state or MVP spawn point,
depending on persistence maturity;
- disconnect removes the player's pawn from active control and captures a
player reconnect snapshot during `AAgrarianGameGameMode::Logout`;
- the reconnect snapshot preserves transform, survival, care history, and
inventory through `UAgrarianPersistenceSubsystem::SavePlayerSnapshot`;
- 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
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.
#include "AgrarianGameGameMode.h"
#include "AgrarianGameCharacter.h"
#include "AgrarianDebugHUD.h"
#include "AgrarianGameState.h"
#include "AgrarianPersistenceSubsystem.h"
AAgrarianGameGameMode::AAgrarianGameGameMode()
{
GameStateClass = AAgrarianGameState::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 */
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();
for (const AAgrarianGameCharacter* Character : Players)
{
const UAgrarianSurvivalComponent* SurvivalComponent = Character ? Character->GetSurvivalComponent() : nullptr;
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);
CapturePlayerIntoSave(Character, SaveGame);
}
return SaveGame->Players.Num();
@@ -209,38 +192,32 @@ int32 UAgrarianPersistenceSubsystem::RestorePlayers(const UAgrarianSaveGame* Sav
int32 RestoredCount = 0;
for (AAgrarianGameCharacter* Character : Players)
{
UAgrarianSurvivalComponent* SurvivalComponent = Character ? Character->GetSurvivalComponent() : nullptr;
if (!Character || !SurvivalComponent)
if (RestorePlayerFromSave(Character, SaveGame))
{
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;
}
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
{
if (!SaveGame)
@@ -339,6 +316,64 @@ bool UAgrarianPersistenceSubsystem::LoadCurrentWorld(int32& RestoredPlayerCount,
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
{
OutComponents.Reset();
@@ -59,6 +59,12 @@ public:
UFUNCTION(BlueprintCallable, Category = "Agrarian|Persistence")
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")
int32 CaptureResourceNodes(UAgrarianSaveGame* SaveGame) const;
@@ -72,6 +78,8 @@ public:
bool LoadCurrentWorld(int32& RestoredPlayerCount, int32& RestoredWorldActorCount, bool bClearExistingActors = true) const;
protected:
bool CapturePlayerIntoSave(const AAgrarianGameCharacter* Character, UAgrarianSaveGame* SaveGame) const;
bool RestorePlayerFromSave(AAgrarianGameCharacter* Character, const UAgrarianSaveGame* SaveGame) const;
void FindPersistentComponents(TArray<UAgrarianPersistentActorComponent*>& OutComponents) const;
void FindAgrarianPlayers(TArray<AAgrarianGameCharacter*>& OutPlayers) const;
void FindResourceNodes(TArray<AAgrarianResourceNode*>& OutResourceNodes) const;