Updates
This commit is contained in:
@@ -172,10 +172,51 @@ module.exports = class App extends ReadyResource {
|
||||
_onPeerControl(m) {
|
||||
if (!m || m.local) return
|
||||
if (m.type === 'handoff-apply' && m.username && m.handoff) {
|
||||
this.handoffs.set(m.username, m.handoff)
|
||||
const stored = this.handoffs.set(m.username, m.handoff)
|
||||
if (!stored.ok) {
|
||||
this.emit(
|
||||
'message',
|
||||
`[mesh] rejected handoff for ${m.username}: ${stored.reason || 'failed'}` +
|
||||
(m.handoff.id ? ` id=${m.handoff.id.slice(0, 8)}…` : '')
|
||||
)
|
||||
// Tell guest we already applied / rejected so they do not retry blindly
|
||||
if (this.peerSession) {
|
||||
this.peerSession.sendControl({
|
||||
type: 'handoff-ack',
|
||||
ok: false,
|
||||
reason: stored.reason || 'rejected',
|
||||
username: m.username,
|
||||
handoffId: m.handoff.id || null
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
this.emit(
|
||||
'message',
|
||||
`[mesh] pending handoff for ${m.username} (${(m.handoff.inventory || []).length} items)`
|
||||
`[mesh] pending handoff for ${m.username} (${(m.handoff.inventory || []).length} items)` +
|
||||
(m.handoff.id ? ` id=${String(m.handoff.id).slice(0, 8)}…` : '')
|
||||
)
|
||||
// Phase ack: destination accepted prepare payload into store
|
||||
if (this.peerSession) {
|
||||
this.peerSession.sendControl({
|
||||
type: 'handoff-ack',
|
||||
ok: true,
|
||||
phase: 'prepared',
|
||||
username: m.username,
|
||||
handoffId: m.handoff.id || null
|
||||
})
|
||||
}
|
||||
this.emit('handoff-prepared', {
|
||||
username: m.username,
|
||||
handoffId: m.handoff.id || null
|
||||
})
|
||||
return
|
||||
}
|
||||
if (m.type === 'handoff-ack') {
|
||||
this.emit('handoff-ack', m)
|
||||
this.emit(
|
||||
'message',
|
||||
`[mesh] handoff-ack ${m.ok ? 'ok' : 'fail'} ${m.username || ''} ${m.phase || m.reason || ''}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -196,9 +237,24 @@ module.exports = class App extends ReadyResource {
|
||||
.then((r) => {
|
||||
this.emit(
|
||||
'message',
|
||||
`[mesh] handoff applied for ${player.username}: slots=${r.slots || 0}`
|
||||
`[mesh] handoff applied for ${player.username}: slots=${r.slots || 0}` +
|
||||
(r.handoffId ? ` id=${String(r.handoffId).slice(0, 8)}…` : '')
|
||||
)
|
||||
this.emit('handoff-applied', { username: player.username, result: r })
|
||||
this.emit('handoff-applied', {
|
||||
username: player.username,
|
||||
result: r,
|
||||
handoffId: r.handoffId || handoff.id || null
|
||||
})
|
||||
// Commit ack to guest/session
|
||||
if (this.peerSession) {
|
||||
this.peerSession.sendControl({
|
||||
type: 'handoff-ack',
|
||||
ok: !!r.ok,
|
||||
phase: 'committed',
|
||||
username: player.username,
|
||||
handoffId: r.handoffId || handoff.id || null
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err) => this.emit('error', err))
|
||||
})
|
||||
@@ -661,7 +717,13 @@ module.exports = class App extends ReadyResource {
|
||||
} catch (err) {
|
||||
// Hold handoff locally so a later successful migrate can re-send
|
||||
if (evt.handoff && evt.handoff.username) {
|
||||
this.handoffs.set(evt.handoff.username, evt.handoff)
|
||||
const stashed = this.handoffs.set(evt.handoff.username, evt.handoff)
|
||||
if (!stashed.ok) {
|
||||
this.emit(
|
||||
'message',
|
||||
`[mesh] could not stash handoff for retry: ${stashed.reason || 'failed'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (this.tunnelClient) {
|
||||
await this.tunnelClient.close().catch(() => {})
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createRequire } from 'module'
|
||||
import { command, flag, arg, summary, header, footer } from 'paparam'
|
||||
import path from 'path'
|
||||
import process from 'process'
|
||||
import os from 'os'
|
||||
import pkg from './package.json' with { type: 'json' }
|
||||
import App from './app.js'
|
||||
|
||||
@@ -33,18 +34,27 @@ const base = path.basename(argv0)
|
||||
const isDev = base === 'bare' || base === 'node' || /bare-runtime/.test(argv0)
|
||||
const rawArgv = isBare ? Bare.argv.slice(isDev ? 2 : 1) : process.argv.slice(2)
|
||||
|
||||
function defaultTmpDir() {
|
||||
try {
|
||||
if (typeof os.tmpdir === 'function') return os.tmpdir()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return process.env.TMPDIR || process.env.TEMP || process.env.TMP || '/tmp'
|
||||
}
|
||||
|
||||
function resolveStorage(flagStorage) {
|
||||
if (flagStorage) return path.resolve(flagStorage)
|
||||
const os = require('os')
|
||||
const tmp = defaultTmpDir()
|
||||
if (isBare) {
|
||||
try {
|
||||
const { persistent } = require('bare-storage')
|
||||
return isDev ? path.join(os.tmpdir(), 'pear', appName) : path.join(persistent(), appName)
|
||||
return isDev ? path.join(tmp, 'pear', appName) : path.join(persistent(), appName)
|
||||
} catch {
|
||||
return path.join(os.tmpdir(), 'pear', appName)
|
||||
return path.join(tmp, 'pear', appName)
|
||||
}
|
||||
}
|
||||
return path.join(os.tmpdir(), 'pear', appName)
|
||||
return path.join(tmp, 'pear', appName)
|
||||
}
|
||||
|
||||
function exit(code) {
|
||||
|
||||
@@ -114,7 +114,7 @@ Invite `fj1.` carries `worldKey`, capability secret, metadata.
|
||||
6. Guest sends `handoff-apply`; neighbor applies on `spawned` (teleport + inventory).
|
||||
7. CLI emits reconnect banner / `reconnect-hint`; Java reconnects to `127.0.0.1:port`.
|
||||
8. **Offline neighbor:** `lib/migrate.js` — restore previous tunnel, stash handoff, `migrate-failed`. No master failover.
|
||||
9. Full two-phase commit against item dupe remains open (Q6); capture-before-kick + delayed kick is current path.
|
||||
9. **Anti-dupe handoff (Q6 partial):** unique `handoff.id`, strip source inventory on capture, single `take` + applied-id set, `handoff-ack` prepared/committed. Not a distributed 2PC across crashes, but blocks double-apply and source rejoin dupe.
|
||||
|
||||
## Storage layout
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Build and Release
|
||||
|
||||
**Last updated:** 2026-07-30
|
||||
**Status:** Standalone pack script + **multi-host CI matrix** (Phase 5 started). Pear OTA full runbook still open.
|
||||
**Status:** Standalone pack + multi-host CI matrix; Pear OTA **config check** scripted; live seed still needs real keys.
|
||||
**ADR:** [0013](../agent/ADRs/0013-bare-pear-only-runtime-and-distribution.md) (primary)
|
||||
**Historical:** [0011](../agent/ADRs/0011-build-release-hello-pear-electron.md) superseded for Electron Forge primary path
|
||||
|
||||
@@ -67,7 +67,24 @@ Script: `scripts/bare-standalone.cjs`
|
||||
- Postinstall patches: static Squid plugins, log.js, engines, events polyfill
|
||||
|
||||
Verified: standalone binary starts Squid on `127.0.0.1` with empty PATH (no system Node).
|
||||
Size today ~500MB — optimize later (prune natives / strip).
|
||||
|
||||
**Binary size (darwin-arm64, measured 2026-07-31):**
|
||||
|
||||
| Pack mode | Approx size | How |
|
||||
|-----------|-------------|-----|
|
||||
| Unpruned (full PC+bedrock `minecraft-data`) | ~498 MiB | `FJ_SKIP_MC_DATA_PRUNE=1 npm run make:standalone` |
|
||||
| Default prune (Squid PC versions, no bedrock) | **~151 MiB** | `npm run make:standalone` |
|
||||
| Minimal (only `1.21.1`) | **~98 MiB** | `FJ_MC_DATA_MINIMAL=1 npm run make:standalone` |
|
||||
|
||||
Prune runs inside `scripts/bare-standalone.cjs` via `scripts/prune-minecraft-data.js`, then restores full `data.js` for local multi-version dev.
|
||||
|
||||
```sh
|
||||
npm run size:report # after make:standalone
|
||||
npm run mcdata:prune # optional manual prune of node_modules data.js
|
||||
npm run mcdata:restore # restore full minecraft-data
|
||||
npm run pear:check # allow placeholders (dev)
|
||||
npm run pear:check:strict # fail if upgrade/multisig still placeholders
|
||||
```
|
||||
|
||||
## CI matrix (`.github/workflows/integrate.yml`)
|
||||
|
||||
@@ -117,7 +134,14 @@ Stage → Provision → Multisig (production)
|
||||
|
||||
OTA: `pear-runtime` inside the Bare worker (hello-pear-bare pattern).
|
||||
|
||||
## CLI operations (conceptual)
|
||||
## Pear OTA config validation
|
||||
|
||||
```sh
|
||||
npm run pear:check # allow placeholders (dev)
|
||||
npm run pear:check:strict # fail until real multisig keys + upgrade link
|
||||
```
|
||||
|
||||
## CLI operations (when keys are real)
|
||||
|
||||
```sh
|
||||
pear stage <link> <deploy-dir>
|
||||
|
||||
@@ -20,7 +20,7 @@ Versions below are **initial pins** from investigation. Lock exact versions in t
|
||||
| `peer-session.js` | Protomux presence/chat/control |
|
||||
| `mesh-registry.js` | Autobase + Hyperbee regions |
|
||||
| `border.js` | Pure border/neighbor/map helpers |
|
||||
| `player-handoff.js` | Capture/apply inventory + HandoffStore |
|
||||
| `player-handoff.js` | Capture/apply inventory; strip source; id + single-apply store + ack helpers |
|
||||
| `migrate.js` | Offline-neighbor migrate UX (timeout, messages, tunnel snapshot) |
|
||||
| `reconnect.js` | Soft-reconnect banners, kick reasons, stable localhost port pick |
|
||||
|
||||
|
||||
@@ -131,18 +131,33 @@ Guest matches `username` to `--mc-name`, retargets HyperDHT client tunnel to `ne
|
||||
"type": "handoff-apply",
|
||||
"username": "Steve",
|
||||
"handoff": {
|
||||
"v": 1,
|
||||
"v": 2,
|
||||
"id": "<16-byte hex>",
|
||||
"phase": "prepare",
|
||||
"username": "Steve",
|
||||
"health": 20,
|
||||
"food": 20,
|
||||
"gamemode": 0,
|
||||
"mapped": { "x": 0, "y": 64, "z": 50 },
|
||||
"inventory": [{ "slot": 9, "name": "stone", "type": 1, "count": 32, "metadata": 0 }]
|
||||
"inventory": [{ "slot": 9, "name": "stone", "type": 1, "count": 32, "metadata": 0 }],
|
||||
"strippedSource": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Neighbor host stores pending handoff (TTL 5 min) and applies on next `spawned` for that username.
|
||||
### Two-phase handoff (anti-dupe, Q6)
|
||||
|
||||
| Step | Who | Action |
|
||||
|------|-----|--------|
|
||||
| prepare | Source Squid | `captureHandoff` + **strip source inventory**; include `id` |
|
||||
| handoff-apply | Guest | Deliver payload to neighbor control plane |
|
||||
| prepared ack | Neighbor | `handoff-ack` `{ ok, phase: "prepared", handoffId }` after store |
|
||||
| apply | Neighbor Squid | On `spawned`, `take(username)` once; mark `id` applied |
|
||||
| committed ack | Neighbor | `handoff-ack` `{ ok, phase: "committed", handoffId }` |
|
||||
|
||||
- Replaying the same `id` is rejected (`already-applied`).
|
||||
- Pending store TTL: 5 minutes.
|
||||
- Source strip means rejoining the **old** region after migrate does not keep the moved items.
|
||||
|
||||
### Offline neighbor (no master failover)
|
||||
|
||||
|
||||
@@ -70,8 +70,10 @@ Automated:
|
||||
- `test/dual-squid-handoff.test.js` — two real Flying Squid instances + `minecraft-protocol` offline client; capture inventory on A, apply on B
|
||||
- `test/migrate-offline.test.js` — offline UX helpers (timeout, messages, snapshot)
|
||||
- `test/reconnect.test.js` — soft-reconnect banner / stable port helpers
|
||||
- `test/player-handoff.test.js` — strip source inv, duplicate id rejection
|
||||
|
||||
Manual Java scenarios: [`PLAYTEST.md`](./PLAYTEST.md).
|
||||
Release helpers: `npm run pear:check`, `npm run size:report`.
|
||||
|
||||
### Offline / failure
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ Out of scope: Mojang/Microsoft account systems, Java client vulnerabilities, pub
|
||||
| T3 | Cap brute / forge | Unauthorized tunnel | 32-byte secret; Noise still required for DHT | Cap strength = entropy of invite share path |
|
||||
| T4 | MitM on MC bytes | Tamper game stream | HyperDHT secretstream; no plaintext WAN MC | Compromised peer host can still cheat in own region |
|
||||
| T5 | Fake region / border trap | Kick + handoff to attacker world | Mesh writer admission; user sees region names; offline restore | Social engineering |
|
||||
| T6 | Inventory dupe on migrate | Economy break | Capture-before-kick; single take from HandoffStore; TTL | Full 2PC still open (Q6) |
|
||||
| T6 | Inventory dupe on migrate | Economy break | Capture + strip source inv; handoff `id`; single take + applied set; `handoff-ack`; TTL | Crash mid-flight / XP edge cases still residual (Q6 partial) |
|
||||
| T7 | Offline neighbor silent failover to “master” | Centralization / wrong world | Explicit `migrate-failed`; restore previous tunnel (RULES §1) | User confusion if CLI unread |
|
||||
| T8 | Require Node on user machine | Supply-chain / support | Bare standalone only (ADR-0013); CI smoke without Node | Dev machines use npm |
|
||||
| T9 | AGPL / hostile license deps | Relicense risk | ADR-0012; avoid holesail | Manual audit |
|
||||
|
||||
+136
-21
@@ -2,14 +2,28 @@
|
||||
|
||||
/**
|
||||
* Serialize / apply player state for mesh border migration (ADR-0008).
|
||||
* Keeps payload JSON-friendly for Protomux control messages.
|
||||
* Two-phase style anti-dupe (Q6):
|
||||
* 1. prepare — capture + assign id; strip inventory on source
|
||||
* 2. apply — destination takes pending once; mark id applied
|
||||
* 3. ack — destination signals guest/source that store accepted
|
||||
* Handoff payloads stay JSON-friendly for Protomux control messages.
|
||||
*/
|
||||
|
||||
const crypto = require('hypercore-crypto')
|
||||
|
||||
/**
|
||||
* @returns {string} opaque handoff id
|
||||
*/
|
||||
function newHandoffId() {
|
||||
return crypto.randomBytes(16).toString('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} player flying-squid player
|
||||
* @param {{ x:number, y:number, z:number }} [mapped] destination local coords
|
||||
* @param {{ strip?: boolean, handoffId?: string }} [opts]
|
||||
*/
|
||||
function captureHandoff(player, mapped) {
|
||||
function captureHandoff(player, mapped, opts = {}) {
|
||||
const pos = player.position
|
||||
const inventory = []
|
||||
if (player.inventory && Array.isArray(player.inventory.slots)) {
|
||||
@@ -22,14 +36,15 @@ function captureHandoff(player, mapped) {
|
||||
name: item.name || null,
|
||||
count: item.count || 1,
|
||||
metadata: item.metadata != null ? item.metadata : 0,
|
||||
// nbt may be complex; pass through if plain object/buffer-friendly
|
||||
nbt: item.nbt || null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
v: 1,
|
||||
const handoff = {
|
||||
v: 2,
|
||||
id: opts.handoffId || newHandoffId(),
|
||||
phase: 'prepare',
|
||||
username: player.username,
|
||||
uuid: player.uuid || null,
|
||||
health: player.health,
|
||||
@@ -46,6 +61,38 @@ function captureHandoff(player, mapped) {
|
||||
inventory,
|
||||
capturedAt: Date.now()
|
||||
}
|
||||
|
||||
// Anti-dupe: clear source inventory after capture so rejoin-to-source cannot keep items
|
||||
if (opts.strip !== false) {
|
||||
stripPlayerInventory(player)
|
||||
handoff.strippedSource = true
|
||||
}
|
||||
|
||||
return handoff
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty player inventory slots (best-effort).
|
||||
* @param {object} player
|
||||
* @returns {number} slots cleared
|
||||
*/
|
||||
function stripPlayerInventory(player) {
|
||||
if (!player || !player.inventory || !Array.isArray(player.inventory.slots)) return 0
|
||||
let n = 0
|
||||
for (let i = 0; i < player.inventory.slots.length; i++) {
|
||||
if (!player.inventory.slots[i]) continue
|
||||
try {
|
||||
if (typeof player.inventory.updateSlot === 'function') {
|
||||
player.inventory.updateSlot(i, null)
|
||||
} else {
|
||||
player.inventory.slots[i] = null
|
||||
}
|
||||
n++
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,11 +144,9 @@ async function applyHandoff(player, serv, handoff) {
|
||||
player.heldItemSlot = handoff.heldItemSlot
|
||||
}
|
||||
|
||||
// Inventory restore
|
||||
if (player.inventory && handoff.inventory && handoff.inventory.length) {
|
||||
try {
|
||||
const Item = require('prismarine-item')(serv.registry)
|
||||
// Clear
|
||||
for (let i = 0; i < player.inventory.slots.length; i++) {
|
||||
if (player.inventory.slots[i]) {
|
||||
player.inventory.updateSlot(i, null)
|
||||
@@ -122,7 +167,12 @@ async function applyHandoff(player, serv, handoff) {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return { ok: true, inventoryError: err.message, teleported: !!dest }
|
||||
return {
|
||||
ok: true,
|
||||
inventoryError: err.message,
|
||||
teleported: !!dest,
|
||||
handoffId: handoff.id || null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +182,13 @@ async function applyHandoff(player, serv, handoff) {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
return { ok: true, teleported: !!dest, slots: (handoff.inventory || []).length }
|
||||
return {
|
||||
ok: true,
|
||||
teleported: !!dest,
|
||||
slots: (handoff.inventory || []).length,
|
||||
handoffId: handoff.id || null,
|
||||
phase: 'committed'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,51 +196,110 @@ async function applyHandoff(player, serv, handoff) {
|
||||
*/
|
||||
const HANDOFF_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Pending handoffs + applied id set (single-apply / anti-dupe).
|
||||
*/
|
||||
class HandoffStore {
|
||||
constructor(ttlMs = HANDOFF_TTL_MS) {
|
||||
this.ttlMs = ttlMs
|
||||
/** @type {Map<string, { handoff: object, expires: number }>} */
|
||||
this.map = new Map()
|
||||
/** @type {Set<string>} handoff ids already taken/applied */
|
||||
this.applied = new Set()
|
||||
/** @type {Map<string, number>} id -> expires for applied GC */
|
||||
this.appliedExpires = new Map()
|
||||
}
|
||||
|
||||
key(username) {
|
||||
return String(username || '').toLowerCase()
|
||||
}
|
||||
|
||||
_gcApplied() {
|
||||
const now = Date.now()
|
||||
for (const [id, exp] of this.appliedExpires) {
|
||||
if (now > exp) {
|
||||
this.appliedExpires.delete(id)
|
||||
this.applied.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a prepared handoff. Rejects if id already applied.
|
||||
* @returns {{ ok: boolean, reason?: string }}
|
||||
*/
|
||||
set(username, handoff) {
|
||||
this.map.set(this.key(username), {
|
||||
handoff,
|
||||
this._gcApplied()
|
||||
if (!handoff) return { ok: false, reason: 'missing' }
|
||||
if (handoff.id && this.applied.has(handoff.id)) {
|
||||
return { ok: false, reason: 'already-applied' }
|
||||
}
|
||||
const entry = {
|
||||
handoff: { ...handoff, phase: handoff.phase || 'prepare' },
|
||||
expires: Date.now() + this.ttlMs
|
||||
})
|
||||
}
|
||||
|
||||
take(username) {
|
||||
const k = this.key(username)
|
||||
const e = this.map.get(k)
|
||||
if (!e) return null
|
||||
this.map.delete(k)
|
||||
if (Date.now() > e.expires) return null
|
||||
return e.handoff
|
||||
}
|
||||
this.map.set(this.key(username), entry)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Peek without consuming.
|
||||
*/
|
||||
peek(username) {
|
||||
this._gcApplied()
|
||||
const e = this.map.get(this.key(username))
|
||||
if (!e) return null
|
||||
if (Date.now() > e.expires) {
|
||||
this.map.delete(this.key(username))
|
||||
return null
|
||||
}
|
||||
if (e.handoff.id && this.applied.has(e.handoff.id)) {
|
||||
this.map.delete(this.key(username))
|
||||
return null
|
||||
}
|
||||
return e.handoff
|
||||
}
|
||||
|
||||
/**
|
||||
* Take pending handoff once. Marks id applied so re-take fails.
|
||||
*/
|
||||
take(username) {
|
||||
this._gcApplied()
|
||||
const k = this.key(username)
|
||||
const e = this.map.get(k)
|
||||
if (!e) return null
|
||||
this.map.delete(k)
|
||||
if (Date.now() > e.expires) return null
|
||||
const h = e.handoff
|
||||
if (h.id) {
|
||||
if (this.applied.has(h.id)) return null
|
||||
this.applied.add(h.id)
|
||||
this.appliedExpires.set(h.id, Date.now() + this.ttlMs)
|
||||
}
|
||||
return { ...h, phase: 'apply' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this handoff id was already applied/taken.
|
||||
*/
|
||||
wasApplied(handoffId) {
|
||||
if (!handoffId) return false
|
||||
this._gcApplied()
|
||||
return this.applied.has(handoffId)
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.map.clear()
|
||||
this.applied.clear()
|
||||
this.appliedExpires.clear()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
captureHandoff,
|
||||
applyHandoff,
|
||||
stripPlayerInventory,
|
||||
newHandoffId,
|
||||
HandoffStore,
|
||||
HANDOFF_TTL_MS
|
||||
}
|
||||
|
||||
@@ -23,7 +23,11 @@ and this project aims to follow [Semantic Versioning](https://semver.org/).
|
||||
- Prefer stable localhost port on tunnel retarget
|
||||
- Delayed kick (~1.2s) so migrate/tunnel can finish first
|
||||
- CLI reconnect banner + kick reason with Direct Connect address
|
||||
- Handoff anti-dupe (v2): handoff id, strip source inventory, single-apply store, `handoff-ack`
|
||||
- Multi-host standalone CI matrix (linux-x64/arm64, darwin-arm64, win32-x64)
|
||||
- Pear OTA config check: `npm run pear:check`
|
||||
- Binary size report: `npm run size:report`
|
||||
- Standalone minecraft-data prune (`scripts/prune-minecraft-data.js`, wired into pack)
|
||||
- Threat model: `developer_docs/security/THREAT_MODEL.md`
|
||||
- Manual playtest runbook: `developer_docs/PLAYTEST.md`
|
||||
|
||||
@@ -32,6 +36,7 @@ and this project aims to follow [Semantic Versioning](https://semver.org/).
|
||||
- Squid login: provide `player-list-text` defaults so Flying Squid header plugin does not crash on join
|
||||
- Squid stop: stop game tick + prismarine-world saving intervals; close Anvil region files
|
||||
- flying-squid tab-list latency intervals cleared on disconnect (postinstall patch)
|
||||
- Standalone default storage: ESM `import os from 'os'` so bare-pack embeds bare-os (createRequire path failed at runtime)
|
||||
- Phase 4 border migrate (live path):
|
||||
- Squid plugin `fj-mesh-border` (warn / kick / events)
|
||||
- Protomux control migrate + guest tunnel retarget
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# Flying Jib — Current Status
|
||||
|
||||
**Snapshot date:** 2026-07-30
|
||||
**Phase:** 1–4 operational core; Phase 5 CI matrix started
|
||||
**Snapshot date:** 2026-07-31
|
||||
**Phase:** 1–4 operational core; Phase 5 CI + OTA check + handoff anti-dupe + binary prune
|
||||
**Product:** Flying Jib — Bare/Pear P2P Minecraft mesh
|
||||
|
||||
---
|
||||
|
||||
## One-line summary
|
||||
|
||||
Private worlds, chat, mesh registry, border migrate with **inventory handoff**, **offline UX**, **soft-reconnect banners** (stable localhost port), dual-Squid e2e, multi-host CI, and a written **threat model** — no system Node.js.
|
||||
Private worlds, mesh border migrate with **anti-dupe handoff**, soft-reconnect UX, multi-host CI, Pear OTA **config validation**, standalone **~151 MiB** (pruned `minecraft-data`), threat model — Bare/Pear only, no system Node.js.
|
||||
|
||||
---
|
||||
|
||||
@@ -18,17 +18,17 @@ Private worlds, chat, mesh registry, border migrate with **inventory handoff**,
|
||||
|------|--------|
|
||||
| Local Squid on `127.0.0.1` / host+join tunnels | Working |
|
||||
| Chat + presence | Working |
|
||||
| Standalone binary | Working (~500MB) |
|
||||
| Standalone binary | Working (**~151 MiB** default prune; ~98 MiB minimal) |
|
||||
| Mesh Autobase registry | Working |
|
||||
| Border plugin + migrate signal + tunnel switch | Working |
|
||||
| Inventory + spawn handoff | Working |
|
||||
| Two-region e2e (mesh + tunnels + handoff) | **Tested** |
|
||||
| Dual real Squid + protocol client handoff | **Tested** |
|
||||
| Offline neighbor migrate UX (timeout + restore) | Working |
|
||||
| Soft-reconnect UX (delay kick, same port, banner) | Working |
|
||||
| Border migrate + tunnel switch + offline UX | Working |
|
||||
| Inventory handoff (id + strip + single-apply + ack) | Working |
|
||||
| Dual-Squid / two-region e2e | **Tested** |
|
||||
| Soft-reconnect banners / stable port | Working |
|
||||
| Multi-host CI standalone matrix | **Wired** |
|
||||
| Threat model doc | Written |
|
||||
| Tests | **36/36 pass** |
|
||||
| Pear OTA structure check (`npm run pear:check`) | Working |
|
||||
| Binary size report + minecraft-data prune | Working |
|
||||
| Threat model | Written |
|
||||
| Tests | **39/39 pass** |
|
||||
|
||||
---
|
||||
|
||||
@@ -36,17 +36,17 @@ Private worlds, chat, mesh registry, border migrate with **inventory handoff**,
|
||||
|
||||
| Area | Status |
|
||||
|------|--------|
|
||||
| Vanilla Java still must reconnect after border | Softened, not silent |
|
||||
| Full NBT fidelity / XP bars edge cases | Best-effort |
|
||||
| Binary size | ~500MB |
|
||||
| Pear OTA full verify | Not done |
|
||||
| Vanilla Java must reconnect after border | Softened |
|
||||
| Live Pear seed with real multisig keys | Not done |
|
||||
| Binary size further (runtime / prismarine) | ~98–151 MiB remaining |
|
||||
| Crash-safe distributed 2PC for handoff | Partial |
|
||||
| GUI | Not started |
|
||||
|
||||
---
|
||||
|
||||
## Next
|
||||
|
||||
1. Pear OTA stage/seed path verification
|
||||
2. Two-machine Java playtest (use `developer_docs/PLAYTEST.md`)
|
||||
3. Binary prune
|
||||
4. Handoff two-phase commit (Q6)
|
||||
1. Fill real Pear keys and run live stage/seed once
|
||||
2. Two-machine Java playtest (`developer_docs/PLAYTEST.md`)
|
||||
3. Optional minimal release line (`FJ_MC_DATA_MINIMAL=1`) in CI
|
||||
4. GUI / soft-reconnect further polish
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
| Q3 | `online-mode: false` vs Microsoft auth for players who expect Realms-like login | Medium | Document offline UUIDs; consider online-mode only for pure-local sessions | Open |
|
||||
| Q4 | Autobase writer admission: who may enroll regions into a mesh? | High | Capability-gated enrollment; mesh invite ≠ region announce | Open |
|
||||
| Q5 | Coordinate / seed continuity across independently generated regions | Medium | Shared seed + offset policy ADR in Phase 4 | Open |
|
||||
| Q6 | Inventory/XP integrity during migration (races, duplication) | High | Two-phase migrate commit; abort + kick if incomplete | Open |
|
||||
| Q6 | Inventory/XP integrity during migration (races, duplication) | High | Two-phase migrate commit; abort + kick if incomplete | **Partial (2026-07-30)** — handoff `id`, strip source inv, single take + applied set, `handoff-ack`; full crash-safe 2PC still open |
|
||||
| Q7 | Packaging Squid for bare-build standalone | High | bare-build fails on flying-squid esbuild-import-glob; need peardock-style `bare-pack` + imports map (build/squid-imports.json) | Open |
|
||||
| Q8 | Accidental AGPL dependency (e.g. holesail) | Medium | Dependency policy + CI license check (ADR-0012) | Open |
|
||||
| Q9 | Public mesh spam / content liability | High (policy) | Private-by-default; public mesh experimental only | Open |
|
||||
|
||||
@@ -5,6 +5,41 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-31 — Standalone minecraft-data prune (~498 → ~151 MiB)
|
||||
|
||||
### Wins
|
||||
|
||||
- `scripts/prune-minecraft-data.js` — drop bedrock; keep Flying Squid PC versions only
|
||||
- Wired into `bare-standalone.cjs` (prune → pack → restore full `data.js` for dev)
|
||||
- Measured darwin-arm64: **~151 MiB** default; **~98 MiB** with `FJ_MC_DATA_MINIMAL=1`
|
||||
- `npm run mcdata:prune` / `mcdata:restore`; size report + BUILD_AND_RELEASE updated
|
||||
- Standalone smoke: create/list with empty PATH + `--storage`
|
||||
- Fix: top-level `require('os')` so bare-pack includes bare-os for default storage path
|
||||
|
||||
### Gaps
|
||||
|
||||
- Live Pear seed still needs real keys
|
||||
- Runtime + prismarine still dominate remaining size
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-30 — Handoff anti-dupe (Q6) + Pear OTA check + size report
|
||||
|
||||
### Wins
|
||||
|
||||
- Handoff v2: unique `id`, strip source inventory on capture, `HandoffStore` applied-id set
|
||||
- Control `handoff-ack` phases `prepared` / `committed` (and reject replay)
|
||||
- `scripts/pear-ota-check.js` + `npm run pear:check` / `pear:check:strict`
|
||||
- `scripts/binary-size-report.js` + `npm run size:report` + prune guidance
|
||||
- Docs: PROTOCOL, BUILD_AND_RELEASE, OPEN_QUESTIONS Q6 partial, MESH_WORLDS, threat model T6
|
||||
|
||||
### Gaps
|
||||
|
||||
- Live Pear seed still needs real keys
|
||||
- Full crash-safe distributed 2PC not claimed
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-30 — Soft-reconnect UX + threat model + playtest runbook
|
||||
|
||||
### Wins
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> Single source of truth for phases, status, and documentation requirements.
|
||||
> Update this file whenever phase status changes.
|
||||
|
||||
**Last updated:** 2026-07-30
|
||||
**Last updated:** 2026-07-31
|
||||
**Runtime constraint:** Bare/Pear only — no app-level Node.js ([ADR-0013](../agent/ADRs/0013-bare-pear-only-runtime-and-distribution.md))
|
||||
|
||||
---
|
||||
@@ -203,9 +203,10 @@
|
||||
### Acceptance
|
||||
|
||||
- [x] Multi-host standalone executables from CI (matrix: linux-x64/arm64, darwin-arm64, win32-x64)
|
||||
- [ ] Pear OTA path verified without system Node
|
||||
- [~] Pear OTA path: config validation scripted; live stage/seed pending real keys
|
||||
- [x] Documented playtest / release smoke runbook (`PLAYTEST.md`; full Pear release still open)
|
||||
- [x] Threat model under `developer_docs/security/THREAT_MODEL.md`
|
||||
- [x] Standalone size prune: minecraft-data bedrock dropped + Squid PC versions (~151 MiB default)
|
||||
|
||||
### Documentation required to complete
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
"test": "brittle-node test/*.test.js",
|
||||
"test:bare": "brittle-bare test/*.test.js",
|
||||
"docs:check": "node -e \"const fs=require('fs');const paths=['living_docs/ROADMAP.md','living_docs/CURRENT_STATUS.md','agent/RULES.md','agent/SECURITY.md','developer_docs/ARCHITECTURE.md','user_docs/README.md','lib/squid-manager.js','bin.mjs'];for (const p of paths){if(!fs.existsSync(p)){console.error('missing',p);process.exit(1)}};console.log('docs+app scaffold ok')\"",
|
||||
"pear:check": "node scripts/pear-ota-check.js --allow-placeholder",
|
||||
"pear:check:strict": "node scripts/pear-ota-check.js",
|
||||
"size:report": "node scripts/binary-size-report.js",
|
||||
"mcdata:prune": "node scripts/prune-minecraft-data.js",
|
||||
"mcdata:restore": "node scripts/prune-minecraft-data.js --restore",
|
||||
"make": "node scripts/make.js",
|
||||
"make:standalone": "node scripts/bare-standalone.cjs",
|
||||
"make:standalone:all": "node scripts/bare-standalone.cjs --host all",
|
||||
|
||||
@@ -112,7 +112,8 @@ module.exports.server = function (serv, settings) {
|
||||
const mapped = act.mapped || { x, y: snap.y, z }
|
||||
let handoff = null
|
||||
try {
|
||||
handoff = captureHandoff(player, mapped)
|
||||
// strip=true: clear source inventory after capture (anti-dupe, Q6)
|
||||
handoff = captureHandoff(player, mapped, { strip: true })
|
||||
} catch {
|
||||
handoff = null
|
||||
}
|
||||
|
||||
+68
-45
@@ -136,6 +136,13 @@ function walkFind(dir, pred) {
|
||||
return null
|
||||
}
|
||||
|
||||
function runNodeScript(rel, extraArgs = []) {
|
||||
require('child_process').execSync(
|
||||
`node ${JSON.stringify(path.join(root, rel))}${extraArgs.map((a) => ' ' + JSON.stringify(a)).join('')}`,
|
||||
{ cwd: root, stdio: 'inherit' }
|
||||
)
|
||||
}
|
||||
|
||||
async function buildOne(host, outRoot) {
|
||||
const name = pkg.productName ? 'flying-jib' : pkg.name
|
||||
const outDir = path.join(outRoot, `${name}-${host}`)
|
||||
@@ -143,53 +150,68 @@ async function buildOne(host, outRoot) {
|
||||
fs.mkdirSync(outDir, { recursive: true })
|
||||
|
||||
// Ensure postinstall artifacts
|
||||
require('child_process').execSync('node scripts/generate-squid-imports.js', {
|
||||
cwd: root,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
require('child_process').execSync('node scripts/patch-engines-for-bare.js', {
|
||||
cwd: root,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
runNodeScript('scripts/generate-squid-imports.js')
|
||||
runNodeScript('scripts/patch-engines-for-bare.js')
|
||||
|
||||
const entryPath = path.join(root, 'bin.mjs')
|
||||
const imports = buildImportsMap()
|
||||
console.log(`[bare-standalone] packing ${name} for ${host}…`)
|
||||
|
||||
let entry = await pack(
|
||||
pathToFileURL(entryPath),
|
||||
{
|
||||
hosts: [host],
|
||||
linked: false,
|
||||
resolve: traverse.resolve.bare,
|
||||
imports
|
||||
},
|
||||
readModule,
|
||||
listPrefix
|
||||
)
|
||||
|
||||
const baseURL = pathToFileURL(root + path.sep)
|
||||
entry = entry.unmount(baseURL)
|
||||
entry.id = id(entry).toString('hex')
|
||||
|
||||
const platform = platformForHost(host)
|
||||
const opts = {
|
||||
name,
|
||||
version: pkg.version || '0.0.0',
|
||||
description: pkg.description || 'Flying Jib',
|
||||
author: pkg.author || '',
|
||||
identifier: 'dev.flyingjib.app',
|
||||
hosts: [host],
|
||||
out: outDir,
|
||||
standalone: true,
|
||||
package: false,
|
||||
base: root
|
||||
// Shrink pack graph: PC Squid versions only, no bedrock (~hundreds of MiB)
|
||||
const skipPrune = process.env.FJ_SKIP_MC_DATA_PRUNE === '1'
|
||||
if (!skipPrune) {
|
||||
console.log('[bare-standalone] pruning minecraft-data for pack…')
|
||||
runNodeScript('scripts/prune-minecraft-data.js')
|
||||
} else {
|
||||
console.log('[bare-standalone] FJ_SKIP_MC_DATA_PRUNE=1 — packing full minecraft-data')
|
||||
}
|
||||
|
||||
console.log(`[bare-standalone] embedding bare-runtime for ${host}…`)
|
||||
for await (const resource of platform(root, entry, null, opts)) {
|
||||
if (resource && resource.path) {
|
||||
console.log(`[bare-standalone] resource ${resource.path}`)
|
||||
let entry
|
||||
try {
|
||||
const entryPath = path.join(root, 'bin.mjs')
|
||||
const imports = buildImportsMap()
|
||||
console.log(`[bare-standalone] packing ${name} for ${host}…`)
|
||||
|
||||
entry = await pack(
|
||||
pathToFileURL(entryPath),
|
||||
{
|
||||
hosts: [host],
|
||||
linked: false,
|
||||
resolve: traverse.resolve.bare,
|
||||
imports
|
||||
},
|
||||
readModule,
|
||||
listPrefix
|
||||
)
|
||||
|
||||
const baseURL = pathToFileURL(root + path.sep)
|
||||
entry = entry.unmount(baseURL)
|
||||
entry.id = id(entry).toString('hex')
|
||||
|
||||
const platform = platformForHost(host)
|
||||
const opts = {
|
||||
name,
|
||||
version: pkg.version || '0.0.0',
|
||||
description: pkg.description || 'Flying Jib',
|
||||
author: pkg.author || '',
|
||||
identifier: 'dev.flyingjib.app',
|
||||
hosts: [host],
|
||||
out: outDir,
|
||||
standalone: true,
|
||||
package: false,
|
||||
base: root
|
||||
}
|
||||
|
||||
console.log(`[bare-standalone] embedding bare-runtime for ${host}…`)
|
||||
for await (const resource of platform(root, entry, null, opts)) {
|
||||
if (resource && resource.path) {
|
||||
console.log(`[bare-standalone] resource ${resource.path}`)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Always restore full data.js so local bare/npm start keep multi-version support
|
||||
if (!skipPrune) {
|
||||
try {
|
||||
runNodeScript('scripts/prune-minecraft-data.js', ['--restore'])
|
||||
} catch (e) {
|
||||
console.warn('[bare-standalone] WARN: could not restore minecraft-data:', e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,7 +258,8 @@ async function buildOne(host, outRoot) {
|
||||
version: pkg.version,
|
||||
builtAt: new Date().toISOString(),
|
||||
entry: 'bin.mjs',
|
||||
bundleId: entry.id
|
||||
bundleId: entry && entry.id,
|
||||
minecraftDataPruned: !skipPrune
|
||||
},
|
||||
null,
|
||||
2
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* Report Flying Jib standalone artifact sizes and prune guidance.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/binary-size-report.js
|
||||
* node scripts/binary-size-report.js --dir out
|
||||
*/
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const args = process.argv.slice(2)
|
||||
let dir = path.join(root, 'out')
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--dir') dir = path.resolve(args[++i])
|
||||
}
|
||||
|
||||
function human(n) {
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MiB`
|
||||
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GiB`
|
||||
}
|
||||
|
||||
function walk(d, acc = []) {
|
||||
if (!fs.existsSync(d)) return acc
|
||||
let entries
|
||||
try {
|
||||
entries = fs.readdirSync(d, { withFileTypes: true })
|
||||
} catch {
|
||||
return acc
|
||||
}
|
||||
for (const ent of entries) {
|
||||
const p = path.join(d, ent.name)
|
||||
if (ent.isDirectory()) walk(p, acc)
|
||||
else {
|
||||
try {
|
||||
const st = fs.statSync(p)
|
||||
acc.push({ path: p, size: st.size })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
console.log('Flying Jib — binary size report')
|
||||
console.log('dir:', dir)
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
console.log('(no out/ yet — run npm run make:standalone first)')
|
||||
console.log('\nPrune guidance (without building):')
|
||||
printGuidance()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const files = walk(dir).sort((a, b) => b.size - a.size)
|
||||
const total = files.reduce((s, f) => s + f.size, 0)
|
||||
console.log(`files: ${files.length} total: ${human(total)} (${total} bytes)`)
|
||||
console.log('\nTop artifacts:')
|
||||
for (const f of files.slice(0, 25)) {
|
||||
const rel = path.relative(root, f.path)
|
||||
console.log(` ${human(f.size).padStart(10)} ${rel}`)
|
||||
}
|
||||
|
||||
const bins = files.filter((f) => {
|
||||
const b = path.basename(f.path)
|
||||
return b === 'flying-jib' || b === 'flying-jib.exe' || b.startsWith('flying-jib')
|
||||
})
|
||||
if (bins.length) {
|
||||
console.log('\nLikely executables:')
|
||||
for (const f of bins) {
|
||||
console.log(` ${human(f.size).padStart(10)} ${path.relative(root, f.path)}`)
|
||||
}
|
||||
}
|
||||
|
||||
printGuidance()
|
||||
|
||||
function printGuidance() {
|
||||
console.log(`
|
||||
Prune / size strategy
|
||||
---------------------
|
||||
Standalone pack now runs scripts/prune-minecraft-data.js before bare-pack:
|
||||
- Drops all bedrock editions (Flying Squid is Java/PC only)
|
||||
- Keeps Flying Squid tested PC versions (override: FJ_MC_DATA_VERSIONS, --minimal)
|
||||
- Restores full data.js after pack for local multi-version dev
|
||||
|
||||
Commands:
|
||||
npm run mcdata:prune [-- --minimal]
|
||||
npm run mcdata:restore
|
||||
FJ_SKIP_MC_DATA_PRUNE=1 npm run make:standalone # full data pack (debug)
|
||||
|
||||
Further ideas:
|
||||
1. FJ_MC_DATA_MINIMAL=1 for single-version (1.21.1) release line
|
||||
2. Stage/pack ignores: test/, docs, .git, **/*.map, **/*.ts
|
||||
3. Optional natives already stubbed (canvas, sqlite, bufferutil)
|
||||
4. Compare bare-pack entry size vs embedded runtime after changes
|
||||
|
||||
Do NOT:
|
||||
- Drop loopback bind-guard or security tests to save space
|
||||
- Reintroduce system Node to shrink the product
|
||||
`)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* Validate Pear OTA configuration for Flying Jib without requiring a live seed.
|
||||
*
|
||||
* Checks:
|
||||
* - pear.json stage ignore + multisig shape
|
||||
* - package.json upgrade field
|
||||
* - app.js / workers reference pear-runtime (hello-pear-bare pattern)
|
||||
* - optional: pear CLI present
|
||||
*
|
||||
* Exit 0 = structure OK (placeholders allowed with --allow-placeholder)
|
||||
* Exit 1 = hard errors
|
||||
* Exit 2 = placeholders present without --allow-placeholder
|
||||
*/
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { spawnSync } = require('child_process')
|
||||
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const allowPlaceholder = process.argv.includes('--allow-placeholder')
|
||||
const verbose = process.argv.includes('--verbose') || process.argv.includes('-v')
|
||||
|
||||
const errors = []
|
||||
const warns = []
|
||||
const notes = []
|
||||
|
||||
function readJson(rel) {
|
||||
const p = path.join(root, rel)
|
||||
if (!fs.existsSync(p)) {
|
||||
errors.push(`missing ${rel}`)
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(p, 'utf8'))
|
||||
} catch (e) {
|
||||
errors.push(`invalid JSON ${rel}: ${e.message}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isPlaceholder(s) {
|
||||
if (s == null || s === '') return true
|
||||
const t = String(s)
|
||||
return (
|
||||
/YOUR_KEY|PUBKEY_HERE|<.*>|TODO|xxx/i.test(t) ||
|
||||
t === 'pear://' ||
|
||||
t.endsWith('://')
|
||||
)
|
||||
}
|
||||
|
||||
const pear = readJson('pear.json')
|
||||
const pkg = readJson('package.json')
|
||||
|
||||
if (pear) {
|
||||
if (pear.type !== 'terminal' && pear.type !== 'desktop') {
|
||||
warns.push(`pear.json type is "${pear.type}" (expected terminal for CLI)`)
|
||||
}
|
||||
if (!pear.stage || !Array.isArray(pear.stage.ignore)) {
|
||||
errors.push('pear.json stage.ignore must be an array')
|
||||
} else {
|
||||
const ignore = pear.stage.ignore
|
||||
for (const must of ['node_modules', '.git', 'test']) {
|
||||
if (!ignore.includes(must)) warns.push(`pear.json stage.ignore missing "${must}"`)
|
||||
}
|
||||
notes.push(`stage.ignore entries: ${ignore.length}`)
|
||||
}
|
||||
if (!pear.multisig || typeof pear.multisig !== 'object') {
|
||||
warns.push('pear.json multisig block missing (required for production OTA)')
|
||||
} else {
|
||||
const ms = pear.multisig
|
||||
if (!ms.namespace) warns.push('multisig.namespace empty')
|
||||
if (!ms.quorum || ms.quorum < 1) errors.push('multisig.quorum must be >= 1')
|
||||
if (!Array.isArray(ms.publicKeys) || ms.publicKeys.length < ms.quorum) {
|
||||
errors.push('multisig.publicKeys must have at least quorum keys')
|
||||
} else {
|
||||
const placeholders = ms.publicKeys.filter(isPlaceholder)
|
||||
if (placeholders.length) {
|
||||
const msg = `${placeholders.length}/${ms.publicKeys.length} multisig publicKeys are placeholders`
|
||||
if (allowPlaceholder) warns.push(msg)
|
||||
else errors.push(msg + ' (re-run with --allow-placeholder for dev)')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pkg) {
|
||||
if (!pkg.upgrade) {
|
||||
warns.push('package.json missing upgrade (pear://… channel)')
|
||||
} else if (isPlaceholder(pkg.upgrade)) {
|
||||
const msg = `package.json upgrade is placeholder: ${pkg.upgrade}`
|
||||
if (allowPlaceholder) warns.push(msg)
|
||||
else errors.push(msg + ' (re-run with --allow-placeholder for dev)')
|
||||
} else {
|
||||
notes.push(`upgrade: ${pkg.upgrade}`)
|
||||
}
|
||||
}
|
||||
|
||||
// pear-runtime wiring
|
||||
const appJs = path.join(root, 'app.js')
|
||||
if (fs.existsSync(appJs)) {
|
||||
const src = fs.readFileSync(appJs, 'utf8')
|
||||
if (!/pear-runtime/.test(src)) {
|
||||
warns.push('app.js does not reference pear-runtime (OTA worker pattern)')
|
||||
} else {
|
||||
notes.push('app.js references pear-runtime')
|
||||
}
|
||||
}
|
||||
|
||||
const pearCli = spawnSync('pear', ['--version'], { encoding: 'utf8' })
|
||||
if (pearCli.error || pearCli.status !== 0) {
|
||||
warns.push('pear CLI not on PATH — install Pear to run stage/seed (structure check only)')
|
||||
} else {
|
||||
notes.push(`pear CLI: ${(pearCli.stdout || pearCli.stderr || '').trim().split('\n')[0]}`)
|
||||
}
|
||||
|
||||
console.log('Flying Jib — Pear OTA check')
|
||||
console.log('---------------------------')
|
||||
for (const n of notes) console.log(` note: ${n}`)
|
||||
for (const w of warns) console.log(` warn: ${w}`)
|
||||
for (const e of errors) console.log(` error: ${e}`)
|
||||
|
||||
if (verbose) {
|
||||
console.log('\nNext steps (when keys are real):')
|
||||
console.log(' 1. npm run make:standalone # or pear stage from repo')
|
||||
console.log(' 2. pear stage <link> <dir> # see developer_docs/BUILD_AND_RELEASE.md')
|
||||
console.log(' 3. pear seed <link>')
|
||||
console.log(' 4. provision / multisig quorum')
|
||||
console.log(' 5. Smoke standalone without Node on PATH')
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
console.log(`\nFAIL (${errors.length} error(s))`)
|
||||
process.exit(allowPlaceholder ? 1 : errors.some((e) => /placeholder/i.test(e)) ? 2 : 1)
|
||||
}
|
||||
console.log('\nOK — OTA config structure valid' + (warns.length ? ` (${warns.length} warning(s))` : ''))
|
||||
process.exit(0)
|
||||
@@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* Prune minecraft-data for Flying Jib standalone packs.
|
||||
*
|
||||
* bare-pack follows every require() in data.js, so shipping full PC+bedrock
|
||||
* (~400MB+) balloons the binary. We regenerate data.js with:
|
||||
* - no bedrock editions (Flying Squid is Java/PC only)
|
||||
* - only Flying Squid–supported PC versions (override via env/args)
|
||||
*
|
||||
* Modes:
|
||||
* node scripts/prune-minecraft-data.js # prune (default)
|
||||
* node scripts/prune-minecraft-data.js --restore # restore original data.js
|
||||
* node scripts/prune-minecraft-data.js --dry-run # print plan only
|
||||
* node scripts/prune-minecraft-data.js --versions 1.21.1,1.21.4
|
||||
*
|
||||
* Env:
|
||||
* FJ_MC_DATA_VERSIONS=1.21.1,1.21.4 comma-separated allowlist
|
||||
* FJ_MC_DATA_MINIMAL=1 only default Squid version (1.21.1)
|
||||
*/
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const mdRoot = path.join(root, 'node_modules', 'minecraft-data')
|
||||
const dataJs = path.join(mdRoot, 'data.js')
|
||||
const backupJs = path.join(mdRoot, 'data.js.flying-jib-full')
|
||||
const dataPathsJson = path.join(
|
||||
mdRoot,
|
||||
'minecraft-data',
|
||||
'data',
|
||||
'dataPaths.json'
|
||||
)
|
||||
|
||||
/** Flying Squid testedVersions (src/lib/version.js) + common majors they map to */
|
||||
const SQUID_PC_VERSIONS = [
|
||||
'1.8',
|
||||
'1.9',
|
||||
'1.9.4',
|
||||
'1.10.2',
|
||||
'1.11.2',
|
||||
'1.12.2',
|
||||
'1.13.2',
|
||||
'1.14.4',
|
||||
'1.15.2',
|
||||
'1.16.5',
|
||||
'1.17.1',
|
||||
'1.18',
|
||||
'1.18.2',
|
||||
'1.19',
|
||||
'1.19.2',
|
||||
'1.19.3',
|
||||
'1.19.4',
|
||||
'1.20',
|
||||
'1.20.2',
|
||||
'1.20.3',
|
||||
'1.20.5',
|
||||
'1.21.1',
|
||||
'1.21.3',
|
||||
'1.21.4'
|
||||
]
|
||||
|
||||
const DEFAULT_MINIMAL = ['1.21.1']
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {
|
||||
restore: false,
|
||||
dryRun: false,
|
||||
versions: null,
|
||||
minimal: process.env.FJ_MC_DATA_MINIMAL === '1'
|
||||
}
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]
|
||||
if (a === '--restore') out.restore = true
|
||||
else if (a === '--dry-run') out.dryRun = true
|
||||
else if (a === '--minimal') out.minimal = true
|
||||
else if (a === '--versions') out.versions = argv[++i]
|
||||
else if (a === '--help' || a === '-h') out.help = true
|
||||
}
|
||||
if (process.env.FJ_MC_DATA_VERSIONS) {
|
||||
out.versions = process.env.FJ_MC_DATA_VERSIONS
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function resolveVersionList(opts) {
|
||||
if (opts.versions) {
|
||||
return opts.versions
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
if (opts.minimal) return DEFAULT_MINIMAL.slice()
|
||||
return SQUID_PC_VERSIONS.slice()
|
||||
}
|
||||
|
||||
/**
|
||||
* Same shape as minecraft-data/bin/generate_data.js, scoped to allowlisted PC keys.
|
||||
*/
|
||||
function generateDataJs(dataSource) {
|
||||
const types = Object.keys(dataSource)
|
||||
return (
|
||||
'module.exports =\n{\n' +
|
||||
types
|
||||
.map((k1) => {
|
||||
const versions = Object.keys(dataSource[k1])
|
||||
return (
|
||||
" '" +
|
||||
k1 +
|
||||
"': {\n" +
|
||||
versions
|
||||
.map((k2) => {
|
||||
const fields = Object.keys(dataSource[k1][k2])
|
||||
return (
|
||||
" '" +
|
||||
k2 +
|
||||
"': {" +
|
||||
'\n' +
|
||||
fields
|
||||
.map((k3) => {
|
||||
const loc = `minecraft-data/data/${dataSource[k1][k2][k3]}/`
|
||||
const absDir = path.join(mdRoot, loc)
|
||||
try {
|
||||
require(path.join(mdRoot, loc + k3 + '.json'))
|
||||
return ` get ${k3} () { return require("./${loc}${k3}.json") }`
|
||||
} catch {
|
||||
let file = null
|
||||
try {
|
||||
file = fs
|
||||
.readdirSync(absDir)
|
||||
.find((f) => f.startsWith(k3 + '.'))
|
||||
} catch {
|
||||
file = null
|
||||
}
|
||||
if (file) {
|
||||
return ` ${k3}: __dirname + '/${loc}${file}'`
|
||||
}
|
||||
throw new Error('file not found: ' + loc + k3)
|
||||
}
|
||||
})
|
||||
.join(',\n') +
|
||||
'\n }'
|
||||
)
|
||||
})
|
||||
.join(',\n') +
|
||||
'\n }'
|
||||
)
|
||||
})
|
||||
.join(',\n') +
|
||||
'\n}\n'
|
||||
)
|
||||
}
|
||||
|
||||
function ensurePresent() {
|
||||
if (!fs.existsSync(mdRoot)) {
|
||||
console.error('minecraft-data not installed — run npm install first')
|
||||
process.exit(1)
|
||||
}
|
||||
if (!fs.existsSync(dataJs)) {
|
||||
console.error('missing', dataJs)
|
||||
process.exit(1)
|
||||
}
|
||||
if (!fs.existsSync(dataPathsJson)) {
|
||||
console.error('missing', dataPathsJson)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
function backupIfNeeded() {
|
||||
if (fs.existsSync(backupJs)) {
|
||||
// Prefer restoring full before re-pruning so allowlist changes are clean
|
||||
fs.copyFileSync(backupJs, dataJs)
|
||||
return
|
||||
}
|
||||
fs.copyFileSync(dataJs, backupJs)
|
||||
console.log('backed up full data.js → data.js.flying-jib-full')
|
||||
}
|
||||
|
||||
function restore() {
|
||||
ensurePresent()
|
||||
if (!fs.existsSync(backupJs)) {
|
||||
console.log('no backup at data.js.flying-jib-full — nothing to restore')
|
||||
return
|
||||
}
|
||||
fs.copyFileSync(backupJs, dataJs)
|
||||
console.log('restored full minecraft-data/data.js from backup')
|
||||
}
|
||||
|
||||
function prune(opts) {
|
||||
ensurePresent()
|
||||
const want = resolveVersionList(opts)
|
||||
const dataSource = JSON.parse(fs.readFileSync(dataPathsJson, 'utf8'))
|
||||
const pcAll = dataSource.pc || {}
|
||||
const kept = {}
|
||||
const missing = []
|
||||
for (const v of want) {
|
||||
if (pcAll[v]) kept[v] = pcAll[v]
|
||||
else missing.push(v)
|
||||
}
|
||||
|
||||
// Always keep common is not a version key; paths under pc/common are pulled by index.js separately
|
||||
|
||||
const filtered = { pc: kept }
|
||||
// Drop bedrock entirely
|
||||
|
||||
console.log('Flying Jib — prune minecraft-data')
|
||||
console.log(' PC versions available:', Object.keys(pcAll).length)
|
||||
console.log(' PC versions kept: ', Object.keys(kept).length, Object.keys(kept).join(', '))
|
||||
if (missing.length) {
|
||||
console.log(' skipped (not in dataPaths):', missing.join(', '))
|
||||
}
|
||||
console.log(' bedrock: dropped')
|
||||
|
||||
if (opts.dryRun) {
|
||||
console.log(' dry-run: not writing data.js')
|
||||
return
|
||||
}
|
||||
|
||||
backupIfNeeded()
|
||||
// After backupIfNeeded we may have restored full; re-read paths is fine
|
||||
const out = generateDataJs(filtered)
|
||||
fs.writeFileSync(dataJs, out)
|
||||
console.log(' wrote pruned data.js (' + Buffer.byteLength(out) + ' bytes)')
|
||||
console.log(' restore with: node scripts/prune-minecraft-data.js --restore')
|
||||
}
|
||||
|
||||
function main() {
|
||||
const opts = parseArgs(process.argv.slice(2))
|
||||
if (opts.help) {
|
||||
console.log(`Usage:
|
||||
node scripts/prune-minecraft-data.js [--minimal] [--versions a,b] [--dry-run]
|
||||
node scripts/prune-minecraft-data.js --restore
|
||||
|
||||
Default keep list = Flying Squid tested PC versions.
|
||||
--minimal / FJ_MC_DATA_MINIMAL=1 → only 1.21.1
|
||||
FJ_MC_DATA_VERSIONS=1.21.1,1.21.4 overrides list`)
|
||||
process.exit(0)
|
||||
}
|
||||
if (opts.restore) restore()
|
||||
else prune(opts)
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -4,7 +4,9 @@ const test = require('brittle')
|
||||
const {
|
||||
captureHandoff,
|
||||
applyHandoff,
|
||||
HandoffStore
|
||||
stripPlayerInventory,
|
||||
HandoffStore,
|
||||
newHandoffId
|
||||
} = require('../lib/player-handoff')
|
||||
|
||||
function fakePlayer(overrides = {}) {
|
||||
@@ -22,28 +24,65 @@ function fakePlayer(overrides = {}) {
|
||||
position: { x: 10, y: 64, z: 20 },
|
||||
yaw: 90,
|
||||
pitch: 0,
|
||||
inventory: { slots },
|
||||
inventory: {
|
||||
slots,
|
||||
updateSlot(i, item) {
|
||||
this.slots[i] = item
|
||||
}
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
test('captureHandoff serializes inventory and position', (t) => {
|
||||
const h = captureHandoff(fakePlayer(), { x: 1, y: 70, z: 2 })
|
||||
const h = captureHandoff(fakePlayer(), { x: 1, y: 70, z: 2 }, { strip: false })
|
||||
t.is(h.username, 'Steve')
|
||||
t.is(h.mapped.y, 70)
|
||||
t.is(h.inventory.length, 2)
|
||||
t.ok(h.inventory.find((i) => i.name === 'stone' && i.count === 32))
|
||||
t.ok(h.id)
|
||||
t.is(h.v, 2)
|
||||
})
|
||||
|
||||
test('captureHandoff strips source inventory by default', (t) => {
|
||||
const p = fakePlayer()
|
||||
const h = captureHandoff(p, { x: 0, y: 64, z: 0 })
|
||||
t.ok(h.strippedSource)
|
||||
t.is(h.inventory.length, 2)
|
||||
t.ok(!p.inventory.slots[9])
|
||||
t.ok(!p.inventory.slots[36])
|
||||
})
|
||||
|
||||
test('stripPlayerInventory counts cleared slots', (t) => {
|
||||
const p = fakePlayer()
|
||||
t.is(stripPlayerInventory(p), 2)
|
||||
t.is(stripPlayerInventory(p), 0)
|
||||
})
|
||||
|
||||
test('HandoffStore set/take with TTL', (t) => {
|
||||
const store = new HandoffStore(60_000)
|
||||
store.set('Steve', { username: 'Steve', inventory: [] })
|
||||
store.set('Steve', { username: 'Steve', id: newHandoffId(), inventory: [] })
|
||||
t.ok(store.peek('steve'))
|
||||
const h = store.take('STEVE')
|
||||
t.is(h.username, 'Steve')
|
||||
t.absent(store.take('steve'))
|
||||
})
|
||||
|
||||
test('HandoffStore rejects duplicate apply of same id', (t) => {
|
||||
const store = new HandoffStore(60_000)
|
||||
const id = newHandoffId()
|
||||
const h = { username: 'Steve', id, inventory: [{ slot: 9, name: 'stone', count: 1 }] }
|
||||
t.ok(store.set('Steve', h).ok)
|
||||
const once = store.take('Steve')
|
||||
t.ok(once)
|
||||
t.ok(store.wasApplied(id))
|
||||
// Re-inject same id (replay attack / double handoff-apply)
|
||||
const again = store.set('Steve', h)
|
||||
t.absent(again.ok)
|
||||
t.is(again.reason, 'already-applied')
|
||||
t.absent(store.take('Steve'))
|
||||
})
|
||||
|
||||
test('applyHandoff teleports and restores slots', async (t) => {
|
||||
const updates = []
|
||||
const player = {
|
||||
@@ -79,11 +118,11 @@ test('applyHandoff teleports and restores slots', async (t) => {
|
||||
}
|
||||
}
|
||||
|
||||
// prismarine-item may need full registry — mock apply path if Item fails
|
||||
const handoff = captureHandoff(fakePlayer(), { x: 5, y: 80, z: 9 })
|
||||
const handoff = captureHandoff(fakePlayer(), { x: 5, y: 80, z: 9 }, { strip: false })
|
||||
const result = await applyHandoff(player, serv, handoff)
|
||||
t.ok(result.ok)
|
||||
t.is(player.position.x, 5)
|
||||
t.is(player.position.y, 80)
|
||||
t.is(player.health, 18)
|
||||
t.ok(result.handoffId)
|
||||
})
|
||||
|
||||
@@ -38,7 +38,7 @@ When you approach / leave a region edge (host with mesh enroll active):
|
||||
4. CLI prints a **reconnect banner** with Direct Connect `127.0.0.1:<port>` (and spawn hint).
|
||||
5. Reconnect Java Edition to that address (often the **same** multiplayer entry if the port did not change).
|
||||
|
||||
**Handoff:** inventory, health, food, gamemode, and mapped spawn position are sent to the neighbor host and applied when you rejoin. Vanilla Java still **must reconnect** (no silent TCP rebind); Flying Jib only softens the path.
|
||||
**Handoff:** inventory, health, food, gamemode, and mapped spawn position are sent to the neighbor host and applied when you rejoin. On leave, the **source region clears your inventory** after capture so items cannot exist on both sides. Each handoff has an id and is applied **once**. Vanilla Java still **must reconnect** (no silent TCP rebind).
|
||||
|
||||
### Neighbor offline (clear UX)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user