Add MVP reconnect snapshots
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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++;
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user