# v0.4.0 Advanced Property Types — Implementation Plan

> **For agentic workers:** Steps use checkbox (`- [ ]`) syntax for tracking. Self-review before reporting per superpowers:subagent-driven-development.

**Goal:** Extend `PropertyCoercion` from v0.3.0 to handle USTRUCT / TArray / TMap / TSet / FObjectProperty, plus add property-name path traversal. No new MCP handlers; existing `set_actor_property` and friends benefit transparently.

**Spec:** [`docs/superpowers/specs/2026-05-08-v0.4.0-advanced-property-types-design.md`](../specs/2026-05-08-v0.4.0-advanced-property-types-design.md)

**Branch:** `feat/v0.4.0-advanced-property-types` off `4b44e67` (post-v0.3.0).

---

## Phase 1 — Extend PropertyCoercion (single subagent, all C++)

### Task 1: Extend PropertyCoercion for all v0.4.0 types + path traversal

**Files:**
- Modify: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/PropertyCoercion.h`
- Modify: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/PropertyCoercion.cpp`
- Modify: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_SetActorProperty.cpp` — wire path traversal at entry point.
- Modify: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_SpawnActor.cpp` — same: wire path traversal where it iterates `properties` map.
- Modify: `UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_AddComponent.cpp` — same for `relative_transform`.

**Steps:**

- [ ] **Step 1: Add header signatures**

In `PropertyCoercion.h`, extend the public surface:

```cpp
namespace UCMCP::PropertyCoercion
{
    // Existing function signatures get a new param:
    FCoerceOutcome SetProperty(
        UObject* Container,
        FProperty* Property,
        void* PropAddr,
        const TSharedPtr<FJsonValue>& Value,
        const FString& CurrentPath = TEXT("."),  // NEW
        int32 Depth = 0                            // NEW
    );

    TSharedPtr<FJsonValue> EncodeProperty(
        FProperty* Property,
        const void* PropAddr,
        const FString& CurrentPath = TEXT("."),  // NEW
        int32 Depth = 0                            // NEW
    );

    // NEW: path traversal entry point
    struct FResolvedProperty
    {
        FProperty* Property = nullptr;
        void* Container = nullptr;        // The struct/object memory the property lives in
        void* PropAddr = nullptr;          // Address of the property value within Container
        FString ResolvedPath;              // The full path that was resolved (for error messages)
    };

    FCoerceOutcome ResolvePropertyPath(
        UObject* RootObject,
        const FString& DottedPath,
        FResolvedProperty& OutResolved
    );

    // NEW: depth limit constant
    constexpr int32 kMaxCoercionDepth = 8;
}
```

- [ ] **Step 2: Implement `ResolvePropertyPath`**

In `PropertyCoercion.cpp`, add at the top of the namespace:

```cpp
FCoerceOutcome ResolvePropertyPath(UObject* RootObject, const FString& DottedPath, FResolvedProperty& OutResolved)
{
    if (!RootObject)
    {
        return MakeOutcome(ECoerceResult::TypeMismatch, TEXT("ResolvePropertyPath: null root object"));
    }

    TArray<FString> Segments;
    DottedPath.ParseIntoArray(Segments, TEXT("."), /*CullEmpty=*/true);
    if (Segments.Num() == 0)
    {
        return MakeOutcome(ECoerceResult::Invalid, TEXT("empty property path"));
    }

    UStruct* CurrentStruct = RootObject->GetClass();
    void* CurrentContainer = RootObject;
    FString PathSoFar;

    for (int32 i = 0; i < Segments.Num(); ++i)
    {
        const FString& Segment = Segments[i];
        PathSoFar = PathSoFar.IsEmpty() ? Segment : PathSoFar + TEXT(".") + Segment;

        FProperty* Prop = CurrentStruct->FindPropertyByName(FName(*Segment));
        if (!Prop)
        {
            return MakeOutcome(
                ECoerceResult::PropertyNotFound,
                FString::Printf(TEXT("property_not_found at .%s: '%s' on %s"),
                    *PathSoFar, *Segment, *CurrentStruct->GetName())
            );
        }

        void* PropAddr = Prop->ContainerPtrToValuePtr<void>(CurrentContainer);

        if (i == Segments.Num() - 1)
        {
            // Last segment — this is the target.
            OutResolved.Property = Prop;
            OutResolved.Container = CurrentContainer;
            OutResolved.PropAddr = PropAddr;
            OutResolved.ResolvedPath = PathSoFar;
            return MakeOutcome(ECoerceResult::Success);
        }

        // Not last — must be traversable.
        if (FStructProperty* StructProp = CastField<FStructProperty>(Prop))
        {
            CurrentStruct = StructProp->Struct;
            CurrentContainer = PropAddr;
        }
        else if (FObjectProperty* ObjProp = CastField<FObjectProperty>(Prop))
        {
            UObject* Pointed = ObjProp->GetObjectPropertyValue(PropAddr);
            if (!Pointed)
            {
                return MakeOutcome(
                    ECoerceResult::TypeMismatch,
                    FString::Printf(TEXT("path_traversal_null at .%s: cannot continue through null UObject"), *PathSoFar)
                );
            }
            CurrentStruct = Pointed->GetClass();
            CurrentContainer = Pointed;
        }
        else
        {
            return MakeOutcome(
                ECoerceResult::Unsupported,
                FString::Printf(TEXT("path_traversal_invalid_type at .%s: cannot traverse through %s"),
                    *PathSoFar, *Prop->GetClass()->GetName())
            );
        }
    }

    return MakeOutcome(ECoerceResult::Invalid, TEXT("unreachable"));
}
```

