p4
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
+23
-11
@@ -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 (!body.worldKey || !body.cap) {
|
if (type === 'private-world') {
|
||||||
throw new Error('invite requires worldKey and cap')
|
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)
|
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') {
|
||||||
|
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}`)
|
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)) {
|
if (body.expires != null && Number(body.expires) > 0 && Date.now() > Number(body.expires)) {
|
||||||
throw new Error('Invite expired')
|
throw new Error('Invite expired')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
# Flying Jib — Current Status
|
# Flying Jib — Current Status
|
||||||
|
|
||||||
**Snapshot date:** 2026-07-30
|
**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
|
**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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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 region’s 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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user