Updates
This commit is contained in:
@@ -172,10 +172,51 @@ module.exports = class App extends ReadyResource {
|
|||||||
_onPeerControl(m) {
|
_onPeerControl(m) {
|
||||||
if (!m || m.local) return
|
if (!m || m.local) return
|
||||||
if (m.type === 'handoff-apply' && m.username && m.handoff) {
|
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(
|
this.emit(
|
||||||
'message',
|
'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) => {
|
.then((r) => {
|
||||||
this.emit(
|
this.emit(
|
||||||
'message',
|
'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))
|
.catch((err) => this.emit('error', err))
|
||||||
})
|
})
|
||||||
@@ -661,7 +717,13 @@ module.exports = class App extends ReadyResource {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Hold handoff locally so a later successful migrate can re-send
|
// Hold handoff locally so a later successful migrate can re-send
|
||||||
if (evt.handoff && evt.handoff.username) {
|
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) {
|
if (this.tunnelClient) {
|
||||||
await this.tunnelClient.close().catch(() => {})
|
await this.tunnelClient.close().catch(() => {})
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { createRequire } from 'module'
|
|||||||
import { command, flag, arg, summary, header, footer } from 'paparam'
|
import { command, flag, arg, summary, header, footer } from 'paparam'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import process from 'process'
|
import process from 'process'
|
||||||
|
import os from 'os'
|
||||||
import pkg from './package.json' with { type: 'json' }
|
import pkg from './package.json' with { type: 'json' }
|
||||||
import App from './app.js'
|
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 isDev = base === 'bare' || base === 'node' || /bare-runtime/.test(argv0)
|
||||||
const rawArgv = isBare ? Bare.argv.slice(isDev ? 2 : 1) : process.argv.slice(2)
|
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) {
|
function resolveStorage(flagStorage) {
|
||||||
if (flagStorage) return path.resolve(flagStorage)
|
if (flagStorage) return path.resolve(flagStorage)
|
||||||
const os = require('os')
|
const tmp = defaultTmpDir()
|
||||||
if (isBare) {
|
if (isBare) {
|
||||||
try {
|
try {
|
||||||
const { persistent } = require('bare-storage')
|
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 {
|
} 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) {
|
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).
|
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`.
|
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.
|
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
|
## Storage layout
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Build and Release
|
# Build and Release
|
||||||
|
|
||||||
**Last updated:** 2026-07-30
|
**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)
|
**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
|
**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
|
- 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).
|
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`)
|
## 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).
|
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
|
```sh
|
||||||
pear stage <link> <deploy-dir>
|
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 |
|
| `peer-session.js` | Protomux presence/chat/control |
|
||||||
| `mesh-registry.js` | Autobase + Hyperbee regions |
|
| `mesh-registry.js` | Autobase + Hyperbee regions |
|
||||||
| `border.js` | Pure border/neighbor/map helpers |
|
| `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) |
|
| `migrate.js` | Offline-neighbor migrate UX (timeout, messages, tunnel snapshot) |
|
||||||
| `reconnect.js` | Soft-reconnect banners, kick reasons, stable localhost port pick |
|
| `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",
|
"type": "handoff-apply",
|
||||||
"username": "Steve",
|
"username": "Steve",
|
||||||
"handoff": {
|
"handoff": {
|
||||||
"v": 1,
|
"v": 2,
|
||||||
|
"id": "<16-byte hex>",
|
||||||
|
"phase": "prepare",
|
||||||
"username": "Steve",
|
"username": "Steve",
|
||||||
"health": 20,
|
"health": 20,
|
||||||
"food": 20,
|
"food": 20,
|
||||||
"gamemode": 0,
|
"gamemode": 0,
|
||||||
"mapped": { "x": 0, "y": 64, "z": 50 },
|
"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)
|
### 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/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/migrate-offline.test.js` — offline UX helpers (timeout, messages, snapshot)
|
||||||
- `test/reconnect.test.js` — soft-reconnect banner / stable port helpers
|
- `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).
|
Manual Java scenarios: [`PLAYTEST.md`](./PLAYTEST.md).
|
||||||
|
Release helpers: `npm run pear:check`, `npm run size:report`.
|
||||||
|
|
||||||
### Offline / failure
|
### 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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).
|
* 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 {object} player flying-squid player
|
||||||
* @param {{ x:number, y:number, z:number }} [mapped] destination local coords
|
* @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 pos = player.position
|
||||||
const inventory = []
|
const inventory = []
|
||||||
if (player.inventory && Array.isArray(player.inventory.slots)) {
|
if (player.inventory && Array.isArray(player.inventory.slots)) {
|
||||||
@@ -22,14 +36,15 @@ function captureHandoff(player, mapped) {
|
|||||||
name: item.name || null,
|
name: item.name || null,
|
||||||
count: item.count || 1,
|
count: item.count || 1,
|
||||||
metadata: item.metadata != null ? item.metadata : 0,
|
metadata: item.metadata != null ? item.metadata : 0,
|
||||||
// nbt may be complex; pass through if plain object/buffer-friendly
|
|
||||||
nbt: item.nbt || null
|
nbt: item.nbt || null
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const handoff = {
|
||||||
v: 1,
|
v: 2,
|
||||||
|
id: opts.handoffId || newHandoffId(),
|
||||||
|
phase: 'prepare',
|
||||||
username: player.username,
|
username: player.username,
|
||||||
uuid: player.uuid || null,
|
uuid: player.uuid || null,
|
||||||
health: player.health,
|
health: player.health,
|
||||||
@@ -46,6 +61,38 @@ function captureHandoff(player, mapped) {
|
|||||||
inventory,
|
inventory,
|
||||||
capturedAt: Date.now()
|
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
|
player.heldItemSlot = handoff.heldItemSlot
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inventory restore
|
|
||||||
if (player.inventory && handoff.inventory && handoff.inventory.length) {
|
if (player.inventory && handoff.inventory && handoff.inventory.length) {
|
||||||
try {
|
try {
|
||||||
const Item = require('prismarine-item')(serv.registry)
|
const Item = require('prismarine-item')(serv.registry)
|
||||||
// Clear
|
|
||||||
for (let i = 0; i < player.inventory.slots.length; i++) {
|
for (let i = 0; i < player.inventory.slots.length; i++) {
|
||||||
if (player.inventory.slots[i]) {
|
if (player.inventory.slots[i]) {
|
||||||
player.inventory.updateSlot(i, null)
|
player.inventory.updateSlot(i, null)
|
||||||
@@ -122,7 +167,12 @@ async function applyHandoff(player, serv, handoff) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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 */
|
/* 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
|
const HANDOFF_TTL_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pending handoffs + applied id set (single-apply / anti-dupe).
|
||||||
|
*/
|
||||||
class HandoffStore {
|
class HandoffStore {
|
||||||
constructor(ttlMs = HANDOFF_TTL_MS) {
|
constructor(ttlMs = HANDOFF_TTL_MS) {
|
||||||
this.ttlMs = ttlMs
|
this.ttlMs = ttlMs
|
||||||
/** @type {Map<string, { handoff: object, expires: number }>} */
|
/** @type {Map<string, { handoff: object, expires: number }>} */
|
||||||
this.map = new Map()
|
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) {
|
key(username) {
|
||||||
return String(username || '').toLowerCase()
|
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) {
|
set(username, handoff) {
|
||||||
this.map.set(this.key(username), {
|
this._gcApplied()
|
||||||
handoff,
|
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
|
expires: Date.now() + this.ttlMs
|
||||||
})
|
}
|
||||||
}
|
this.map.set(this.key(username), entry)
|
||||||
|
return { ok: true }
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Peek without consuming.
|
||||||
|
*/
|
||||||
peek(username) {
|
peek(username) {
|
||||||
|
this._gcApplied()
|
||||||
const e = this.map.get(this.key(username))
|
const e = this.map.get(this.key(username))
|
||||||
if (!e) return null
|
if (!e) return null
|
||||||
if (Date.now() > e.expires) {
|
if (Date.now() > e.expires) {
|
||||||
this.map.delete(this.key(username))
|
this.map.delete(this.key(username))
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
if (e.handoff.id && this.applied.has(e.handoff.id)) {
|
||||||
|
this.map.delete(this.key(username))
|
||||||
|
return null
|
||||||
|
}
|
||||||
return e.handoff
|
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() {
|
clear() {
|
||||||
this.map.clear()
|
this.map.clear()
|
||||||
|
this.applied.clear()
|
||||||
|
this.appliedExpires.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
captureHandoff,
|
captureHandoff,
|
||||||
applyHandoff,
|
applyHandoff,
|
||||||
|
stripPlayerInventory,
|
||||||
|
newHandoffId,
|
||||||
HandoffStore,
|
HandoffStore,
|
||||||
HANDOFF_TTL_MS
|
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
|
- Prefer stable localhost port on tunnel retarget
|
||||||
- Delayed kick (~1.2s) so migrate/tunnel can finish first
|
- Delayed kick (~1.2s) so migrate/tunnel can finish first
|
||||||
- CLI reconnect banner + kick reason with Direct Connect address
|
- 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)
|
- 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`
|
- Threat model: `developer_docs/security/THREAT_MODEL.md`
|
||||||
- Manual playtest runbook: `developer_docs/PLAYTEST.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 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
|
- Squid stop: stop game tick + prismarine-world saving intervals; close Anvil region files
|
||||||
- flying-squid tab-list latency intervals cleared on disconnect (postinstall patch)
|
- 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):
|
- Phase 4 border migrate (live path):
|
||||||
- Squid plugin `fj-mesh-border` (warn / kick / events)
|
- Squid plugin `fj-mesh-border` (warn / kick / events)
|
||||||
- Protomux control migrate + guest tunnel retarget
|
- Protomux control migrate + guest tunnel retarget
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
# Flying Jib — Current Status
|
# Flying Jib — Current Status
|
||||||
|
|
||||||
**Snapshot date:** 2026-07-30
|
**Snapshot date:** 2026-07-31
|
||||||
**Phase:** 1–4 operational core; Phase 5 CI matrix started
|
**Phase:** 1–4 operational core; Phase 5 CI + OTA check + handoff anti-dupe + binary prune
|
||||||
**Product:** Flying Jib — Bare/Pear P2P Minecraft mesh
|
**Product:** Flying Jib — Bare/Pear P2P Minecraft mesh
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## One-line summary
|
## 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 |
|
| Local Squid on `127.0.0.1` / host+join tunnels | Working |
|
||||||
| Chat + presence | Working |
|
| Chat + presence | Working |
|
||||||
| Standalone binary | Working (~500MB) |
|
| Standalone binary | Working (**~151 MiB** default prune; ~98 MiB minimal) |
|
||||||
| Mesh Autobase registry | Working |
|
| Mesh Autobase registry | Working |
|
||||||
| Border plugin + migrate signal + tunnel switch | Working |
|
| Border migrate + tunnel switch + offline UX | Working |
|
||||||
| Inventory + spawn handoff | Working |
|
| Inventory handoff (id + strip + single-apply + ack) | Working |
|
||||||
| Two-region e2e (mesh + tunnels + handoff) | **Tested** |
|
| Dual-Squid / two-region e2e | **Tested** |
|
||||||
| Dual real Squid + protocol client handoff | **Tested** |
|
| Soft-reconnect banners / stable port | Working |
|
||||||
| Offline neighbor migrate UX (timeout + restore) | Working |
|
|
||||||
| Soft-reconnect UX (delay kick, same port, banner) | Working |
|
|
||||||
| Multi-host CI standalone matrix | **Wired** |
|
| Multi-host CI standalone matrix | **Wired** |
|
||||||
| Threat model doc | Written |
|
| Pear OTA structure check (`npm run pear:check`) | Working |
|
||||||
| Tests | **36/36 pass** |
|
| 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 |
|
| Area | Status |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| Vanilla Java still must reconnect after border | Softened, not silent |
|
| Vanilla Java must reconnect after border | Softened |
|
||||||
| Full NBT fidelity / XP bars edge cases | Best-effort |
|
| Live Pear seed with real multisig keys | Not done |
|
||||||
| Binary size | ~500MB |
|
| Binary size further (runtime / prismarine) | ~98–151 MiB remaining |
|
||||||
| Pear OTA full verify | Not done |
|
| Crash-safe distributed 2PC for handoff | Partial |
|
||||||
| GUI | Not started |
|
| GUI | Not started |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
1. Pear OTA stage/seed path verification
|
1. Fill real Pear keys and run live stage/seed once
|
||||||
2. Two-machine Java playtest (use `developer_docs/PLAYTEST.md`)
|
2. Two-machine Java playtest (`developer_docs/PLAYTEST.md`)
|
||||||
3. Binary prune
|
3. Optional minimal release line (`FJ_MC_DATA_MINIMAL=1`) in CI
|
||||||
4. Handoff two-phase commit (Q6)
|
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
## 2026-07-30 — Soft-reconnect UX + threat model + playtest runbook
|
||||||
|
|
||||||
### Wins
|
### Wins
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
> Single source of truth for phases, status, and documentation requirements.
|
> Single source of truth for phases, status, and documentation requirements.
|
||||||
> Update this file whenever phase status changes.
|
> 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))
|
**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
|
### Acceptance
|
||||||
|
|
||||||
- [x] Multi-host standalone executables from CI (matrix: linux-x64/arm64, darwin-arm64, win32-x64)
|
- [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] Documented playtest / release smoke runbook (`PLAYTEST.md`; full Pear release still open)
|
||||||
- [x] Threat model under `developer_docs/security/THREAT_MODEL.md`
|
- [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
|
### Documentation required to complete
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,11 @@
|
|||||||
"test": "brittle-node test/*.test.js",
|
"test": "brittle-node test/*.test.js",
|
||||||
"test:bare": "brittle-bare 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')\"",
|
"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": "node scripts/make.js",
|
||||||
"make:standalone": "node scripts/bare-standalone.cjs",
|
"make:standalone": "node scripts/bare-standalone.cjs",
|
||||||
"make:standalone:all": "node scripts/bare-standalone.cjs --host all",
|
"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 }
|
const mapped = act.mapped || { x, y: snap.y, z }
|
||||||
let handoff = null
|
let handoff = null
|
||||||
try {
|
try {
|
||||||
handoff = captureHandoff(player, mapped)
|
// strip=true: clear source inventory after capture (anti-dupe, Q6)
|
||||||
|
handoff = captureHandoff(player, mapped, { strip: true })
|
||||||
} catch {
|
} catch {
|
||||||
handoff = null
|
handoff = null
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-45
@@ -136,6 +136,13 @@ function walkFind(dir, pred) {
|
|||||||
return null
|
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) {
|
async function buildOne(host, outRoot) {
|
||||||
const name = pkg.productName ? 'flying-jib' : pkg.name
|
const name = pkg.productName ? 'flying-jib' : pkg.name
|
||||||
const outDir = path.join(outRoot, `${name}-${host}`)
|
const outDir = path.join(outRoot, `${name}-${host}`)
|
||||||
@@ -143,53 +150,68 @@ async function buildOne(host, outRoot) {
|
|||||||
fs.mkdirSync(outDir, { recursive: true })
|
fs.mkdirSync(outDir, { recursive: true })
|
||||||
|
|
||||||
// Ensure postinstall artifacts
|
// Ensure postinstall artifacts
|
||||||
require('child_process').execSync('node scripts/generate-squid-imports.js', {
|
runNodeScript('scripts/generate-squid-imports.js')
|
||||||
cwd: root,
|
runNodeScript('scripts/patch-engines-for-bare.js')
|
||||||
stdio: 'inherit'
|
|
||||||
})
|
|
||||||
require('child_process').execSync('node scripts/patch-engines-for-bare.js', {
|
|
||||||
cwd: root,
|
|
||||||
stdio: 'inherit'
|
|
||||||
})
|
|
||||||
|
|
||||||
const entryPath = path.join(root, 'bin.mjs')
|
// Shrink pack graph: PC Squid versions only, no bedrock (~hundreds of MiB)
|
||||||
const imports = buildImportsMap()
|
const skipPrune = process.env.FJ_SKIP_MC_DATA_PRUNE === '1'
|
||||||
console.log(`[bare-standalone] packing ${name} for ${host}…`)
|
if (!skipPrune) {
|
||||||
|
console.log('[bare-standalone] pruning minecraft-data for pack…')
|
||||||
let entry = await pack(
|
runNodeScript('scripts/prune-minecraft-data.js')
|
||||||
pathToFileURL(entryPath),
|
} else {
|
||||||
{
|
console.log('[bare-standalone] FJ_SKIP_MC_DATA_PRUNE=1 — packing full minecraft-data')
|
||||||
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}…`)
|
let entry
|
||||||
for await (const resource of platform(root, entry, null, opts)) {
|
try {
|
||||||
if (resource && resource.path) {
|
const entryPath = path.join(root, 'bin.mjs')
|
||||||
console.log(`[bare-standalone] resource ${resource.path}`)
|
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,
|
version: pkg.version,
|
||||||
builtAt: new Date().toISOString(),
|
builtAt: new Date().toISOString(),
|
||||||
entry: 'bin.mjs',
|
entry: 'bin.mjs',
|
||||||
bundleId: entry.id
|
bundleId: entry && entry.id,
|
||||||
|
minecraftDataPruned: !skipPrune
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2
|
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 {
|
const {
|
||||||
captureHandoff,
|
captureHandoff,
|
||||||
applyHandoff,
|
applyHandoff,
|
||||||
HandoffStore
|
stripPlayerInventory,
|
||||||
|
HandoffStore,
|
||||||
|
newHandoffId
|
||||||
} = require('../lib/player-handoff')
|
} = require('../lib/player-handoff')
|
||||||
|
|
||||||
function fakePlayer(overrides = {}) {
|
function fakePlayer(overrides = {}) {
|
||||||
@@ -22,28 +24,65 @@ function fakePlayer(overrides = {}) {
|
|||||||
position: { x: 10, y: 64, z: 20 },
|
position: { x: 10, y: 64, z: 20 },
|
||||||
yaw: 90,
|
yaw: 90,
|
||||||
pitch: 0,
|
pitch: 0,
|
||||||
inventory: { slots },
|
inventory: {
|
||||||
|
slots,
|
||||||
|
updateSlot(i, item) {
|
||||||
|
this.slots[i] = item
|
||||||
|
}
|
||||||
|
},
|
||||||
...overrides
|
...overrides
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test('captureHandoff serializes inventory and position', (t) => {
|
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.username, 'Steve')
|
||||||
t.is(h.mapped.y, 70)
|
t.is(h.mapped.y, 70)
|
||||||
t.is(h.inventory.length, 2)
|
t.is(h.inventory.length, 2)
|
||||||
t.ok(h.inventory.find((i) => i.name === 'stone' && i.count === 32))
|
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) => {
|
test('HandoffStore set/take with TTL', (t) => {
|
||||||
const store = new HandoffStore(60_000)
|
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'))
|
t.ok(store.peek('steve'))
|
||||||
const h = store.take('STEVE')
|
const h = store.take('STEVE')
|
||||||
t.is(h.username, 'Steve')
|
t.is(h.username, 'Steve')
|
||||||
t.absent(store.take('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) => {
|
test('applyHandoff teleports and restores slots', async (t) => {
|
||||||
const updates = []
|
const updates = []
|
||||||
const player = {
|
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 }, { strip: false })
|
||||||
const handoff = captureHandoff(fakePlayer(), { x: 5, y: 80, z: 9 })
|
|
||||||
const result = await applyHandoff(player, serv, handoff)
|
const result = await applyHandoff(player, serv, handoff)
|
||||||
t.ok(result.ok)
|
t.ok(result.ok)
|
||||||
t.is(player.position.x, 5)
|
t.is(player.position.x, 5)
|
||||||
t.is(player.position.y, 80)
|
t.is(player.position.y, 80)
|
||||||
t.is(player.health, 18)
|
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).
|
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).
|
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)
|
### Neighbor offline (clear UX)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user