Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions docs/OPENWORLDS_APP_SURFACE_MAP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# OpenWorlds App Surface Map

Date: 2026-05-26

This document is the app-side handoff map for the OpenWorlds macOS shell. It
describes what is wired today, what is display-only, and which GitHub issue owns
the next implementation. The native app remains a supervisor and read/action
surface; the engine/viewer remain the game-state authority.

## Capability Legend

- **Wired**: usable against current viewer/native bridge APIs.
- **Read-only**: backed by real data, but no mutation path yet.
- **Partial**: some real backing exists, but the screen still contains local or
prototype-only behavior.
- **Display-only**: retained for design fidelity and roadmap shape, not backed
by ClawDnD data yet.
- **Engine-needed**: needs a new engine/viewer read model or engine-owned action.
- **Bridge-needed**: needs native supervisor bridge work.

## Product Shell

| Area | Current state | Evidence | Owner issue | Next action |
| --- | --- | --- | --- | --- |
| Native app host | Wired | `RootView` launches viewer and hosts `/openworlds/` in `WKWebView` | #82, #113 | Keep as product shell; leave old SwiftUI shell as debug only |
| Bundled OpenWorlds assets | Wired | App packages `viewer/openworlds` and sets `CLAWDND_OPENWORLDS_DIR` | #134 | Keep UI assets app-bundled; engine/viewer stay repo-backed |
| Sparkle local beta | Partial | `UpdaterService`, `checkForUpdates`, local appcast | #134 | Manual update smoke; notarization remains separate |
| Window controls | Partial | OpenWorlds traffic buttons call `windowCommand`; AppKit drag strip exists | #136 | Manual close/minimize/zoom/drag smoke; tune drag hit box if needed |
| Native settings/provider/log bridge | Partial | Settings screen calls `appStatus`, provider actions, diagnostics, Sparkle | #132, #133 | Move remaining Swift debug-only controls into OpenWorlds or mark unavailable |

## OpenWorlds Screens

| Screen | Current state | Backing today | Owner issue | Handoff notes |
| --- | --- | --- | --- | --- |
| Chronicles / launcher | Partial | `/openworlds/campaigns.json`; local selected campaign state | #114 | Resume/view navigates to Table. New chronicle modal currently creates local-only demo rows and must be replaced with native/provider start flow before treating it as real. |
| Session / table | Wired/partial | `/session-surface`; enabled actions post `/move` | #115 | Best real gameplay surface today. Continue enriching read model fields rather than writing local state. |
| Battle / combat | Wired/partial | `/combat-surface`; action bar posts `/move` | #116 | Tactical board is engine-owned projection. Improve legality, event cards, targeting, and refresh behavior through viewer read models. |
| Atlas / map | Wired/partial | `/atlas-surface`; available travel posts `/move` | #117 | Strategic map is partially real. Camp/rest UI is still mostly informational until rest/camp actions are engine-owned. |
| Settings / native app | Partial | Native bridge app status, dependencies, providers, Sparkle | #132, #134 | `ClawDnD` section is real. Audio/display/gameplay/controls/accessibility/saves are display-only until preferences are bridged. |
| Quest journal | Display-only/read-model-needed | `state.quests` demo/store fallback only | #120 or new journal projection issue | Should consume player-known quest projection from viewer, not static seed data. |
| Character / heroes | Display-only/engine-needed | `state.party` only | #80/#81 if build planner, or new character projection issue | Needs character sheet projection, level-up/build planner actions, rest-prep through engine. |
| Inventory / stash | Display-only/engine-needed | `state.stash` only; equip/use/drop are local toasts | #119 | Replace local interactions with engine-owned item action model. |
| Merchant / market | Display-only/engine-needed | static `MERCHANTS`, local cart/coins | #119 | Needs economy/merchant read models and buy/sell/haggle player-intent actions. |
| Forge | Display-only/engine-needed | static recipe list and local random roll | #119 | Must not keep local crafting resolution; needs engine-owned craft preview/action. |
| Relations / camp | Display-only/engine-needed | static factions/NPCs | #118 | Needs companion/camp/faction/NPC disposition projection and player-known filtering. |
| Bestiary / codex | Display-only/engine-needed | static `BESTIARY`, `PEOPLE`, `LORE` | #121 | Must consume player-known lore only; never expose hidden/private lore. |
| Acts | Display-only/engine-needed | static `ACTS` | #120 | Needs campaign-director projection and player-visible chronology/payoff markers. |
| Dialogue / parley | Display-only/provider-needed | static `DIALOGUE`; local choice tree | #118 or provider dialogue issue | Needs provider/engine dialogue read model; choices should post player intent, not mutate local branch only. |
| Creation Plane | Display-only/engine-needed | local character-creation wizard | #79/#81 or new onboarding issue | Can remain design reference until character creation/start-game contract exists. |
| World Seed | Display-only/engine-needed | local seed settings and toasts | #114/#79 | Needs read-only campaign seed projection first; destructive reseed must be unavailable until engine-owned. |

