0/0
CRADL // DOCUMENTATION
PORTAL DEV WIKI TELEPORT_IMPLEMENTATION
UTC 00:00:00
◀ RETURN
TELEPORT_IMPLEMENTATION.md 4923 words ~22 min read Updated 2026-07-03

CRADL Teleport Implementation

Companion to TELEPORT_SYSTEM.md (the contract), COMBAT_IMPLEMENTATION.md (the respawn streaming-source flow this build generalizes), and ARCHITECTURE.md. This doc tracks the build order for v1 teleport: phased delivery, per-phase rationale, task checklists, and verification gates. The contract says what teleport is; this doc says what we build first, what depends on what, and how we know each step works.

This is a greenfield verb on existing foundations: teleport adds no new subsystem archetype. Every phase either introduces a (new) symbol under an existing pattern or generalizes one existing symbol (the respawn streaming-source provider) to a second caller. No phase invents a new foundation.

Open Questions — resolved at derive time

The contract's four Open Questions were resolved before phasing; the resolutions are baked into the phases below:

  1. Map identity → teleport stations get a distinct world-map landmark + minimap icon, not the shared Map.Icon.Station. Exact Map.* leaf names are grounded against MAP_SYSTEM.md in Phase 3 (the only tags deferred out of Phase 0, because they depend on the map bake rules).
  2. Combat gate + cooldown → teleport is combat-blocked (InCombatTooltipText on the confirm + ActivationBlockedTags = Status.InCombat on the execute ability); no cooldown in v1 (free/repeatable like OSRS spirit trees). No Cooldown.Teleport tag is introduced.
  3. Enumeration mechanism → mirror UCradlRecipeRegistry's tag-keyed primary-asset index (CradlRecipeRegistry.h) — but async-load the definitions (LoadPrimaryAssets / FStreamableManager), honoring the contract's async Rule rather than the registry's current synchronous TryLoad (whose own // Switch to LoadPrimaryAssets if recipe count grows comment sanctions the async path). Landed in Phase 1.
  4. Non-owner peer requirement pre-checkmoot for v1. Teleport is self-service: only the owning player opens its own modal, and the server re-validates the owner's own state on execute. No peer ever computes another player's teleport eligibility, so the COND_OwnerOnly peer fallback never triggers here. No phase. (This is distinct from server-authority-on-requirements, which the contract already settles in Teleport Ability & Authority Split.)