- [ ] **Step 3: Add depth check at the top of SetProperty + EncodeProperty**

```cpp
if (Depth >= kMaxCoercionDepth)
{
    return MakeOutcome(
        ECoerceResult::Invalid,
        FString::Printf(TEXT("recursion_depth_exceeded at %s (max %d)"), *CurrentPath, kMaxCoercionDepth)
    );
}
```

- [ ] **Step 4: Add USTRUCT branch in SetProperty**

Inside `SetProperty`, after existing primitive branches:

```cpp
if (FStructProperty* StructProp = CastField<FStructProperty>(Property))
{
    const TSharedPtr<FJsonObject>* AsObj;
    if (!Value->TryGetObject(AsObj))
    {
        return MakeOutcome(ECoerceResult::TypeMismatch,
            FString::Printf(TEXT("expected JSON object for struct at %s"), *CurrentPath));
    }

    UScriptStruct* Struct = StructProp->Struct;
    for (const auto& Pair : (*AsObj)->Values)
    {
        const FString& FieldName = Pair.Key;
        FProperty* FieldProp = Struct->FindPropertyByName(FName(*FieldName));
        if (!FieldProp)
        {
            return MakeOutcome(ECoerceResult::PropertyNotFound,
                FString::Printf(TEXT("field_not_found at %s.%s: '%s' on %s"),
                    *CurrentPath, *FieldName, *FieldName, *Struct->GetName()));
        }
        void* FieldAddr = FieldProp->ContainerPtrToValuePtr<void>(PropAddr);
        const FString ChildPath = (CurrentPath == TEXT(".")) ? (TEXT(".") + FieldName) : (CurrentPath + TEXT(".") + FieldName);
        FCoerceOutcome FieldOutcome = SetProperty(Container, FieldProp, FieldAddr, Pair.Value, ChildPath, Depth + 1);
        if (FieldOutcome.Result != ECoerceResult::Success)
        {
            return FieldOutcome;  // Bubble up the path-prefixed error
        }
    }
    return MakeOutcome(ECoerceResult::Success);
}
```

- [ ] **Step 5: Add TArray branch**

```cpp
if (FArrayProperty* ArrayProp = CastField<FArrayProperty>(Property))
{
    const TArray<TSharedPtr<FJsonValue>>* AsArr;
    if (!Value->TryGetArray(AsArr))
    {
        return MakeOutcome(ECoerceResult::TypeMismatch,
            FString::Printf(TEXT("expected JSON array for TArray at %s"), *CurrentPath));
    }

    FScriptArrayHelper Helper(ArrayProp, PropAddr);
    Helper.EmptyAndAddUninitializedValues(AsArr->Num());
    for (int32 i = 0; i < AsArr->Num(); ++i)
    {
        void* ElemAddr = Helper.GetRawPtr(i);
        const FString ChildPath = FString::Printf(TEXT("%s[%d]"), *CurrentPath, i);
        FCoerceOutcome ElemOutcome = SetProperty(Container, ArrayProp->Inner, ElemAddr, (*AsArr)[i], ChildPath, Depth + 1);
        if (ElemOutcome.Result != ECoerceResult::Success)
        {
            return ElemOutcome;
        }
    }
    return MakeOutcome(ECoerceResult::Success);
}
```

- [ ] **Step 6: Add TSet branch (parallel to TArray)**

