P5 Work
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 '<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)
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
+111
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
**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))
|
||||
**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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
@@ -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'))
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
@@ -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:<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 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
|
||||
|
||||
Reference in New Issue
Block a user