// Fill out your copyright notice in the Description page of Project Settings. #include "ScheduleAssetRecheck.h" #include "AssetRegistry/AssetRegistryModule.h" #include "AssetRegistry/IAssetRegistry.h" #include "Editor.h" #include "EngineUtils.h" #include "FileHelpers.h" #include "Framework/Notifications/NotificationManager.h" #include "Modules/ModuleManager.h" #include "Widgets/Notifications/SNotificationList.h" #include "ProjectEleri/Ai/NpcScheduleAsset.h" #include "ProjectEleri/GameObjects/EleriStandPoint.h" #include "ProjectEleri/Public/Interface/ActorPersistentIdInterface.h" #define LOCTEXT_NAMESPACE "ScheduleAssetRecheck" namespace ScheduleAssetRecheck { namespace { /** The id a schedule would name this actor by. Implementing the interface is the whole of the * "can this actor be an anchor" rule, because the interface is what carries the id a schedule * stores, so this looks for anchors exactly the way the game and the details panel do rather * than through a notion of furniture of its own. * * Written out rather than shared with FNpcScheduleActionCustomization, which answers the same * question about the details panel's selection and keeps the answer to itself. It is one line * of casting, and duplicating it is the smaller fault next to a picker that only ever sees one * action at a time having to grow a second responsibility. */ FGuid GetAnchorId(AActor* Actor) { if (Actor == nullptr) { return FGuid(); } const IActorPersistentIdInterface* Identified = Cast(Actor); return Identified != nullptr ? Identified->GetPersistentId() : FGuid(); } /** Every anchor the open level can answer for, by the id a schedule names it with. * * One walk for the whole sweep rather than a walk per action: the levels this runs on hold a * project's worth of schedules, and asking the level once per action would make the sweep * quadratic in the size of the level for no reason, since the answer cannot change under it. * * The first actor to claim an id keeps it, which is the same answer a game arrives at, both * sides walking the level and taking what they meet first. A second claimant is logged while * somebody can still do something about it, because a schedule naming that id is going to * follow one of the two and neither of them gets a say in which. */ void CollectAnchorsById(UWorld& World, TMap& OutAnchorsById) { for (TActorIterator It(&World); It; ++It) { AActor* Actor = *It; const FGuid AnchorId = GetAnchorId(Actor); if (!AnchorId.IsValid()) { continue; } AActor*& Claimant = OutAnchorsById.FindOrAdd(AnchorId); if (Claimant == nullptr) { Claimant = Actor; continue; } UE_LOG(LogTemp, Warning, TEXT("NPC schedule anchor %s is claimed by both %s and %s, and a schedule naming it will follow %s."), *AnchorId.ToString(EGuidFormats::Digits), *Claimant->GetName(), *Actor->GetName(), *Claimant->GetName()); } } } FResult RecheckAllScheduleAssets() { FResult Result; // One place that says how it went, so that the answer reads the same whether the sweep found // nothing to do, did its job, or could not finish it. const auto Notify = [](const FText& Summary, const FText& Detail, SNotificationItem::ECompletionState State) { FNotificationInfo Info(Summary); if (!Detail.IsEmpty()) { Info.SubText = Detail; } Info.bUseSuccessFailIcons = true; Info.ExpireDuration = 5.0f; if (TSharedPtr Item = FSlateNotificationManager::Get().AddNotification(Info)) { Item->SetCompletionState(State); } }; // The editor world is the only one this can read, both because it is the only one there is // while nothing is playing and because what is being compared is authored data against the // level it was authored in. With no level there is nothing to compare against at all, and // writing the schedules from nowhere would be inventing a place rather than recording one. UWorld* World = GEditor != nullptr ? GEditor->GetEditorWorldContext().World() : nullptr; if (World == nullptr) { Notify(LOCTEXT("NoEditorWorld", "Recheck Schedule Assets: no level is open."), FText(), SNotificationItem::CS_Fail); return Result; } TMap AnchorsById; CollectAnchorsById(*World, AnchorsById); IAssetRegistry& AssetRegistry = FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")).Get(); TArray ScheduleAssets; AssetRegistry.GetAssetsByClass(UNpcScheduleAsset::StaticClass()->GetClassPathName(), ScheduleAssets, /*bSearchSubClasses=*/true); // Only the packages that were actually written are gathered, so that a sweep which found // nothing to do leaves the disk alone, and one that found several saves them in a single step // rather than one stamp of the clock per asset. TArray PackagesToSave; for (const FAssetData& AssetData : ScheduleAssets) { // The live object rather than the registry's tags, because what is being read is UPROPERTY // data the registry does not index, and because what is being written has to go through the // object the registry would otherwise be left holding a stale idea of. UNpcScheduleAsset* Schedule = Cast(AssetData.GetAsset()); if (Schedule == nullptr) { continue; } ++Result.NumAssetsScanned; bool bScheduleChanged = false; for (FScheduledAction& Action : Schedule->ScheduledActions) { // An action with no anchor happens wherever the NPC already is. There is no anchor for // it to have moved away from, so there is nothing here that can be stale. if (!Action.UsableObjectGuid.IsValid()) { continue; } ++Result.NumAnchorsChecked; AActor* const* Anchor = AnchorsById.Find(Action.UsableObjectGuid); if (Anchor == nullptr) { // Not in this level: either the cell the furniture lives in is not loaded, or the // schedule names something that is gone, and nothing here can tell those apart. The // copy is left exactly as it was, the same way the details panel leaves it alone // when it cannot find an anchor: it is the only thing anybody knows about where // that anchor is, and this sweep has nothing better to put in its place. ++Result.NumAnchorsUnresolved; continue; } const FTransform AnchorTransform = EleriStandPoint::ResolveAnchorTransform(*Anchor); // The whole transform rather than the point on its own, because which way an anchor // faces is part of where an NPC is put: a bed turned around is still walked to and // still slept in, with the NPC doing it facing the wall. Comparing what the details // panel would have written is also what makes a schedule this agrees with one the // panel has nothing left to say about. if (Action.bHasAnchorTransform && Action.AnchorTransform.Equals(AnchorTransform)) { continue; } Action.AnchorTransform = AnchorTransform; Action.bHasAnchorTransform = true; ++Result.NumAnchorsUpdated; bScheduleChanged = true; } if (!bScheduleChanged) { continue; } // Notified and dirtied the way the details panel does it, so that an asset open in another // tab follows the write instead of sitting there showing the transform that has just been // replaced, and so that anything else watching the asset sees the change. Schedule->PostEditChange(); Schedule->MarkPackageDirty(); if (UPackage* Package = Schedule->GetPackage()) { PackagesToSave.Add(Package); ++Result.NumAssetsUpdated; } } // One save for the whole sweep, over the packages that were written and no others. A false // answer here is the one outcome that has to be said out loud rather than logged: the assets // are then changed in memory and not on disk, and an author who takes this for a save loses // the whole of it on the next reload. const bool bSaved = PackagesToSave.Num() == 0 || UEditorLoadingAndSavingUtils::SavePackages(PackagesToSave, /*bOnlyDirty=*/true); UE_LOG(LogTemp, Log, TEXT("Recheck Schedule Assets: %d asset(s) scanned, %d saved, %d anchor(s) checked, %d refreshed, %d not in this level."), Result.NumAssetsScanned, Result.NumAssetsUpdated, Result.NumAnchorsChecked, Result.NumAnchorsUpdated, Result.NumAnchorsUnresolved); // Said out loud rather than folded into the counts, because it is a question rather than an // answer: an id nobody claims is either furniture in a cell that is not loaded, or a schedule // naming something that no longer exists, and only the author can say which of the two it was // meant to be. It goes beside every outcome below, since it is true of all of them. const FText UnresolvedNote = Result.NumAnchorsUnresolved == 0 ? FText() : FText::Format( LOCTEXT("UnresolvedAnchors", "{0} anchor(s) are not in this level, and their recorded places were left alone."), FText::AsNumber(Result.NumAnchorsUnresolved)); // No assets at all is not a sweep that found everything clean, it is a sweep that looked in the // wrong place, and it must not read as a pass. if (Result.NumAssetsScanned == 0) { Notify(LOCTEXT("NoScheduleAssets", "Recheck Schedule Assets: no NPC schedule assets were found."), FText(), SNotificationItem::CS_None); return Result; } if (!bSaved) { Notify( FText::Format( LOCTEXT("SaveFailed", "Recheck Schedule Assets: {0} asset(s) were updated but could not be saved."), FText::AsNumber(Result.NumAssetsUpdated)), UnresolvedNote, SNotificationItem::CS_Fail); return Result; } if (Result.NumAssetsUpdated == 0) { Notify( FText::Format( LOCTEXT("UpToDate", "Recheck Schedule Assets: all {0} asset(s) already match this level."), FText::AsNumber(Result.NumAssetsScanned)), UnresolvedNote, SNotificationItem::CS_Success); return Result; } Notify( FText::Format( LOCTEXT("Updated", "Recheck Schedule Assets: saved {0} of {1} asset(s), {2} anchor(s) refreshed."), FText::AsNumber(Result.NumAssetsUpdated), FText::AsNumber(Result.NumAssetsScanned), FText::AsNumber(Result.NumAnchorsUpdated)), UnresolvedNote, SNotificationItem::CS_Success); return Result; } } #undef LOCTEXT_NAMESPACE