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,
|
||||
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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user