// © 2025 Naked People Team. All Rights Reserved. #include "SessionLossResolver.h" #include "DefaultLevelSequenceInstanceData.h" #include "LevelSequence.h" #include "LevelSequenceActor.h" #include "LevelSequencePlayer.h" #include "LossPresentationConfig.h" #include "MovieSceneSequencePlaybackSettings.h" #include "NakedDesireGameInstance.h" #include "GameFramework/Character.h" #include "GameFramework/CharacterMovementComponent.h" #include "GameFramework/PlayerStart.h" #include "Kismet/GameplayStatics.h" #include "NakedDesire/Clothing/ClothingItemInstance.h" #include "NakedDesire/Interactables/ItemPickup.h" #include "NakedDesire/SaveGame/GlobalSaveGameData.h" #include "NakedDesire/SaveGame/SaveSubsystem.h" const FName USessionLossResolver::HomePlayerStartTag = FName(TEXT("Home")); void USessionLossResolver::OnWorldBeginPlay(UWorld& InWorld) { Super::OnWorldBeginPlay(InWorld); if (USessionManagerSubsystem* SessionManager = InWorld.GetSubsystem()) { SessionManager->OnSessionEnd.AddDynamic(this, &USessionLossResolver::ResolveLoss); } } void USessionLossResolver::ResolveLoss(ESessionLossCause Cause) { // §4.4 precedence: if a chase is in progress, the loss always resolves as // police capture — the chase wins regardless of what actually fired. if (const USessionManagerSubsystem* SessionManager = GetWorld()->GetSubsystem()) { if (SessionManager->IsPoliceChaseActive()) { Cause = ESessionLossCause::PoliceCapture; } } // §4.4: equipped clothing is never forcibly removed — the resolver simply never // touches equipped items. // TODO(§6.4): when bags exist, a bag placed in the world is lost with its // contents — mark its world record for deletion here. bool bWentToHoldingCell = false; switch (Cause) { case ESessionLossCause::SafeReturn: // Normal session end; nothing is lost. Autosave below. break; case ESessionLossCause::EmbarrassmentMax: // Fade to apartment with no extra cost — no time skip, money, or rep hit. break; case ESessionLossCause::EnergyZero: // Forced sleep cycle: everything left outside the apartment is guaranteed lost. LoseAllWorldClothing(); break; case ESessionLossCause::PoliceCapture: { UGlobalSaveGameData* Save = GetSave(); if (Save && Save->Money >= PoliceCaptureMoneyPenalty) { // Can pay: deduct the penalty and go home. Save->Money -= PoliceCaptureMoneyPenalty; } else { // Can't pay: a night in the holding cell settles the debt; no money owed. bWentToHoldingCell = true; } ClearWanted(); } break; } Autosave(); OnSessionLossResolved.Broadcast(Cause, bWentToHoldingCell); // Presentation: play the cause's cutscene, then teleport the player home on finish. BeginLossPresentation(Cause); } void USessionLossResolver::ResolveSleepLoss() { // §4.4: sleeping is the deterministic side of the theft model — everything left // outside the apartment is lost, regardless of the in-session grace / roll state. // No autosave here: the sleep flow persists once via UTimeOfDaySubsystem::Sleep(), // which runs immediately after this. Callers must ensure a save follows. LoseAllWorldClothing(); } void USessionLossResolver::LoseAllWorldClothing() { UGlobalSaveGameData* Save = GetSave(); // Loose clothing in the world is represented by AItemPickup actors. Dropping only // happens outside, and the apartment stores items in the wardrobe (not as pickups), // so every pickup currently counts as "outside the apartment". // TODO: once an apartment volume query exists, spare pickups that sit inside it. TArray Pickups; UGameplayStatics::GetAllActorsOfClass(this, AItemPickup::StaticClass(), Pickups); for (AActor* Actor : Pickups) { AItemPickup* Pickup = Cast(Actor); if (!Pickup) continue; if (Save) { if (UClothingItemInstance* Item = Pickup->GetItem()) { Save->RemoveWorldItem(Item); } } Pickup->Destroy(); } } void USessionLossResolver::ClearWanted() { // TODO(§7.7 / Phase 6): clear the `wanted` tag once the Wanted attribute exists. } void USessionLossResolver::BeginLossPresentation(ESessionLossCause Cause) { // SafeReturn = the player walked home under their own power; nothing to present. if (Cause == ESessionLossCause::SafeReturn) return; ULevelSequence* Sequence = nullptr; if (const ULossPresentationConfig* Config = GetPresentationConfig()) { if (const TSoftObjectPtr* Found = Config->LossCutscenes.Find(Cause)) { // Synchronous load is acceptable here: the loss has already resolved and the // player is stationary, so the brief hitch is hidden by the transition. Sequence = Found->LoadSynchronous(); } } if (!Sequence) { // No cutscene authored for this cause — go straight home. TeleportPlayerHome(); return; } // Lock the player out of movement / look while the cutscene owns the view; state is // restored when the sequence finishes (just before we teleport home). FMovieSceneSequencePlaybackSettings Settings; Settings.bDisableMovementInput = true; Settings.bDisableLookAtInput = true; // Restore track state on finish so the view/player return to normal before we teleport. Settings.FinishCompletionStateOverride = EMovieSceneCompletionModeOverride::ForceRestoreState; ALevelSequenceActor* SequenceActor = nullptr; ActiveLossSequencePlayer = ULevelSequencePlayer::CreateLevelSequencePlayer(this, Sequence, Settings, SequenceActor); ActiveLossSequenceActor = SequenceActor; ActiveLossSequenceActor->bOverrideInstanceData = true; UDefaultLevelSequenceInstanceData* InstanceData = Cast(ActiveLossSequenceActor->DefaultInstanceData.Get()); InstanceData->TransformOriginActor = UGameplayStatics::GetPlayerPawn(GetWorld(), 0); if (!ActiveLossSequencePlayer) { // Playback couldn't be created — don't strand the player outside. TeleportPlayerHome(); return; } ActiveLossSequencePlayer->OnFinished.AddDynamic(this, &USessionLossResolver::HandleLossCutsceneFinished); ActiveLossSequencePlayer->Play(); } void USessionLossResolver::HandleLossCutsceneFinished() { if (ActiveLossSequencePlayer) { ActiveLossSequencePlayer->OnFinished.RemoveDynamic(this, &USessionLossResolver::HandleLossCutsceneFinished); } TeleportPlayerHome(); // Tear down the spawned sequence player/actor now that the cutscene is done. if (ActiveLossSequenceActor) { ActiveLossSequenceActor->Destroy(); } ActiveLossSequenceActor = nullptr; ActiveLossSequencePlayer = nullptr; } void USessionLossResolver::TeleportPlayerHome() { UWorld* World = GetWorld(); if (!World) return; APawn* PlayerPawn = UGameplayStatics::GetPlayerPawn(World, 0); if (!PlayerPawn) return; // Prefer a PlayerStart tagged "Home"; fall back to the first one in the level. TArray Starts; UGameplayStatics::GetAllActorsOfClass(World, APlayerStart::StaticClass(), Starts); APlayerStart* Home = nullptr; for (AActor* Actor : Starts) { APlayerStart* Start = Cast(Actor); if (!Start) continue; if (!Home) Home = Start; if (Start->PlayerStartTag == HomePlayerStartTag) { Home = Start; break; } } if (!Home) { UE_LOG(LogTemp, Warning, TEXT("USessionLossResolver: no APlayerStart found; cannot teleport player home.")); return; } PlayerPawn->TeleportTo(Home->GetActorLocation(), Home->GetActorRotation()); // Kill residual velocity so the character doesn't slide on arrival. if (const ACharacter* Character = Cast(PlayerPawn)) { if (UCharacterMovementComponent* Movement = Character->GetCharacterMovement()) { Movement->StopMovementImmediately(); } } } void USessionLossResolver::Autosave() const { if (const UGameInstance* GameInstance = GetWorld()->GetGameInstance()) { if (USaveSubsystem* SaveSubsystem = GameInstance->GetSubsystem()) { SaveSubsystem->SaveGame(); } } } UGlobalSaveGameData* USessionLossResolver::GetSave() const { if (const UGameInstance* GameInstance = GetWorld()->GetGameInstance()) { if (USaveSubsystem* SaveSubsystem = GameInstance->GetSubsystem()) { return SaveSubsystem->GetCurrentSave(); } } return nullptr; } ULossPresentationConfig* USessionLossResolver::GetPresentationConfig() const { const UNakedDesireGameInstance* GameInstance = Cast(GetWorld() ? GetWorld()->GetGameInstance() : nullptr); return GameInstance ? GameInstance->LossPresentation : nullptr; }