```cpp
if (FSetProperty* SetProp = CastField<FSetProperty>(Property))
{
    const TArray<TSharedPtr<FJsonValue>>* AsArr;
    if (!Value->TryGetArray(AsArr))
    {
        return MakeOutcome(ECoerceResult::TypeMismatch,
            FString::Printf(TEXT("expected JSON array for TSet at %s"), *CurrentPath));
    }

    FScriptSetHelper Helper(SetProp, PropAddr);
    Helper.EmptyElements(AsArr->Num());
    for (int32 i = 0; i < AsArr->Num(); ++i)
    {
        const int32 NewIndex = Helper.AddDefaultValue_Invalid_NeedsRehash();
        void* ElemAddr = Helper.GetElementPtr(NewIndex);
        const FString ChildPath = FString::Printf(TEXT("%s[%d]"), *CurrentPath, i);
        FCoerceOutcome ElemOutcome = SetProperty(Container, SetProp->ElementProp, ElemAddr, (*AsArr)[i], ChildPath, Depth + 1);
        if (ElemOutcome.Result != ECoerceResult::Success)
        {
            return ElemOutcome;
        }
    }
    Helper.Rehash();
    return MakeOutcome(ECoerceResult::Success);
}
```

- [ ] **Step 7: Add TMap branch (string-keyed only)**

```cpp
if (FMapProperty* MapProp = CastField<FMapProperty>(Property))
{
    if (!CastField<FStrProperty>(MapProp->KeyProp) && !CastField<FNameProperty>(MapProp->KeyProp))
    {
        return MakeOutcome(ECoerceResult::Unsupported,
            FString::Printf(TEXT("unsupported_property_type at %s: TMap with non-string key (got %s)"),
                *CurrentPath, *MapProp->KeyProp->GetClass()->GetName()));
    }

    const TSharedPtr<FJsonObject>* AsObj;
    if (!Value->TryGetObject(AsObj))
    {
        return MakeOutcome(ECoerceResult::TypeMismatch,
            FString::Printf(TEXT("expected JSON object for TMap at %s"), *CurrentPath));
    }

    FScriptMapHelper Helper(MapProp, PropAddr);
    Helper.EmptyValues();
    for (const auto& Pair : (*AsObj)->Values)
    {
        const int32 NewIndex = Helper.AddDefaultValue_Invalid_NeedsRehash();
        void* KeyAddr = Helper.GetKeyPtr(NewIndex);
        void* ValAddr = Helper.GetValuePtr(NewIndex);

        // Set key (string or name)
        if (FStrProperty* StrKey = CastField<FStrProperty>(MapProp->KeyProp))
        {
            StrKey->SetPropertyValue(KeyAddr, Pair.Key);
        }
        else if (FNameProperty* NameKey = CastField<FNameProperty>(MapProp->KeyProp))
        {
            NameKey->SetPropertyValue(KeyAddr, FName(*Pair.Key));
        }

        // Coerce value
        const FString ChildPath = FString::Printf(TEXT("%s.%s"), *CurrentPath, *Pair.Key);
        FCoerceOutcome ValOutcome = SetProperty(Container, MapProp->ValueProp, ValAddr, Pair.Value, ChildPath, Depth + 1);
        if (ValOutcome.Result != ECoerceResult::Success)
        {
            return ValOutcome;
        }
    }
    Helper.Rehash();
    return MakeOutcome(ECoerceResult::Success);
}
```

- [ ] **Step 8: Add FObjectProperty branch**

```cpp
if (FObjectProperty* ObjProp = CastField<FObjectProperty>(Property))
{
    FString S;
    if (!Value->TryGetString(S))
    {
        return MakeOutcome(ECoerceResult::TypeMismatch,
            FString::Printf(TEXT("expected asset path string at %s"), *CurrentPath));
    }
    if (S.IsEmpty())
    {
        ObjProp->SetObjectPropertyValue(PropAddr, nullptr);
        return MakeOutcome(ECoerceResult::Success);
    }
    UObject* Loaded = LoadObject<UObject>(nullptr, *S);
    if (!Loaded)
    {
        return MakeOutcome(ECoerceResult::TypeMismatch,
            FString::Printf(TEXT("asset_not_found at %s: '%s'"), *CurrentPath, *S));
    }
    if (!Loaded->IsA(ObjProp->PropertyClass))
    {
        return MakeOutcome(ECoerceResult::TypeMismatch,
            FString::Printf(TEXT("wrong_object_class at %s: expected %s, got %s"),
                *CurrentPath,
                *ObjProp->PropertyClass->GetName(),
                *Loaded->GetClass()->GetName()));
    }
    ObjProp->SetObjectPropertyValue(PropAddr, Loaded);
    return MakeOutcome(ECoerceResult::Success);
}
```

