diff --git a/app.js b/app.js
index 577c8b8..c382679 100644
--- a/app.js
+++ b/app.js
@@ -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(() => {})
diff --git a/bin.mjs b/bin.mjs
index 50afb0d..3f5028e 100644
--- a/bin.mjs
+++ b/bin.mjs
@@ -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) {
diff --git a/developer_docs/ARCHITECTURE.md b/developer_docs/ARCHITECTURE.md
index 93e0b0d..a225ae6 100644
--- a/developer_docs/ARCHITECTURE.md
+++ b/developer_docs/ARCHITECTURE.md
@@ -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
diff --git a/developer_docs/BUILD_AND_RELEASE.md b/developer_docs/BUILD_AND_RELEASE.md
index 91b1662..20d66e8 100644
--- a/developer_docs/BUILD_AND_RELEASE.md
+++ b/developer_docs/BUILD_AND_RELEASE.md
@@ -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
diff --git a/developer_docs/MODULES.md b/developer_docs/MODULES.md
index 57f10b7..4ee2943 100644
--- a/developer_docs/MODULES.md
+++ b/developer_docs/MODULES.md
@@ -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 |
diff --git a/developer_docs/PROTOCOL.md b/developer_docs/PROTOCOL.md
index 0ad61d5..b053421 100644
--- a/developer_docs/PROTOCOL.md
+++ b/developer_docs/PROTOCOL.md
@@ -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)
diff --git a/developer_docs/TESTING.md b/developer_docs/TESTING.md
index 4db4a22..15f5411 100644
--- a/developer_docs/TESTING.md
+++ b/developer_docs/TESTING.md
@@ -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).
+Manual Java scenarios: [`PLAYTEST.md`](./PLAYTEST.md).
+Release helpers: `npm run pear:check`, `npm run size:report`.
### Offline / failure
diff --git a/developer_docs/security/THREAT_MODEL.md b/developer_docs/security/THREAT_MODEL.md
index a9a1e2b..d899b68 100644
--- a/developer_docs/security/THREAT_MODEL.md
+++ b/developer_docs/security/THREAT_MODEL.md
@@ -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 |
diff --git a/lib/player-handoff.js b/lib/player-handoff.js
index 2f7542e..9a7f0d2 100644
--- a/lib/player-handoff.js
+++ b/lib/player-handoff.js
@@ -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} */
this.map = new Map()
+ /** @type {Set} handoff ids already taken/applied */
+ this.applied = new Set()
+ /** @type {Map} 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
}
diff --git a/living_docs/CHANGELOG.md b/living_docs/CHANGELOG.md
index 0662d28..5c8e12f 100644
--- a/living_docs/CHANGELOG.md
+++ b/living_docs/CHANGELOG.md
@@ -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
diff --git a/living_docs/CURRENT_STATUS.md b/living_docs/CURRENT_STATUS.md
index e63bf0d..4698a3a 100644
--- a/living_docs/CURRENT_STATUS.md
+++ b/living_docs/CURRENT_STATUS.md
@@ -1,14 +1,14 @@
# Flying Jib — Current Status
-**Snapshot date:** 2026-07-30
-**Phase:** 1–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
diff --git a/living_docs/OPEN_QUESTIONS.md b/living_docs/OPEN_QUESTIONS.md
index 5e537a7..5ed8464 100644
--- a/living_docs/OPEN_QUESTIONS.md
+++ b/living_docs/OPEN_QUESTIONS.md
@@ -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 |
diff --git a/living_docs/PROGRESS.md b/living_docs/PROGRESS.md
index 34febf7..5aa499f 100644
--- a/living_docs/PROGRESS.md
+++ b/living_docs/PROGRESS.md
@@ -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
diff --git a/living_docs/ROADMAP.md b/living_docs/ROADMAP.md
index d69aaa6..046d299 100644
--- a/living_docs/ROADMAP.md
+++ b/living_docs/ROADMAP.md
@@ -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
diff --git a/package.json b/package.json
index 31a9c3f..31fad01 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/plugins/fj-mesh-border.js b/plugins/fj-mesh-border.js
index 0eabf0a..55b07ba 100644
--- a/plugins/fj-mesh-border.js
+++ b/plugins/fj-mesh-border.js
@@ -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
}
diff --git a/scripts/bare-standalone.cjs b/scripts/bare-standalone.cjs
index 582efa1..48d2015 100644
--- a/scripts/bare-standalone.cjs
+++ b/scripts/bare-standalone.cjs
@@ -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
diff --git a/scripts/binary-size-report.js b/scripts/binary-size-report.js
new file mode 100644
index 0000000..02f8cb6
--- /dev/null
+++ b/scripts/binary-size-report.js
@@ -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
+`)
+}
diff --git a/scripts/pear-ota-check.js b/scripts/pear-ota-check.js
new file mode 100644
index 0000000..14cff53
--- /dev/null
+++ b/scripts/pear-ota-check.js
@@ -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 # see developer_docs/BUILD_AND_RELEASE.md')
+ console.log(' 3. pear seed ')
+ 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)
diff --git a/scripts/prune-minecraft-data.js b/scripts/prune-minecraft-data.js
new file mode 100644
index 0000000..d9b6dfb
--- /dev/null
+++ b/scripts/prune-minecraft-data.js
@@ -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()
diff --git a/test/player-handoff.test.js b/test/player-handoff.test.js
index 6786553..c391ea3 100644
--- a/test/player-handoff.test.js
+++ b/test/player-handoff.test.js
@@ -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)
})
diff --git a/user_docs/MESH_WORLDS.md b/user_docs/MESH_WORLDS.md
index 84997a5..e57752d 100644
--- a/user_docs/MESH_WORLDS.md
+++ b/user_docs/MESH_WORLDS.md
@@ -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:` (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)