diff --git a/AGRARIAN_DEVELOPMENT_ROADMAP.md b/AGRARIAN_DEVELOPMENT_ROADMAP.md index bf67746..4f5defa 100644 --- a/AGRARIAN_DEVELOPMENT_ROADMAP.md +++ b/AGRARIAN_DEVELOPMENT_ROADMAP.md @@ -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 diff --git a/Docs/MultiplayerNetworkingDesign.md b/Docs/MultiplayerNetworkingDesign.md index bd589eb..d887aa0 100644 --- a/Docs/MultiplayerNetworkingDesign.md +++ b/Docs/MultiplayerNetworkingDesign.md @@ -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. diff --git a/Scripts/verify_disconnect_reconnect_handling.py b/Scripts/verify_disconnect_reconnect_handling.py new file mode 100644 index 0000000..cb13a82 --- /dev/null +++ b/Scripts/verify_disconnect_reconnect_handling.py @@ -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()) diff --git a/Source/AgrarianGame/AgrarianGameGameMode.cpp b/Source/AgrarianGame/AgrarianGameGameMode.cpp index 9d66d14..718ebb1 100644 --- a/Source/AgrarianGame/AgrarianGameGameMode.cpp +++ b/Source/AgrarianGame/AgrarianGameGameMode.cpp @@ -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(NewPlayer->GetPawn()) : nullptr; + UAgrarianPersistenceSubsystem* Persistence = GetGameInstance() ? GetGameInstance()->GetSubsystem() : 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(Exiting->GetPawn()) : nullptr; + UAgrarianPersistenceSubsystem* Persistence = GetGameInstance() ? GetGameInstance()->GetSubsystem() : nullptr; + if (AgrarianCharacter && Persistence) + { + Persistence->SavePlayerSnapshot(AgrarianCharacter); + } + + Super::Logout(Exiting); +} diff --git a/Source/AgrarianGame/AgrarianGameGameMode.h b/Source/AgrarianGame/AgrarianGameGameMode.h index eb1ec13..83754c6 100644 --- a/Source/AgrarianGame/AgrarianGameGameMode.h +++ b/Source/AgrarianGame/AgrarianGameGameMode.h @@ -18,6 +18,9 @@ public: /** Constructor */ AAgrarianGameGameMode(); + + virtual void RestartPlayer(AController* NewPlayer) override; + virtual void Logout(AController* Exiting) override; }; diff --git a/Source/AgrarianGame/AgrarianPersistenceSubsystem.cpp b/Source/AgrarianGame/AgrarianPersistenceSubsystem.cpp index 7ad693f..66ebe8b 100644 --- a/Source/AgrarianGame/AgrarianPersistenceSubsystem.cpp +++ b/Source/AgrarianGame/AgrarianPersistenceSubsystem.cpp @@ -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& OutComponents) const { OutComponents.Reset(); diff --git a/Source/AgrarianGame/AgrarianPersistenceSubsystem.h b/Source/AgrarianGame/AgrarianPersistenceSubsystem.h index db98c8e..f72ea79 100644 --- a/Source/AgrarianGame/AgrarianPersistenceSubsystem.h +++ b/Source/AgrarianGame/AgrarianPersistenceSubsystem.h @@ -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& OutComponents) const; void FindAgrarianPlayers(TArray& OutPlayers) const; void FindResourceNodes(TArray& OutResourceNodes) const;