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)