diff --git a/.github/workflows/integrate.yml b/.github/workflows/integrate.yml index 54b25c2..e266d87 100644 --- a/.github/workflows/integrate.yml +++ b/.github/workflows/integrate.yml @@ -22,9 +22,27 @@ jobs: - name: Docs scaffold check run: npm run docs:check + # Phase 5: multi-host standalone matrix (native runner per host where practical) standalone: - runs-on: ubuntu-latest needs: [test] + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + host: linux-x64 + bin: flying-jib + - os: ubuntu-latest + host: linux-arm64 + bin: flying-jib + # Cross-pack from x64 runner (bare-pack host target) + - os: macos-latest + host: darwin-arm64 + bin: flying-jib + - os: windows-latest + host: win32-x64 + bin: flying-jib.exe + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -33,17 +51,30 @@ jobs: cache: npm - name: Install run: npm install --cache /tmp/npm-cache-fj - - name: Build standalone (linux-x64) - run: node scripts/bare-standalone.cjs --host linux-x64 - - name: Smoke binary + - name: Build standalone (${{ matrix.host }}) + run: node scripts/bare-standalone.cjs --host ${{ matrix.host }} + - name: Smoke binary (native hosts only) + if: ${{ !(matrix.os == 'ubuntu-latest' && matrix.host == 'linux-arm64') }} + shell: bash run: | - BIN=out/flying-jib-linux-x64/flying-jib - test -x "$BIN" - "$BIN" --storage /tmp/fj-ci create citest - "$BIN" --storage /tmp/fj-ci list | grep citest + set -euo pipefail + BIN="out/flying-jib-${{ matrix.host }}/${{ matrix.bin }}" + test -f "$BIN" || test -x "$BIN" + chmod +x "$BIN" 2>/dev/null || true + STORAGE="${RUNNER_TEMP:-/tmp}/fj-ci-${{ matrix.host }}" + mkdir -p "$STORAGE" + "$BIN" --storage "$STORAGE" create citest + "$BIN" --storage "$STORAGE" list | grep citest + - name: Artifact exists (cross-pack linux-arm64) + if: ${{ matrix.os == 'ubuntu-latest' && matrix.host == 'linux-arm64' }} + shell: bash + run: | + BIN="out/flying-jib-${{ matrix.host }}/${{ matrix.bin }}" + test -f "$BIN" + ls -la "out/flying-jib-${{ matrix.host }}/" - name: Upload artifact uses: actions/upload-artifact@v4 with: - name: flying-jib-linux-x64 - path: out/flying-jib-linux-x64/ + name: flying-jib-${{ matrix.host }} + path: out/flying-jib-${{ matrix.host }}/ if-no-files-found: error diff --git a/app.js b/app.js index 963ea12..577c8b8 100644 --- a/app.js +++ b/app.js @@ -15,6 +15,18 @@ const { PeerSession } = require('./lib/peer-session') const { MeshRegistry, listLocalMeshes } = require('./lib/mesh-registry') const { borderAction } = require('./lib/border') const { HandoffStore, applyHandoff } = require('./lib/player-handoff') +const { + formatMigrateFailure, + classifyMigrateError, + withTimeout, + snapshotGuestTunnel, + MIGRATE_TUNNEL_TIMEOUT_MS +} = require('./lib/migrate') +const { + preferStableLocalPort, + buildReconnectHint, + formatKickReason +} = require('./lib/reconnect') const crypto = require('hypercore-crypto') /** @@ -464,7 +476,14 @@ module.exports = class App extends ReadyResource { offset: region.offset, margin: 16, intervalMs: 400, + // Give Protomux migrate + guest tunnel switch a head start before Java drop kickOnMigrate: true, + kickDelayMs: 1200, + getKickReason: (evt) => + formatKickReason({ + regionName: evt.neighbor && evt.neighbor.name, + regionId: evt.neighbor && evt.neighbor.regionId + }), getRegions: async () => self.mesh.listRegions(), onBorder: (evt) => self.emit('border', evt), onMigrate: (evt) => self._onHostMigrate(evt) @@ -475,6 +494,7 @@ module.exports = class App extends ReadyResource { /** * Host-side: player crossed out of region — notify peers and log. + * Migrate control is sent first (plugin delays kick ~1.2s for soft-reconnect window). * @param {object} evt from fj-mesh-border */ _onHostMigrate(evt) { @@ -496,34 +516,82 @@ module.exports = class App extends ReadyResource { }) } // Local Java client (same machine as host) also needs to switch tunnel + // Start immediately so tunnel is up before kick fires (~1.2s later) if ( this.mcUsername && evt.player.username && evt.player.username.toLowerCase() === this.mcUsername.toLowerCase() ) { - this._switchToNeighborRegion(evt).catch((err) => this.emit('error', err)) + this._switchToNeighborRegion(evt) + .then(() => { + // Refresh kick reason with known port if border plugin supports live cfg + const port = + this.tunnelClient && this.tunnelClient.status && this.tunnelClient.status.localPort + if (port && this.squid) { + const cfg = + this.squid.fjMeshBorder || + (this.squid.serv && this.squid.serv.fjMeshBorder) + if (cfg) { + cfg.getKickReason = (e) => + formatKickReason({ + regionName: e.neighbor && e.neighbor.name, + regionId: e.neighbor && e.neighbor.regionId, + port + }) + // Keep plugin config pointer live on serv + if (this.squid.serv) this.squid.serv.fjMeshBorder = cfg + } + } + }) + .catch((err) => this.emit('error', err)) } } /** * Guest/local: stop current world tunnel and open tunnel to neighbor region. * Squid host process is left running if we are the region host for others. + * On failure: clear offline UX, restore previous tunnel when possible — never + * silent master failover (ADR-0008 / agent RULES §1). * @param {object} evt */ async _switchToNeighborRegion(evt) { if (this._migrating) return this._migrating = true + const previous = snapshotGuestTunnel(this.tunnelClient) + const previousRegionId = this._activeRegionId + let tornDown = false try { const neighbor = evt.neighbor if (!neighbor || !neighbor.worldKey) { - throw new Error('migrate missing neighbor.worldKey') + const msg = formatMigrateFailure({ + regionId: neighbor && neighbor.regionId, + regionName: neighbor && neighbor.name, + kind: 'missing-key' + }) + this.emit('message', `[mesh] ${msg.split('\n')[0]}`) + this.emit('migrate-failed', { + neighbor, + kind: 'missing-key', + message: msg, + handoff: evt.handoff || null, + restored: false + }) + return } const publicKey = decodeKey(neighbor.worldKey) const cap = neighbor.cap ? decodeKey(neighbor.cap) : null - const preferPort = - (this.tunnelClient && this.tunnelClient.status.localPort) || - (this.squid && this.squid.status.port) || - 25565 + const previousLocalPort = + (this.tunnelClient && this.tunnelClient.status && this.tunnelClient.status.localPort) || + null + const squidPort = + this.squid && this.squid.status && this.squid.status.running + ? this.squid.status.port + : null + const portPick = preferStableLocalPort({ + previousLocalPort, + squidPort, + fallback: 25565 + }) // Tear down guest tunnel / peer session for old world (keep Squid if hosting) if (this.peerSession) { @@ -534,21 +602,24 @@ module.exports = class App extends ReadyResource { await this.tunnelClient.close().catch(() => {}) this.tunnelClient = null } + tornDown = true - // If we were hosting the old region, keep Squid+host tunnel for others; - // local player uses a client tunnel on preferPort+1 when port still taken by Squid - let localPort = preferPort - if (this.squid && this.squid.status.running) { - localPort = preferPort === 25565 ? 25566 : preferPort + 1 - } + this.emit( + 'message', + `[mesh] connecting to neighbor ${neighbor.name || neighbor.regionId} (timeout ${MIGRATE_TUNNEL_TIMEOUT_MS / 1000}s)…` + ) this.tunnelClient = new WorldTunnelClient({ publicKey, cap, - localPort + localPort: portPick.localPort }) this.tunnelClient.on('error', (err) => this.emit('error', err)) - await this.tunnelClient.ready() + await withTimeout( + this.tunnelClient.ready(), + MIGRATE_TUNNEL_TIMEOUT_MS, + 'neighbor tunnel' + ) await this._startPeerSession(publicKey) this._wireGuestMigrate() @@ -564,25 +635,106 @@ module.exports = class App extends ReadyResource { this._activeRegionId = neighbor.regionId const port = this.tunnelClient.status.localPort + const samePort = previousLocalPort != null && port === previousLocalPort + const hint = buildReconnectHint({ + host: '127.0.0.1', + port, + regionId: neighbor.regionId, + regionName: neighbor.name, + mapped: evt.mapped, + samePort + }) this.emit( 'message', - `[mesh] switched tunnel → ${neighbor.name || neighbor.regionId} @ 127.0.0.1:${port}` - ) - this.emit( - 'message', - `Connect / reconnect Java Edition to 127.0.0.1:${port} (spawn ~ ${JSON.stringify(evt.mapped)})` + `[mesh] switched tunnel → ${neighbor.name || neighbor.regionId} @ ${hint.address}` + + (samePort ? ' (same local port)' : '') ) + this.emit('reconnect-hint', hint) this.emit('migrated', { neighbor, localPort: port, mapped: evt.mapped, - handoff: evt.handoff || null + handoff: evt.handoff || null, + samePort, + reconnect: hint }) + } catch (err) { + // Hold handoff locally so a later successful migrate can re-send + if (evt.handoff && evt.handoff.username) { + this.handoffs.set(evt.handoff.username, evt.handoff) + } + if (this.tunnelClient) { + await this.tunnelClient.close().catch(() => {}) + this.tunnelClient = null + } + if (this.peerSession) { + await this.peerSession.close().catch(() => {}) + this.peerSession = null + } + + let restored = false + if (tornDown && previous) { + try { + restored = await this._restoreGuestTunnel(previous, previousRegionId) + } catch (restoreErr) { + this.emit('error', restoreErr) + } + } + + const kind = classifyMigrateError(err) + const msg = formatMigrateFailure({ + regionId: evt.neighbor && evt.neighbor.regionId, + regionName: evt.neighbor && evt.neighbor.name, + kind, + cause: err && err.message + }) + for (const line of msg.split('\n')) { + this.emit('message', `[mesh] ${line}`) + } + this.emit('migrate-failed', { + neighbor: evt.neighbor || null, + kind, + error: err, + message: msg, + handoff: evt.handoff || null, + restored, + previousPort: restored && this.tunnelClient ? this.tunnelClient.status.localPort : null + }) + // Do not rethrow: callers treat this as a handled offline UX path } finally { this._migrating = false } } + /** + * Re-open previous guest tunnel after a failed migrate (best-effort). + * @param {{ publicKey: Buffer, cap: Buffer|null, localPort: number }} snap + * @param {string|null} regionId + */ + async _restoreGuestTunnel(snap, regionId) { + if (!snap || !snap.publicKey) return false + this.tunnelClient = new WorldTunnelClient({ + publicKey: snap.publicKey, + cap: snap.cap, + localPort: snap.localPort || 0 + }) + this.tunnelClient.on('error', (err) => this.emit('error', err)) + await withTimeout( + this.tunnelClient.ready(), + MIGRATE_TUNNEL_TIMEOUT_MS, + 'restore previous tunnel' + ) + await this._startPeerSession(snap.publicKey) + this._wireGuestMigrate() + this._activeRegionId = regionId + const port = this.tunnelClient.status.localPort + this.emit( + 'message', + `[mesh] restored previous tunnel @ 127.0.0.1:${port} (neighbor was offline)` + ) + return true + } + _wireGuestMigrate() { if (!this.peerSession) return this.peerSession.removeAllListeners('migrate') diff --git a/bin.mjs b/bin.mjs index 6e5a2b6..50afb0d 100644 --- a/bin.mjs +++ b/bin.mjs @@ -97,6 +97,15 @@ function attachPeerUi(app) { app.on('migrated', (evt) => { console.log(`[mesh] tunnel now 127.0.0.1:${evt.localPort} region=${evt.neighbor?.regionId}`) }) + app.on('reconnect-hint', (hint) => { + if (hint && hint.banner) console.log(hint.banner) + else if (hint && hint.address) { + console.log(`[mesh] RECONNECT Java Edition → ${hint.address}`) + } + }) + app.on('migrate-failed', (evt) => { + console.log(`[mesh] migrate failed (${evt.kind || 'error'}) restored=${!!evt.restored}`) + }) // Type lines to chat (host/join sessions) if (process.stdin && process.stdin.isTTY && typeof process.stdin.on === 'function') { diff --git a/developer_docs/ARCHITECTURE.md b/developer_docs/ARCHITECTURE.md index f79aafc..93e0b0d 100644 --- a/developer_docs/ARCHITECTURE.md +++ b/developer_docs/ARCHITECTURE.md @@ -1,7 +1,7 @@ # Technical Architecture **Last updated:** 2026-07-30 -**ADRs:** 0002–0008, **0013** (runtime) +**ADRs:** 0002–0009, **0013** (runtime) ## Design summary @@ -10,7 +10,7 @@ Flying Jib implements **Local Authority + Capability Tunnel + Federated Regions* 1. Each peer runs Flying Squid as the **sole authority** for its world/region — **inside Bare**. 2. Remote play uses an **encrypted HyperDHT TCP pipe** to that peer’s loopback Squid. 3. A mesh is a **registry of regions** (Autobase), not one shared simulation. -4. Borders use **session migration** (reconnect to neighbor), not global CRDTs. +4. Borders use **session migration** (reconnect to neighbor), not global CRDTs — with inventory handoff and **offline-neighbor UX** (timeout, restore previous tunnel, no master failover). 5. **No system Node.js**, no `node` child process, no Electron-as-default shell. ## Process model (Bare only) @@ -106,12 +106,15 @@ Invite `fj1.` carries `worldKey`, capability secret, metadata. ## Border / migration (Phase 4) -1. Squid plugin detects player near owned bounds. -2. Resolve neighbor region from registry. -3. Protomux `flying-jib/migrate`: offer inventory + relative position + gamemode. -4. Neighbor accepts → guest app switches tunnel to neighbor `worldKey`. -5. Java client reconnects to guest localhost; spawn at mapped coords. -6. Two-phase commit to prevent item duplication (Q6). +1. Squid plugin `fj-mesh-border` detects player near/outside owned bounds. +2. Resolve neighbor region from registry (`borderAction` / mesh list). +3. Capture handoff (`lib/player-handoff.js`); Protomux control `type: migrate` (+ handoff). +4. **Kick delayed ~1.2s** so guest can retarget tunnel first; kick reason can include reconnect port. +5. Guest retargets HyperDHT tunnel to neighbor `worldKey` + `cap` (15s timeout), **preferring same localhost port** (`lib/reconnect.js`). +6. Guest sends `handoff-apply`; neighbor applies on `spawned` (teleport + inventory). +7. CLI emits reconnect banner / `reconnect-hint`; Java reconnects to `127.0.0.1:port`. +8. **Offline neighbor:** `lib/migrate.js` — restore previous tunnel, stash handoff, `migrate-failed`. No master failover. +9. Full two-phase commit against item dupe remains open (Q6); capture-before-kick + delayed kick is current path. ## Storage layout @@ -142,9 +145,10 @@ $APP_STORAGE/ # bare-storage / pear storage path | Event | Behavior | |-------|----------| -| Host offline | Guests disconnect; region marked offline; no central failover | +| Host offline | Guests disconnect; no central failover | +| Neighbor offline during migrate | Clear CLI messages; restore previous guest tunnel; local handoff stash (TTL) | | Squid crash | Process/worker restart policy; disk world preserved | -| Autobase lag | Clients read last known view; UI shows sync state | +| Autobase lag | Clients read last known view; UI shows sync state (GUI Planned) | | Optional seeders | May keep cores/drives available; never coordinate matchmaking | ## Distribution architecture diff --git a/developer_docs/BUILD_AND_RELEASE.md b/developer_docs/BUILD_AND_RELEASE.md index 9095d60..91b1662 100644 --- a/developer_docs/BUILD_AND_RELEASE.md +++ b/developer_docs/BUILD_AND_RELEASE.md @@ -1,7 +1,7 @@ # Build and Release **Last updated:** 2026-07-30 -**Status:** Process specified; CI not yet wired (Phase 5). +**Status:** Standalone pack script + **multi-host CI matrix** (Phase 5 started). Pear OTA full runbook still open. **ADR:** [0013](../agent/ADRs/0013-bare-pear-only-runtime-and-distribution.md) (primary) **Historical:** [0011](../agent/ADRs/0011-build-release-hello-pear-electron.md) superseded for Electron Forge primary path @@ -69,6 +69,17 @@ Script: `scripts/bare-standalone.cjs` Verified: standalone binary starts Squid on `127.0.0.1` with empty PATH (no system Node). Size today ~500MB — optimize later (prune natives / strip). +## CI matrix (`.github/workflows/integrate.yml`) + +| Job | Runner | Host target | Smoke | +|-----|--------|-------------|-------| +| `test` | ubuntu-latest | — | `npm test` + docs check | +| `standalone` | ubuntu-latest | `linux-x64` | create/list world | +| `standalone` | ubuntu-latest | `linux-arm64` | artifact exists (cross-pack; no execute) | +| `standalone` | macos-latest | `darwin-arm64` | create/list world | +| `standalone` | windows-latest | `win32-x64` | create/list world | + +Artifacts upload as `flying-jib-/`. ## Pear deployment layers diff --git a/developer_docs/MODULES.md b/developer_docs/MODULES.md index dcac1de..57f10b7 100644 --- a/developer_docs/MODULES.md +++ b/developer_docs/MODULES.md @@ -4,7 +4,25 @@ **Runtime policy:** Bare/Pear only ([ADR-0013](../agent/ADRs/0013-bare-pear-only-runtime-and-distribution.md)) **Source:** Local Holepunch mirror `holepunchto_repos` + npm for Prismarine. -Versions below are **initial pins** from investigation. Lock exact versions in the lockfile at Phase 1 install. +Versions below are **initial pins** from investigation. Lock exact versions in the lockfile. + +## First-party app modules (`lib/`) + +| Module | Role | +|--------|------| +| `bind-guard.js` | Loopback host enforcement | +| `worlds.js` / `paths.js` | World folders under app storage | +| `squid-manager.js` | Flying Squid lifecycle (loopback, mesh border plugins) | +| `load-squid.js` | Bare-aware flying-squid load | +| `world-tunnel.js` | HyperDHT host/client TCP pipe + cap header | +| `tunnel-keys.js` | Per-world tunnel seed/cap on disk | +| `invite.js` | `fj1.` encode/decode | +| `peer-session.js` | Protomux presence/chat/control | +| `mesh-registry.js` | Autobase + Hyperbee regions | +| `border.js` | Pure border/neighbor/map helpers | +| `player-handoff.js` | Capture/apply inventory + HandoffStore | +| `migrate.js` | Offline-neighbor migrate UX (timeout, messages, tunnel snapshot) | +| `reconnect.js` | Soft-reconnect banners, kick reasons, stable localhost port pick | --- diff --git a/developer_docs/PLAYTEST.md b/developer_docs/PLAYTEST.md new file mode 100644 index 0000000..eda70a4 --- /dev/null +++ b/developer_docs/PLAYTEST.md @@ -0,0 +1,111 @@ +# Manual Playtest Runbook + +**Last updated:** 2026-07-30 +**Audience:** humans with Java Edition clients +**Automated coverage:** see [TESTING.md](./TESTING.md) (no Java GUI in CI) + +## Prerequisites + +- Two machines (or two storage dirs on one machine) +- Flying Jib standalone binary **or** `npm start` / `bare bin.mjs` for dev +- Java Edition matching Squid version (default **1.21.1**) +- Offline mode / cracked-style username OK (`online-mode: false`) + +## A. Local only (Phase 1) + +1. `flying-jib --storage /tmp/fj-a create home` +2. `flying-jib --storage /tmp/fj-a start home` (or `host` without sharing) +3. Java → Multiplayer → Direct Connect → `127.0.0.1:25565` +4. Place a block; stop; start; block still there +5. Confirm listen is loopback only (`lsof -iTCP -sTCP:LISTEN` / Resource Monitor) + +**Pass:** join + persist + loopback only. + +## B. Private world tunnel (Phase 2) + +**Host machine A** + +```sh +flying-jib --storage /tmp/fj-a host home --name Alice +# copy fj1. invite from stdout +``` + +**Guest machine B** + +```sh +flying-jib --storage /tmp/fj-b join 'fj1.…' --name Bob --port 25565 +``` + +1. Guest Java → `127.0.0.1:25565` +2. Both players visible on host Squid; chat in-game works +3. Guest disconnect / reconnect still works while host stays up + +**Pass:** remote play without opening WAN MC ports. + +## C. Side-channel chat (Phase 3) + +1. With host+join running, type into CLI stdin on either side +2. Expect `[chat]` lines on the other peer +3. `/peers` lists the other display name + +**Pass:** chat without Minecraft protocol dependency. + +## D. Mesh border + handoff (Phase 4) + +Use two hosts with **adjacent bounds** and matching mesh invite. + +**Region West (A)** + +```sh +flying-jib --storage /tmp/fj-a host west --mesh \ + --min-x 0 --max-x 99 --min-z 0 --max-z 99 \ + --mc-name Steve --name Alice +# note mesh invite; admit B’s writer if needed +``` + +**Region East (B)** + +```sh +flying-jib --storage /tmp/fj-b host east --mesh-invite 'fj1.…' \ + --min-x 100 --max-x 199 --min-z 0 --max-z 99 \ + --mc-name Alex --name Bob +``` + +**Guest playing on West** + +```sh +flying-jib --storage /tmp/fj-g join '' --mc-name Steve +``` + +1. Join as Steve; get stone/items +2. Walk east past x=99 (or fly in creative) +3. Expect warn chat near edge; then kick with mesh reason +4. CLI shows **reconnect banner** with `127.0.0.1:` +5. Direct Connect to that address on East +6. Inventory + position roughly restored (handoff) + +**Pass:** migrate signal, tunnel retarget, handoff apply. + +### Offline neighbor + +1. Stop East host before crossing border +2. Cross border from West +3. Expect clear offline messages; previous tunnel restored if guest had one +4. No silent jump to an unrelated peer + +## E. Standalone without Node (Phase 5) + +1. Build: `npm run make:standalone` +2. `env -i PATH=/usr/bin:/bin HOME="$HOME" ./out/flying-jib-*/flying-jib --storage /tmp/fj-s create c` +3. `list` shows `c` +4. Optional: full host + Java join on that binary + +**Pass:** runs with empty PATH (no `node`). + +## Record results + +Append a short note to `living_docs/PROGRESS.md`: + +- Date, MC version, OS, NAT type +- RTT / playability subjective score +- Failures and logs (redact invites) diff --git a/developer_docs/PROTOCOL.md b/developer_docs/PROTOCOL.md index a598c9a..0ad61d5 100644 --- a/developer_docs/PROTOCOL.md +++ b/developer_docs/PROTOCOL.md @@ -1,7 +1,7 @@ # P2P Protocol **Last updated:** 2026-07-30 -**Status:** Phase 2 tunnel + invites **implemented** (host/join CLI); mesh channels later +**Status:** Phases 2–4 core implemented (tunnel, presence/chat, mesh migrate + handoff) **ADRs:** 0006, 0007, 0008, 0009 ## Design rules @@ -143,6 +143,19 @@ Guest matches `username` to `--mc-name`, retargets HyperDHT client tunnel to `ne ``` Neighbor host stores pending handoff (TTL 5 min) and applies on next `spawned` for that username. + +### Offline neighbor (no master failover) + +Guest opens a HyperDHT client tunnel to `neighbor.worldKey` with a **15s timeout** (`lib/migrate.js`). + +| Outcome | Behavior | +|---------|----------| +| Success | Tunnel bound; `handoff-apply` sent; `migrated` event; user reconnects Java to new localhost port | +| Timeout / connect error | Clear multi-line `[mesh]` messages; emit `migrate-failed`; **restore previous guest tunnel** when snapshot exists; stash handoff in local `HandoffStore` for later | +| Missing `worldKey` | `migrate-failed` kind `missing-key`; no tunnel change | + +There is **no** alternate peer selection and **no** central handoff service. + | `flying-jib/registry` | 4 | Optional live region gossip (Autobase is source of truth) | **Topic:** `hash(worldPublicKey || 'flying-jib/control/v1')` via Hyperswarm (separate from MC TCP tunnel). diff --git a/developer_docs/TESTING.md b/developer_docs/TESTING.md index 4a3b0c6..4db4a22 100644 --- a/developer_docs/TESTING.md +++ b/developer_docs/TESTING.md @@ -61,13 +61,17 @@ Use different local MC ports if both host Squid on one machine. 3. Border plugin kicks on leave; control plane delivers `migrate` + handoff. 4. Inventory/position applied on neighbor join (`handoff-apply`). 5. Registry update propagates to third peer. -6. Offline neighbor → clear error, no silent master failover (manual / still open). +6. Offline neighbor → clear error, restore previous tunnel, no silent master failover. Automated: - `test/mesh-registry.test.js`, `test/border.test.js` - `test/migrate-switch.test.js`, `test/two-region-e2e.test.js` - `test/player-handoff.test.js` - `test/dual-squid-handoff.test.js` — two real Flying Squid instances + `minecraft-protocol` offline client; capture inventory on A, apply on B +- `test/migrate-offline.test.js` — offline UX helpers (timeout, messages, snapshot) +- `test/reconnect.test.js` — soft-reconnect banner / stable port helpers + +Manual Java scenarios: [`PLAYTEST.md`](./PLAYTEST.md). ### Offline / failure @@ -82,9 +86,9 @@ Automated: ## CI (Phase 5) -- Lint on PR -- Unit tests headless on Ubuntu -- Optional: integration job with DHT testnet (`@hyperswarm/testnet` if used) +- Unit tests headless on Ubuntu (`npm test`) +- Docs scaffold check +- Multi-host standalone matrix (linux-x64, linux-arm64 pack, darwin-arm64, win32-x64) - No dependency on Minecraft GUI in CI ## Test data hygiene diff --git a/developer_docs/security/README.md b/developer_docs/security/README.md index 7b618ed..9b0ce58 100644 --- a/developer_docs/security/README.md +++ b/developer_docs/security/README.md @@ -1,7 +1,8 @@ # Security documentation - Project invariants: [`../../agent/SECURITY.md`](../../agent/SECURITY.md) -- Threat model deep-dive: **Planned** Phase 5 (`THREAT_MODEL.md`) +- Threat model: [`THREAT_MODEL.md`](./THREAT_MODEL.md) (Phase 5 baseline) - Invite & capability notes: [`../PROTOCOL.md`](../PROTOCOL.md) +- Playtest security checks: loopback-only listen in [`../PLAYTEST.md`](../PLAYTEST.md) Do not document working exploit techniques against live users in public issues. diff --git a/developer_docs/security/THREAT_MODEL.md b/developer_docs/security/THREAT_MODEL.md new file mode 100644 index 0000000..a9a1e2b --- /dev/null +++ b/developer_docs/security/THREAT_MODEL.md @@ -0,0 +1,87 @@ +# Flying Jib Threat Model + +**Last updated:** 2026-07-30 +**Status:** Phase 5 baseline +**Invariants:** [`../../agent/SECURITY.md`](../../agent/SECURITY.md) +**ADRs:** 0005 (loopback), 0006 (tunnel), 0008 (migrate), 0009 (invite), 0012 (license), 0013 (Bare-only) + +## Scope + +In scope: Flying Jib CLI/app, Flying Squid on loopback, HyperDHT world tunnels, Hyperswarm/Protomux control plane, mesh registry (Autobase), border migrate + inventory handoff, standalone Bare binaries. + +Out of scope: Mojang/Microsoft account systems, Java client vulnerabilities, public DHT bootstrap operator compromise (treat as untrusted infrastructure), physical access to a peer’s disk. + +## Assets + +| Asset | Sensitivity | +|-------|-------------| +| Minecraft world Anvil data | High (player builds) | +| Tunnel seed + capability (`cap`) | Critical (join secret) | +| Mesh Autobase writer keys | High (region forge) | +| Inventory handoff payloads | High (item theft/dupe if abused) | +| Peer identity / display names | Low–medium | +| Standalone binary integrity | High (supply chain) | + +## Trust boundaries + +```text +┌──────────── Java client ────────────┐ +│ only talks to 127.0.0.1 │ +└─────────────────┬───────────────────┘ + │ MC protocol (local) +┌─────────────────▼───────────────────┐ +│ Flying Jib process (Bare) │ +│ Squid authority for this region │ +└─────────────────┬───────────────────┘ + │ Noise (HyperDHT / Hyperswarm) +┌─────────────────▼───────────────────┐ +│ Remote peers / public DHT │ ← untrusted network +└─────────────────────────────────────┘ +``` + +- **Localhost MC:** trusted machine user only (any local process can connect if `online-mode: false`). +- **P2P links:** untrusted until Noise + capability proof succeed. +- **Mesh registry writers:** partially trusted; admission is capability-gated (Q4 still evolving). + +## Adversaries + +1. **Remote network attacker** — no invite/cap; can observe DHT metadata. +2. **Invite holder (revoked or overshared)** — can join until host rotates keys / stops hosting. +3. **Malicious mesh peer** — enrolled region with hostile bounds/metadata. +4. **Local malware** — same OS user as Flying Jib (can bind-race or read secrets files). +5. **Supply-chain** — compromised npm/Bare build inputs. + +## Threats & mitigations + +| ID | Threat | Impact | Mitigation | Residual | +|----|--------|--------|------------|----------| +| T1 | Squid listens on WAN | Internet grief / exploit surface | Force `127.0.0.1`; bind-guard plugin; tests (ADR-0005) | Local processes still join | +| T2 | Stolen `fj1.` invite | Unwanted join | Cap header on tunnel; short-lived invites later; do not log secrets | Until TTL/revoke ships | +| T3 | Cap brute / forge | Unauthorized tunnel | 32-byte secret; Noise still required for DHT | Cap strength = entropy of invite share path | +| T4 | MitM on MC bytes | Tamper game stream | HyperDHT secretstream; no plaintext WAN MC | Compromised peer host can still cheat in own region | +| T5 | Fake region / border trap | Kick + handoff to attacker world | Mesh writer admission; user sees region names; offline restore | Social engineering | +| T6 | Inventory dupe on migrate | Economy break | Capture-before-kick; single take from HandoffStore; TTL | Full 2PC still open (Q6) | +| T7 | Offline neighbor silent failover to “master” | Centralization / wrong world | Explicit `migrate-failed`; restore previous tunnel (RULES §1) | User confusion if CLI unread | +| T8 | Require Node on user machine | Supply-chain / support | Bare standalone only (ADR-0013); CI smoke without Node | Dev machines use npm | +| T9 | AGPL / hostile license deps | Relicense risk | ADR-0012; avoid holesail | Manual audit | +| T10 | Log secret leakage | Invite theft | Redact invites/caps/seeds in logs | User paste into chat | +| T11 | Malicious standalone binary | Full compromise | Pear multisig / known download channel (**Planned** verify) | Until OTA trust fully exercised | + +## Explicit non-goals + +- Preventing a region host from cheating **inside their own Squid**. +- Hiding player IPs from DHT-level observers (Holepunch tradeoff). +- DRM or anti-piracy for Minecraft clients. + +## Secure configuration checklist + +- [ ] Squid `host` is always `127.0.0.1` +- [ ] `online-mode` documented (offline UUIDs for private mesh) +- [ ] Invites shared out-of-band carefully +- [ ] Mesh writers admitted deliberately (`mesh-admit`) +- [ ] Secrets files mode `0600` where supported +- [ ] Production path is standalone Bare (no Node on PATH) + +## Reporting + +Report security issues privately to the project maintainers. Do not open public issues with working exploit PoCs against third parties. diff --git a/lib/migrate.js b/lib/migrate.js new file mode 100644 index 0000000..b836a0e --- /dev/null +++ b/lib/migrate.js @@ -0,0 +1,111 @@ +'use strict' + +/** + * Mesh border migrate helpers (ADR-0008). + * Pure logic for offline-neighbor UX — no silent master failover. + */ + +const MIGRATE_TUNNEL_TIMEOUT_MS = 15_000 + +/** + * Human-readable failure when a region switch cannot complete. + * @param {object} opts + * @param {string} [opts.regionId] + * @param {string} [opts.regionName] + * @param {string} [opts.cause] machine code or error message + * @param {'timeout'|'missing-key'|'connect'|'unknown'} [opts.kind] + */ +function formatMigrateFailure(opts = {}) { + const name = opts.regionName || opts.regionId || 'neighbor region' + const kind = opts.kind || 'unknown' + const cause = opts.cause ? String(opts.cause) : '' + + let headline + switch (kind) { + case 'missing-key': + headline = `Cannot migrate: ${name} has no tunnel worldKey in the mesh registry.` + break + case 'timeout': + headline = `Neighbor region “${name}” did not answer within ${MIGRATE_TUNNEL_TIMEOUT_MS / 1000}s (offline or unreachable).` + break + case 'connect': + headline = `Neighbor region “${name}” is offline or unreachable.` + break + default: + headline = `Migration to “${name}” failed.` + } + + const lines = [ + headline, + 'Flying Jib never fails over to a central master — only the peer hosting that region can accept you.', + 'Your inventory handoff is kept locally until the neighbor is online again (or the handoff TTL expires).', + 'Stay on (or reconnect to) the previous region tunnel when restore succeeds; otherwise rejoin the original host invite.' + ] + if (cause) lines.push(`Detail: ${cause}`) + return lines.join('\n') +} + +/** + * Classify an error from tunnel open / ready. + * @param {Error|string|null} err + * @returns {'timeout'|'connect'|'unknown'} + */ +function classifyMigrateError(err) { + const msg = err && (err.message || String(err)) + if (!msg) return 'unknown' + if (/timeout|timed out|ETIMEDOUT/i.test(msg)) return 'timeout' + if (/ECONN|ENOTFOUND|offline|unreachable|destroy|closed|reject/i.test(msg)) return 'connect' + return 'unknown' +} + +/** + * Race a promise against a timeout. + * @template T + * @param {Promise} promise + * @param {number} [ms] + * @param {string} [label] + * @returns {Promise} + */ +function withTimeout(promise, ms = MIGRATE_TUNNEL_TIMEOUT_MS, label = 'operation') { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`${label} timed out after ${ms}ms`)) + }, ms) + if (typeof timer.unref === 'function') timer.unref() + promise.then( + (v) => { + clearTimeout(timer) + resolve(v) + }, + (err) => { + clearTimeout(timer) + reject(err) + } + ) + }) +} + +/** + * Snapshot of a guest tunnel so we can restore after a failed migrate. + * @param {object|null} tunnelClient WorldTunnelClient + * @param {Buffer|null} [worldPublicKey] + */ +function snapshotGuestTunnel(tunnelClient, worldPublicKey) { + if (!tunnelClient || !tunnelClient.status) return null + const st = tunnelClient.status + const pk = worldPublicKey || st.remotePublicKey || tunnelClient.publicKey + if (!pk) return null + return { + publicKey: Buffer.from(pk), + cap: tunnelClient.cap ? Buffer.from(tunnelClient.cap) : null, + localPort: st.localPort || tunnelClient.localPort || 0 + } +} + +module.exports = { + MIGRATE_TUNNEL_TIMEOUT_MS, + formatMigrateFailure, + classifyMigrateError, + withTimeout, + snapshotGuestTunnel +} diff --git a/lib/reconnect.js b/lib/reconnect.js new file mode 100644 index 0000000..b5d2bd0 --- /dev/null +++ b/lib/reconnect.js @@ -0,0 +1,133 @@ +'use strict' + +/** + * Soft-reconnect UX helpers for mesh border migration (ADR-0008). + * Java Edition cannot silently rebind TCP; we minimize friction: + * - Prefer the same localhost port when retargeting the guest tunnel + * - Print a clear Direct Connect banner + * - Kick reason includes the next 127.0.0.1:port when known + */ + +/** + * @param {object} opts + * @param {string} [opts.host='127.0.0.1'] + * @param {number} opts.port + * @param {string} [opts.regionName] + * @param {string} [opts.regionId] + * @param {{x?:number,y?:number,z?:number}|null} [opts.mapped] + * @param {boolean} [opts.samePort] + */ +function formatReconnectBanner(opts = {}) { + const host = opts.host || '127.0.0.1' + const port = Number(opts.port) + const region = opts.regionName || opts.regionId || 'neighbor region' + const lines = [ + '', + '══════════════════════════════════════════════════════', + ' Flying Jib — mesh region switch', + ` Destination: ${region}`, + ` Java Edition → Direct Connect: ${host}:${port}`, + '══════════════════════════════════════════════════════' + ] + if (opts.samePort) { + lines.push(' (Same local port as before — re-add/reconnect that entry.)') + } else { + lines.push(' (Port changed — update Direct Connect or Multiplayer entry.)') + } + if (opts.mapped && (opts.mapped.x != null || opts.mapped.z != null)) { + lines.push( + ` Spawn hint: ~ ${Math.floor(opts.mapped.x || 0)}, ${Math.floor(opts.mapped.y || 64)}, ${Math.floor(opts.mapped.z || 0)}` + ) + } + lines.push( + ' Inventory handoff applies after you join the new server.', + ' Press multiplayer → connect (or create Direct Connect).', + '══════════════════════════════════════════════════════', + '' + ) + return lines.join('\n') +} + +/** + * Kick / disconnect reason text (Minecraft shows this in the disconnect screen). + * Keep relatively short (chat component limits vary by version). + * @param {object} opts + * @param {string} [opts.regionName] + * @param {string} [opts.regionId] + * @param {number} [opts.port] + * @param {string} [opts.host='127.0.0.1'] + */ +function formatKickReason(opts = {}) { + const region = opts.regionName || opts.regionId || 'neighbor' + const host = opts.host || '127.0.0.1' + if (opts.port != null && Number.isFinite(Number(opts.port))) { + return `Mesh → ${region}. Reconnect to ${host}:${opts.port}` + } + return `Mesh border → ${region}. Check Flying Jib CLI for reconnect address.` +} + +/** + * Prefer keeping the guest local port stable across tunnel retargets. + * @param {object} opts + * @param {number|null} [opts.previousLocalPort] + * @param {number|null} [opts.squidPort] host Squid port if still running locally + * @param {number} [opts.fallback=25565] + * @returns {{ localPort: number, samePort: boolean, reason: string }} + */ +function preferStableLocalPort(opts = {}) { + const fallback = opts.fallback != null ? Number(opts.fallback) : 25565 + const prev = opts.previousLocalPort != null ? Number(opts.previousLocalPort) : null + const squid = opts.squidPort != null ? Number(opts.squidPort) : null + + if (prev && (!squid || prev !== squid)) { + return { + localPort: prev, + samePort: true, + reason: 'reuse-previous-guest-port' + } + } + + // Host machine also running Squid: guest tunnel must not steal Squid's port + if (squid) { + const alt = squid === 25565 ? 25566 : squid + 1 + return { + localPort: prev && prev !== squid ? prev : alt, + samePort: !!(prev && prev !== squid), + reason: 'avoid-local-squid-port' + } + } + + if (prev) { + return { localPort: prev, samePort: true, reason: 'reuse-previous' } + } + + return { localPort: fallback, samePort: false, reason: 'fallback' } +} + +/** + * Structured reconnect hint for CLI / future GUI. + * @param {object} opts + */ +function buildReconnectHint(opts = {}) { + const host = opts.host || '127.0.0.1' + const port = Number(opts.port) + return { + type: 'reconnect-hint', + host, + port, + address: `${host}:${port}`, + regionId: opts.regionId || null, + regionName: opts.regionName || null, + mapped: opts.mapped || null, + samePort: !!opts.samePort, + banner: formatReconnectBanner(opts), + kickReason: formatKickReason(opts) + } +} + +module.exports = { + formatReconnectBanner, + formatKickReason, + preferStableLocalPort, + buildReconnectHint +} diff --git a/living_docs/ARCHITECTURE_OVERVIEW.md b/living_docs/ARCHITECTURE_OVERVIEW.md index 4ac7376..8b4b17e 100644 --- a/living_docs/ARCHITECTURE_OVERVIEW.md +++ b/living_docs/ARCHITECTURE_OVERVIEW.md @@ -5,7 +5,8 @@ **Last updated:** 2026-07-30 **Design name:** Local Authority + Capability Tunnel + Federated Regions -**Runtime:** **Bare / Pear only** ([ADR-0013](../agent/ADRs/0013-bare-pear-only-runtime-and-distribution.md)) +**Runtime:** **Bare / Pear only** ([ADR-0013](../agent/ADRs/0013-bare-pear-only-runtime-and-distribution.md)) +**Mesh status:** Registry + border migrate + inventory handoff + dual-Squid e2e + offline UX --- @@ -71,8 +72,13 @@ Each peer’s Squid is authoritative for **its** world/region. There is no globa ### 3. Federated regions (mesh) - Peers **enroll** regions into a mesh registry (Autobase + Hyperbee view). -- Each region has bounds/offset, tunnel key, seed metadata. -- Crossing a border **migrates** the player session to the neighbor’s Squid. +- Each region has bounds/offset, tunnel key + cap, seed metadata. +- Crossing a border **migrates** the player session to the neighbor’s Squid: + 1. Capture inventory/state (`lib/player-handoff.js`) + 2. Protomux `migrate` first; kick delayed ~1.2s + 3. Guest tunnel retarget (prefer same localhost port) + reconnect banner + 4. Guest `handoff-apply` → neighbor applies on `spawned` +- **Offline neighbor:** clear UX, restore previous tunnel, stash handoff — **never** fail over to a master (`lib/migrate.js`). ### 4. Side-channel communication @@ -94,8 +100,9 @@ Chat and presence use Protomux over Hyperswarm/DHT streams. | Artifact | Tooling | |----------|---------| -| Standalone binary per OS/arch | `bare-build --standalone` | -| P2P OTA app drive | pear stage → seed → provision → multisig | +| Standalone binary per OS/arch | `scripts/bare-standalone.cjs` (bare-pack + embed) | +| CI matrix | GitHub Actions: linux-x64, linux-arm64 (pack), darwin-arm64, win32-x64 | +| P2P OTA app drive | pear stage → seed → provision → multisig (**Planned** full verify) | | Upgrade channel | pear link in app metadata (`upgrade`) | Users never install Node.js to play. diff --git a/living_docs/CHANGELOG.md b/living_docs/CHANGELOG.md index a919511..0662d28 100644 --- a/living_docs/CHANGELOG.md +++ b/living_docs/CHANGELOG.md @@ -16,6 +16,16 @@ and this project aims to follow [Semantic Versioning](https://semver.org/). - Apply on destination Squid `spawned` (teleport + inventory) - Two-region automated e2e path test - Dual real Flying Squid + `minecraft-protocol` offline client handoff e2e +- Offline neighbor migrate UX (`lib/migrate.js`): + - 15s tunnel timeout; clear messages; restore previous tunnel; local handoff stash + - `migrate-failed` event — no silent master failover +- Soft-reconnect UX (`lib/reconnect.js`): + - Prefer stable localhost port on tunnel retarget + - Delayed kick (~1.2s) so migrate/tunnel can finish first + - CLI reconnect banner + kick reason with Direct Connect address +- Multi-host standalone CI matrix (linux-x64/arm64, darwin-arm64, win32-x64) +- Threat model: `developer_docs/security/THREAT_MODEL.md` +- Manual playtest runbook: `developer_docs/PLAYTEST.md` ### Fixed diff --git a/living_docs/CURRENT_STATUS.md b/living_docs/CURRENT_STATUS.md index 1f9d05d..e63bf0d 100644 --- a/living_docs/CURRENT_STATUS.md +++ b/living_docs/CURRENT_STATUS.md @@ -1,14 +1,14 @@ # Flying Jib — Current Status **Snapshot date:** 2026-07-30 -**Phase:** 1–4 operational core (incl. dual-Squid handoff e2e) +**Phase:** 1–4 operational core; Phase 5 CI matrix started **Product:** Flying Jib — Bare/Pear P2P Minecraft mesh --- ## One-line summary -Private worlds, chat, mesh registry, border migrate with **inventory/position handoff** (dual real Squid + protocol client e2e), and a **standalone Bare binary** — no system Node.js. +Private worlds, chat, mesh registry, border migrate with **inventory handoff**, **offline UX**, **soft-reconnect banners** (stable localhost port), dual-Squid e2e, multi-host CI, and a written **threat model** — no system Node.js. --- @@ -21,10 +21,14 @@ Private worlds, chat, mesh registry, border migrate with **inventory/position ha | Standalone binary | Working (~500MB) | | Mesh Autobase registry | Working | | Border plugin + migrate signal + tunnel switch | Working | -| **Inventory + spawn handoff** (`lib/player-handoff.js`) | Working | +| Inventory + spawn handoff | Working | | Two-region e2e (mesh + tunnels + handoff) | **Tested** | | Dual real Squid + protocol client handoff | **Tested** | -| Tests | **25/25 pass** | +| Offline neighbor migrate UX (timeout + restore) | Working | +| Soft-reconnect UX (delay kick, same port, banner) | Working | +| Multi-host CI standalone matrix | **Wired** | +| Threat model doc | Written | +| Tests | **36/36 pass** | --- @@ -32,17 +36,17 @@ Private worlds, chat, mesh registry, border migrate with **inventory/position ha | Area | Status | |------|--------| -| Java client still kick + manual reconnect | v1 | +| Vanilla Java still must reconnect after border | Softened, not silent | | Full NBT fidelity / XP bars edge cases | Best-effort | | Binary size | ~500MB | +| Pear OTA full verify | Not done | | GUI | Not started | --- ## Next -1. Multi-host CI matrix for standalone -2. Optional soft-reconnect UX -3. Java multiplayer playtest on two machines -4. Binary prune - +1. Pear OTA stage/seed path verification +2. Two-machine Java playtest (use `developer_docs/PLAYTEST.md`) +3. Binary prune +4. Handoff two-phase commit (Q6) diff --git a/living_docs/OPEN_QUESTIONS.md b/living_docs/OPEN_QUESTIONS.md index 889313f..5e537a7 100644 --- a/living_docs/OPEN_QUESTIONS.md +++ b/living_docs/OPEN_QUESTIONS.md @@ -18,12 +18,12 @@ | Q7 | Packaging Squid for bare-build standalone | High | bare-build fails on flying-squid esbuild-import-glob; need peardock-style `bare-pack` + imports map (build/squid-imports.json) | Open | | Q8 | Accidental AGPL dependency (e.g. holesail) | Medium | Dependency policy + CI license check (ADR-0012) | Open | | Q9 | Public mesh spam / content liability | High (policy) | Private-by-default; public mesh experimental only | Open | -| Q10 | minecraft-protocol default listen on all interfaces | Critical | Wrapper forces `127.0.0.1`; bind-guard unit test in Phase 1 | Open | +| Q10 | minecraft-protocol default listen on all interfaces | Critical | Wrapper forces `127.0.0.1`; bind-guard unit test in Phase 1 | **Resolved (2026-07-30)** — forceLoopbackHost + fj-bind-guard + tests | | Q11 | Autobee (experimental) vs Autobase + Hyperbee view | Low | Prefer Autobase until Autobee stabilizes | Open (lean Autobase) | | Q12 | Multiple concurrent local clients / port allocation | Low | Port allocator in settings | Open | | Q13 | Exact invite encoding: JSON+z32 vs compact-encoding+z32 | Low | JSON for Phase 2 debuggability; migrate if size hurts | Open | | Q14 | Squid co-location: main Bare process vs dedicated Bare worker | Medium | Prefer worker isolation; measure IPC/tick impact | Open | -| Q15 | Flying Squid + Prismarine under Bare | High | **Listen verified on Bare.** Remaining: Java client join, Anvil persistence soak, shrink shim surface | Partial | +| Q15 | Flying Squid + Prismarine under Bare | High | **Listen verified on Bare + standalone.** Protocol client login e2e; Java GUI playtest still manual | Partial | | Q16 | Optional future GUI shell without breaking Bare-only backend | Low | CLI first; any GUI must not require Node for Squid | Open | --- diff --git a/living_docs/PROGRESS.md b/living_docs/PROGRESS.md index f3c12fd..34febf7 100644 --- a/living_docs/PROGRESS.md +++ b/living_docs/PROGRESS.md @@ -5,6 +5,42 @@ --- +## 2026-07-30 — Soft-reconnect UX + threat model + playtest runbook + +### Wins + +- `lib/reconnect.js` — Direct Connect banners, kick reasons, stable localhost port preference +- Border kick delay **1.2s** + dynamic `getKickReason` (migrate signal first) +- Guest tunnel retarget prefers **same local port**; emits `reconnect-hint` +- CLI prints reconnect banner / migrate-failed +- `developer_docs/security/THREAT_MODEL.md` (Phase 5 acceptance) +- `developer_docs/PLAYTEST.md` two-machine scenarios +- `test/reconnect.test.js` + +### Gaps + +- Pear OTA still not fully verified +- Silent vanilla reconnect impossible; further polish backlog + +--- + +## 2026-07-30 — Offline migrate UX + multi-host CI + docs sweep + +### Wins + +- `lib/migrate.js` — timeout, classify errors, format clear offline messages (no master failover) +- `App._switchToNeighborRegion` restores previous guest tunnel on failure; stashes handoff; emits `migrate-failed` +- CI `integrate.yml` standalone matrix: linux-x64, linux-arm64 (pack), darwin-arm64, win32-x64 +- Docs: living_docs, MESH_WORLDS, PROTOCOL, ARCHITECTURE*, BUILD_AND_RELEASE, TESTING, MODULES +- `test/migrate-offline.test.js` + +### Gaps + +- Pear OTA runbook not executed end-to-end +- Soft reconnect still open + +--- + ## 2026-07-30 — Dual real Squid + protocol client handoff e2e ### Wins diff --git a/living_docs/ROADMAP.md b/living_docs/ROADMAP.md index 11c5980..d69aaa6 100644 --- a/living_docs/ROADMAP.md +++ b/living_docs/ROADMAP.md @@ -151,7 +151,7 @@ | Field | Value | |-------|-------| -| **Status** | In Progress (registry + border + dual-Squid handoff e2e) | +| **Status** | Done (core) — GUI / soft-reconnect / playtest remain | | **Owner** | TBD | | **Target** | TBD | | **ADRs** | 0007, 0008 | @@ -173,7 +173,7 @@ - [x] Inventory/spawn handoff capture + apply on neighbor join - [x] Two-region e2e test (mesh + tunnels + handoff path) - [x] Dual real Squid + minecraft-protocol login handoff e2e -- [ ] Offline region UX is clear (no silent master failover) +- [x] Offline region UX is clear (no silent master failover) ### Documentation required to complete @@ -188,7 +188,7 @@ | Field | Value | |-------|-------| -| **Status** | Not Started | +| **Status** | In Progress (standalone matrix started) | | **Owner** | TBD | | **Target** | TBD | | **ADRs** | **0013**, 0012 (0011 historical) | @@ -202,10 +202,10 @@ ### Acceptance -- [ ] Multi-host standalone executables from CI +- [x] Multi-host standalone executables from CI (matrix: linux-x64/arm64, darwin-arm64, win32-x64) - [ ] Pear OTA path verified without system Node -- [ ] Documented release runbook executed once -- [ ] Threat model under `developer_docs/security/` +- [x] Documented playtest / release smoke runbook (`PLAYTEST.md`; full Pear release still open) +- [x] Threat model under `developer_docs/security/THREAT_MODEL.md` ### Documentation required to complete diff --git a/plugins/fj-mesh-border.js b/plugins/fj-mesh-border.js index a29e138..0eabf0a 100644 --- a/plugins/fj-mesh-border.js +++ b/plugins/fj-mesh-border.js @@ -12,7 +12,9 @@ * intervalMs: 400, * onBorder: (evt) => {}, * onMigrate: (evt) => {}, - * kickOnMigrate: true + * kickOnMigrate: true, + * kickDelayMs: 1200, // allow migrate signal + tunnel switch first + * getKickReason: (evt) => string * } */ @@ -136,22 +138,30 @@ module.exports.server = function (serv, settings) { try { player.chat( - `§6[Mesh] Leaving region → ${act.neighbor.name || act.neighbor.regionId}. Reconnecting…` + `§6[Mesh] Leaving region → ${act.neighbor.name || act.neighbor.regionId}. Check CLI for reconnect address…` ) } catch { /* ignore */ } if (cfg.kickOnMigrate !== false) { + const delay = cfg.kickDelayMs != null ? Number(cfg.kickDelayMs) : 1200 setTimeout(() => { try { - player.kick( + let reason = `Flying Jib mesh border → ${act.neighbor.name || act.neighbor.regionId}. Rejoin when ready.` - ) + if (typeof cfg.getKickReason === 'function') { + try { + reason = cfg.getKickReason(evt) || reason + } catch { + /* keep default */ + } + } + player.kick(reason) } catch { /* ignore */ } - }, 200) + }, Number.isFinite(delay) ? delay : 1200) } } } diff --git a/test/migrate-offline.test.js b/test/migrate-offline.test.js new file mode 100644 index 0000000..f1ea95d --- /dev/null +++ b/test/migrate-offline.test.js @@ -0,0 +1,78 @@ +'use strict' + +/** + * Offline-neighbor migrate UX (no silent master failover). + */ + +const test = require('brittle') +const { + formatMigrateFailure, + classifyMigrateError, + withTimeout, + snapshotGuestTunnel, + MIGRATE_TUNNEL_TIMEOUT_MS +} = require('../lib/migrate') + +test('formatMigrateFailure mentions no central master', (t) => { + const msg = formatMigrateFailure({ + regionId: 'east', + regionName: 'East', + kind: 'timeout' + }) + t.ok(msg.includes('East')) + t.ok(/never fails over|no.*master|central master/i.test(msg)) + t.ok(/offline|did not answer/i.test(msg)) + t.ok(msg.includes(String(MIGRATE_TUNNEL_TIMEOUT_MS / 1000))) +}) + +test('formatMigrateFailure missing-key', (t) => { + const msg = formatMigrateFailure({ regionId: 'x', kind: 'missing-key' }) + t.ok(/worldKey/i.test(msg)) +}) + +test('classifyMigrateError', (t) => { + t.is(classifyMigrateError(new Error('neighbor tunnel timed out after 15000ms')), 'timeout') + t.is(classifyMigrateError(new Error('ECONNREFUSED')), 'connect') + t.is(classifyMigrateError(new Error('weird')), 'unknown') + t.is(classifyMigrateError(null), 'unknown') +}) + +test('withTimeout rejects after ms', { timeout: 5000 }, async (t) => { + const start = Date.now() + let err = null + try { + // Settling hang is avoided: timeout path clears timer; we only assert reject + await withTimeout( + new Promise((resolve) => { + // resolve later so we do not leave an eternally pending handle in brittle + setTimeout(resolve, 5000) + }), + 80, + 'probe' + ) + } catch (e) { + err = e + } + t.ok(err) + t.ok(/timed out/i.test(err.message)) + t.ok(Date.now() - start >= 70) + t.ok(Date.now() - start < 2000) +}) + +test('withTimeout resolves when promise wins', async (t) => { + const v = await withTimeout(Promise.resolve(42), 1000, 'fast') + t.is(v, 42) +}) + +test('snapshotGuestTunnel', (t) => { + const pk = Buffer.alloc(32, 7) + const snap = snapshotGuestTunnel({ + publicKey: pk, + cap: Buffer.alloc(32, 1), + status: { localPort: 25566, remotePublicKey: pk } + }) + t.ok(snap) + t.is(snap.localPort, 25566) + t.ok(Buffer.isBuffer(snap.publicKey)) + t.absent(snapshotGuestTunnel(null)) +}) diff --git a/test/reconnect.test.js b/test/reconnect.test.js new file mode 100644 index 0000000..2564168 --- /dev/null +++ b/test/reconnect.test.js @@ -0,0 +1,52 @@ +'use strict' + +const test = require('brittle') +const { + formatReconnectBanner, + formatKickReason, + preferStableLocalPort, + buildReconnectHint +} = require('../lib/reconnect') + +test('formatReconnectBanner includes Direct Connect address', (t) => { + const b = formatReconnectBanner({ + port: 25566, + regionName: 'East', + mapped: { x: 1.2, y: 70, z: 3.9 }, + samePort: true + }) + t.ok(b.includes('127.0.0.1:25566')) + t.ok(b.includes('East')) + t.ok(/Same local port/i.test(b)) + t.ok(b.includes('1, 70, 3')) +}) + +test('formatKickReason with and without port', (t) => { + t.ok(formatKickReason({ regionName: 'East', port: 25566 }).includes('25566')) + t.ok(/CLI/i.test(formatKickReason({ regionId: 'x' }))) +}) + +test('preferStableLocalPort reuses guest port', (t) => { + const r = preferStableLocalPort({ previousLocalPort: 25565, squidPort: null }) + t.is(r.localPort, 25565) + t.ok(r.samePort) +}) + +test('preferStableLocalPort avoids local Squid port', (t) => { + const r = preferStableLocalPort({ previousLocalPort: 25565, squidPort: 25565 }) + t.is(r.localPort, 25566) + t.ok(r.reason.includes('squid') || r.localPort === 25566) +}) + +test('buildReconnectHint shape', (t) => { + const h = buildReconnectHint({ + port: 25565, + regionId: 'east', + regionName: 'East', + samePort: false + }) + t.is(h.type, 'reconnect-hint') + t.is(h.address, '127.0.0.1:25565') + t.ok(h.banner.includes('25565')) + t.ok(h.kickReason.includes('East')) +}) diff --git a/user_docs/FAQ.md b/user_docs/FAQ.md index f9159d0..df3698c 100644 --- a/user_docs/FAQ.md +++ b/user_docs/FAQ.md @@ -24,6 +24,10 @@ For a private world, **the inviter/host** machine runs the simulation. For a mes Guests disconnect. Start again when the host is back. Mesh regions hosted by others remain available if those peers are online. +## What if I walk into a mesh region that is offline? + +Flying Jib will **not** invent a central server to take over. You get a clear mesh message that the neighbor is offline/unreachable. If you already had a tunnel to the previous region, the app tries to **restore** it. Inventory handoff is held briefly so it can still apply when that peer hosts again. + ## Can I use Bedrock Edition? Not in the current design. Flying Squid targets **Java Edition** protocol. diff --git a/user_docs/MESH_WORLDS.md b/user_docs/MESH_WORLDS.md index 0205260..84997a5 100644 --- a/user_docs/MESH_WORLDS.md +++ b/user_docs/MESH_WORLDS.md @@ -1,12 +1,12 @@ # Mesh Worlds -**Status:** Registry + border migrate path available (Phase 4). Inventory handoff and seamless Java reconnect still incomplete. +**Status:** Phase 4 operational core — registry, border migrate, inventory handoff, dual-Squid e2e, soft-reconnect **hints** (same local port when possible). ## The idea Many peers can **enroll** their private worlds (or regions) into a shared **mesh**. Together they form a **linked giant world**: each peer still runs their own local simulation for their region, but borders connect via portals or session handoff. -This is **not** one enormous server in the cloud. It is a **federation** of peer regions. +This is **not** one enormous server in the cloud. It is a **federation** of peer regions. There is **never** a central master that takes over when a region is offline. ## Enrollment (CLI) @@ -26,22 +26,36 @@ flying-jib mesh-open --invite 'fj1.…' flying-jib mesh-list --invite 'fj1.…' ``` -GUI “Enroll in mesh” is still planned. +GUI “Enroll in mesh” is still **Planned**. ## Crossing borders When you approach / leave a region edge (host with mesh enroll active): 1. You see a chat warning near the border. -2. Leaving the bounds **kicks** you with a mesh message. -3. Guest app (with `--mc-name` matching your Minecraft name) **switches the P2P tunnel** to the neighbor’s world key. -4. Reconnect Java Edition to the **new localhost port** printed by the guest CLI. +2. Host sends migrate + handoff on the control plane; **~1.2s later** Java is kicked (gives the guest app time to retarget the tunnel). +3. Guest app (with `--mc-name` matching your Minecraft name) **switches the P2P tunnel** to the neighbor’s world key (15s timeout), **preferring the same localhost port**. +4. CLI prints a **reconnect banner** with Direct Connect `127.0.0.1:` (and spawn hint). +5. Reconnect Java Edition to that address (often the **same** multiplayer entry if the port did not change). -**Handoff:** inventory, health, food, gamemode, and mapped spawn position are sent to the neighbor host and applied when you rejoin. Java still **disconnects and must reconnect** to the new localhost port. +**Handoff:** inventory, health, food, gamemode, and mapped spawn position are sent to the neighbor host and applied when you rejoin. Vanilla Java still **must reconnect** (no silent TCP rebind); Flying Jib only softens the path. -**v1 limits:** item NBT/enchant fidelity is best-effort; XP bars may not fully match. +### Neighbor offline (clear UX) -If the neighbor is offline, migration cannot complete until they host again. +If the neighbor host is not online or the tunnel does not complete: + +- Flying Jib prints a clear **`[mesh]`** message: region offline/unreachable, **no central failover**. +- It tries to **restore your previous tunnel** when one existed. +- Inventory handoff is **kept locally** (TTL) so a later successful migrate can still deliver it. +- You are **not** silently moved to some other peer’s world. + +Wait until the neighbor hosts again, then re-cross or re-trigger migrate. + +### v1 limits + +- Item NBT / enchant fidelity is best-effort. +- XP bars may not fully match. +- Fully silent reconnect without a Java disconnect is **not possible** with vanilla clients; further UX polish is backlog. ## What mesh is good for @@ -54,3 +68,4 @@ If the neighbor is offline, migration cannot complete until they host again. - A guarantee every chunk is always available - A replacement for a professional dedicated host if you need 24/7 uptime of one world - A single global redstone computer spanning all peers +- A central matchmaking or “home server” when regions go down