This commit is contained in:
Raven Scott
2026-07-30 23:43:12 -04:00
parent 432bca222c
commit 21066b35cb
14 changed files with 1036 additions and 52 deletions
+49
View File
@@ -0,0 +1,49 @@
name: Integrate
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- name: Install
run: npm install --cache /tmp/npm-cache-fj
- name: Unit tests
run: npm test
- name: Docs scaffold check
run: npm run docs:check
standalone:
runs-on: ubuntu-latest
needs: [test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
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
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
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: flying-jib-linux-x64
path: out/flying-jib-linux-x64/
if-no-files-found: error
+143 -3
View File
@@ -10,13 +10,16 @@ const { worldsRoot } = require('./lib/paths')
const { ensureDir } = require('./lib/worlds') const { ensureDir } = require('./lib/worlds')
const { WorldTunnelHost, WorldTunnelClient } = require('./lib/world-tunnel') const { WorldTunnelHost, WorldTunnelClient } = require('./lib/world-tunnel')
const { loadOrCreateTunnelKeys } = require('./lib/tunnel-keys') const { loadOrCreateTunnelKeys } = require('./lib/tunnel-keys')
const { encodeInvite, decodeInvite, decodeKey } = require('./lib/invite') const { encodeInvite, decodeInvite, decodeKey, encodeKey } = require('./lib/invite')
const { PeerSession } = require('./lib/peer-session') const { PeerSession } = require('./lib/peer-session')
const { MeshRegistry, listLocalMeshes } = require('./lib/mesh-registry')
const { borderAction } = require('./lib/border')
const crypto = require('hypercore-crypto')
/** /**
* Flying Jib Bare application shell. * Flying Jib Bare application shell.
* - Optional pear-runtime worker for OTA (hello-pear-bare pattern) * - Optional pear-runtime worker for OTA (hello-pear-bare pattern)
* - Squid + HyperDHT tunnels + Protomux peer session in this process (ADR-0013) * - Squid + HyperDHT tunnels + Protomux peer session + mesh registry (ADR-0013)
*/ */
module.exports = class App extends ReadyResource { module.exports = class App extends ReadyResource {
constructor({ dir, appPath, updates, version, upgrade, name, displayName }) { constructor({ dir, appPath, updates, version, upgrade, name, displayName }) {
@@ -34,6 +37,7 @@ module.exports = class App extends ReadyResource {
this.tunnelHost = null this.tunnelHost = null
this.tunnelClient = null this.tunnelClient = null
this.peerSession = null this.peerSession = null
this.mesh = null
this.activeWorld = null this.activeWorld = null
this._shuttingDown = false this._shuttingDown = false
} }
@@ -92,9 +96,17 @@ module.exports = class App extends ReadyResource {
await this.squid.close().catch(() => {}) await this.squid.close().catch(() => {})
this.squid = null this.squid = null
} }
// mesh stays open until exit unless stopMesh called
this.activeWorld = null this.activeWorld = null
} }
async stopMesh() {
if (this.mesh) {
await this.mesh.close().catch(() => {})
this.mesh = null
}
}
_onWorkerMessage(data) { _onWorkerMessage(data) {
const message = data.toString() const message = data.toString()
if (message === 'updating') { if (message === 'updating') {
@@ -253,6 +265,126 @@ module.exports = class App extends ReadyResource {
return true return true
} }
listMeshes() {
return listLocalMeshes(this.dir)
}
/**
* Create a new mesh (local becomes first writer).
* @param {{ name?: string }} opts
*/
async createMesh(opts = {}) {
if (this.mesh) await this.stopMesh()
this.mesh = new MeshRegistry({
storageDir: this.dir,
bootstrap: null,
name: opts.name || 'Flying Jib Mesh'
})
await this.mesh.ready()
const invite = encodeInvite({
type: 'mesh',
meshKey: this.mesh.keyZ32,
name: opts.name || 'Flying Jib Mesh'
})
return {
key: this.mesh.keyZ32,
meshId: this.mesh.meshId,
writable: this.mesh.writable,
localWriterKey: this.mesh.localWriterKey
? encodeKey(this.mesh.localWriterKey)
: null,
invite
}
}
/**
* Open/join mesh by key or fj1. mesh invite.
* @param {{ key?: string, invite?: string, name?: string }} opts
*/
async openMesh(opts) {
if (this.mesh) await this.stopMesh()
let key = opts.key
let name = opts.name
if (opts.invite) {
const inv = decodeInvite(opts.invite)
if (inv.type !== 'mesh') throw new Error('Invite is not a mesh invite')
key = inv.meshKey
name = inv.name || name
}
if (!key) throw new Error('openMesh requires key or mesh invite')
this.mesh = new MeshRegistry({
storageDir: this.dir,
bootstrap: key,
name: name || 'mesh'
})
await this.mesh.ready()
return {
key: this.mesh.keyZ32,
meshId: this.mesh.meshId,
writable: this.mesh.writable,
localWriterKey: this.mesh.localWriterKey
? encodeKey(this.mesh.localWriterKey)
: null
}
}
/**
* Admit a peer's writer key (must be mesh writer).
* @param {string} writerKeyZ32
*/
async meshAddWriter(writerKeyZ32) {
if (!this.mesh) throw new Error('No mesh open')
await this.mesh.addWriter(writerKeyZ32)
return { ok: true }
}
/**
* Enroll active (or named) world into open mesh.
* @param {{ world?: string, minX?: number, maxX?: number, minZ?: number, maxZ?: number, offsetX?: number, offsetZ?: number }} opts
*/
async enrollWorld(opts = {}) {
if (!this.mesh) throw new Error('No mesh open — create-mesh or open-mesh first')
const worldName = opts.world || (this.activeWorld && this.activeWorld.id)
if (!worldName) throw new Error('enroll requires world name or active hosted world')
const { meta } = resolveWorldPaths(this.dir, worldName)
const keys = loadOrCreateTunnelKeys(this.dir, meta.id)
const minX = opts.minX != null ? Number(opts.minX) : 0
const maxX = opts.maxX != null ? Number(opts.maxX) : 9999
const minZ = opts.minZ != null ? Number(opts.minZ) : 0
const maxZ = opts.maxZ != null ? Number(opts.maxZ) : 9999
const region = await this.mesh.enroll({
regionId: meta.id + '-' + crypto.randomBytes(4).toString('hex'),
worldKey: keys.publicKeyZ32,
ownerKey: this.mesh.localWriterKey ? encodeKey(this.mesh.localWriterKey) : null,
bounds: { minX, maxX, minZ, maxZ },
offset: {
x: opts.offsetX != null ? Number(opts.offsetX) : minX,
z: opts.offsetZ != null ? Number(opts.offsetZ) : minZ
},
mcVersion: meta.version,
name: meta.name,
portalPolicy: 'teleport'
})
return region
}
async listMeshRegions() {
if (!this.mesh) throw new Error('No mesh open')
return this.mesh.listRegions()
}
/**
* Border advisory for a position in the current mesh.
* @param {{ regionId: string, x: number, y?: number, z: number, margin?: number }} opts
*/
async checkBorder(opts) {
if (!this.mesh) throw new Error('No mesh open')
const regions = await this.mesh.listRegions()
const current = regions.find((r) => r.regionId === opts.regionId)
if (!current) throw new Error('region not found: ' + opts.regionId)
return borderAction(regions, current, opts.x, opts.z, opts.margin)
}
getStatus() { getStatus() {
return { return {
storage: this.dir, storage: this.dir,
@@ -262,7 +394,14 @@ module.exports = class App extends ReadyResource {
tunnelHost: this.tunnelHost ? this.tunnelHost.status : null, tunnelHost: this.tunnelHost ? this.tunnelHost.status : null,
tunnelClient: this.tunnelClient ? this.tunnelClient.status : null, tunnelClient: this.tunnelClient ? this.tunnelClient.status : null,
peers: this.peerSession ? this.peerSession.peerList : [], peers: this.peerSession ? this.peerSession.peerList : [],
peerId: this.peerSession ? this.peerSession.peerId : null peerId: this.peerSession ? this.peerSession.peerId : null,
mesh: this.mesh
? {
key: this.mesh.keyZ32,
meshId: this.mesh.meshId,
writable: this.mesh.writable
}
: null
} }
} }
@@ -272,6 +411,7 @@ module.exports = class App extends ReadyResource {
if (typeof Bare !== 'undefined') Bare.exitCode = code if (typeof Bare !== 'undefined') Bare.exitCode = code
try { try {
await this.stopWorld() await this.stopWorld()
await this.stopMesh()
} catch { } catch {
// ignore // ignore
} }
+179 -1
View File
@@ -338,12 +338,185 @@ const statusCmd = command(
const worlds = app.listWorlds() const worlds = app.listWorlds()
console.log('worlds:', worlds.length) console.log('worlds:', worlds.length)
for (const w of worlds) console.log(' -', w.id, w.version) for (const w of worlds) console.log(' -', w.id, w.version)
const meshes = app.listMeshes()
console.log('meshes:', meshes.length)
for (const m of meshes) console.log(' -', m.meshId, m.key)
} finally { } finally {
await app.close() await app.close()
} }
} }
) )
const meshCreateCmd = command(
'mesh-create',
summary('Create a new mesh and print fj1. mesh invite'),
flag('--name [name]', 'mesh display name'),
async (cmd) => {
const storage = resolveStorage(root.flags.storage)
const app = makeApp(storage, false)
await app.ready()
try {
const r = await app.createMesh({ name: cmd.flags.name })
console.log('Mesh created')
console.log(' meshId:', r.meshId)
console.log(' key: ', r.key)
console.log(' writer:', r.localWriterKey)
console.log('')
console.log('Mesh invite (share to join registry):')
console.log(r.invite)
console.log('')
console.log('Keep process alive to seed registry, or re-open later with mesh-open.')
console.log('Enroll a world: flying-jib mesh-enroll <world> --min-x 0 --max-x 9999 ...')
// Keep mesh swarm alive until Ctrl+C
attachShutdown(app)
await new Promise(() => {})
} catch (err) {
console.error('[mesh-create failed]', err)
await app.close()
exit(1)
}
}
)
const meshOpenCmd = command(
'mesh-open',
summary('Open mesh by key or fj1. mesh invite'),
flag('--key [key]', 'mesh public key (z32)'),
flag('--invite [invite]', 'fj1. mesh invite'),
flag('--name [name]', 'local display name for mesh folder'),
async (cmd) => {
const storage = resolveStorage(root.flags.storage)
const app = makeApp(storage, false)
await app.ready()
try {
const r = await app.openMesh({
key: cmd.flags.key,
invite: cmd.flags.invite,
name: cmd.flags.name
})
console.log('Mesh open')
console.log(' meshId: ', r.meshId)
console.log(' key: ', r.key)
console.log(' writable:', r.writable)
console.log(' writer: ', r.localWriterKey)
if (!r.writable) {
console.log('')
console.log('Not yet a writer. Ask a mesh writer to run:')
console.log(` flying-jib mesh-admit ${r.localWriterKey}`)
}
attachShutdown(app)
await new Promise(() => {})
} catch (err) {
console.error('[mesh-open failed]', err)
await app.close()
exit(1)
}
}
)
const meshAdmitCmd = command(
'mesh-admit',
summary('Admit a peer writer key (requires open writable mesh in this process)'),
arg('<writerKey>', 'peer local writer key (z32 or hex)'),
flag('--invite [invite]', 'open mesh from invite first'),
flag('--key [key]', 'open mesh from key first'),
async (cmd) => {
const storage = resolveStorage(root.flags.storage)
const app = makeApp(storage, false)
await app.ready()
try {
if (cmd.flags.invite || cmd.flags.key) {
await app.openMesh({ key: cmd.flags.key, invite: cmd.flags.invite })
}
if (!app.mesh) throw new Error('No mesh open; pass --invite or --key')
await app.meshAddWriter(cmd.args.writerKey)
console.log('Writer admitted:', cmd.args.writerKey)
await app.stopMesh()
await app.close()
} catch (err) {
console.error('[mesh-admit failed]', err)
await app.close()
exit(1)
}
}
)
const meshEnrollCmd = command(
'mesh-enroll',
summary('Enroll a local world region into the open mesh'),
arg('<world>', 'world id to enroll'),
flag('--invite [invite]', 'mesh invite to open'),
flag('--key [key]', 'mesh key to open'),
flag('--min-x [n]', 'bounds minX (default 0)'),
flag('--max-x [n]', 'bounds maxX (default 9999)'),
flag('--min-z [n]', 'bounds minZ (default 0)'),
flag('--max-z [n]', 'bounds maxZ (default 9999)'),
flag('--offset-x [n]', 'region offset x'),
flag('--offset-z [n]', 'region offset z'),
async (cmd) => {
const storage = resolveStorage(root.flags.storage)
const app = makeApp(storage, false)
await app.ready()
try {
if (cmd.flags.invite || cmd.flags.key) {
await app.openMesh({ key: cmd.flags.key, invite: cmd.flags.invite })
}
if (!app.mesh) throw new Error('No mesh open; pass --invite or --key')
const region = await app.enrollWorld({
world: cmd.args.world,
minX: cmd.flags.minX,
maxX: cmd.flags.maxX,
minZ: cmd.flags.minZ,
maxZ: cmd.flags.maxZ,
offsetX: cmd.flags.offsetX,
offsetZ: cmd.flags.offsetZ
})
console.log('Enrolled region:')
console.log(JSON.stringify(region, null, 2))
await app.stopMesh()
await app.close()
} catch (err) {
console.error('[mesh-enroll failed]', err)
await app.close()
exit(1)
}
}
)
const meshListCmd = command(
'mesh-list',
summary('List regions in a mesh'),
flag('--invite [invite]', 'mesh invite'),
flag('--key [key]', 'mesh key'),
async (cmd) => {
const storage = resolveStorage(root.flags.storage)
const app = makeApp(storage, false)
await app.ready()
try {
if (cmd.flags.invite || cmd.flags.key) {
await app.openMesh({ key: cmd.flags.key, invite: cmd.flags.invite })
}
if (!app.mesh) throw new Error('No mesh open; pass --invite or --key')
const regions = await app.listMeshRegions()
if (!regions.length) {
console.log('(no regions enrolled yet)')
} else {
for (const r of regions) {
console.log(
`- ${r.regionId} ${r.name || ''} bounds=${JSON.stringify(r.bounds)} worldKey=${r.worldKey.slice(0, 12)}`
)
}
}
await app.stopMesh()
await app.close()
} catch (err) {
console.error('[mesh-list failed]', err)
await app.close()
exit(1)
}
}
)
const root = command( const root = command(
appName, appName,
header(`${appName} — decentralized P2P Minecraft (Bare/Pear)`), header(`${appName} — decentralized P2P Minecraft (Bare/Pear)`),
@@ -356,7 +529,12 @@ const root = command(
startCmd, startCmd,
hostCmd, hostCmd,
joinCmd, joinCmd,
statusCmd statusCmd,
meshCreateCmd,
meshOpenCmd,
meshAdmitCmd,
meshEnrollCmd,
meshListCmd
) )
root.parse(rawArgv) root.parse(rawArgv)
+7 -4
View File
@@ -56,10 +56,13 @@ Use different local MC ports if both host Squid on one machine.
### Phase 4 — Mesh border ### Phase 4 — Mesh border
1. Two regions enrolled with adjacent bounds. 1. Two regions enrolled with adjacent bounds (`mesh-enroll`).
2. Player walks to border; migration triggers. 2. `borderAction` / `checkBorder` returns migrate/warn for positions near edges.
3. Inventory preserved; wrong-neighbor offline → clear error, no item loss. 3. **Planned:** player walks to border; live migration triggers.
4. Registry update propagates to third peer. 4. Inventory preserved; wrong-neighbor offline → clear error, no item loss.
5. Registry update propagates to third peer.
Automated: `test/mesh-registry.test.js`, `test/border.test.js`.
### Offline / failure ### Offline / failure
+125
View File
@@ -0,0 +1,125 @@
'use strict'
/**
* Border / neighbor helpers for mesh regions (ADR-0008).
* Pure functions — no I/O. Used by CLI and future Squid plugin.
*/
/**
* Distance to nearest edge of bounds (positive inside, negative outside).
* @param {{ minX:number, maxX:number, minZ:number, maxZ:number }} bounds
* @param {number} x
* @param {number} z
*/
function distanceToEdge(bounds, x, z) {
const dx = Math.min(x - bounds.minX, bounds.maxX - x)
const dz = Math.min(z - bounds.minZ, bounds.maxZ - z)
return Math.min(dx, dz)
}
/**
* @param {object} bounds
* @param {number} x
* @param {number} z
* @param {number} [margin=16]
*/
function nearBorder(bounds, x, z, margin = 16) {
if (!bounds) return false
if (x < bounds.minX || x > bounds.maxX || z < bounds.minZ || z > bounds.maxZ) {
return true
}
return distanceToEdge(bounds, x, z) <= margin
}
/**
* Pick a neighboring region for a position leaving `current`.
* Prefers region containing (x,z); else nearest by center distance.
* @param {object[]} regions
* @param {object} current current region record
* @param {number} x
* @param {number} z
*/
function findNeighbor(regions, current, x, z) {
const others = regions.filter((r) => r.regionId !== current.regionId)
const containing = others.find(
(r) =>
r.bounds &&
x >= r.bounds.minX &&
x <= r.bounds.maxX &&
z >= r.bounds.minZ &&
z <= r.bounds.maxZ
)
if (containing) return containing
// Nearest center among regions
let best = null
let bestD = Infinity
for (const r of others) {
if (!r.bounds) continue
const cx = (r.bounds.minX + r.bounds.maxX) / 2
const cz = (r.bounds.minZ + r.bounds.maxZ) / 2
const d = (cx - x) * (cx - x) + (cz - z) * (cz - z)
if (d < bestD) {
bestD = d
best = r
}
}
return best
}
/**
* Map position into neighbor local space using offsets.
* @param {object} fromRegion
* @param {object} toRegion
* @param {number} x
* @param {number} y
* @param {number} z
*/
function mapPosition(fromRegion, toRegion, x, y, z) {
const fo = fromRegion.offset || { x: 0, z: 0 }
const to = toRegion.offset || { x: 0, z: 0 }
// Global ≈ local + offset; remap: localTo = global - to.offset
const gx = x + fo.x
const gz = z + fo.z
return {
x: gx - to.x,
y,
z: gz - to.z
}
}
/**
* Suggest portal policy action.
* @returns {{ action: 'none'|'warn'|'migrate', neighbor: object|null, margin: number }}
*/
function borderAction(regions, current, x, z, margin = 16) {
if (!current || !current.bounds) {
return { action: 'none', neighbor: null, margin }
}
if (!nearBorder(current.bounds, x, z, margin)) {
return { action: 'none', neighbor: null, margin }
}
const outside =
x < current.bounds.minX ||
x > current.bounds.maxX ||
z < current.bounds.minZ ||
z > current.bounds.maxZ
const neighbor = findNeighbor(regions, current, x, z)
if (!neighbor) {
return { action: 'warn', neighbor: null, margin }
}
return {
action: outside ? 'migrate' : 'warn',
neighbor,
margin,
mapped: outside ? mapPosition(current, neighbor, x, 64, z) : null
}
}
module.exports = {
distanceToEdge,
nearBorder,
findNeighbor,
mapPosition,
borderAction
}
+21 -9
View File
@@ -16,18 +16,27 @@ const CURRENT_VERSION = 1
* @returns {string} * @returns {string}
*/ */
function encodeInvite(payload) { function encodeInvite(payload) {
const type = payload.type || 'private-world'
const body = { const body = {
v: payload.v != null ? payload.v : CURRENT_VERSION, v: payload.v != null ? payload.v : CURRENT_VERSION,
type: payload.type || 'private-world', type,
worldKey: payload.worldKey,
cap: payload.cap,
name: payload.name || null, name: payload.name || null,
mcVersion: payload.mcVersion || null,
portHint: payload.portHint != null ? payload.portHint : null,
expires: payload.expires != null ? payload.expires : null expires: payload.expires != null ? payload.expires : null
} }
if (type === 'private-world') {
body.worldKey = payload.worldKey
body.cap = payload.cap
body.mcVersion = payload.mcVersion || null
body.portHint = payload.portHint != null ? payload.portHint : null
if (!body.worldKey || !body.cap) { if (!body.worldKey || !body.cap) {
throw new Error('invite requires worldKey and cap') throw new Error('private-world invite requires worldKey and cap')
}
} else if (type === 'mesh') {
body.meshKey = payload.meshKey
body.enrollCap = payload.enrollCap || null
if (!body.meshKey) throw new Error('mesh invite requires meshKey')
} else {
throw new Error(`Unsupported invite type: ${type}`)
} }
const json = JSON.stringify(body) const json = JSON.stringify(body)
return PREFIX + z32.encode(b4a.from(json)) return PREFIX + z32.encode(b4a.from(json))
@@ -57,12 +66,15 @@ function decodeInvite(str) {
if (body.v !== CURRENT_VERSION) { if (body.v !== CURRENT_VERSION) {
throw new Error(`Unsupported invite version: ${body.v}`) throw new Error(`Unsupported invite version: ${body.v}`)
} }
if (body.type !== 'private-world') { if (body.type === 'private-world') {
throw new Error(`Unsupported invite type: ${body.type}`)
}
if (!body.worldKey || !body.cap) { if (!body.worldKey || !body.cap) {
throw new Error('Invalid invite: missing worldKey or cap') throw new Error('Invalid invite: missing worldKey or cap')
} }
} else if (body.type === 'mesh') {
if (!body.meshKey) throw new Error('Invalid mesh invite: missing meshKey')
} else {
throw new Error(`Unsupported invite type: ${body.type}`)
}
if (body.expires != null && Number(body.expires) > 0 && Date.now() > Number(body.expires)) { if (body.expires != null && Number(body.expires) > 0 && Date.now() > Number(body.expires)) {
throw new Error('Invite expired') throw new Error('Invite expired')
} }
+357
View File
@@ -0,0 +1,357 @@
'use strict'
/**
* Mesh region registry (ADR-0007).
* Autobase multi-writer log + Hyperbee view of regions and spatial cells.
*/
const path = require('path')
const fs = require('fs')
const ReadyResource = require('ready-resource')
const Corestore = require('corestore')
const Autobase = require('autobase')
const Hyperbee = require('hyperbee')
const Hyperswarm = require('hyperswarm')
const b4a = require('b4a')
const crypto = require('hypercore-crypto')
const { encodeKey, decodeKey } = require('./invite')
const CELL = 512 // blocks per spatial index cell
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true })
}
function meshStorePath(storageDir, meshId) {
return path.join(storageDir, 'mesh', meshId)
}
/**
* @param {{ minX:number, maxX:number, minZ:number, maxZ:number }} bounds
* @returns {string[]}
*/
function cellsCovering(bounds) {
const cells = []
const x0 = Math.floor(bounds.minX / CELL)
const x1 = Math.floor(bounds.maxX / CELL)
const z0 = Math.floor(bounds.minZ / CELL)
const z1 = Math.floor(bounds.maxZ / CELL)
for (let gx = x0; gx <= x1; gx++) {
for (let gz = z0; gz <= z1; gz++) {
cells.push(`${gx}:${gz}`)
}
}
return cells
}
function cellAt(x, z) {
return `${Math.floor(x / CELL)}:${Math.floor(z / CELL)}`
}
function openView(store) {
const core = store.get('mesh-regions-view')
return new Hyperbee(core, {
keyEncoding: 'utf-8',
valueEncoding: 'json'
})
}
async function applyOps(nodes, view, host) {
for (const node of nodes) {
let op = node.value
if (typeof op === 'string') {
try {
op = JSON.parse(op)
} catch {
continue
}
}
if (!op || typeof op !== 'object') continue
if (op.addWriter) {
const writerKey =
typeof op.addWriter === 'string' ? decodeKeyMaybe(op.addWriter) : b4a.from(op.addWriter)
await host.addWriter(writerKey, { indexer: true })
continue
}
if (op.type === 'enroll' && op.region) {
const r = op.region
const batch = view.batch()
await batch.put('region:' + r.regionId, r)
if (r.bounds) {
for (const cell of cellsCovering(r.bounds)) {
await batch.put('cell:' + cell, r.regionId)
}
}
await batch.flush()
continue
}
if (op.type === 'unenroll' && op.regionId) {
const batch = view.batch()
const existing = await view.get('region:' + op.regionId)
if (existing && existing.value && existing.value.bounds) {
for (const cell of cellsCovering(existing.value.bounds)) {
const cur = await view.get('cell:' + cell)
if (cur && cur.value === op.regionId) await batch.del('cell:' + cell)
}
}
await batch.del('region:' + op.regionId)
await batch.flush()
}
}
}
function decodeKeyMaybe(s) {
try {
return decodeKey(s)
} catch {
try {
return b4a.from(s, 'hex')
} catch {
return b4a.from(s)
}
}
}
async function closeView(view) {
await view.close()
}
class MeshRegistry extends ReadyResource {
/**
* @param {object} opts
* @param {string} opts.storageDir app storage root
* @param {string} [opts.meshId] local folder id (default from key)
* @param {Buffer|string|null} [opts.bootstrap] mesh key to join; null = create
* @param {string} [opts.name]
*/
constructor(opts) {
super()
this.storageDir = opts.storageDir
this.bootstrap = opts.bootstrap
? typeof opts.bootstrap === 'string'
? decodeKeyMaybe(opts.bootstrap)
: b4a.from(opts.bootstrap)
: null
this.name = opts.name || 'mesh'
this.meshId = opts.meshId || null
this.store = null
this.base = null
this.swarm = null
this._discovery = null
}
get key() {
return this.base && this.base.key
}
get keyZ32() {
return this.key ? encodeKey(this.key) : null
}
get writable() {
return !!(this.base && this.base.writable)
}
get localWriterKey() {
return this.base && this.base.local && this.base.local.key
}
async _open() {
const id =
this.meshId ||
(this.bootstrap ? encodeKey(this.bootstrap).slice(0, 16) : 'local-' + Date.now().toString(36))
this.meshId = id
const dir = meshStorePath(this.storageDir, id)
ensureDir(dir)
this.store = new Corestore(path.join(dir, 'corestore'))
this.base = new Autobase(this.store, this.bootstrap, {
open: openView,
apply: applyOps,
close: closeView,
valueEncoding: 'json',
ackInterval: 1000
})
await this.base.ready()
// Persist mesh meta
const metaPath = path.join(dir, 'meta.json')
if (!fs.existsSync(metaPath)) {
fs.writeFileSync(
metaPath,
JSON.stringify(
{
meshId: this.meshId,
key: this.keyZ32,
name: this.name,
createdAt: Date.now()
},
null,
2
)
)
}
// Replicate
this.swarm = new Hyperswarm()
this.swarm.on('connection', (conn) => {
this.base.replicate(conn)
})
const topic = crypto.discoveryKey(this.base.key)
this._discovery = this.swarm.join(topic, { server: true, client: true })
await this.swarm.flush()
}
/**
* Admit another peer's writer key (must be writable).
* @param {Buffer|string} writerKey
*/
async addWriter(writerKey) {
if (!this.writable) throw new Error('Not a mesh writer; cannot addWriter')
const key = typeof writerKey === 'string' ? decodeKeyMaybe(writerKey) : b4a.from(writerKey)
await this.base.append({
addWriter: encodeKey(key)
})
await this.base.update()
}
/**
* Enroll a region into the mesh.
* @param {object} region RegionRecord
*/
async enroll(region) {
if (!this.writable) throw new Error('Not a mesh writer; cannot enroll')
if (!region.regionId || !region.worldKey || !region.bounds) {
throw new Error('region requires regionId, worldKey, bounds')
}
const record = {
regionId: region.regionId,
ownerKey: region.ownerKey || null,
worldKey: region.worldKey,
offset: region.offset || { x: 0, z: 0 },
bounds: region.bounds,
seed: region.seed != null ? region.seed : null,
mcVersion: region.mcVersion || null,
portalPolicy: region.portalPolicy || 'teleport',
name: region.name || region.regionId,
updatedAt: Date.now()
}
await this.base.append({ type: 'enroll', region: record })
await this.base.update()
this.emit('enroll', record)
return record
}
async unenroll(regionId) {
if (!this.writable) throw new Error('Not a mesh writer')
await this.base.append({ type: 'unenroll', regionId })
await this.base.update()
}
/**
* @returns {Promise<object[]>}
*/
async listRegions() {
await this.base.update()
const view = this.base.view
const out = []
for await (const entry of view.createReadStream({ gte: 'region:', lt: 'region;' })) {
if (entry.value) out.push(entry.value)
}
return out
}
/**
* Resolve region owning world coordinates (x,z).
* @param {number} x
* @param {number} z
*/
async regionAt(x, z) {
await this.base.update()
const cell = cellAt(x, z)
const hit = await this.base.view.get('cell:' + cell)
if (!hit || !hit.value) {
// fallback scan (sparse enrollments)
const all = await this.listRegions()
return (
all.find(
(r) =>
r.bounds &&
x >= r.bounds.minX &&
x <= r.bounds.maxX &&
z >= r.bounds.minZ &&
z <= r.bounds.maxZ
) || null
)
}
const reg = await this.base.view.get('region:' + hit.value)
return reg ? reg.value : null
}
async _close() {
if (this._discovery) {
try {
await this._discovery.destroy()
} catch {
/* ignore */
}
this._discovery = null
}
if (this.swarm) {
try {
await this.swarm.destroy()
} catch {
/* ignore */
}
this.swarm = null
}
if (this.base) {
try {
await this.base.close()
} catch {
/* ignore */
}
this.base = null
}
if (this.store) {
try {
await this.store.close()
} catch {
/* ignore */
}
this.store = null
}
}
}
/**
* List locally known meshes (from disk).
* @param {string} storageDir
*/
function listLocalMeshes(storageDir) {
const root = path.join(storageDir, 'mesh')
if (!fs.existsSync(root)) return []
const out = []
for (const name of fs.readdirSync(root)) {
const meta = path.join(root, name, 'meta.json')
if (!fs.existsSync(meta)) continue
try {
out.push(JSON.parse(fs.readFileSync(meta, 'utf8')))
} catch {
/* skip */
}
}
return out
}
module.exports = {
MeshRegistry,
cellsCovering,
cellAt,
CELL,
listLocalMeshes,
meshStorePath
}
+5
View File
@@ -11,6 +11,11 @@ and this project aims to follow [Semantic Versioning](https://semver.org/).
### Added ### Added
- Phase 4 mesh registry (core):
- Autobase + Hyperbee region enrollment
- CLI: `mesh-create`, `mesh-open`, `mesh-admit`, `mesh-enroll`, `mesh-list`
- Border advisory helpers (`lib/border.js`)
- GitHub Actions integrate workflow
- Phase 3 peer communication (CLI): - Phase 3 peer communication (CLI):
- Hyperswarm/Protomux presence + chat for private worlds - Hyperswarm/Protomux presence + chat for private worlds
- Stdin chat on `host`/`join`; `/peers` command - Stdin chat on `host`/`join`; `/peers` command
+21 -24
View File
@@ -1,14 +1,14 @@
# Flying Jib — Current Status # Flying Jib — Current Status
**Snapshot date:** 2026-07-30 **Snapshot date:** 2026-07-30
**Phase:** 13 core working · standalone binary ships Squid without system Node **Phase:** 13 solid · **Phase 4 mesh registry core in** · standalone + CI scaffold
**Product:** Flying Jib — Bare/Pear P2P Minecraft mesh **Product:** Flying Jib — Bare/Pear P2P Minecraft mesh
--- ---
## One-line summary ## One-line summary
**Single Bare executable** runs Flying Squid on loopback, shares worlds via HyperDHT + `fj1.` invites, and side-channel chat/presence over Hyperswarm/Protomux — **no system Node.js**. Bare CLI + standalone binary: local Squid, private world tunnels, chat/presence, and **Autobase mesh region registry** (enroll/list/border helpers). No system Node for the product binary.
--- ---
@@ -16,12 +16,13 @@
| Area | Status | | Area | Status |
|------|--------| |------|--------|
| CLI (`create` / `list` / `start` / `host` / `join` / `status`) | Working | | Worlds / Squid loopback / host+join tunnels | Working |
| Flying Squid on Bare + standalone binary | Loopback listen verified **without Node on PATH** | | Chat + presence | Working |
| HyperDHT world tunnel + `fj1.` invites | Working | | Standalone binary (`make:standalone`) | Working (~500MB) |
| Peer chat + presence (Protomux) | Working (tests + CLI stdin chat) | | Mesh create / open / enroll / list (Autobase+Hyperbee) | Working |
| `npm run make:standalone` | Produces `out/flying-jib-<host>/flying-jib` (~500MB) | | Border helpers (`nearBorder`, `borderAction`) | Working (lib; not yet live Squid migrate) |
| Unit/integration tests | **12/12 pass** | | GitHub Actions integrate workflow | Added |
| Tests | **17/17 pass** |
--- ---
@@ -29,34 +30,30 @@
| Area | Status | | Area | Status |
|------|--------| |------|--------|
| Binary size optimization | ~500MB — prune later | | Live player migration at borders | Stub only (lib + `checkBorder`) |
| Mesh enrollment / borders | Phase 4 | | Mesh multi-writer admit UX polish | CLI `mesh-admit` present |
| Binary size | ~500MB |
| GUI | Not started | | GUI | Not started |
| Production Pear OTA key | Placeholder |
| Two-machine Java playtest | Manual | | Two-machine Java playtest | Manual |
--- ---
## How to try ## Mesh CLI (Phase 4)
```sh ```sh
# Dev
BARE=./node_modules/bare-runtime/bin/bare BARE=./node_modules/bare-runtime/bin/bare
$BARE bin.mjs host demo --port 25565 --name Alice $BARE bin.mjs mesh-create --name Giant # prints fj1. mesh invite
$BARE bin.mjs join 'fj1.…' --port 25566 --name Bob $BARE bin.mjs mesh-enroll home --invite 'fj1.…' --min-x 0 --max-x 9999
# type chat lines; /peers $BARE bin.mjs mesh-list --invite 'fj1.…'
# peer: mesh-open --invite … → share localWriterKey → mesh-admit <key>
# Standalone (no Node)
npm run make:standalone
./out/flying-jib-darwin-arm64/flying-jib create demo
./out/flying-jib-darwin-arm64/flying-jib start demo --port 25565
``` ```
--- ---
## Next actions ## Next actions
1. Two-machine Java Edition playtest 1. Squid plugin for border warn/migrate using registry
2. Shrink standalone binary / CI matrix for all hosts 2. Wire migrate Protomux to tunnel switch
3. Phase 4 mesh registry + border migration 3. Multi-host CI standalone matrix
4. Java multiplayer playtest
+18
View File
@@ -5,6 +5,24 @@
--- ---
## 2026-07-30 — Phase 4 mesh registry core
### Wins
- `lib/mesh-registry.js` — Autobase + Hyperbee regions/cells; Hyperswarm replication
- `lib/border.js` — pure border/neighbor/migrate advisory helpers
- CLI: `mesh-create`, `mesh-open`, `mesh-admit`, `mesh-enroll`, `mesh-list`
- Mesh `fj1.` invite type
- Tests: mesh registry + border (**17/17**)
- GitHub Actions `integrate.yml` (test + linux standalone smoke)
### Next
- Live border migrate via Squid plugin + tunnel switch
- Writer admission multi-peer e2e
---
## 2026-07-30 — Phase 3 chat/presence + standalone binary ## 2026-07-30 — Phase 3 chat/presence + standalone binary
### Wins ### Wins
+6 -3
View File
@@ -151,7 +151,7 @@
| Field | Value | | Field | Value |
|-------|-------| |-------|-------|
| **Status** | Not Started | | **Status** | In Progress (registry + border helpers) |
| **Owner** | TBD | | **Owner** | TBD |
| **Target** | TBD | | **Target** | TBD |
| **ADRs** | 0007, 0008 | | **ADRs** | 0007, 0008 |
@@ -165,8 +165,11 @@
### Acceptance ### Acceptance
- [ ] Multiple peers announce non-overlapping regions - [x] Autobase mesh registry enroll/list/regionAt
- [ ] Crossing border migrates player to neighbor Squid - [x] CLI mesh-create / open / enroll / list / admit
- [x] Border action helpers (warn/migrate advisory)
- [ ] Multiple peers announce non-overlapping regions (e2e)
- [ ] Crossing border migrates player to neighbor Squid (live)
- [ ] Offline region UX is clear (no silent master failover) - [ ] Offline region UX is clear (no silent master failover)
### Documentation required to complete ### Documentation required to complete
+40
View File
@@ -0,0 +1,40 @@
'use strict'
const test = require('brittle')
const { nearBorder, findNeighbor, mapPosition, borderAction } = require('../lib/border')
const a = {
regionId: 'a',
bounds: { minX: 0, maxX: 100, minZ: 0, maxZ: 100 },
offset: { x: 0, z: 0 }
}
const b = {
regionId: 'b',
bounds: { minX: 100, maxX: 200, minZ: 0, maxZ: 100 },
offset: { x: 100, z: 0 }
}
test('nearBorder', (t) => {
t.absent(nearBorder(a.bounds, 50, 50, 16))
t.ok(nearBorder(a.bounds, 95, 50, 16))
t.ok(nearBorder(a.bounds, 101, 50, 16))
})
test('findNeighbor prefers containing region', (t) => {
const n = findNeighbor([a, b], a, 150, 50)
t.is(n.regionId, 'b')
})
test('mapPosition across offsets', (t) => {
const p = mapPosition(a, b, 100, 64, 50)
t.is(p.x, 0)
t.is(p.z, 50)
t.is(p.y, 64)
})
test('borderAction migrate when outside', (t) => {
const act = borderAction([a, b], a, 120, 50, 16)
t.is(act.action, 'migrate')
t.is(act.neighbor.regionId, 'b')
t.ok(act.mapped)
})
+43
View File
@@ -0,0 +1,43 @@
'use strict'
const test = require('brittle')
const fs = require('fs')
const os = require('os')
const path = require('path')
const { MeshRegistry } = require('../lib/mesh-registry')
const { encodeInvite, decodeInvite } = require('../lib/invite')
test('create mesh, enroll region, list and regionAt', { timeout: 60000 }, async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fj-mesh-'))
const mesh = new MeshRegistry({ storageDir: dir, bootstrap: null, name: 'test' })
await mesh.ready()
t.ok(mesh.key)
t.ok(mesh.writable)
const region = await mesh.enroll({
regionId: 'r1',
worldKey: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
bounds: { minX: 0, maxX: 999, minZ: 0, maxZ: 999 },
offset: { x: 0, z: 0 },
mcVersion: '1.21.1',
name: 'Home'
})
t.is(region.regionId, 'r1')
const list = await mesh.listRegions()
t.is(list.length, 1)
t.is(list[0].name, 'Home')
const hit = await mesh.regionAt(100, 100)
t.is(hit.regionId, 'r1')
const miss = await mesh.regionAt(5000, 5000)
t.absent(miss)
const invite = encodeInvite({ type: 'mesh', meshKey: mesh.keyZ32, name: 'test' })
const inv = decodeInvite(invite)
t.is(inv.type, 'mesh')
t.is(inv.meshKey, mesh.keyZ32)
await mesh.close()
})
+20 -6
View File
@@ -1,6 +1,6 @@
# Mesh Worlds # Mesh Worlds
**Status:** Planned (Phase 4) **Status:** Registry CLI available (Phase 4 in progress). Live border teleport not finished.
## The idea ## The idea
@@ -8,12 +8,26 @@ Many peers can **enroll** their private worlds (or regions) into a shared **mesh
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.
## Enrollment ## Enrollment (CLI)
1. Create or open a private world. ```sh
2. Choose **Enroll in mesh** and paste a mesh invite (or create a new mesh). # Peer A — create mesh
3. Publish your regions bounds / location on the shared map. flying-jib mesh-create --name Giant
4. Other enrolled peers see your region when online. # copy fj1. mesh invite; keep process running to seed (or re-open later)
# Peer A — enroll a world as a region
flying-jib create home
flying-jib mesh-enroll home --invite 'fj1.…' --min-x 0 --max-x 9999 --min-z 0 --max-z 9999
# Peer B — open mesh (may need Peer A to mesh-admit your writer key)
flying-jib mesh-open --invite 'fj1.…'
# if not writable, share localWriterKey with A:
# flying-jib mesh-admit <writerKey> --invite 'fj1.…'
flying-jib mesh-list --invite 'fj1.…'
```
GUI “Enroll in mesh” is still planned.
## Crossing borders ## Crossing borders