## Highest-Value App Follow-Ups

1. **Finish manual beta smoke for PR #150**: verify OpenWorlds loads inside the
packaged app, Sparkle reads the local appcast, traffic buttons control the
real native window, and the drag strip behaves well.
2. **Replace local-only launcher creation**: `NewCampaignModal` must call the
native/provider start flow or be marked `Display-only`.
3. **Bridge Settings preferences**: persist real native prefs for repo path,
port, state dir, provider commands, budgets, voice backend, and update state.
4. **Complete Session/Combat/Atlas before new surfaces**: these already have
viewer read models and `/move` lanes, so they compound fastest.
5. **Keep all inventory/crafting/merchant actions disabled until engine-owned**:
the current UI is visually useful but must not resolve item/economy state in
browser-local code.
6. **Add notarization as a separate release-trust issue**: current beta is
Developer ID signed but intentionally not notarized.

## Non-Negotiable Boundary

The macOS app and OpenWorlds browser code may supervise, display, and submit
player intent. They must not directly write `snapshot.json`, `play-state`,
`qa/state`, inventory, quests, XP, clocks, companion state, or private lore.

75 changes: 68 additions & 7 deletions docs/OPENWORLDS_NATIVE_APP_ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,46 @@ Every OpenWorlds screen should expose one of these labels:
5. Sprint 4: make Chronicles the real app home with live/stale run state.
6. Sprint 5+: finish gameplay surfaces in impact order: table, combat, atlas,
relations/camp, inventory/economy, acts, bestiary/codex.
7. Release trust: add a Sparkle-backed update channel (#134) after the local `.app`,
signing, and bundle identity are stable. This should let owners update the
native app without repeated manual rebuild/download cycles, while keeping the
viewer/engine state directories outside the app bundle.
7. Release trust: add a Sparkle-backed local beta channel (#134) after the local
`.app`, signing, and bundle identity are stable. This lets owners update the
native app and bundled OpenWorlds UI without repeated manual rebuild/download
cycles, while keeping the viewer/engine state directories outside the app
bundle.

## Sparkle Update Lane

Sparkle is intentionally out of the correction PR's runtime scope. Track it as a
release-trust feature in #134 after the OpenWorlds host and native bridge are
stable.
Sparkle is the beta distribution lane for #134. Maintainers currently use a
Lexar-backed local channel at `/Volumes/LEXAR/Codex/clawdnd-beta-channel`.
Contributors can use a different local channel by setting `BETA_OUTPUT_DIR` and,
when needed, `CLAWDND_FEED_URL` before running the packaging script. Sparkle
must fetch appcasts over HTTP(S), so the app points Sparkle at the running
loopback viewer, and the viewer serves the local channel artifacts without
moving game state into the app bundle.

Local maintainer setup:

- app bundle: `/Volumes/LEXAR/Codex/clawdnd-beta-channel/ClawDnD.app`
- update feed: `http://127.0.0.1:<viewer-port>/appcast.xml`
- local channel files: `/Volumes/LEXAR/Codex/clawdnd-beta-channel/appcast.xml`
- release script: `script/package_macos_beta.sh`
- bundle id: `dev.clawdnd.app`
- version/build: `0.3.0` / `2026052601` for `0.3.0-beta.1`
- signing identity: `Developer ID Application: Andrew Ryan (TC6MS3T6NN)`

Contributor override example:

```bash
BETA_OUTPUT_DIR="/Volumes/LEXAR/Codex/my-clawdnd-beta-channel" \
CLAWDND_FEED_URL="http://127.0.0.1:8765/appcast.xml" \
./script/package_macos_beta.sh --version 0.3.0 --build 2026052601 --channel local-beta
```

The beta packaging script intentionally rejects output paths outside
`/Volumes/LEXAR/Codex` and release identifiers containing path separators.

The Sparkle private key lives only under
`/Volumes/LEXAR/Codex/clawdnd-release-secrets/`. The repo stores only the public
key in `macos/ClawDnDApp/SparklePublicKey.txt`.

Implementation goals:

Expand All @@ -82,6 +112,12 @@ Implementation goals:
bridge, not through a second visible SwiftUI settings shell.
- Keep local dev builds working without Sparkle so contributors can still use
`./script/build_and_run.sh --verify`.
- Package `viewer/openworlds/` into `ClawDnD.app/Contents/Resources/openworlds`
and launch the repo-backed Python viewer with `CLAWDND_OPENWORLDS_DIR` pointing
at those bundled assets when available.
- Launch the viewer with `CLAWDND_BETA_CHANNEL_DIR` when a local beta channel is
configured, so `/appcast.xml` and `ClawDnD-*` artifacts are served from the
same loopback host/port as the OpenWorlds shell.

## Window Chrome Lane

Expand Down Expand Up @@ -114,8 +150,33 @@ Supported request types:
- `stopProvider`
- `diagnostics`
- `copyDiagnostics`
- `updaterStatus`
- `checkForUpdates`
- `windowCommand`
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- `openFallbackDashboard`

Update and window request payloads:

```json
{ "type": "updaterStatus", "payload": {} }
```

Returns `{ "updater": { "version": "0.3.0", "build": "2026052601", "feedURL": "...", "channel": "local-beta", "canCheckForUpdates": true, "status": "ready", "lastError": "" } }`.

```json
{ "type": "checkForUpdates", "payload": {} }
```

Triggers Sparkle's user-facing update check and returns the same updater status
shape.

```json
{ "type": "windowCommand", "payload": { "command": "close" } }
```

`command` must be one of `close`, `minimize`, or `zoom`; the native reply
includes `{ "command": "...", "performed": true }`.

Native replies:

```json
Expand Down
14 changes: 14 additions & 0 deletions macos/ClawDnDApp/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion macos/ClawDnDApp/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,15 @@ let package = Package(
products: [
.executable(name: "ClawDnDApp", targets: ["ClawDnDApp"])
],
dependencies: [
.package(url: "https://github.com/sparkle-project/Sparkle", exact: "2.9.2")
],
targets: [
.executableTarget(name: "ClawDnDApp")
.executableTarget(
name: "ClawDnDApp",
dependencies: [
.product(name: "Sparkle", package: "Sparkle")
]
)
]
)
3 changes: 3 additions & 0 deletions macos/ClawDnDApp/Sources/ClawDnDApp/App/ClawDnDApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,21 @@ struct ClawDnDApp: App {
@Environment(\.openWindow) private var openWindow
@StateObject private var processService = AppProcessService()
@StateObject private var campaignStore = CampaignStore()
@StateObject private var updaterService = UpdaterService()

var body: some Scene {
WindowGroup {
RootView()
.environmentObject(processService)
.environmentObject(campaignStore)
.environmentObject(updaterService)
.frame(minWidth: 1120, minHeight: 720)
}
WindowGroup("Debug Control Center", id: "debug-control-center") {
DebugControlCenterView()
.environmentObject(processService)
.environmentObject(campaignStore)
.environmentObject(updaterService)
.frame(minWidth: 1120, minHeight: 720)
}
.commands {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ struct LocalEndpoint: Identifiable, Equatable {
return components?.url ?? url.appendingPathComponent("openworlds/")
}

var appcastURL: URL {
url.appendingPathComponent("appcast.xml")
}

var monitorURL: URL {
url.appendingPathComponent("monitor")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ final class AppProcessService: ObservableObject {
@Published var providerLog: String = ""
@Published var lastError: String?
@Published var providerLaunchMetadata: ProviderLaunchMetadata?
@Published var openWorldsAssetsPath: String?
@Published var localBetaChannelPath: String?

private var viewerProcess: ManagedProcess?
private var providerProcess: ManagedProcess?
Expand All @@ -22,6 +24,8 @@ final class AppProcessService: ObservableObject {
ClawDnD Native App Diagnostics
Viewer: \(viewerEndpoint?.url.absoluteString ?? "stopped")
Viewer status: \(viewerEndpoint?.status.rawValue ?? "stopped")
OpenWorlds assets: \(openWorldsAssetsPath ?? "repo default")
Local beta channel: \(localBetaChannelPath ?? "not configured")
Active campaign: \(activeCampaignID ?? "none")
Running provider: \(runningProvider?.rawValue ?? "none")
Last error: \(lastError ?? "none")
Expand Down Expand Up @@ -60,6 +64,11 @@ final class AppProcessService: ObservableObject {
guard RepositoryLocator.looksLikeRepo(repoURL) else {
try throwAndRecord("Repo path is not a ClawDnD checkout: \(repoPath)")
}
guard RepositoryLocator.supportsOpenWorldsViewer(repoURL) else {
try throwAndRecord(
"Repo checkout is missing OpenWorlds viewer routes. Update the checkout or choose an OpenWorlds-capable ClawDnD worktree: \(repoPath)"
)
}
guard Shell.which("python3") != nil else {
try throwAndRecord("python3 is missing. Install Python 3 before launching the viewer.")
}
Expand All @@ -74,6 +83,8 @@ final class AppProcessService: ObservableObject {
if !stateDir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
env["CLAWDND_STATE_DIR"] = (stateDir as NSString).expandingTildeInPath
}
attachBundledOpenWorldsAssets(to: &env)
attachLocalBetaChannel(to: &env)
let args = ["python3", "viewer/server.py", campaignID ?? "", String(port)]
let managed = try launchManagedProcess(
name: "viewer",
Expand Down Expand Up @@ -119,6 +130,11 @@ final class AppProcessService: ObservableObject {
guard RepositoryLocator.looksLikeRepo(repoURL) else {
try throwAndRecord("Repo path is not a ClawDnD checkout: \(repoPath)")
}
guard RepositoryLocator.supportsOpenWorldsViewer(repoURL) else {
try throwAndRecord(
"Repo checkout is missing OpenWorlds viewer routes. Update the checkout or choose an OpenWorlds-capable ClawDnD worktree: \(repoPath)"
)
}

guard let port = PortFinder.firstFreePort(startingAt: preferredPort) else {
try throwAndRecord("Could not find a free provider viewer port near \(preferredPort).")
Expand Down Expand Up @@ -152,13 +168,16 @@ final class AppProcessService: ObservableObject {
runningProvider = nil
providerLaunchMetadata = nil
providerLog = ""
var providerEnvironment = request.environment
attachBundledOpenWorldsAssets(to: &providerEnvironment)
attachLocalBetaChannel(to: &providerEnvironment)
let metadata = ProviderLaunchMetadata(
kind: kind,
processName: request.name,
executable: request.executable,
arguments: request.arguments,
workingDirectory: request.workingDirectory,
environment: request.environment,
environment: providerEnvironment,
world: world,
runId: runId,
port: port,
Expand All @@ -170,7 +189,7 @@ final class AppProcessService: ObservableObject {
executable: request.executable,
arguments: request.arguments,
workingDirectory: request.workingDirectory,
environment: request.environment,
environment: providerEnvironment,
stream: .provider,
providerMetadata: metadata
)
Expand Down Expand Up @@ -331,6 +350,49 @@ final class AppProcessService: ObservableObject {
return (trimmed as NSString).expandingTildeInPath
}

private func attachBundledOpenWorldsAssets(to environment: inout [String: String]) {
guard let bundledAssets = bundledOpenWorldsAssets() else {
openWorldsAssetsPath = nil
return
}
environment["CLAWDND_OPENWORLDS_DIR"] = bundledAssets.path
openWorldsAssetsPath = bundledAssets.path
}

private func bundledOpenWorldsAssets() -> URL? {
guard let resourceURL = Bundle.main.resourceURL else { return nil }
let assetsURL = resourceURL.appendingPathComponent("openworlds", isDirectory: true)
let indexURL = assetsURL.appendingPathComponent("index.html")
guard FileManager.default.fileExists(atPath: indexURL.path) else { return nil }
return assetsURL
}

private func attachLocalBetaChannel(to environment: inout [String: String]) {
guard let channelURL = localBetaChannel() else {
localBetaChannelPath = nil
return
}
environment["CLAWDND_BETA_CHANNEL_DIR"] = channelURL.path
localBetaChannelPath = channelURL.path
}

private func localBetaChannel() -> URL? {
let fileManager = FileManager.default
if let rawPath = Bundle.main.object(forInfoDictionaryKey: "ClawDnDLocalBetaChannelPath") as? String {
let url = URL(fileURLWithPath: (rawPath as NSString).expandingTildeInPath, isDirectory: true)
if fileManager.fileExists(atPath: url.appendingPathComponent("appcast.xml").path) {
return url
}
}

let appDirectory = Bundle.main.bundleURL.deletingLastPathComponent()
if fileManager.fileExists(atPath: appDirectory.appendingPathComponent("appcast.xml").path) {
return appDirectory
}

return nil
}

private func throwAndRecord(_ message: String) throws -> Never {
lastError = message
append(message, stream: .supervisor)
Expand Down
Loading
Loading