This commit is contained in:
Raven Scott
2026-07-31 00:24:47 -04:00
parent fdd66e53d6
commit a927a6dfaa
24 changed files with 986 additions and 85 deletions
+41 -10
View File
@@ -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
+172 -20
View File
@@ -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')
+9
View File
@@ -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') {
+14 -10
View File
@@ -1,7 +1,7 @@
# Technical Architecture
**Last updated:** 2026-07-30
**ADRs:** 00020008, **0013** (runtime)
**ADRs:** 00020009, **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 peers 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
+12 -1
View File
@@ -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-<host>/`.
## Pear deployment layers
+19 -1
View File
@@ -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 |
---
+111
View File
@@ -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 Bs 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 '<west-world-invite>' --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:<port>`
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)
+14 -1
View File
@@ -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 24 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).
+8 -4
View File
@@ -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
+2 -1
View File
@@ -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.
+87
View File
@@ -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 peers 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 | Lowmedium |
| 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.
+111
View File
@@ -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<T>} promise
* @param {number} [ms]
* @param {string} [label]
* @returns {Promise<T>}
*/
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
}
+133
View File
@@ -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
}
+12 -5
View File
@@ -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 peers 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 neighbors Squid.
- Each region has bounds/offset, tunnel key + cap, seed metadata.
- Crossing a border **migrates** the player session to the neighbors 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.
+10
View File
@@ -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
+14 -10
View File
@@ -1,14 +1,14 @@
# Flying Jib — Current Status
**Snapshot date:** 2026-07-30
**Phase:** 14 operational core (incl. dual-Squid handoff e2e)
**Phase:** 14 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)
+2 -2
View File
@@ -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 |
---
+36
View File
@@ -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
+6 -6
View File
@@ -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
+15 -5
View File
@@ -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)
}
}
}
+78
View File
@@ -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))
})
+52
View File
@@ -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'))
})
+4
View File
@@ -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.
+24 -9
View File
@@ -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 neighbors 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 neighbors world key (15s timeout), **preferring the same localhost port**.
4. CLI prints a **reconnect banner** with Direct Connect `127.0.0.1:<port>` (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 peers 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