docs(B.4b): implementation plan — 6 tasks, TDD picker + handler wiring
Task-by-task plan with full code in every step, no placeholders. Tasks 1+2: WorldPicker.BuildRay + WorldPicker.Pick (TDD: tests first, 8 xUnit Facts total). Task 3: rename _selectedTargetGuid -> _selectedGuid (mechanical). Task 4: add 3 OnInputAction switch cases + 3 private helpers (PickAndStoreSelection, UseCurrentSelection, SendUse). Task 5: Holtburg inn doorway visual test (user-performed). Task 6: ship handoff + close #57 + roadmap/CLAUDE.md/memory updates. Self-review table at bottom maps every spec section to its task(s); all covered. Companion to spec docs/superpowers/specs/2026-05-13-phase-b4b-design.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ffa404d236
commit
179e441d11
1 changed files with 788 additions and 0 deletions
788
docs/superpowers/plans/2026-05-13-phase-b4b-plan.md
Normal file
788
docs/superpowers/plans/2026-05-13-phase-b4b-plan.md
Normal file
|
|
@ -0,0 +1,788 @@
|
|||
# Phase B.4b — Outbound Use Handler Wiring Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Wire double-left-click and the R hotkey to a server `BuildUse` packet via a new `WorldPicker` so the M1 demo target *"open the inn door"* works and L.2g slice 1's deferred visual test verifies in the same scenario.
|
||||
|
||||
**Architecture:** New static `AcDream.Core.Selection.WorldPicker` (pure `BuildRay` + `Pick` functions, no state); rename `_selectedTargetGuid` → `_selectedGuid` on `GameWindow` (unify combat + interaction selection on one field); add three switch cases (`SelectLeft`, `SelectDblLeft`, `UseSelected`) to `GameWindow.OnInputAction` calling three private helpers (`PickAndStoreSelection`, `UseCurrentSelection`, `SendUse`). Spec: [`docs/superpowers/specs/2026-05-13-phase-b4b-design.md`](../specs/2026-05-13-phase-b4b-design.md).
|
||||
|
||||
**Tech Stack:** C# .NET 10 · xUnit · Silk.NET · System.Numerics
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| File | Op | Why |
|
||||
|---|---|---|
|
||||
| `src/AcDream.Core/Selection/WorldPicker.cs` | Create | Static helper with `BuildRay(mouse→world ray)` + `Pick(ray→entity guid)`. No state, no deps beyond `WorldEntity` + `System.Numerics`. |
|
||||
| `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs` | Create | 8 xUnit `[Fact]`s covering BuildRay (center + offset) and Pick (hit/miss/closer/skip-guid/skip-zero/max-distance). |
|
||||
| `src/AcDream.App/Rendering/GameWindow.cs` | Modify | Rename `_selectedTargetGuid` → `_selectedGuid` (~5 sites). Add 3 switch cases + 3 helper methods. |
|
||||
|
||||
No solution-file edits. New files land in existing projects (`AcDream.Core` for the picker, `AcDream.Core.Tests` for its tests; `AcDream.App` for the handler).
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — `WorldPicker.BuildRay` (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.Core/Selection/WorldPicker.cs`
|
||||
- Create: `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests for `BuildRay`**
|
||||
|
||||
Create `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs` with:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Selection;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Selection;
|
||||
|
||||
public class WorldPickerTests
|
||||
{
|
||||
private const float Epsilon = 0.01f;
|
||||
|
||||
private static (Matrix4x4 View, Matrix4x4 Projection) MakeIdentityCamera()
|
||||
{
|
||||
var view = Matrix4x4.Identity;
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
fieldOfView: MathF.PI / 3f,
|
||||
aspectRatio: 16f / 9f,
|
||||
nearPlaneDistance: 0.1f,
|
||||
farPlaneDistance: 100f);
|
||||
return (view, proj);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRay_CenterOfViewport_ReturnsForwardRay()
|
||||
{
|
||||
var (view, proj) = MakeIdentityCamera();
|
||||
const float vpW = 1920f, vpH = 1080f;
|
||||
|
||||
var (_, direction) = WorldPicker.BuildRay(
|
||||
mouseX: vpW / 2f, mouseY: vpH / 2f,
|
||||
viewportW: vpW, viewportH: vpH,
|
||||
view, proj);
|
||||
|
||||
// Right-handed perspective + identity view -> camera looks down -Z.
|
||||
// Center pixel ray = (0, 0, -1) within float epsilon.
|
||||
Assert.True(MathF.Abs(direction.X) < Epsilon, $"direction.X = {direction.X}");
|
||||
Assert.True(MathF.Abs(direction.Y) < Epsilon, $"direction.Y = {direction.Y}");
|
||||
Assert.True(direction.Z < -0.99f, $"direction.Z = {direction.Z}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRay_OffsetMouseRight_DeflectsRayPositiveX()
|
||||
{
|
||||
var (view, proj) = MakeIdentityCamera();
|
||||
const float vpW = 1920f, vpH = 1080f;
|
||||
|
||||
var (_, direction) = WorldPicker.BuildRay(
|
||||
mouseX: vpW * 0.75f, mouseY: vpH / 2f,
|
||||
viewportW: vpW, viewportH: vpH,
|
||||
view, proj);
|
||||
|
||||
Assert.True(direction.X > 0.1f, $"direction.X = {direction.X} (expected > 0.1)");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests, expect fail (class doesn't exist)**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerTests"`
|
||||
|
||||
Expected: build error `CS0246: The type or namespace name 'WorldPicker' could not be found` (or equivalent — `AcDream.Core.Selection` namespace doesn't exist yet).
|
||||
|
||||
- [ ] **Step 3: Create `WorldPicker.cs` with `BuildRay`**
|
||||
|
||||
Create `src/AcDream.Core/Selection/WorldPicker.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Numerics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.Core.Selection;
|
||||
|
||||
/// <summary>
|
||||
/// Mouse-to-entity picker. Pure static functions; no state, no DI.
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="BuildRay"/> turns a pixel + view/projection into a world-space ray.</item>
|
||||
/// <item><see cref="Pick"/> ray-sphere intersects against entity candidates and returns the nearest hit's ServerGuid.</item>
|
||||
/// </list>
|
||||
/// Used by <c>GameWindow.OnInputAction</c> to wire SelectLeft / SelectDblLeft / UseSelected to <c>InteractRequests.BuildUse</c>.
|
||||
/// </summary>
|
||||
public static class WorldPicker
|
||||
{
|
||||
/// <summary>
|
||||
/// Unprojects a pixel coordinate to a world-space ray using the supplied
|
||||
/// view + projection matrices (System.Numerics row-vector convention,
|
||||
/// composed as view * projection — same as the rest of acdream's camera
|
||||
/// pipeline; see GameWindow.cs:6445 FrustumPlanes.FromViewProjection).
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// (origin = world point on the near plane, direction = normalized
|
||||
/// world-space ray direction). Returns (Vector3.Zero, Vector3.Zero)
|
||||
/// if the view-projection composition is singular.
|
||||
/// </returns>
|
||||
public static (Vector3 Origin, Vector3 Direction) BuildRay(
|
||||
float mouseX, float mouseY,
|
||||
float viewportW, float viewportH,
|
||||
Matrix4x4 view, Matrix4x4 projection)
|
||||
{
|
||||
// Pixel -> NDC. y flipped: top-left pixel maps to ndc.y = +1.
|
||||
float ndcX = (2f * mouseX) / viewportW - 1f;
|
||||
float ndcY = 1f - (2f * mouseY) / viewportH;
|
||||
|
||||
var vp = view * projection;
|
||||
if (!Matrix4x4.Invert(vp, out var invVp))
|
||||
return (Vector3.Zero, Vector3.Zero);
|
||||
|
||||
// Unproject near (ndc.z = -1) and far (ndc.z = +1) clip points.
|
||||
var nearClip = new Vector4(ndcX, ndcY, -1f, 1f);
|
||||
var farClip = new Vector4(ndcX, ndcY, +1f, 1f);
|
||||
var n4 = Vector4.Transform(nearClip, invVp);
|
||||
var f4 = Vector4.Transform(farClip, invVp);
|
||||
if (n4.W == 0f || f4.W == 0f)
|
||||
return (Vector3.Zero, Vector3.Zero);
|
||||
|
||||
var nearWorld = new Vector3(n4.X, n4.Y, n4.Z) / n4.W;
|
||||
var farWorld = new Vector3(f4.X, f4.Y, f4.Z) / f4.W;
|
||||
var dir = farWorld - nearWorld;
|
||||
if (dir.LengthSquared() < 1e-10f)
|
||||
return (Vector3.Zero, Vector3.Zero);
|
||||
return (nearWorld, Vector3.Normalize(dir));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests, expect pass**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerTests"`
|
||||
|
||||
Expected: `Passed: 2, Failed: 0`. Both `BuildRay_*` tests pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.Core/Selection/WorldPicker.cs tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(B.4b): WorldPicker.BuildRay — mouse-to-world ray unprojection
|
||||
|
||||
New AcDream.Core.Selection.WorldPicker static helper. BuildRay
|
||||
unprojects pixel (mouseX, mouseY) through a view+projection matrix
|
||||
pair into a world-space (origin, direction) ray. Used by
|
||||
GameWindow.OnInputAction to drive entity picking on click.
|
||||
|
||||
Pure math, no state, no DI. Composes view*projection (System.Numerics
|
||||
row-vector convention, matching the rest of acdream's camera path —
|
||||
see GameWindow.cs:6445 FrustumPlanes.FromViewProjection). 2 xUnit
|
||||
tests cover center-of-viewport (forward ray) and right-of-center
|
||||
(positive-X deflection).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — `WorldPicker.Pick` (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.Core/Selection/WorldPicker.cs`
|
||||
- Modify: `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests for `Pick`**
|
||||
|
||||
Append to `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs` (inside the same class, before the closing `}`):
|
||||
|
||||
```csharp
|
||||
private static WorldEntity MakeEntity(uint serverGuid, Vector3 position) => new()
|
||||
{
|
||||
Id = serverGuid == 0u ? 1u : serverGuid,
|
||||
ServerGuid = serverGuid,
|
||||
SourceGfxObjOrSetupId = 0u,
|
||||
Position = position,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Pick_RayThroughEntity_ReturnsServerGuid()
|
||||
{
|
||||
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -10));
|
||||
|
||||
var result = WorldPicker.Pick(
|
||||
origin: Vector3.Zero,
|
||||
direction: -Vector3.UnitZ,
|
||||
candidates: new[] { entity },
|
||||
skipServerGuid: 0u);
|
||||
|
||||
Assert.Equal(0xABCDu, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_RayMisses_ReturnsNull()
|
||||
{
|
||||
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -10));
|
||||
|
||||
var result = WorldPicker.Pick(
|
||||
origin: Vector3.Zero,
|
||||
direction: Vector3.UnitX,
|
||||
candidates: new[] { entity },
|
||||
skipServerGuid: 0u);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_TwoEntitiesInLine_ReturnsCloser()
|
||||
{
|
||||
var near = MakeEntity(0x1111u, new Vector3(0, 0, -5));
|
||||
var far = MakeEntity(0x2222u, new Vector3(0, 0, -20));
|
||||
|
||||
var result = WorldPicker.Pick(
|
||||
origin: Vector3.Zero,
|
||||
direction: -Vector3.UnitZ,
|
||||
candidates: new[] { far, near }, // iteration order shouldn't matter
|
||||
skipServerGuid: 0u);
|
||||
|
||||
Assert.Equal(0x1111u, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_SkipsSkipGuid()
|
||||
{
|
||||
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -10));
|
||||
|
||||
var result = WorldPicker.Pick(
|
||||
origin: Vector3.Zero,
|
||||
direction: -Vector3.UnitZ,
|
||||
candidates: new[] { entity },
|
||||
skipServerGuid: 0xABCDu);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_SkipsZeroServerGuid()
|
||||
{
|
||||
// Atlas-tier scenery / dat-hydrated statics carry ServerGuid=0
|
||||
// and aren't valid Use targets — server would reject guid=0.
|
||||
var entity = MakeEntity(0u, new Vector3(0, 0, -10));
|
||||
|
||||
var result = WorldPicker.Pick(
|
||||
origin: Vector3.Zero,
|
||||
direction: -Vector3.UnitZ,
|
||||
candidates: new[] { entity },
|
||||
skipServerGuid: 0xDEADu);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_BeyondMaxDistance_ReturnsNull()
|
||||
{
|
||||
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -100));
|
||||
|
||||
var result = WorldPicker.Pick(
|
||||
origin: Vector3.Zero,
|
||||
direction: -Vector3.UnitZ,
|
||||
candidates: new[] { entity },
|
||||
skipServerGuid: 0u); // default maxDistance = 50f
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
```
|
||||
|
||||
Also add `using AcDream.Core.World;` to the top of `WorldPickerTests.cs` (next to the existing `using AcDream.Core.Selection;`).
|
||||
|
||||
- [ ] **Step 2: Run the tests, expect fail (Pick doesn't exist)**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerTests"`
|
||||
|
||||
Expected: build error `CS0117: 'WorldPicker' does not contain a definition for 'Pick'`.
|
||||
|
||||
- [ ] **Step 3: Add `Pick` to `WorldPicker.cs`**
|
||||
|
||||
Open `src/AcDream.Core/Selection/WorldPicker.cs`, add `using System;` and `using System.Collections.Generic;` to the imports, and append this method inside the `WorldPicker` class (after `BuildRay`):
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Ray-sphere intersection against each candidate's <see cref="WorldEntity.Position"/>
|
||||
/// using a fixed 5m sphere radius. Returns the <see cref="WorldEntity.ServerGuid"/>
|
||||
/// of the closest hit within <paramref name="maxDistance"/>, or null on miss.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Entities with <c>ServerGuid == 0</c> (atlas-tier scenery, dat-hydrated
|
||||
/// statics) are skipped — they have no server-side identity and can't be
|
||||
/// the target of a Use packet. The player's own guid is skipped via
|
||||
/// <paramref name="skipServerGuid"/>.
|
||||
/// </remarks>
|
||||
public static uint? Pick(
|
||||
Vector3 origin, Vector3 direction,
|
||||
IEnumerable<WorldEntity> candidates,
|
||||
uint skipServerGuid,
|
||||
float maxDistance = 50f)
|
||||
{
|
||||
const float Radius = 5f;
|
||||
const float Radius2 = Radius * Radius;
|
||||
|
||||
if (direction.LengthSquared() < 1e-10f) return null;
|
||||
|
||||
uint? bestGuid = null;
|
||||
float bestT = float.PositiveInfinity;
|
||||
foreach (var entity in candidates)
|
||||
{
|
||||
if (entity.ServerGuid == 0u) continue;
|
||||
if (entity.ServerGuid == skipServerGuid) continue;
|
||||
|
||||
// Geometric ray-sphere: oc = origin - center, b = dot(oc, dir),
|
||||
// c = |oc|^2 - r^2, discriminant = b^2 - c. If discriminant < 0
|
||||
// the ray misses the sphere. Otherwise nearest intersection is
|
||||
// t = -b - sqrt(discriminant).
|
||||
var oc = origin - entity.Position;
|
||||
float b = Vector3.Dot(oc, direction);
|
||||
float c = Vector3.Dot(oc, oc) - Radius2;
|
||||
float d = b * b - c;
|
||||
if (d < 0f) continue;
|
||||
|
||||
float t = -b - MathF.Sqrt(d);
|
||||
if (t < 0f) continue; // ray points away or origin inside
|
||||
if (t >= maxDistance) continue;
|
||||
if (t < bestT)
|
||||
{
|
||||
bestT = t;
|
||||
bestGuid = entity.ServerGuid;
|
||||
}
|
||||
}
|
||||
return bestGuid;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests, expect pass**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerTests"`
|
||||
|
||||
Expected: `Passed: 8, Failed: 0`. All 8 `WorldPicker*` tests pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.Core/Selection/WorldPicker.cs tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(B.4b): WorldPicker.Pick — ray-sphere entity pick
|
||||
|
||||
Adds Pick(origin, direction, candidates, skipServerGuid, maxDistance)
|
||||
to AcDream.Core.Selection.WorldPicker. Iterates candidates, skips
|
||||
entities with ServerGuid==0 (atlas/dat-hydrated statics — no server
|
||||
identity) and the caller's skipServerGuid (the player self).
|
||||
Geometric ray-sphere intersection at 5m radius (matches
|
||||
WorldEntity.DefaultAabbRadius). Returns the nearest hit's ServerGuid
|
||||
within maxDistance (50m default), or null on miss.
|
||||
|
||||
6 xUnit tests added: hit, miss, two-in-line-returns-closer, skip-guid,
|
||||
skip-zero-server-guid, beyond-max-distance.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 — Rename `_selectedTargetGuid` → `_selectedGuid`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
|
||||
|
||||
Refactor only — no behavior change. Unifies combat (Q-cycle) and interaction (B.4b click) selection on one field. Retail-faithful: AC has one "current target," not two.
|
||||
|
||||
- [ ] **Step 1: Locate every reference**
|
||||
|
||||
Run (Grep tool):
|
||||
```
|
||||
pattern: _selectedTargetGuid
|
||||
path: src/AcDream.App/Rendering/GameWindow.cs
|
||||
output: content with -n
|
||||
```
|
||||
|
||||
Expected: ~5 hits, all inside `GameWindow.cs`. Then verify there are no references elsewhere:
|
||||
|
||||
```
|
||||
pattern: _selectedTargetGuid
|
||||
path: src
|
||||
output: files_with_matches
|
||||
```
|
||||
|
||||
Expected: only `GameWindow.cs` matches.
|
||||
|
||||
- [ ] **Step 2: Replace via the Edit tool (replace_all)**
|
||||
|
||||
Edit `src/AcDream.App/Rendering/GameWindow.cs` with `replace_all: true`:
|
||||
- `old_string: _selectedTargetGuid`
|
||||
- `new_string: _selectedGuid`
|
||||
|
||||
- [ ] **Step 3: Build green**
|
||||
|
||||
Run: `dotnet build -c Debug`
|
||||
|
||||
Expected: build succeeds with no new errors or warnings tied to the rename.
|
||||
|
||||
- [ ] **Step 4: Tests green**
|
||||
|
||||
Run: `dotnet test`
|
||||
|
||||
Expected: 8 new `WorldPickerTests` pass on top of the prior baseline. The L.2g slice 1 handoff reported "1037 pass / 8 pre-existing-baseline fail." With +8 from Tasks 1+2, expect **1045 pass / 8 pre-existing fail**.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/GameWindow.cs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor(B.4b): unify _selectedTargetGuid -> _selectedGuid
|
||||
|
||||
Retail's selection model is a single "current target" used by combat,
|
||||
interaction, NPC dialog, and HUD alike — not two parallel selections.
|
||||
Renames the existing combat-only field on GameWindow so the upcoming
|
||||
B.4b click handler and the existing Q-cycle SelectClosestCombatTarget
|
||||
share the same selection state.
|
||||
|
||||
Mechanical rename, no behavior change. Build + tests green.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 — Wire `OnInputAction` handlers
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
|
||||
|
||||
Add three private helper methods + three switch cases. Switch-case behavior is verified at runtime (Task 5 visual test); helpers depend on `GameWindow` state and aren't unit-tested.
|
||||
|
||||
- [ ] **Step 1: Add the three helper methods**
|
||||
|
||||
Insert these three methods immediately above `SelectClosestCombatTarget` (around line 8706 — keep the selection-related helpers grouped). Use the `Edit` tool anchored on the line `private uint? SelectClosestCombatTarget(bool showToast)`:
|
||||
|
||||
`old_string`:
|
||||
```
|
||||
private uint? SelectClosestCombatTarget(bool showToast)
|
||||
```
|
||||
|
||||
`new_string`:
|
||||
```
|
||||
// ============================================================
|
||||
// Phase B.4b — outbound Use handler. Wires three input actions
|
||||
// (LMB click select, LMB-double-click select+use, R hotkey
|
||||
// use-selected) through WorldPicker into InteractRequests.BuildUse.
|
||||
// The inbound reply (SetState 0xF74B) lands via L.2g slice 1.
|
||||
// ============================================================
|
||||
|
||||
private void PickAndStoreSelection(bool useImmediately)
|
||||
{
|
||||
if (_cameraController is null || _window is null) return;
|
||||
|
||||
var camera = _cameraController.Active;
|
||||
var (origin, direction) = AcDream.Core.Selection.WorldPicker.BuildRay(
|
||||
mouseX: _lastMouseX, mouseY: _lastMouseY,
|
||||
viewportW: _window.Size.X, viewportH: _window.Size.Y,
|
||||
view: camera.View, projection: camera.Projection);
|
||||
|
||||
if (direction.LengthSquared() < 1e-6f) return; // degenerate ray
|
||||
|
||||
var picked = AcDream.Core.Selection.WorldPicker.Pick(
|
||||
origin, direction,
|
||||
_entitiesByServerGuid.Values,
|
||||
skipServerGuid: _playerServerGuid,
|
||||
maxDistance: 50f);
|
||||
|
||||
if (picked is uint guid)
|
||||
{
|
||||
_selectedGuid = guid;
|
||||
string label = DescribeLiveEntity(guid);
|
||||
Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}");
|
||||
_debugVm?.AddToast($"Selected: {label}");
|
||||
if (useImmediately) SendUse(guid);
|
||||
}
|
||||
else
|
||||
{
|
||||
_debugVm?.AddToast("Nothing to select");
|
||||
}
|
||||
}
|
||||
|
||||
private void UseCurrentSelection()
|
||||
{
|
||||
if (_selectedGuid is uint sel)
|
||||
SendUse(sel);
|
||||
else
|
||||
_debugVm?.AddToast("Nothing selected");
|
||||
}
|
||||
|
||||
private void SendUse(uint guid)
|
||||
{
|
||||
if (_liveSession is null
|
||||
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
||||
{
|
||||
_debugVm?.AddToast("Not in world");
|
||||
return;
|
||||
}
|
||||
var seq = _liveSession.NextGameActionSequence();
|
||||
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
|
||||
_liveSession.SendGameAction(body);
|
||||
Console.WriteLine($"[B.4b] use guid=0x{guid:X8} seq={seq}");
|
||||
}
|
||||
|
||||
private uint? SelectClosestCombatTarget(bool showToast)
|
||||
```
|
||||
|
||||
(The `Edit` replaces the single anchor line with the three new helpers + the same anchor line at the end, leaving `SelectClosestCombatTarget`'s body untouched.)
|
||||
|
||||
- [ ] **Step 2: Add the three switch cases**
|
||||
|
||||
In `GameWindow.OnInputAction`'s switch (currently `GameWindow.cs:8546-8646`), add three new `case` blocks immediately before the `case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:` branch.
|
||||
|
||||
Use the `Edit` tool anchored on:
|
||||
|
||||
`old_string`:
|
||||
```
|
||||
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
|
||||
if (_cameraController?.IsFlyMode == true)
|
||||
```
|
||||
|
||||
`new_string`:
|
||||
```
|
||||
case AcDream.UI.Abstractions.Input.InputAction.SelectLeft:
|
||||
PickAndStoreSelection(useImmediately: false);
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.SelectDblLeft:
|
||||
PickAndStoreSelection(useImmediately: true);
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.UseSelected:
|
||||
UseCurrentSelection();
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
|
||||
if (_cameraController?.IsFlyMode == true)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build green**
|
||||
|
||||
Run: `dotnet build -c Debug`
|
||||
|
||||
Expected: build succeeds with no new errors. Any new warnings should be tied only to the additions.
|
||||
|
||||
- [ ] **Step 4: Tests green**
|
||||
|
||||
Run: `dotnet test`
|
||||
|
||||
Expected: same **1045 pass / 8 pre-existing-baseline fail** from Task 3.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/GameWindow.cs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(B.4b): GameWindow wires Select/Use handlers via WorldPicker
|
||||
|
||||
Closes #57. Adds three OnInputAction switch cases (SelectLeft,
|
||||
SelectDblLeft, UseSelected) and three private helpers
|
||||
(PickAndStoreSelection, UseCurrentSelection, SendUse). Single-click
|
||||
selects but does not Use; double-click selects + Uses; R hotkey
|
||||
sends Use on the existing _selectedGuid. ImGui mouse-capture
|
||||
filtering already happens in InputDispatcher — no new guard needed.
|
||||
|
||||
Diagnostic lines emitted for log grep:
|
||||
[B.4b] pick guid=0x{guid:X8} name={label}
|
||||
[B.4b] use guid=0x{guid:X8} seq={seq}
|
||||
|
||||
Build green; tests 1045/1053 (8 pre-existing-baseline fails
|
||||
unchanged). Switch-case behavior verified at runtime via the Holtburg
|
||||
inn doorway visual test (per spec §Testing → Runtime verification).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5 — Visual verification at Holtburg inn doorway
|
||||
|
||||
**This task is performed by the user.** The implementing agent runs the launch command (background) and reports completion; the user observes the running client and reports the result.
|
||||
|
||||
- [ ] **Step 1: Kill any stale client process**
|
||||
|
||||
Run via Bash tool:
|
||||
```powershell
|
||||
Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||
Start-Sleep -Seconds 3
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Launch the client with B.4b + L.2g probes enabled**
|
||||
|
||||
Run via Bash tool with `run_in_background: true`:
|
||||
```powershell
|
||||
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
||||
$env:ACDREAM_LIVE = "1"
|
||||
$env:ACDREAM_TEST_HOST = "127.0.0.1"
|
||||
$env:ACDREAM_TEST_PORT = "9000"
|
||||
$env:ACDREAM_TEST_USER = "testaccount"
|
||||
$env:ACDREAM_TEST_PASS = "testpassword"
|
||||
$env:ACDREAM_DEVTOOLS = "1"
|
||||
$env:ACDREAM_PROBE_BUILDING = "1"
|
||||
$env:ACDREAM_PROBE_RESOLVE = "1"
|
||||
dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug 2>&1 |
|
||||
Tee-Object -FilePath "launch-b4b.log"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: User performs the scenario**
|
||||
|
||||
In the running client:
|
||||
1. Wait ~8s for the player to spawn at Holtburg.
|
||||
2. Walk to the inn doorway (north side of the south building).
|
||||
3. Double-left-click the closed door.
|
||||
4. Observe: swing animation should play.
|
||||
5. Walk forward through the open doorway.
|
||||
6. Wait ~30s in the inn.
|
||||
7. Observe: auto-close animation should fire.
|
||||
8. Close the client window.
|
||||
|
||||
- [ ] **Step 4: Grep the log**
|
||||
|
||||
```powershell
|
||||
Select-String -Path launch-b4b.log -Pattern `
|
||||
"B\.4b|setstate-hex|\[setstate\]|input.*SelectDblLeft|entity-source.*Door"
|
||||
```
|
||||
|
||||
Expected matches (approximate order):
|
||||
- `[entity-source] name=Door ... state=0x00000000 flags=None ...` (door spawn at world load)
|
||||
- `[input] SelectDblLeft Press` (dispatcher fires on the user's click)
|
||||
- `[B.4b] pick guid=0x000F???? name=Door` (picker hit)
|
||||
- `[B.4b] use guid=0x000F???? seq=N` (outbound Use fires)
|
||||
- `[setstate-hex] body.len=16 ...` (L.2g hex probe — first SetState body)
|
||||
- `[setstate] guid=0x000F???? state=0x00000014` (or `0x00000004`) (door opens)
|
||||
- `[setstate] guid=0x000F???? state=0x00000000` ~30s later (auto-close)
|
||||
|
||||
- [ ] **Step 5: Decide on follow-up based on the observed state value**
|
||||
|
||||
- If the state bits include `0x10` (so the value is `0x14` or higher), `CollisionExemption.ShouldSkip` short-circuits as designed — no follow-up needed.
|
||||
- If the state is `0x4` (ETHEREAL only, no IGNORE_COLLISIONS), file a tiny **L.2g slice 1b** to widen the check. The fix is a one-line edit to `src/AcDream.Core/Physics/CollisionExemption.cs`. **Out of B.4b scope** — record the finding and move on.
|
||||
|
||||
---
|
||||
|
||||
## Task 6 — Ship handoff + post-merge updates
|
||||
|
||||
**Files (all modified or created):**
|
||||
- Create: `docs/research/2026-05-13-b4b-shipped-handoff.md`
|
||||
- Modify: `docs/ISSUES.md` (close #57)
|
||||
- Modify: `docs/plans/2026-04-11-roadmap.md` (add B.4b row to "shipped" table)
|
||||
- Modify: `CLAUDE.md` ("Currently in Phase L.2" paragraph)
|
||||
- Modify (outside-repo): `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\project_interaction_pipeline.md`
|
||||
|
||||
- [ ] **Step 1: Write the ship-handoff doc**
|
||||
|
||||
Create `docs/research/2026-05-13-b4b-shipped-handoff.md` summarizing:
|
||||
- 4 commits (BuildRay, Pick, rename, handler wiring)
|
||||
- The actual `state=0x??` value observed in Task 5 step 4
|
||||
- Whether L.2g slice 1b is needed (decided in Task 5 step 5)
|
||||
- Whether picker tuning is needed (5m radius too generous/strict)
|
||||
|
||||
Use the same structure as `docs/research/2026-05-12-l2g-slice1-shipped-handoff.md` — TL;DR + commit table + end-to-end flow + open notes + reproducibility.
|
||||
|
||||
- [ ] **Step 2: Move #57 from Active to Recently Closed in `docs/ISSUES.md`**
|
||||
|
||||
Edit `docs/ISSUES.md`:
|
||||
- Cut the `## #57 — B.4 interaction-handler missing` block from "Active issues".
|
||||
- Paste it under "Recently closed" with header changed to `## #57 — [DONE 2026-05-13] B.4 interaction-handler missing: clicking on doors / NPCs / items silently does nothing` and add a **Closed:** line with this PR's merge commit SHA.
|
||||
|
||||
- [ ] **Step 3: Update the roadmap's shipped table**
|
||||
|
||||
Edit `docs/plans/2026-04-11-roadmap.md`. Add a new row to the "shipped" table (preserving existing column structure):
|
||||
|
||||
```
|
||||
| 2026-05-13 | Phase B.4b — Outbound Use handler wiring | <commit> | Closes #57. WorldPicker + 3 switch cases. M1 demo target "open the inn door" verified at Holtburg. |
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `CLAUDE.md` "Currently in Phase L.2" paragraph**
|
||||
|
||||
Edit `CLAUDE.md`:
|
||||
- Change the "L.2g slice 1 is CODE-COMPLETE..." paragraph to "L.2g slice 1 + B.4b shipped and visual-verified 2026-05-13 at the Holtburg inn doorway."
|
||||
- Remove the "natural next step is Phase B.4b" paragraph; replace with the next phase candidate from the existing candidate list (the user picks order; in absence of new evidence, the **Triage open issues** option is the natural follow-up).
|
||||
|
||||
- [ ] **Step 5: Update the memory file**
|
||||
|
||||
Edit `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\project_interaction_pipeline.md`:
|
||||
- Mark Phase B.4 outbound-handler gap as closed by B.4b (2026-05-13).
|
||||
- Add the new flow: LMB-dblclick → WorldPicker → BuildUse → SendGameAction.
|
||||
- Update the `WorldPicker` and `SelectionState` claims:
|
||||
- `WorldPicker` now exists in `AcDream.Core.Selection`.
|
||||
- `SelectionState` still doesn't exist — deferred to M2 HUD work.
|
||||
|
||||
- [ ] **Step 6: Commit the in-repo docs**
|
||||
|
||||
```bash
|
||||
git add docs/research/2026-05-13-b4b-shipped-handoff.md docs/ISSUES.md docs/plans/2026-04-11-roadmap.md CLAUDE.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs(B.4b): ship handoff + close #57 + roadmap/CLAUDE update
|
||||
|
||||
L.2g slice 1 + B.4b verified at Holtburg inn doorway:
|
||||
- Player double-clicks closed door
|
||||
- BuildUse fires, ACE responds with SetState 0xF74B
|
||||
- ShadowObjectRegistry mutates ETHEREAL bit
|
||||
- CollisionExemption short-circuits, player walks through
|
||||
- 30s auto-close fires on schedule
|
||||
|
||||
Closes #57. Updates roadmap shipped table and CLAUDE.md Phase L.2
|
||||
paragraph. Memory file project_interaction_pipeline.md updated outside
|
||||
the repo.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
(The memory file lives outside the repo and isn't tracked by git — update it but don't include it in the commit.)
|
||||
|
||||
- [ ] **Step 7: Merge to main**
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git merge --no-ff claude/compassionate-wilson-23ff99 -m "Merge branch 'claude/compassionate-wilson-23ff99' — Phase B.4b + L.2g slice 1 visual-verified"
|
||||
```
|
||||
|
||||
Do NOT push without explicit user authorization (CLAUDE.md rule).
|
||||
|
||||
---
|
||||
|
||||
## Self-review against the spec
|
||||
|
||||
| Spec section | Plan task(s) | Coverage |
|
||||
|---|---|---|
|
||||
| §Architecture: `WorldPicker.cs` in `AcDream.Core.Selection` | Tasks 1, 2 | covered |
|
||||
| §Architecture: rename `_selectedTargetGuid` | Task 3 | covered |
|
||||
| §Architecture: 3 switch cases + 3 helpers | Task 4 | covered |
|
||||
| §Components: `BuildRay` signature + math | Task 1 step 3 | covered |
|
||||
| §Components: `Pick` signature + ServerGuid==0 skip | Task 2 step 3 | covered |
|
||||
| §Components: `PickAndStoreSelection` toast + `[B.4b] pick` log | Task 4 step 1 | covered |
|
||||
| §Components: `SendUse` gate + `[B.4b] use` log | Task 4 step 1 | covered |
|
||||
| §Components: `UseCurrentSelection` | Task 4 step 1 | covered |
|
||||
| §Components: 3 switch cases | Task 4 step 2 | covered |
|
||||
| §Testing: 8 unit tests | Tasks 1+2 | covered (2 BuildRay + 6 Pick) |
|
||||
| §Testing: runtime verification at Holtburg | Task 5 | covered |
|
||||
| §Testing: log grep + state-value decision | Task 5 step 4-5 | covered |
|
||||
| §Acceptance: build + tests green | Tasks 3+4 steps 3-4 | covered |
|
||||
| §Acceptance: ISSUES.md #57 → Recently closed | Task 6 step 2 | covered |
|
||||
| §Acceptance: roadmap update | Task 6 step 3 | covered |
|
||||
| §Acceptance: CLAUDE.md update | Task 6 step 4 | covered |
|
||||
| §Open question: state 0x4 vs 0x14 follow-up | Task 5 step 5 | covered (deferred to L.2g slice 1b if needed) |
|
||||
| §Non-goals: BuildPickUp / UseWithTarget UX / SelectionState class | (none — explicitly deferred) | covered by omission |
|
||||
|
||||
No placeholders. No "TBD." Every code step has the actual code; every command step has the exact command and the expected output. Type names match across tasks (`WorldPicker.BuildRay` / `WorldPicker.Pick`, `_selectedGuid`, `PickAndStoreSelection` / `UseCurrentSelection` / `SendUse`).
|
||||
Loading…
Add table
Add a link
Reference in a new issue