- [ ] **Step 9: Implement matching EncodeProperty (read-back) branches**

For each new SET branch above, add a matching ENCODE (read) branch. USTRUCT encodes to JSON object, TArray to JSON array, TMap to JSON object, TSet to JSON array, FObjectProperty to JSON string (path) or null.

Use depth tracking + path tracking on encode side too. Keep symmetric to SetProperty.

- [ ] **Step 10: Update existing primitive branches with path-prefixed error messages**

For consistency, all existing error messages get the `at <CurrentPath>` suffix. e.g.:
```cpp
// before:
TEXT("expected bool")
// after:
FString::Printf(TEXT("expected bool at %s"), *CurrentPath)
```

- [ ] **Step 11: Wire path traversal in `Handler_SetActorProperty.cpp`**

Replace the existing single-property lookup with `ResolvePropertyPath`:

```cpp
// before (existing):
FProperty* Prop = Tex->GetClass()->FindPropertyByName(FName(*PropertyName));
if (!Prop) { OutError = ...; return nullptr; }

// after:
PropertyCoercion::FResolvedProperty Resolved;
FCoerceOutcome ResolveOutcome = PropertyCoercion::ResolvePropertyPath(Actor, PropertyName, Resolved);
if (ResolveOutcome.Result != ECoerceResult::Success)
{
    OutError = FString::Printf(TEXT("set_actor_property: %s"), *ResolveOutcome.ErrorMessage);
    return nullptr;
}
// Use Resolved.Property, Resolved.Container, Resolved.PropAddr from here
```

Same pattern in `Handler_SpawnActor.cpp` (for the `properties` iteration) and `Handler_AddComponent.cpp` (for `relative_transform` if applicable — though `relative_transform` is always at the root level of the component, may not need full path traversal).

- [ ] **Step 12: Run pytest sanity check**

```bash
py -m pytest tests/ -v
```

Expected: same count as v0.3.0 baseline (tests pass; we haven't bumped version assertion yet).

- [ ] **Step 13: Commit**

```bash
git add UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/PropertyCoercion.h \
        UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/PropertyCoercion.cpp \
        UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_SetActorProperty.cpp \
        UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_SpawnActor.cpp \
        UnrealClaudeMCP/Source/UnrealClaudeMCP/Private/MCP/Handlers/Handler_AddComponent.cpp
git commit -m "feat(unreal): extend PropertyCoercion for USTRUCT/TArray/TMap/TSet/FObject + path traversal"
```

(Use full HEREDOC commit message describing all the additions.)

---

## Phase 2 — Version bumps + smoke + docs (inline edits, single commit)

### Task 2: Bump version + add smoke step + docs

- [ ] Update `UnrealClaudeMCP/UnrealClaudeMCP.uplugin` — version 0.3.0 → 0.4.0; description tweak.
- [ ] Update `UnrealClaudeMCP/Resources/mcp_manifest.json` — version 0.3.0 → 0.4.0; `set_actor_property` description mentions advanced types.
- [ ] Update `bridge/unreal_claude_mcp_bridge.py` — `SERVER_VERSION = "0.4.0"`. Description on `set_actor_property` extended.
- [ ] Update `tests/test_bridge.py` — bump version assertion in `test_manifest_version_matches_bridge_server_version` chain.
- [ ] Update `examples/smoke_test.py` — add new step "9. advanced property types round-trip" (USTRUCT via `RootComponent.RelativeLocation`, TArray via `Tags`).
- [ ] Update `docs/TOOLS.md` — extend `set_actor_property`'s "Supported types" table; add property-path-traversal subsection.
- [ ] Update `README.md` — version 0.3.0 → 0.4.0; status block date 2026-05-08; tools count stays 19.
- [ ] Run `py -m pytest tests/` — verify all green.
- [ ] Single commit: `chore(v0.4.0): version bumps + advanced-props smoke step + docs`.

---

## Phase 3 — Build + smoke + PR

- [ ] **Sync plugin** to host project (robocopy)
- [ ] **Build** with `Build.bat UnrealClaudeMCPTestEditor Win64 Development -project=...`
- [ ] **Live smoke** with running UE — expect step 9 to pass
- [ ] **Push branch** + **open PR**
