From 21066b35cbf00652fc9e8e6c541673efa9197bb9 Mon Sep 17 00:00:00 2001 From: Raven Scott Date: Thu, 30 Jul 2026 23:43:12 -0400 Subject: [PATCH] p4 --- .github/workflows/integrate.yml | 49 +++++ app.js | 146 ++++++++++++- bin.mjs | 180 +++++++++++++++- developer_docs/TESTING.md | 11 +- lib/border.js | 125 +++++++++++ lib/invite.js | 34 ++- lib/mesh-registry.js | 357 ++++++++++++++++++++++++++++++++ living_docs/CHANGELOG.md | 5 + living_docs/CURRENT_STATUS.md | 45 ++-- living_docs/PROGRESS.md | 18 ++ living_docs/ROADMAP.md | 9 +- test/border.test.js | 40 ++++ test/mesh-registry.test.js | 43 ++++ user_docs/MESH_WORLDS.md | 26 ++- 14 files changed, 1036 insertions(+), 52 deletions(-) create mode 100644 .github/workflows/integrate.yml create mode 100644 lib/border.js create mode 100644 lib/mesh-registry.js create mode 100644 test/border.test.js create mode 100644 test/mesh-registry.test.js diff --git a/.github/workflows/integrate.yml b/.github/workflows/integrate.yml new file mode 100644 index 0000000..54b25c2 --- /dev/null +++ b/.github/workflows/integrate.yml @@ -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 diff --git a/app.js b/app.js index d605624..d55a98f 100644 --- a/app.js +++ b/app.js @@ -10,13 +10,16 @@ const { worldsRoot } = require('./lib/paths') const { ensureDir } = require('./lib/worlds') const { WorldTunnelHost, WorldTunnelClient } = require('./lib/world-tunnel') 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 { MeshRegistry, listLocalMeshes } = require('./lib/mesh-registry') +const { borderAction } = require('./lib/border') +const crypto = require('hypercore-crypto') /** * Flying Jib Bare application shell. * - 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 { constructor({ dir, appPath, updates, version, upgrade, name, displayName }) { @@ -34,6 +37,7 @@ module.exports = class App extends ReadyResource { this.tunnelHost = null this.tunnelClient = null this.peerSession = null + this.mesh = null this.activeWorld = null this._shuttingDown = false } @@ -92,9 +96,17 @@ module.exports = class App extends ReadyResource { await this.squid.close().catch(() => {}) this.squid = null } + // mesh stays open until exit unless stopMesh called this.activeWorld = null } + async stopMesh() { + if (this.mesh) { + await this.mesh.close().catch(() => {}) + this.mesh = null + } + } + _onWorkerMessage(data) { const message = data.toString() if (message === 'updating') { @@ -253,6 +265,126 @@ module.exports = class App extends ReadyResource { 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() { return { storage: this.dir, @@ -262,7 +394,14 @@ module.exports = class App extends ReadyResource { tunnelHost: this.tunnelHost ? this.tunnelHost.status : null, tunnelClient: this.tunnelClient ? this.tunnelClient.status : null, 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 try { await this.stopWorld() + await this.stopMesh() } catch { // ignore } diff --git a/bin.mjs b/bin.mjs index 8320502..b07db4a 100644 --- a/bin.mjs +++ b/bin.mjs @@ -338,12 +338,185 @@ const statusCmd = command( const worlds = app.listWorlds() console.log('worlds:', worlds.length) 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 { 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 --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('', '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 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( appName, header(`${appName} — decentralized P2P Minecraft (Bare/Pear)`), @@ -356,7 +529,12 @@ const root = command( startCmd, hostCmd, joinCmd, - statusCmd + statusCmd, + meshCreateCmd, + meshOpenCmd, + meshAdmitCmd, + meshEnrollCmd, + meshListCmd ) root.parse(rawArgv) diff --git a/developer_docs/TESTING.md b/developer_docs/TESTING.md index 41b49ab..4e2e794 100644 --- a/developer_docs/TESTING.md +++ b/developer_docs/TESTING.md @@ -56,10 +56,13 @@ Use different local MC ports if both host Squid on one machine. ### Phase 4 — Mesh border -1. Two regions enrolled with adjacent bounds. -2. Player walks to border; migration triggers. -3. Inventory preserved; wrong-neighbor offline → clear error, no item loss. -4. Registry update propagates to third peer. +1. Two regions enrolled with adjacent bounds (`mesh-enroll`). +2. `borderAction` / `checkBorder` returns migrate/warn for positions near edges. +3. **Planned:** player walks to border; live migration triggers. +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 diff --git a/lib/border.js b/lib/border.js new file mode 100644 index 0000000..4dd4f2e --- /dev/null +++ b/lib/border.js @@ -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 +} diff --git a/lib/invite.js b/lib/invite.js index 691c949..9acc779 100644 --- a/lib/invite.js +++ b/lib/invite.js @@ -16,18 +16,27 @@ const CURRENT_VERSION = 1 * @returns {string} */ function encodeInvite(payload) { + const type = payload.type || 'private-world' const body = { v: payload.v != null ? payload.v : CURRENT_VERSION, - type: payload.type || 'private-world', - worldKey: payload.worldKey, - cap: payload.cap, + type, name: payload.name || null, - mcVersion: payload.mcVersion || null, - portHint: payload.portHint != null ? payload.portHint : null, expires: payload.expires != null ? payload.expires : null } - if (!body.worldKey || !body.cap) { - throw new Error('invite requires worldKey and cap') + 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) { + 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) return PREFIX + z32.encode(b4a.from(json)) @@ -57,12 +66,15 @@ function decodeInvite(str) { if (body.v !== CURRENT_VERSION) { throw new Error(`Unsupported invite version: ${body.v}`) } - if (body.type !== 'private-world') { + if (body.type === 'private-world') { + if (!body.worldKey || !body.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.worldKey || !body.cap) { - throw new Error('Invalid invite: missing worldKey or cap') - } if (body.expires != null && Number(body.expires) > 0 && Date.now() > Number(body.expires)) { throw new Error('Invite expired') } diff --git a/lib/mesh-registry.js b/lib/mesh-registry.js new file mode 100644 index 0000000..6a31b21 --- /dev/null +++ b/lib/mesh-registry.js @@ -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} + */ + 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 +} diff --git a/living_docs/CHANGELOG.md b/living_docs/CHANGELOG.md index 6de63be..b81481a 100644 --- a/living_docs/CHANGELOG.md +++ b/living_docs/CHANGELOG.md @@ -11,6 +11,11 @@ and this project aims to follow [Semantic Versioning](https://semver.org/). ### 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): - Hyperswarm/Protomux presence + chat for private worlds - Stdin chat on `host`/`join`; `/peers` command diff --git a/living_docs/CURRENT_STATUS.md b/living_docs/CURRENT_STATUS.md index 515c179..dc7da1a 100644 --- a/living_docs/CURRENT_STATUS.md +++ b/living_docs/CURRENT_STATUS.md @@ -1,14 +1,14 @@ # Flying Jib — Current Status **Snapshot date:** 2026-07-30 -**Phase:** 1–3 core working · standalone binary ships Squid without system Node +**Phase:** 1–3 solid · **Phase 4 mesh registry core in** · standalone + CI scaffold **Product:** Flying Jib — Bare/Pear P2P Minecraft mesh --- ## 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 | |------|--------| -| CLI (`create` / `list` / `start` / `host` / `join` / `status`) | Working | -| Flying Squid on Bare + standalone binary | Loopback listen verified **without Node on PATH** | -| HyperDHT world tunnel + `fj1.` invites | Working | -| Peer chat + presence (Protomux) | Working (tests + CLI stdin chat) | -| `npm run make:standalone` | Produces `out/flying-jib-/flying-jib` (~500MB) | -| Unit/integration tests | **12/12 pass** | +| Worlds / Squid loopback / host+join tunnels | Working | +| Chat + presence | Working | +| Standalone binary (`make:standalone`) | Working (~500MB) | +| Mesh create / open / enroll / list (Autobase+Hyperbee) | Working | +| Border helpers (`nearBorder`, `borderAction`) | Working (lib; not yet live Squid migrate) | +| GitHub Actions integrate workflow | Added | +| Tests | **17/17 pass** | --- @@ -29,34 +30,30 @@ | Area | Status | |------|--------| -| Binary size optimization | ~500MB — prune later | -| Mesh enrollment / borders | Phase 4 | +| Live player migration at borders | Stub only (lib + `checkBorder`) | +| Mesh multi-writer admit UX polish | CLI `mesh-admit` present | +| Binary size | ~500MB | | GUI | Not started | -| Production Pear OTA key | Placeholder | | Two-machine Java playtest | Manual | --- -## How to try +## Mesh CLI (Phase 4) ```sh -# Dev BARE=./node_modules/bare-runtime/bin/bare -$BARE bin.mjs host demo --port 25565 --name Alice -$BARE bin.mjs join 'fj1.…' --port 25566 --name Bob -# type chat lines; /peers - -# 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 +$BARE bin.mjs mesh-create --name Giant # prints fj1. mesh invite +$BARE bin.mjs mesh-enroll home --invite 'fj1.…' --min-x 0 --max-x 9999 +$BARE bin.mjs mesh-list --invite 'fj1.…' +# peer: mesh-open --invite … → share localWriterKey → mesh-admit ``` --- ## Next actions -1. Two-machine Java Edition playtest -2. Shrink standalone binary / CI matrix for all hosts -3. Phase 4 mesh registry + border migration +1. Squid plugin for border warn/migrate using registry +2. Wire migrate Protomux to tunnel switch +3. Multi-host CI standalone matrix +4. Java multiplayer playtest diff --git a/living_docs/PROGRESS.md b/living_docs/PROGRESS.md index c9d85cc..f489af5 100644 --- a/living_docs/PROGRESS.md +++ b/living_docs/PROGRESS.md @@ -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 ### Wins diff --git a/living_docs/ROADMAP.md b/living_docs/ROADMAP.md index 786728c..0c11d5c 100644 --- a/living_docs/ROADMAP.md +++ b/living_docs/ROADMAP.md @@ -151,7 +151,7 @@ | Field | Value | |-------|-------| -| **Status** | Not Started | +| **Status** | In Progress (registry + border helpers) | | **Owner** | TBD | | **Target** | TBD | | **ADRs** | 0007, 0008 | @@ -165,8 +165,11 @@ ### Acceptance -- [ ] Multiple peers announce non-overlapping regions -- [ ] Crossing border migrates player to neighbor Squid +- [x] Autobase mesh registry enroll/list/regionAt +- [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) ### Documentation required to complete diff --git a/test/border.test.js b/test/border.test.js new file mode 100644 index 0000000..3e05f11 --- /dev/null +++ b/test/border.test.js @@ -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) +}) diff --git a/test/mesh-registry.test.js b/test/mesh-registry.test.js new file mode 100644 index 0000000..0cb92fd --- /dev/null +++ b/test/mesh-registry.test.js @@ -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() +}) diff --git a/user_docs/MESH_WORLDS.md b/user_docs/MESH_WORLDS.md index 33283bd..28a625c 100644 --- a/user_docs/MESH_WORLDS.md +++ b/user_docs/MESH_WORLDS.md @@ -1,6 +1,6 @@ # Mesh Worlds -**Status:** Planned (Phase 4) +**Status:** Registry CLI available (Phase 4 in progress). Live border teleport not finished. ## 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. -## Enrollment +## Enrollment (CLI) -1. Create or open a private world. -2. Choose **Enroll in mesh** and paste a mesh invite (or create a new mesh). -3. Publish your region’s bounds / location on the shared map. -4. Other enrolled peers see your region when online. +```sh +# Peer A — create mesh +flying-jib mesh-create --name Giant +# 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 --invite 'fj1.…' + +flying-jib mesh-list --invite 'fj1.…' +``` + +GUI “Enroll in mesh” is still planned. ## Crossing borders