Added session system

This commit is contained in:
2026-05-30 00:18:14 +03:00
parent 8f132c463d
commit d53ca8e56b
8 changed files with 413 additions and 12 deletions
+7 -5
View File
@@ -24,7 +24,9 @@ State of the C++ module as of the latest pass. File references use `Source/Naked
- **AI sight + behavior tree** — `NPC/NPCAIController.cpp` runs a BT with `Player` / `TargetLocation` / `SpawnLocation` blackboard keys. `NPC/NPCSpawner.cpp` proximity-gated spawn with day / night caps.
- **Mission framework** — `Mission``MissionGoal``GoalRestriction` composition (`MissionBuilder/`). Two goals (`FlashGoal`, `MinTimeGoal`), three restrictions (`EquipClothing`, `ExposeBodyPart`, `Location`). Iterate-and-mutate bug in `MissionsManager::RefreshDailyMissions` resolved (`MissionsManager.cpp:53-68`).
- **Daily-mission OOB guarded** — `NakedDesireGameMode::RefreshDailyMissions` now clamps `DaysPassed` to the authored array bounds (`NakedDesireGameMode.cpp:70-71`). Still a hand-authored list (see §1.3).
- **Location triggers** — `Locations/LocationTrigger`, `Locations/LocationData` via gameplay tags (GDD §10.4 area foundation).
- **Location triggers** — `Locations/LocationTrigger`, `Locations/LocationData` via gameplay tags (GDD §10.4 area foundation). `ALocationTrigger` now carries a `bIsApartment` flag; when set, its box overlap drives session start / end on the session subsystem.
- **Session manager (GDD §4.1–§4.4)** — `Global/SessionManagerSubsystem.h/.cpp` (`UWorldSubsystem`). Tracks `bSessionActive`, emits `OnSessionStart` / `OnSessionEnd(ESessionLossCause)`; `ESessionLossCause = { SafeReturn, EmbarrassmentMax, EnergyZero, PoliceCapture }`. Apartment `ALocationTrigger` starts a session on exit and safely ends it on re-entry. Subscribes (next-tick after world begin play) to `UStatsManager::EmbarrassmentUpdate` (max-hit → `EmbarrassmentMax`) and `EnergyUpdate` (≤0 → `EnergyZero`). Exposes `bPoliceChaseActive` (+ setter / getter) for the §4.4 loss-precedence rule the resolver owns. Replaces the old `EndGameEmbarrassed` GameMode BP call, which `UStatsManager::IncreaseEmbarrassment` no longer invokes. The `EndGameEmbarrassed` BlueprintImplementableEvent declaration still exists on `ANakedDesireGameMode` but is now dead from C++ and should be removed once BP no longer references it.
- **Session loss resolver (GDD §4.4)** — `Global/SessionLossResolver.h/.cpp` (`UWorldSubsystem`). Single entry point `ResolveLoss(ESessionLossCause)`, bound to `USessionManagerSubsystem::OnSessionEnd`. Applies the police-chase precedence override (any cause → `PoliceCapture` while `bPoliceChaseActive`), then per cause: `EmbarrassmentMax` no-cost; `EnergyZero` destroys every world `AItemPickup` + clears its save record (guaranteed sleep loss); `PoliceCapture` deducts `PoliceCaptureMoneyPenalty` if affordable else flags a holding-cell outcome; `SafeReturn` no loss. Never strips equipped clothing. Autosaves, then broadcasts `OnSessionLossResolved(FinalCause, bWentToHoldingCell)` for the BP presentation / time-skip layer. See §1.3 for the pieces still delegated to BP / later phases.
- **Movement** — `EnhancedInput`, walk / run / crouch (`NakedDesireCharacter.cpp:115-127`), stamina-gated run (`Tick` lines 91-113).
- **Wardrobe interactable** — `Interactables/Wardrobe.h` holds `TArray<TObjectPtr<UClothingItemInstance>> ClothingItems`; `NakedDesireGameMode::BuyItem` charges money and pushes into the wardrobe.
@@ -40,7 +42,7 @@ State of the C++ module as of the latest pass. File references use `Source/Naked
### 1.3 Missing
- **Session system (GDD §4)** — no `USessionManager`, no session start / end events, no `USessionLossResolver`. Game-over on embarrassment is a single `EndGameEmbarrassed` BP event in `NakedDesireGameMode`; none of §4.4's behavior (energy-zero cutscene to home, bag-placed-in-world loss, sleep-deterministic clothing loss, embarrassment-max → apartment with no extras) is implemented.
- **Session loss — remaining (GDD §4.4)** — the transactional resolver exists (`USessionLossResolver`, see §1.1), but several outcomes depend on systems not yet built: the energy-zero trudge-home cutscene, the holding-cell cutscene, the fade/teleport-to-apartment, and the time skips (one sleep cycle / fast-forward to next morning) are all delegated to BP via `OnSessionLossResolved` and not yet authored. Bag-placed-in-world loss is a TODO (bags absent, §6.4). `ClearWanted()` is a stub (no Wanted attribute until Phase 4/6, §7.7). Apartment-interior detection for loose-item loss is approximated (all `AItemPickup`s treated as "outside").
- **Three progression paths runtime (§5)** — enum exists; no XP pool, no per-path level derived from investment, no path-gated unlocks at runtime, no level-up flow. **XP is a single shared pool, not per-path** (GDD §5, §7.10).
- **Phone (§9)** — entire system absent: camera, gallery, livestream, bank, Feetex, maps, health tracker. Includes the new sub-systems:
- **Battery (§9.8)** — passive base + per-app multiplier drain; apartment charger; portable powerbank consumable (Convenience Store); hard shutdown at 0%; mid-livestream cutoff with earnings-to-date deposited; sleep always charges to 100%.
@@ -198,8 +200,8 @@ Phase estimates are rough and assume one engineer. Adjust as we go.
### Phase 3 — Session system + loss resolver (1 week)
- `USessionManager` (subsystem on `UNakedDesireGameInstance` or a `UWorldSubsystem`). Apartment `ALocationTrigger` flag drives session start / end. Emits `OnSessionStart` / `OnSessionEnd` with cause.
- `USessionLossResolver` — single class, one method `ResolveLoss(ESessionLossCause Cause)`. Implements GDD §4.4:
- ~~`USessionManager` (subsystem on `UNakedDesireGameInstance` or a `UWorldSubsystem`). Apartment `ALocationTrigger` flag drives session start / end. Emits `OnSessionStart` / `OnSessionEnd` with cause.~~ **DONE (DEV-99)**`USessionManagerSubsystem` (`UWorldSubsystem`, `Global/`). See §1.1.
- ~~`USessionLossResolver` — single class, one method `ResolveLoss(ESessionLossCause Cause)`.~~ **DONE (DEV-100)**`Global/SessionLossResolver` (`UWorldSubsystem`). See §1.1. C++ owns the transaction (precedence, money, item loss, autosave); cutscenes / fade / time-skip are delegated to BP via `OnSessionLossResolved`. Implements GDD §4.4:
- **Police chase precedence**: if a chase is active (between detection and the disengage timer, §10.3), force `Cause = PoliceCapture` regardless of what triggered the loss. Chase always wins.
- Equipped clothing **stays** equipped (do not strip).
- Bag placed in world: mark its world record for deletion.
@@ -208,7 +210,7 @@ Phase estimates are rough and assume one engineer. Adjust as we go.
- **Embarrassment = max**: fade to apartment. **No extra cost** (no time skip, no money, no rep).
- **Police capture (can pay)**: fade to apartment, deduct money penalty, clear `wanted`.
- **Police capture (can't pay)**: short non-interactive holding-cell cutscene; time fast-forwards to next morning; debt settled; clear `wanted`. Already-accepted commissions still expire at day end with their normal `failurePenalty` — the cutscene does not pause the day clock.
- Wire `StatsManager::IncreaseEmbarrassment` max-hit and energy-zero into `USessionLossResolver`. Replace `EndGameEmbarrassed` BP-event with the C++ path.
- ~~Wire `StatsManager::IncreaseEmbarrassment` max-hit and energy-zero into~~ the session subsystem — **DONE (DEV-99)**: `USessionManagerSubsystem` subscribes to `EmbarrassmentUpdate` / `EnergyUpdate` and the C++ embarrassment path no longer calls `EndGameEmbarrassed`. DEV-100 routes `OnSessionEnd` into `USessionLossResolver`.
- Add a debug overlay showing the current loss state and what would be lost if a given cause fired.
**Exit criteria:** all four code paths (safe end, embarrassment max, energy zero, police capture) produce the §4.4 outcomes deterministically. Inventory state after each loss matches the §6.6 summary table 1:1.
@@ -0,0 +1,132 @@
// © 2025 Naked People Team. All Rights Reserved.
#include "SessionLossResolver.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"
void USessionLossResolver::OnWorldBeginPlay(UWorld& InWorld)
{
Super::OnWorldBeginPlay(InWorld);
if (USessionManagerSubsystem* SessionManager = InWorld.GetSubsystem<USessionManagerSubsystem>())
{
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<USessionManagerSubsystem>())
{
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);
}
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<AActor*> Pickups;
UGameplayStatics::GetAllActorsOfClass(this, AItemPickup::StaticClass(), Pickups);
for (AActor* Actor : Pickups)
{
AItemPickup* Pickup = Cast<AItemPickup>(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::Autosave() const
{
if (const UGameInstance* GameInstance = GetWorld()->GetGameInstance())
{
if (USaveSubsystem* SaveSubsystem = GameInstance->GetSubsystem<USaveSubsystem>())
{
SaveSubsystem->SaveGame();
}
}
}
UGlobalSaveGameData* USessionLossResolver::GetSave() const
{
if (const UGameInstance* GameInstance = GetWorld()->GetGameInstance())
{
if (USaveSubsystem* SaveSubsystem = GameInstance->GetSubsystem<USaveSubsystem>())
{
return SaveSubsystem->GetCurrentSave();
}
}
return nullptr;
}
@@ -0,0 +1,57 @@
// © 2025 Naked People Team. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/WorldSubsystem.h"
#include "SessionManagerSubsystem.h"
#include "SessionLossResolver.generated.h"
/**
* Broadcast once a loss has been resolved transactionally. The presentation layer
* (fade / cutscene / teleport) and the time skip live in Blueprint / the future
* time subsystem and react to this, switching on FinalCause:
* - EmbarrassmentMax → fade to apartment. No time skip.
* - EnergyZero → trudge-home cutscene, then advance one sleep cycle.
* - PoliceCapture (!cell) → fade to apartment.
* - PoliceCapture (cell) → holding-cell cutscene, then fast-forward to next morning.
* - SafeReturn → no special presentation.
* The C++ side has already applied money / item / wanted changes by the time this fires.
*/
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnSessionLossResolvedSignature, ESessionLossCause, FinalCause, bool, bWentToHoldingCell);
/**
* The single, deterministic owner of "what gets lost" on session end (GDD §4.4).
* Subscribes to USessionManagerSubsystem::OnSessionEnd and routes every cause
* through ResolveLoss. Per the architecture rule, no loss-handling logic should
* live anywhere else.
*/
UCLASS()
class NAKEDDESIRE_API USessionLossResolver : public UWorldSubsystem
{
GENERATED_BODY()
public:
virtual void OnWorldBeginPlay(UWorld& InWorld) override;
// Single entry point for all §4.4 outcomes. Bound to OnSessionEnd; also callable
// directly (e.g. from a debug command) to exercise a path.
UFUNCTION(BlueprintCallable, Category = "Session")
void ResolveLoss(ESessionLossCause Cause);
UPROPERTY(BlueprintAssignable, Category = "Session")
FOnSessionLossResolvedSignature OnSessionLossResolved;
private:
// EnergyZero / sleep: every clothing item left outside the apartment is guaranteed lost.
void LoseAllWorldClothing();
// §7.7: cleared on police capture. No-op until the Wanted attribute exists (Phase 6).
void ClearWanted();
void Autosave() const;
class UGlobalSaveGameData* GetSave() const;
// §21 tuning placeholder — the police-capture money penalty.
float PoliceCaptureMoneyPenalty = 200.0f;
};
@@ -0,0 +1,80 @@
// © 2025 Naked People Team. All Rights Reserved.
#include "SessionManagerSubsystem.h"
#include "Kismet/GameplayStatics.h"
#include "NakedDesire/Global/Constants.h"
#include "NakedDesire/Player/NakedDesireCharacter.h"
#include "NakedDesire/Stats/StatsManager.h"
void USessionManagerSubsystem::OnWorldBeginPlay(UWorld& InWorld)
{
Super::OnWorldBeginPlay(InWorld);
// The player pawn and its UStatsManager may not have finished BeginPlay when
// the world begins play, so defer binding by one tick.
InWorld.GetTimerManager().SetTimerForNextTick(this, &USessionManagerSubsystem::BindToPlayerStats);
}
void USessionManagerSubsystem::BindToPlayerStats()
{
ANakedDesireCharacter* Player = Cast<ANakedDesireCharacter>(UGameplayStatics::GetPlayerCharacter(this, SLOT_PLAYER));
if (!Player || !Player->StatsManager)
{
UE_LOG(LogTemp, Warning, TEXT("USessionManagerSubsystem: no player StatsManager to bind to; loss detection disabled."));
return;
}
Player->StatsManager->EmbarrassmentUpdate.AddDynamic(this, &USessionManagerSubsystem::HandleEmbarrassmentUpdate);
Player->StatsManager->EnergyUpdate.AddDynamic(this, &USessionManagerSubsystem::HandleEnergyUpdate);
}
void USessionManagerSubsystem::NotifyEnteredApartment()
{
// Returning home is the safe end of a session (§4.3).
if (bSessionActive)
{
EndSession(ESessionLossCause::SafeReturn);
}
}
void USessionManagerSubsystem::NotifyLeftApartment()
{
// Leaving the apartment is the only way to start a session (§4.1).
if (!bSessionActive)
{
StartSession();
}
}
void USessionManagerSubsystem::StartSession()
{
bSessionActive = true;
OnSessionStart.Broadcast();
}
void USessionManagerSubsystem::EndSession(const ESessionLossCause Cause)
{
if (!bSessionActive)
return;
bSessionActive = false;
OnSessionEnd.Broadcast(Cause);
}
void USessionManagerSubsystem::HandleEmbarrassmentUpdate(const float CurrentValue, const float MaxValue)
{
if (bSessionActive && MaxValue > 0.0f && CurrentValue >= MaxValue)
{
EndSession(ESessionLossCause::EmbarrassmentMax);
}
}
void USessionManagerSubsystem::HandleEnergyUpdate(const float CurrentValue, const float MaxValue)
{
if (bSessionActive && CurrentValue <= 0.0f)
{
EndSession(ESessionLossCause::EnergyZero);
}
}
@@ -0,0 +1,80 @@
// © 2025 Naked People Team. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/WorldSubsystem.h"
#include "SessionManagerSubsystem.generated.h"
class UStatsManager;
/**
* Why a session ended (GDD §4.4). SafeReturn is a non-loss end (player walked
* home); the other three are the session-loss conditions from §3.3. The actual
* "what gets lost" handling lives in USessionLossResolver (DEV-100), which also
* applies police-chase precedence via bPoliceChaseActive.
*/
UENUM(BlueprintType)
enum class ESessionLossCause : uint8
{
SafeReturn,
EmbarrassmentMax,
EnergyZero,
PoliceCapture
};
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnSessionStartSignature);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSessionEndSignature, ESessionLossCause, Cause);
/**
* Owns the "going outside" session state (GDD §4). A session starts when the
* player leaves the apartment and ends when they return safely or hit a loss
* condition (embarrassment max / energy zero / police capture). The apartment
* ALocationTrigger drives start / end; embarrassment-max and energy-zero are
* detected by subscribing to UStatsManager. This replaces the EndGameEmbarrassed
* Blueprint event on ANakedDesireGameMode.
*/
UCLASS()
class NAKEDDESIRE_API USessionManagerSubsystem : public UWorldSubsystem
{
GENERATED_BODY()
public:
virtual void OnWorldBeginPlay(UWorld& InWorld) override;
// Called by the apartment ALocationTrigger as the player crosses the threshold.
void NotifyEnteredApartment();
void NotifyLeftApartment();
UFUNCTION(BlueprintPure, Category = "Session")
bool IsSessionActive() const { return bSessionActive; }
// Set by the police AI pipeline (Phase 6). Read by USessionLossResolver for
// the §4.4 loss precedence rule: a loss fired mid-chase resolves as capture.
UFUNCTION(BlueprintCallable, Category = "Session")
void SetPoliceChaseActive(bool bActive) { bPoliceChaseActive = bActive; }
UFUNCTION(BlueprintPure, Category = "Session")
bool IsPoliceChaseActive() const { return bPoliceChaseActive; }
UPROPERTY(BlueprintAssignable, Category = "Session")
FOnSessionStartSignature OnSessionStart;
UPROPERTY(BlueprintAssignable, Category = "Session")
FOnSessionEndSignature OnSessionEnd;
private:
void StartSession();
void EndSession(ESessionLossCause Cause);
void BindToPlayerStats();
UFUNCTION()
void HandleEmbarrassmentUpdate(float CurrentValue, float MaxValue);
UFUNCTION()
void HandleEnergyUpdate(float CurrentValue, float MaxValue);
bool bSessionActive = false;
bool bPoliceChaseActive = false;
};
@@ -4,6 +4,8 @@
#include "LocationTrigger.h"
#include "Components/BoxComponent.h"
#include "NakedDesire/Global/SessionManagerSubsystem.h"
#include "NakedDesire/Player/NakedDesireCharacter.h"
ALocationTrigger::ALocationTrigger()
@@ -19,3 +21,38 @@ ULocationData* ALocationTrigger::GetLocationData() const
return LocationData;
}
void ALocationTrigger::BeginPlay()
{
Super::BeginPlay();
if (bIsApartment)
{
BoxTrigger->OnComponentBeginOverlap.AddDynamic(this, &ALocationTrigger::OnTriggerBeginOverlap);
BoxTrigger->OnComponentEndOverlap.AddDynamic(this, &ALocationTrigger::OnTriggerEndOverlap);
}
}
void ALocationTrigger::OnTriggerBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
if (!OtherActor || !OtherActor->IsA<ANakedDesireCharacter>())
return;
if (USessionManagerSubsystem* SessionManager = GetWorld()->GetSubsystem<USessionManagerSubsystem>())
{
SessionManager->NotifyEnteredApartment();
}
}
void ALocationTrigger::OnTriggerEndOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
UPrimitiveComponent* OtherComp, int32 OtherBodyIndex)
{
if (!OtherActor || !OtherActor->IsA<ANakedDesireCharacter>())
return;
if (USessionManagerSubsystem* SessionManager = GetWorld()->GetSubsystem<USessionManagerSubsystem>())
{
SessionManager->NotifyLeftApartment();
}
}
@@ -20,8 +20,26 @@ class NAKEDDESIRE_API ALocationTrigger : public AActor
UPROPERTY(EditAnywhere)
ULocationData* LocationData;
// When set, the player crossing this trigger drives session start / end on
// USessionManagerSubsystem (GDD §4.1 / §4.3). Exactly one trigger — the
// apartment — should have this checked.
UPROPERTY(EditAnywhere, Category = "Session")
bool bIsApartment = false;
public:
ALocationTrigger();
ULocationData* GetLocationData() const;
protected:
virtual void BeginPlay() override;
private:
UFUNCTION()
void OnTriggerBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);
UFUNCTION()
void OnTriggerEndOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
UPrimitiveComponent* OtherComp, int32 OtherBodyIndex);
};
+2 -7
View File
@@ -2,8 +2,6 @@
#include "StatsManager.h"
#include "Kismet/GameplayStatics.h"
#include "NakedDesire/Global/NakedDesireGameMode.h"
UStatsManager::UStatsManager()
{
@@ -45,12 +43,9 @@ void UStatsManager::SetObserved(const bool bObserved, const float CoverageWeight
void UStatsManager::IncreaseEmbarrassment(const float Amount)
{
Embarrassment = FMath::Clamp(Embarrassment + Amount, 0, MaxEmbarrassment);
// Embarrassment-max session loss is handled by USessionManagerSubsystem, which
// subscribes to EmbarrassmentUpdate (GDD §4.4). Broadcast is the integration point.
EmbarrassmentUpdate.Broadcast(Embarrassment, MaxEmbarrassment);
if (Embarrassment == MaxEmbarrassment)
{
ANakedDesireGameMode* GameMode = Cast<ANakedDesireGameMode>(UGameplayStatics::GetGameMode(this));
GameMode->EndGameEmbarrassed();
}
}
void UStatsManager::DecreaseEmbarrassment(const float Amount)