Conventions

  • Phase status legend: [ ] not started · [~] in progress · [x] done · [!] blocked / deferred.
  • Verification gate: every phase ends with a runnable demo / observable behavior. If a phase can't be verified end-to-end, it's split.
  • Cheat commands: test fixtures land on UCradlDebugComponent (the controller's dev-only console host) as exec functions guarded by #if !UE_BUILD_SHIPPING. Per CLAUDE.md, declarations are unconditional; only the body is guarded. Cheats are per-phase fixtures, not a trailing polish phase.
  • Per CLAUDE.md "validators in lockstep": any phase that touches UTeleportDestinationDefinition updates UCradlTeleportDestinationValidator under Source/CRADLEditor/Validators/ in the same change.
  • Per CLAUDE.md "Building": after the C++ edits for a phase land, Claude compiles with the documented Build.bat call (the UE: Build Editor (Development) task) to verify the phase links clean before reporting it done. Runtime/PIE verification remains the user's job.
  • Native tag rule: CRADLEditor validator code resolves Teleport.Network.* via FGameplayTag::RequestGameplayTag(FName, /*ErrorIfNotFound*/false), never CradlTags:: symbols (LNK2001 across modules — per reference_native_tag_no_cross_module_export.md).

Phase tracking

Phase Title Status Unblocks
0 Tag & data scaffolding [x] All later phases
1 Destination catalogue: async registry + validator [x] 4, 5
2 Shared point-warm provider refactor [x] 6 · parallel-able with 3, 4
3 Station + modal open path (+ distinct map identity) [~] 4
4 Destination list UI, selection & confirmation [~] 5
5 Execute ability, authority & client-ready handshake [x] 6
6 Warm-then-move transition [~] — (closes the kill-loop-equivalent; C++ done, Cinematic.Teleport IMC entry on PC_CRADL outstanding)

Phase 0 — Tag & Data Scaffolding

Goal. A compile-clean codebase carrying every teleport tag, the destination DataAsset class shell, its primary-asset registration, and a validator stub. No behavior. Unblocks every later phase by giving them stable symbols to reference.

Rationale. Phase 0 is always tag & data scaffolding (per the impl-doc convention). Teleport's tags and its UPrimaryDataAsset shape are referenced by C++ across four later phases; landing them first means no later phase blocks on a missing symbol.

Tasks.

  • [x] Tags — Config/DefaultGameplayTags.ini + Source/CRADL/CradlGameplayTags.h / .cpp:
  • [x] Action.Modal.Teleport (.ini only — the HUD's ModalPageClasses TMap<FGameplayTag, TSubclassOf<UCommonActivatableWidget>> on CradlHUDLayout.h is BP-authored, so no C++ symbol is needed to key it)
  • [x] Action.Trigger.Modal.Teleport (.ini + header — ATeleportStation::BeginInteract fires CradlTags::Action_Trigger_Modal_Teleport in C++, mirroring Action_Trigger_Modal_Crafting)
  • [x] Action.Trigger.Teleport.Execute (.ini + header, under a new Action.Trigger.Teleport root — the menu widget dispatches it in C++ via FireReplicatedGameplayEvent)
  • [x] Cinematic.Teleport (.ini + header — passed to BeginCinematicControl/EndCinematicControl in C++, alongside existing Cinematic.LevelIntro)
  • [x] Teleport.Network.* root namespace + concrete leaf Teleport.Network.SpiritTree (Phase 1/4 test fixture) (.ini only — content-authored on assets, matched by tag like Station.*; the validator resolves it via RequestGameplayTag string)
  • [ ] Deferred to Phase 3: the distinct Map.* landmark + minimap tags (OQ1) — leaf names depend on MAP_SYSTEM.md bake rules.
  • [x] Not added (OQ2): no Cooldown.Teleport.
  • [x] DataAsset shell — Source/CRADL/World/TeleportDestinationDefinition.h (new):
  • [x] UTeleportDestinationDefinition : UPrimaryDataAsset with Network (FGameplayTag), DisplayName (FText), Icon (TSoftObjectPtr<UTexture2D> — the project-wide icon-field convention), Location (FTransform), Requirements (FQuestRequirements).
  • [x] GetPrimaryAssetId() override returning FPrimaryAssetId(TEXT("TeleportDestinationDefinition"), GetFName()) — mirror URecipeDefinition::GetPrimaryAssetId. The FPrimaryAssetId is the destination's identity — do not add a redundant id field (per feedback_push_back_on_duplicate_identity.md).
  • [x] Primary-asset registration — Config/DefaultGame.ini: added a PrimaryAssetTypesToScan entry for TeleportDestinationDefinition (mirrors the RecipeDefinition entry; scans /Game/Definitions/TeleportDestinations).
  • [x] Validator stub — Source/CRADLEditor/Validators/CradlTeleportDestinationValidator.h (new): UCradlTeleportDestinationValidator (auto-discovered like UCradlRecipeDefinitionValidator — no registration list); ValidateLoadedAsset returns valid (real checks land Phase 1). Existence-in-lockstep now so Phase 1 only fills the body.

Verification.

  • Compile clean (Claude builds via Build.bat; links exit-0).
  • In-editor: create a UTeleportDestinationDefinition asset; confirm it is discoverable as a primary asset of type TeleportDestinationDefinition (Asset Manager settings show the type; the asset resolves via GetPrimaryAssetIdList).

Exits. Phases 1–6 now have concrete tags, the definition type, and the validator to fill in.

Footguns.

  • Add a header symbol only for tags C++ names (Action.Trigger.Modal.Teleport, Action.Trigger.Teleport.Execute, Cinematic.Teleport); Action.Modal.Teleport and Teleport.Network.* stay .ini-only (per feedback_gameplay_tag_decl_minimal.md).
  • Teleport.Network.* is a new root namespace (not under an existing parent). That's intentional — it mirrors Station.* as a content-filter root — but declare it in .ini deliberately, not as a stray.

Phase 1 — Destination Catalogue: Async Registry + Validator

Goal. A subsystem that, given a Teleport.Network.* tag, returns the async-loaded set of UTeleportDestinationDefinition assets for that network; plus a full validator that gates half-authored destinations. This is the data spine every UI/ability phase reads.

Rationale. One spine before its verbs — the destination pool and its requirement evaluation must exist before the list widget (Phase 4) or the execute ability (Phase 5) can consume them. Mirrors the crafting registry so teleport does not introduce a second enumeration idiom (OQ3).

Tasks.

  • [x] Registry — Source/CRADL/World/TeleportDestinationRegistry.h (new):
  • [x] UCradlTeleportDestinationRegistry (subsystem, mirror UCradlRecipeRegistry) building TMap<FGameplayTag, FCradlTeleportDestinationArray> ByNetwork from UAssetManager::GetPrimaryAssetIdList(TeleportDestinationAssetType, …).
  • [x] Async load the definitions via UAssetManager::LoadPrimaryAssets (per OQ3 — not the recipe registry's synchronous Path.TryLoad()). EnsureBuilt(FSimpleDelegate) queues callers during an in-flight load and fires them all on completion (immediately if already built); null-handle / empty-id-list paths finalize synchronously so callers are never stranded.
  • [x] GetDestinationsForNetwork(FGameplayTag NetworkTag, …) — the analogue of GetRecipesForStation (reads the built index; empty until built).
  • [x] Validator body — CradlTeleportDestinationValidator.h: modeled on UCradlRecipeDefinitionValidator + CradlQuestDefinitionValidator:
  • [x] Network resolves under Teleport.Network.* (via RequestGameplayTag string — not CradlTags::).
  • [x] DisplayName non-empty; Icon set + resolves (AssetRegistry).
  • [x] Requirements axes resolve: skills via CradlValidationHelpers::CollectKnownSkillTags, quests via CollectKnownQuestIds, items against the items DataTable, equipped tags under Item.Equipped.*.
  • [x] Location is not the identity/zero sentinel (FTransform::Identity = unauthored arrival point).
  • [x] Cheat fixture — UCradlDebugComponent: Debug_TeleportDumpNetwork <tag> exec (Debug_ prefix — dotted names aren't valid C++ identifiers) — EnsureBuilt then logs every destination the registry returns for a network tag (name, met/unmet against the local player).

Verification.

  • Author 2–3 UTeleportDestinationDefinition assets on one Teleport.Network.<Name>; one with a satisfiable requirement, one with an unsatisfiable one, one with a zeroed Location.
  • Validator fails the zeroed-Location asset and the one with an unresolved requirement tag; passes the well-formed ones (editor validation / Data Validation commandlet).
  • TeleportDumpNetwork <tag> lists exactly the well-formed destinations with correct met/unmet flags.

Exits. Phase 4 (UI) and Phase 5 (ability) can enumerate + evaluate destinations.

Footguns.

  • Invalid requirement rows pass silently at runtime (USkillsComponent::MeetsRequirements skips unresolved tags) so half-authored assets don't lock players out — the validator is the real guard, hence the lockstep body here (per the contract's Requirement-Gating footgun).
  • Do not GetAllActorsOfClass and do not synchronously LoadObject for the pool (CLAUDE.md) — the async registry is the only enumeration path.

Phase 2 — Shared Point-Warm Provider Refactor

Goal. Generalize the respawn streaming-source provider into a shared point-warm provider consumed by both respawn and (later) teleport, with the death→respawn flow proven still-correct after the refactor.

Rationale. Teleport is the genuine second implementer of point-warming; per feedback_interface_rule_reading.md the shared abstraction is introduced now, at the second caller, not after a duplicate rots. Independent of the teleport UI, so it is parallel-able with Phases 3 and 4 — but it is a hard prerequisite for Phase 6.

Pre-work. (Refactor of an existing system.)

  • Generalize URespawnStreamingSourceProvider → a shared UPointWarmStreamingSourceProvider (PointWarmStreamingSourceProvider.h): keep the transform field, Register(UWorld*) / Unregister(UWorld*), the IWorldPartitionStreamingSourceProvider impl, and the IsStreamingCompleted mechanic. Rename SetRespawnTransform → a neutral SetWarmTransform. The respawn-specific registration trigger (death-state transitions) stays with the respawn caller.

Tasks.

  • [x] Renamed/relocated the provider to a system-neutral name + path: URespawnStreamingSourceProvider (in Combat/) → UPointWarmStreamingSourceProvider in Source/CRADL/World/. Old Combat/RespawnStreamingSourceProvider.{h,cpp} deleted; streaming-source debug name generalized CradlRespawnPrewarmCradlPointWarm. Updated the respawn caller (ACradlPlayerController respawn path + forward decl + RespawnStreamingSource member type) to the new type + SetWarmTransform. Also dropped a now-stale include of the old header from CradlDebugComponent.cpp (it referenced the type only via that include).
  • [x] Confirmed the poll cadence fields on CradlPlayerController.h (RespawnStreamingPollInterval, MaxStreamingWaitSeconds) are reusable as-is — both are plain EditDefaultsOnly cadence scalars with no respawn-specific semantics. Shared as-is, not aliased: Phase 6's teleport warm polling reuses these two fields directly (no duplication). RespawnStreamingPollInterval keeps its name to avoid churn; if the respawn flavor reads badly at the teleport call site in Phase 6, revisit then.
  • [x] Kept the null-UWorldPartitionSubsystem guard (non-WP worlds report complete on first poll) intact in the shared mechanic — Register/Unregister still no-op when the subsystem is absent, and IsRespawnStreamingComplete's "no WP → true" path is unchanged.

Verification.

  • Regression gate (mandatory). Run a full death→respawn cycle (cheat-kill → respawn) and confirm the pre-warm + fade still behave exactly as before the refactor — before any teleport code is layered on. If the refactor proves unsafe, fall back to a dedicated duplicate provider (contract's rejected alternative) and note it here.

Exits. Phase 6 can register a second warm instance at the teleport destination.

Footguns.

  • This touches the death→respawn flow — the regression gate is not optional. Generalizing shared streaming code with teleport-shaped assumptions could silently break respawn warming, which has no loud failure (players just see pop-in).
  • World partition is optional: keep proceeding-on-null-subsystem behavior; teleport in a non-WP test map must still complete warming trivially.

Phase 3 — Station + Modal Open Path (+ Distinct Map Identity)

Goal. A placed ATeleportStation you can walk up to and interact with, which opens an (initially empty) teleport modal, and which shows a distinct map/minimap identity.

Rationale. Test fixture before content: the station + open path must exist before the list has anywhere to render. Reuses the station→trigger→modal path verbatim from the other terminals — zero new C++ on the open path beyond the station actor and its fired tag.

Tasks.

  • [x] Station — Source/CRADL/World/TeleportStation.h + .cpp (new): ATeleportStation : AActor, IInteractable, mirroring ACraftingStation/ALoadoutTerminal. Carries NetworkTag (Teleport.Network.*, meta=(Categories="Teleport.Network")) with a GetNetworkTag() getter for the widget. CanInteract range-gates via InteractionDistanceSquared (fails on invalid NetworkTag with InteractCheck.Reason.MissingRequirement, mirroring the crafting station's tag gate); BeginInteract fires CradlTags::Action_Trigger_Modal_Teleport on the interacting pawn's ASC via HandleGameplayEvent, Target = OptionalObject = this, local-only (non-locally-controlled returns true → server re-run no-ops). GatherActions returns the default Teleport action + an Examine.
  • [x] GetMenuHeader() returns the station's FCradlMenuHeader for page branding.
  • [x] Replication: bReplicates = false explicit (mirrors ALoadoutTerminal, not ACraftingStation which sets true). Verified the interaction system's Server_BeginInteract(AActor* Target) RPC resolves this placed actor by its stable level NetGUID without replication, so no replicated state is needed; the teleport event fires on the pawn's own ASC. (P2P audit: no server-mutated station state exists to replicate.)
  • [ ] Modal routing (BP/data — user): add the BP-authored ModalPageClasses entry Action.Modal.Teleport → UTeleportMenuWidget on the HUD layout Blueprint. Author a UCradlModalAbility BP subclass with ModalTag = Action.Modal.Teleport + a GameplayEvent trigger on Action.Trigger.Modal.Teleport, and grant it to the player (mirror the crafting modal ability). C++ side is complete; this is Blueprint wiring only.
  • [x] Distinct map identity (OQ1): grounded against MAP_SYSTEM.md — the system splits coarse Map.Icon.* (live radar) from fine Map.WorldIcon.* (baked landmark). Added Map.Icon.Teleport + Map.WorldIcon.Teleport to Config/DefaultGameplayTags.ini and as native symbols in CradlGameplayTags.h/.cpp (C++ names them in the station ctor). The station's UMapTrackerComponent defaults to MapIconTag = Map_Icon_Teleport + WorldMapIconTag = Map_WorldIcon_Teleport — the DISTINCT teleport iconography, not the shared Map.Icon.Station. Component is VisibleAnywhere so per-placement override stays available. (Content follow-up: add Map.Icon.Teleport + Map.WorldIcon.Teleport presentation rows to UMapIconPresentationDataAsset, else the blip/landmark resolve to no icon.)
  • [x] Menu widget shell — Source/CRADL/UI/TeleportMenuWidget.h + .cpp (new): UTeleportMenuWidget : UCradlActivatableWidget that, on NativeOnActivated, recovers the station via GetModalContext()->Target (concrete Cast<ATeleportStation> — single station source in v1, no ITeleportModalSource interface yet) and caches its NetworkTag. Empty list body; a Warning-level diagnostic logs the resolved station + network per feedback_log_level_warning_for_diagnostics.

Verification.

  • Place an ATeleportStation with a NetworkTag; walk into range → interact prompt appears (range gate) → interacting opens the (empty) teleport modal on the UI.Layer.Modal stack.
  • On the world map + minimap, the station shows the distinct teleport iconography, not the shared Map.Icon.Station.

Exits. Phase 4 renders rows into this widget.

Footguns.

  • Don't name a local Slot or Widget in ATeleportStation/UTeleportMenuWidget code (C4458 hides UWidget::Slot — per feedback_slot_shadows_uwidget.md); use SpawnedRow/DestSlotData.
  • The station's map-tracker component is VisibleAnywhere — decide the distinct landmark deliberately at the CDO, don't leave it defaulting to the shared station icon (that would silently re-collapse OQ1).

Phase 4 — Destination List UI, Selection & Confirmation

Goal. The teleport modal renders one row per destination for the station's network, with met/unmet requirement display, single-selection, and a post-selection Yes/No confirmation that (on Yes) fires the execute trigger.

Rationale. Independent verb built on the Phase 1 spine + Phase 3 open path. The confirmation is bundled here (not split) because it is raised from the menu widget post-selection — it shares the widget's lifetime and selection state, so a single open→select→confirm flow is one verifiable unit.

Tasks.

  • [x] List trio (new) — mirrors the loadout entry→button→group trio verbatim:
  • [x] UTeleportEntryListItem (Source/CRADL/UI/TeleportEntryListItem.h + .cpp) — one row; holds the UTeleportDestinationDefinition, exposes SelectButton + GetDestination(), fires OnDestinationRefresh (mirrors ULoadoutEntryListItem).
  • [x] UTeleportSelectButton : UCommonButtonBase (Source/CRADL/UI/TeleportSelectButton.h + .cpp) — BindWidget'd in the row; deferred OnDataApplied + forced SetIsSelectable(true) in NativeConstruct (mirrors ULoadoutSelectButton); wired into the menu's UCommonButtonGroupBase.
  • [x] UTeleportMenuWidget populates rows from the registry's async result: RebuildListRegistry->EnsureBuilt(CreateUObject(this, &PopulateList)); PopulateList guards on IsActivated(), then builds under the suppression guard. ButtonGroup created in NativeConstruct with SetSelectionRequired(true).
  • [x] Presentation — Source/CRADL/UI/TeleportPresentation.h (new): FTeleportDestinationPresentation carrying DisplayName, soft Icon, DestinationId (FPrimaryAssetId), skill rows via Cradl::UI::BuildSkillRequirementPresentation, quest rows via BuildQuestRequirementPresentation, and bMeetsRequirements. No raw FGameplayTag reaches the widget — tags resolve to DisplayName + Icon companions (per feedback_presentation_struct_resolve_tags.md), mirroring FLoadoutPresentationData. (Item / equipped-tag axes have no client row — server-enforced only, matching the loadout menu; noted in the struct DevComment.)
  • [x] Confirmation: on a genuine row selection, UTeleportMenuWidget::RaiseConfirm calls UCradlHUDLayout::PushConfirmDialog(FCradlConfirmDialogContent, OnResolved); Title = ConfirmTitle, Body = FText::Format(ConfirmBodyFormat, DisplayName) ("Teleport to ?"). InCombatTooltipText populated (OQ2) so the confirm disables under Status.InCombat. OnResolved(true)DispatchTeleportExecute; OnResolved(false)/ESC dismisses. Callback captures a TWeakObjectPtr to the page (survives the page outliving the closure guard).
  • [x] Requirement evaluation (client, advisory): BuildPresentation builds met/unmet locally from the destination Requirements (skills via SkillsComponent, quests via QuestComponent) against BoundPS; HandleSelectedButtonChanged blocks the confirm for !bMeetsRequirements rows (loadout precedent). Authority re-checks on execute (Phase 5). (Note: the doc originally named FQuestRequirements::IsSatisfiedBy for this; I used the row-builder path instead — same result for the visible skill/quest axes, and it also produces the checklist rows the WBP needs. IsSatisfiedBy is the server's all-axes check in Phase 5.)
  • [x] Dispatch (Phase 4 stub): DispatchTeleportExecute fires Action.Trigger.Teleport.Execute on the player's ASC via HandleGameplayEvent (Target = station) and logs the destination id + name at Warning (feedback_log_level_warning_for_diagnostics). Phase 5 replaces this with the UTeleportRequest payload (carrying DestinationId) and the client→authority delivery the ServerOnly ability needs — HandleGameplayEvent here is a local, observable no-op until then.
  • [x] Fire-and-close (ability-first): DispatchTeleportExecute ends with RequestCloseOwningModal() (new shared helper on UCradlActivatableWidget, extracted from NativeOnDeactivated's cancel loop) — teleport is a one-shot verb, not an eager-apply panel like the loadout menu (which deliberately stays open for repeated swaps and owns no Action.ModalClose). The modal ability is the source of truth: canceling it clears Action.Modal.Teleport, and the HUD router's count-0 branch removes the page — the same direction as the hotkey re-press close. Not DeactivateWidget() (widget-first tears the page down around a still-active ability), and not the craft precedent of ActivationOwnedTags = Action.ModalClose on the verb ability — UCraftAbility is LocalPredicted so that tag lands client-side, but UTeleportAbility is ServerOnly, so its owned tags never reach the client ASC where the modal's CancelOnTagsAdded listens. The server's range re-check remains the authoritative backstop against re-dispatch.
  • [x] Replication: none — presentation + confirm are client-local UI state.

Verification.

  • Open the modal at a station whose network has the Phase 1 fixtures → rows appear, one per well-formed destination, with correct met/unmet styling.
  • Select a destination → confirm dialog names it. In combat (Status.InCombat cheat) the confirm is disabled with the in-combat tooltip. Confirm → Action.Trigger.Teleport.Execute fires (log/trace at Warning per feedback_log_level_warning_for_diagnostics.md).

Exits. Phase 5 consumes the fired execute event.

Footguns.

  • Bind child-widget delegates in NativeConstruct, not NativeOnInitialized — the teleport page is a reused modal; NativeDestruct unbinds on every close, so a NativeOnInitialized-only binding is dead after the first reopen (per feedback_bind_child_delegate_in_nativeconstruct.md).
  • Pages initialize pre-activation — any field read in NativeOnActivated (GetModalContext, bound player state) must be set in the AddWidget init lambda.
  • UCommonButtonGroupBase re-entrant selectionOnSelectedButtonBaseChanged fires on AddWidget (auto-select-first) and RemoveAll (cascade). Append rows before AddWidget; snapshot saved selection before RemoveAll.

Phase 5 — Execute Ability, Authority & Client-Ready Handshake

Goal. A ServerOnly ability that, on the fired execute event, re-validates on authority and relocates the server pawn — gated on an owning-client readiness RPC (transition/warming layered in Phase 6).

Rationale. The relocate is an authoritative world mutation, so it lives server-side, mirroring UDeathAbility (ServerOnly, waits on a client-readiness RPC). Depends on Phase 1 (definitions to resolve) and Phase 4 (the widget that dispatches the event).

Tasks.

  • [x] Ability — Source/CRADL/Abilities/TeleportAbility.h + .cpp (new): UTeleportAbility : UCradlGameplayAbility, NetExecutionPolicy = ServerOnly, InstancedPerActor (mirrors UDeathAbility). Triggered by GameplayEvent Action.Trigger.Teleport.Execute. ActivationBlockedTags includes Status.InCombat (OQ2 authoritative combat block). No cooldown (OQ2).
  • [x] Grant — Source/CRADL/Player/CradlPlayerState.cpp (safeguard, not BP-only): granted in C++ in BeginPlay's ability block, mirroring the URelicSwapAbility / URedeemVoucherAbility / UDeathAbility pattern — every HandleGameplayEvent(Action.Trigger.*)-dispatched verb needs a granted spec or the event dispatches to nothing. !DefaultAbilities.Contains(UTeleportAbility::StaticClass()) dedupes if a designer also lists it in the BP CDO. So no BP wiring is required for the execute ability — only the Phase 3 modal ability (UCradlModalAbility subclass) remains BP-authored. Without this the widget's ServerRequestTeleport → HandleGameplayEvent would silently no-op.
  • [x] Request payload — Source/CRADL/Abilities/TeleportRequest.h (new): UTeleportRequest transient UObject carrying the station weak ref + the destination's FPrimaryAssetId (never a raw definition pointer — the id survives the wire and is re-resolved server-side). Mirrors ULoadoutSwapRequest. Built authority-side (not on the client) — see the dispatch deviation below.
  • [x] Dispatch — DEVIATION from the doc (the Phase-4 report's flag, now resolved): the doc named FireReplicatedGameplayEvent(…, OptionalObject=Request) for client→authority delivery, but that overload is authority→peers multicast; on a non-authority client it does a local-only fanout, and GAS's own ServerOnly activation forwarding (CallServerTryActivateAbility) drops the OptionalObject — so the FPrimaryAssetId never reaches the server. Replaced with a dedicated ACradlPlayerController::ServerRequestTeleport(FPrimaryAssetId, AActor* Station) (Server, Reliable): the widget sends the raw id + station; the authority-side handler rebuilds the UTeleportRequest and fires Action.Trigger.Teleport.Execute locally on the owner's ASC (on authority, HandleGameplayEvent reaches the ServerOnly ability with the payload intact). This is idiomatic here — it mirrors the existing ServerNotify* controller-RPC pattern the respawn flow uses.
  • [x] Server-side resolution + validation: UTeleportAbility::OnDestinationResolved async-resolves the FPrimaryAssetIdUTeleportDestinationDefinition via UAssetManager::LoadPrimaryAsset (never LoadObject; null-handle → resolve synchronously so it's never stranded); re-runs FQuestRequirements::IsSatisfiedBy on the owner's server-side ACradlPlayerState; re-checks station range via the station's own CanInteract(Pawn).bCanInteract (skipped when Station is null — the cheat path). Evaluated once at activation (mirror UCraftAbility).
  • [x] Client-ready handshake + the race resolution: added ServerNotifyTeleportReady (Server, Reliable) + OnTeleportReadyReceived delegate on CradlPlayerController.h, parallel to the respawn pair, plus a new UAbilityTask_WaitTeleportReady modeled on UAbilityTask_WaitRespawnReady. Extra ClientStartTeleportWarm(FTransform) (Client, Reliable) added because — unlike respawn, where a replicated state flip triggers the client — the teleport client has no server-side signal to know when to fire ready. After the ability subscribes the wait task, it calls ClientStartTeleportWarm on the owning PC; the client (Phase 5) fires ServerNotifyTeleportReady immediately (Phase 6 inserts fade + warm here — the transform is handed over for exactly that). This makes the handshake race-free: the ready round-trip is a response to a message sent only after subscription, so the broadcast can never precede the subscription.
  • [x] Move: on the ready signal, OnTeleportReady calls SetActorLocationAndRotation(Destination.Location, …, ETeleportType::TeleportPhysics) on the server pawn (authority-gated), then EndAbility. The movement component replicates the new transform to peers.
  • [x] Cheat fixture — UCradlDebugComponent: Debug_TeleportForce <networkTag> <destId> (Debug_ prefix — dotted names aren't valid C++ identifiers) dispatches straight through ServerRequestTeleport with a null station, exercising the server resolve + re-validation + move in isolation; destId accepts a full Type:Name id or the bare asset name.

Replication audit (per feedback_p2p_replication_audit.md — all deliberate):

  • Pawn transform — already replicated by the movement component; server SetActorLocation propagates to peers. ✔
  • UTeleportRequest — transient, authority-side-only; never leaves the server. Destination identity travels client→authority as a bare FPrimaryAssetId inside the ServerRequestTeleport RPC (see the dispatch deviation), then the request object is built on authority and re-resolved server-side. ✔
  • Action.Modal.Teleport — loose modal tag, client-local UI; not replicated authority. ✔
  • Requirement re-validation — server-authoritative, reads owner's own skills/quests/inventory/equipped tags (RequiredEquippedTags is COND_OwnerOnly but the owner is present on its own authority). ✔
  • No cooldown state to replicate (OQ2). ✔

Verification.

  • Cheat Debug_TeleportForce <networkTag> <destId> (bypasses UI): dispatches through ServerRequestTeleport → server pawn relocates to Location, and the move replicates to a second connected peer (verify on a listen-server + client). Requirement-failing destination is refused on authority even if the client dispatches it.

Exits. Phase 6 inserts warm + fade between confirm and the SetActorLocation.

Footguns.

  • Don't make the execute ability LocalPredicted — the move is authoritative and gated on a client-local warm the server can't predict; prediction risks teleporting into unwarmed cells ahead of the server. ServerOnly + client-ready RPC is the correct shape (mirrors respawn; not ULoadoutSwapAbility's LocalPredicted — that mutates prediction-friendly inventory, not spatial geometry).
  • Resolve the destination async server-side, never LoadObject (CLAUDE.md).

Phase 6 — Warm-then-Move Transition

Goal. The full felt experience: confirm → fade to black → warm the destination cells during the fade → move on the server once warm-complete (or timeout) → fade in at the destination. No visible pop-in, no loading screen.

Rationale. Polish/transition lands last, after the authoritative move loop closes. Reuses the cinematic-control fade seam and the Phase 2 shared warm provider. Gating fade-in on warm-complete hides pop-in without a loading-screen floor.

Pre-work. (Depends on Phase 2's shared provider.)

  • The generalized UPointWarmStreamingSourceProvider (Phase 2) — teleport owns a second instance, registered at Destination.Location.

Tasks.

  • [x] Owning-client warm polling on CradlPlayerController.h: StartTeleportWarmPolling / StopTeleportWarmPolling / PollTeleportWarm / FireTeleportReady / IsTeleportStreamingComplete (new), parallel to the respawn pair and reusing the shared poll cadence (RespawnStreamingPollInterval / MaxStreamingWaitSeconds) as-is, not aliased (the Phase 2 decision). Registers a second UPointWarmStreamingSourceProvider (TeleportStreamingSource, distinct from RespawnStreamingSource) at PendingTeleportDestination; polls UWorldPartitionSubsystem::IsStreamingCompleted (poll timer + MaxStreamingWaitSeconds deadline) until complete, then FireTeleportReadyServerNotifyTeleportReady. All IsLocalController()-gated, never replicated — each peer warms its own cells. The immediate first poll (mirroring StartRespawnReadyPolling) means non-WP / already-resident cells complete same-frame.
  • [x] Cinematic-control fade: the owning client's ClientStartTeleportWarm (the seam, not the literal confirm button — the transform is handed over here, per the Phase 5 note) BeginCinematicControl(Cinematic.Teleport) and fades to black (FadeOutDuration, held); warming starts immediately so streaming overlaps the fade-out ramp. Fade-in + EndCinematicControl(Cinematic.Teleport) run in FinishTeleportTransition (FadeInDuration), driven by the completion signal below.
  • [ ] Cinematic IMC entry (BP/data — user): add a Cinematic.Teleport entry to CinematicInputContexts on PC_CRADL, pointing at a new empty (no-binding) IMC_Teleport — the map's documented "fully arrest input" form. Without the entry, BeginCinematicControl(Cinematic.Teleport) hits its deliberate "no IMC authored for mode" early-out (Warning in the log) and never removes the default IMC — the player can click-to-move and fire commands throughout the fade/held black. Do not reuse IMC_LevelIntro: it carries the skip-intro binding, which would fire the intro-skip path mid-teleport.
  • [x] Move gated on FULL black, not the fade-out ramp: FireTeleportReady (→ ServerNotifyTeleportReady → authoritative move) is gated on both the fade-out completing (OnTeleportFadeOutComplete / TeleportFadeOutTimer at FadeOutDuration) and warm-complete. Because warming is effectively instant, it's never the pacing driver — the fade-out completion is — so the relocation always happens under held black, never mid-ramp (the "never reveal the relocation mid-cut" footgun; firing on warm-complete alone would move during the fade-out ramp, or same-frame on a listen server). Warm-complete only becomes the gate in the rare genuinely-slow-stream case, up to the MaxStreamingWaitSeconds safety cap. This is the concrete meaning of the contract's "gated on warm-complete-or-timeout, not a fixed duration."
  • [x] Minimum time-under-black floor (TeleportMinBlackHoldSeconds, default 2s): the fade-in is gated on max(min-black floor, move confirmed) via MaybeStartTeleportFadeIn (both bTeleportMinBlackElapsed + bTeleportMoveConfirmed must be set). The floor runs from fade-out-complete (OnTeleportMinBlackElapsed / TeleportMinBlackTimer), so even when warming + the move resolve instantly the screen still holds black for the full floor — a feel mechanic per the loading min-hold principle ("ready too fast feels unofficial"), so a teleport reads as a deliberate, weighty jump rather than a hitch. The warm + move both happen inside this window. ClientFinishTeleport now just sets the move-confirmed gate; the finish backstop still force-completes (it fires at MaxStreamingWaitSeconds > the floor, so black is already satisfied).
  • [x] DEVIATION — server→client completion signal ClientFinishTeleport (Client, Reliable): unlike respawn (whose fade-in is gated on the replicated ECradlDeathState::Alive flip, guaranteed post-move), teleport has no replicated state flip to signal "move done." A purely client-driven fade-in (fade in the instant warm completes) would be correct only on a listen server, where ServerNotifyTeleportReady runs the move synchronously; on a remote owning client it races the replicated move and could reveal the old location — exactly the footgun "never reveal the relocation mid-cut." So UTeleportAbility::OnTeleportReady calls PC->ClientFinishTeleport() immediately after the authoritative SetActorLocation; the reliable RPC is ordered after the move's property replication, so the reveal always follows the relocation. This is the completion twin of ClientStartTeleportWarm and mirrors respawn's server→client shape.
  • Finish backstop (TeleportFinishDeadlineTimer): armed in FireTeleportReady before notifying (on a listen server the whole authority chain, including ClientFinishTeleport, runs inline and clears it). If ClientFinishTeleport never arrives — the ability ended without moving — the backstop releases the fade + input arrest anyway (MaxStreamingWaitSeconds), so a player is never stranded in held black with input locked. FinishTeleportTransition is guarded by bTeleportTransitionActive so backstop + real RPC can't double-fire.
  • [x] Fade durations: reuses FadeOutDuration / FadeInDuration (the canonical transition-cover fades the respawn flow already uses) rather than introducing teleport-specific fields — the same "share the cadence fields as-is" call as Phase 2's poll cadence.
  • [x] Replication: the entire channel/fade is client-local cosmetic (per CLAUDE.md's cosmetic-state prohibition); only the mid-sequence SetActorLocation is authoritative + replicated. ClientStartTeleportWarm / ClientFinishTeleport are directed RPCs to the owning client, not replicated state.

Verification.

  • Author a destination in a world-partition-streamed-out cell far from spawn. Teleport there: screen fades to black, cells stream in behind the fade, fade-in reveals fully-loaded geometry with no pop-in. On a slow stream the safety timeout still completes the move (fade covers residual pop-in).
  • Non-WP test map: warming reports complete on first poll; the move + fade still sequence correctly (null-subsystem guard).

Footguns.

  • EndCinematicControl is mode-matched — end with the same Cinematic.Teleport mode you began with, so a concurrent intro/other cinematic mode isn't torn down (per LEVEL_FLOW input-arrest composition).
  • Input is arrested during the channel — ensure the execute ability isn't also listening for State.Moving to self-cancel during this window.
  • Never reveal the relocation mid-cut — keep the fade holding black across the SetActorLocation frame (spirit of reference_sequence_exit_fade_pause_at_end.md); a fade-in that races the move shows the player jumping in full view.
  • Regression: this is the second consumer of the Phase 2 provider — re-confirm respawn still warms after teleport polling is added (shared cadence fields, shared provider type).