From 1387cb036bf56db69ba71fd5cf7ace6c92f6cca1 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sun, 23 Feb 2025 03:48:15 +0300 Subject: [PATCH] feat: Replay packets server functionality! (#287) Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- README.MD | 1 + package.json | 2 +- pnpm-lock.yaml | 47 ++- src/appParams.ts | 8 +- src/browserfs.ts | 28 +- src/connect.ts | 3 + src/controls.ts | 4 +- src/downloadAndOpenFile.ts | 18 ++ src/dragndrop.ts | 12 +- src/index.ts | 47 ++- src/mineflayer/plugins/localRelay.ts | 32 ++ src/optionsGuiScheme.tsx | 2 +- .../packetsReplayLegacy.ts} | 4 +- src/packetsReplay/replayPackets.ts | 274 ++++++++++++++++++ src/packetsReplayBase.ts | 60 ---- src/react/AppStatusProvider.tsx | 3 +- src/react/Book.stories.tsx | 2 +- src/react/GameInteractionOverlay.tsx | 6 +- src/react/HotbarRenderApp.tsx | 6 +- src/react/NotificationProvider.tsx | 2 +- src/react/PacketsReplayProvider.tsx | 66 +++++ src/react/ReplayPanel.stories.tsx | 106 +++++++ src/react/ReplayPanel.tsx | 172 +++++++++++ src/react/components/replay/FilterInput.tsx | 159 ++++++++++ src/react/components/replay/PacketList.tsx | 141 +++++++++ src/react/components/replay/ProgressBar.tsx | 36 +++ src/react/components/replay/constants.ts | 11 + src/react/packetsFilter.ts | 55 ++++ src/react/state/packetsReplayState.ts | 19 ++ src/reactUi.tsx | 2 + src/utils.ts | 7 +- 31 files changed, 1232 insertions(+), 103 deletions(-) create mode 100644 src/mineflayer/plugins/localRelay.ts rename src/{packetsReplay.ts => packetsReplay/packetsReplayLegacy.ts} (94%) create mode 100644 src/packetsReplay/replayPackets.ts delete mode 100644 src/packetsReplayBase.ts create mode 100644 src/react/PacketsReplayProvider.tsx create mode 100644 src/react/ReplayPanel.stories.tsx create mode 100644 src/react/ReplayPanel.tsx create mode 100644 src/react/components/replay/FilterInput.tsx create mode 100644 src/react/components/replay/PacketList.tsx create mode 100644 src/react/components/replay/ProgressBar.tsx create mode 100644 src/react/components/replay/constants.ts create mode 100644 src/react/packetsFilter.ts create mode 100644 src/react/state/packetsReplayState.ts diff --git a/README.MD b/README.MD index bd884b43..90e8f35f 100644 --- a/README.MD +++ b/README.MD @@ -144,6 +144,7 @@ General: - **`?setting=:`** - Set and lock the setting on load. You can set multiple settings by separating them with `&` e.g. `?setting=autoParkour:true&setting=renderDistance:4` - `?modal=` - Open specific modal on page load eg `keybindings`. Very useful on UI changes testing during dev. For path use `,` as separator. To get currently opened modal type this in the console: `activeModalStack.at(-1).reactType` +- `?replayFileUrl=` - Load and start a packet replay session from a URL with a integrated server. For debugging / previewing recorded sessions. The file must be CORS enabled. Server specific: diff --git a/package.json b/package.json index 96be22cb..250c8522 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "mojangson": "^2.0.4", "net-browserify": "github:zardoy/prismarinejs-net-browserify", "node-gzip": "^1.1.2", - "mcraft-fun-mineflayer": "0.0.3", + "mcraft-fun-mineflayer": "^0.1.3", "peerjs": "^1.5.0", "pixelarticons": "^1.8.1", "pretty-bytes": "^6.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07a8244b..9f4ccb76 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,8 +135,8 @@ importers: specifier: ^4.17.21 version: 4.17.21 mcraft-fun-mineflayer: - specifier: 0.0.3 - version: 0.0.3(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/4fa1af9964cab91315d8d1ae02615f3039638828(encoding@0.1.13)) + specifier: ^0.1.3 + version: 0.1.3(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/4fa1af9964cab91315d8d1ae02615f3039638828(encoding@0.1.13)) minecraft-data: specifier: 3.83.1 version: 3.83.1 @@ -6251,9 +6251,9 @@ packages: resolution: {integrity: sha512-JBi9frIACmzmpKL38YudZJpml+tWP3UuCeb8ko5iJRHpmCmmChE+X3xzVEbEYnYBI2dMiO7915/5eYnKUVys3Q==} engines: {node: '>=18.0.0'} - mcraft-fun-mineflayer@0.0.3: - resolution: {integrity: sha512-IqYXHk5ihQOF9FzEpMWsFwgilTySklYVj3AODK7sdgaSe+pU9wZllJjVvsYdc/F3uLMgogkyTLuVoEVMD+UiSA==} - version: 0.0.3 + mcraft-fun-mineflayer@0.1.3: + resolution: {integrity: sha512-3Xds5XBLwYHFgH09RS9fHNK7NJI2ZStXiYvU/9FVUMd9TOwcMwLorn1kNYOw022/0nGi3hibT3ldj6vLCjASIg==} + version: 0.1.3 engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} peerDependencies: '@roamhq/wrtc': '*' @@ -6461,6 +6461,11 @@ packages: resolution: {tarball: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/75e940a4cd50d89e0ba03db3733d5d704917a3c8} version: 1.0.1 + minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/5ec3dd4b367fcc039fbcb3edd214fe3cf8178a6d: + resolution: {tarball: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/5ec3dd4b367fcc039fbcb3edd214fe3cf8178a6d} + version: 1.54.0 + engines: {node: '>=22'} + minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/e9eb551ba30ec2e742c49e6927be6402b413bb76: resolution: {tarball: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/e9eb551ba30ec2e742c49e6927be6402b413bb76} version: 1.54.0 @@ -12775,7 +12780,7 @@ snapshots: flatmap: 0.0.3 long: 5.2.3 minecraft-data: 3.83.1 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/e9eb551ba30ec2e742c49e6927be6402b413bb76(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/5ec3dd4b367fcc039fbcb3edd214fe3cf8178a6d(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13) mkdirp: 2.1.6 node-gzip: 1.1.2 node-rsa: 1.1.1 @@ -16690,11 +16695,11 @@ snapshots: apl-image-packer: 1.1.0 zod: 3.24.1 - mcraft-fun-mineflayer@0.0.3(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/4fa1af9964cab91315d8d1ae02615f3039638828(encoding@0.1.13)): + mcraft-fun-mineflayer@0.1.3(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/4fa1af9964cab91315d8d1ae02615f3039638828(encoding@0.1.13)): dependencies: '@zardoy/flying-squid': 0.0.49(encoding@0.1.13) exit-hook: 2.2.1 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/e9eb551ba30ec2e742c49e6927be6402b413bb76(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/5ec3dd4b367fcc039fbcb3edd214fe3cf8178a6d(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13) mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/4fa1af9964cab91315d8d1ae02615f3039638828(encoding@0.1.13) prismarine-item: 1.16.0 ws: 8.18.0 @@ -17003,6 +17008,32 @@ snapshots: - '@types/react' - react + minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/5ec3dd4b367fcc039fbcb3edd214fe3cf8178a6d(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13): + dependencies: + '@types/node-rsa': 1.1.4 + '@types/readable-stream': 4.0.12 + aes-js: 3.1.2 + buffer-equal: 1.0.1 + debug: 4.4.0(supports-color@8.1.1) + endian-toggle: 0.0.0 + lodash.get: 4.4.2 + lodash.merge: 4.6.2 + minecraft-data: 3.83.1 + minecraft-folder-path: 1.2.0 + node-fetch: 2.7.0(encoding@0.1.13) + node-rsa: 0.4.2 + prismarine-auth: 2.4.2(encoding@0.1.13) + prismarine-chat: 1.10.1 + prismarine-nbt: 2.5.0 + prismarine-realms: 1.3.2(encoding@0.1.13) + protodef: 1.18.0 + readable-stream: 4.5.2 + uuid-1345: 1.0.2 + yggdrasil: 1.7.0(encoding@0.1.13) + transitivePeerDependencies: + - encoding + - supports-color + minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/e9eb551ba30ec2e742c49e6927be6402b413bb76(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13): dependencies: '@types/node-rsa': 1.1.4 diff --git a/src/appParams.ts b/src/appParams.ts index 61ee9e07..413b6c2d 100644 --- a/src/appParams.ts +++ b/src/appParams.ts @@ -39,6 +39,12 @@ export type AppQsParams = { suggest_save?: string noPacketsValidation?: string testCrashApp?: string + + // Replay params + replayFilter?: string + replaySpeed?: string + replayFileUrl?: string + replayValidateClient?: string } export type AppQsParamsArray = { @@ -55,7 +61,7 @@ type AppQsParamsArrayTransformed = { export const appQueryParams = new Proxy({} as AppQsParams, { get (target, property) { if (typeof property !== 'string') { - return null + return undefined } return qsParams.get(property) }, diff --git a/src/browserfs.ts b/src/browserfs.ts index bd862ae0..76733e64 100644 --- a/src/browserfs.ts +++ b/src/browserfs.ts @@ -10,7 +10,10 @@ import { fsState, loadSave } from './loadSave' import { installResourcepackPack, installTexturePackFromHandle, updateTexturePackInstalledState } from './resourcePack' import { miscUiState } from './globalState' import { setLoadingScreenStatus } from './appStatus' -const { GoogleDriveFileSystem } = require('google-drive-browserfs/src/backends/GoogleDrive') // disable type checking +import { VALID_REPLAY_EXTENSIONS, openFile } from './packetsReplay/replayPackets' +import { getFixedFilesize } from './downloadAndOpenFile' +import { packetsReplayState } from './react/state/packetsReplayState' +const { GoogleDriveFileSystem } = require('google-drive-browserfs/src/backends/GoogleDrive') browserfs.install(window) const defaultMountablePoints = { @@ -621,22 +624,33 @@ export const openFilePicker = (specificCase?: 'resourcepack') => { if (!picker) { picker = document.createElement('input') picker.type = 'file' - picker.accept = '.zip' + picker.accept = specificCase ? '.zip' : [...VALID_REPLAY_EXTENSIONS, '.zip'].join(',') picker.addEventListener('change', () => { const file = picker.files?.[0] picker.value = '' if (!file) return - if (!file.name.endsWith('.zip')) { - const doContinue = confirm(`Are you sure ${file.name.slice(-20)} is .zip file? Only .zip files are supported. Continue?`) - if (!doContinue) return - } if (specificCase === 'resourcepack') { + if (!file.name.endsWith('.zip')) { + const doContinue = confirm(`Are you sure ${file.name.slice(-20)} is .zip file? ONLY .zip files are supported. Continue?`) + if (!doContinue) return + } void installResourcepackPack(file).catch((err) => { setLoadingScreenStatus(err.message, true) }) } else { - void openWorldZip(file) + // eslint-disable-next-line no-lonely-if + if (VALID_REPLAY_EXTENSIONS.some(ext => file.name.endsWith(ext)) || file.name.startsWith('packets-replay')) { + void file.text().then(contents => { + openFile({ + contents, + filename: file.name, + filesize: file.size + }) + }) + } else { + void openWorldZip(file) + } } }) picker.hidden = true diff --git a/src/connect.ts b/src/connect.ts index 9ada9b8a..a67e6a62 100644 --- a/src/connect.ts +++ b/src/connect.ts @@ -23,6 +23,9 @@ export type ConnectOptions = { peerOptions?: any viewerWsConnect?: string saveServerToHistory?: boolean + + /** Will enable local replay server */ + worldStateFileContents?: string } export const getVersionAutoSelect = (autoVersionSelect = options.serversAutoVersionSelect) => { diff --git a/src/controls.ts b/src/controls.ts index b0add043..965a2a67 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -8,7 +8,7 @@ import { CommandEventArgument, SchemaCommandInput } from 'contro-max/build/types import { stringStartsWith } from 'contro-max/build/stringUtils' import { UserOverrideCommand, UserOverridesConfig } from 'contro-max/build/types/store' import { isGameActive, showModal, gameAdditionalState, activeModalStack, hideCurrentModal, miscUiState, hideModal, hideAllModals } from './globalState' -import { goFullscreen, pointerLock, reloadChunks } from './utils' +import { goFullscreen, isInRealGameSession, pointerLock, reloadChunks } from './utils' import { options } from './optionsStorage' import { openPlayerInventory } from './inventoryWindows' import { chatInputValueGlobal } from './react/Chat' @@ -835,7 +835,7 @@ const selectItem = async () => { addEventListener('mousedown', async (e) => { if ((e.target as HTMLElement).matches?.('#VRButton')) return - if (gameAdditionalState.viewerConnection && !(e.target as HTMLElement).id.includes('ui-root')) return + if (!isInRealGameSession() && !(e.target as HTMLElement).id.includes('ui-root')) return void pointerLock.requestPointerLock() if (!bot) return // wheel click diff --git a/src/downloadAndOpenFile.ts b/src/downloadAndOpenFile.ts index b1cf6859..2cbb3aad 100644 --- a/src/downloadAndOpenFile.ts +++ b/src/downloadAndOpenFile.ts @@ -3,12 +3,30 @@ import { openWorldFromHttpDir, openWorldZip } from './browserfs' import { getResourcePackNames, installResourcepackPack, resourcePackState, updateTexturePackInstalledState } from './resourcePack' import { setLoadingScreenStatus } from './appStatus' import { appQueryParams, appQueryParamsArray } from './appParams' +import { VALID_REPLAY_EXTENSIONS, openFile } from './packetsReplay/replayPackets' export const getFixedFilesize = (bytes: number) => { return prettyBytes(bytes, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } const inner = async () => { + const { replayFileUrl } = appQueryParams + if (replayFileUrl) { + setLoadingScreenStatus('Downloading replay file...') + const response = await fetch(replayFileUrl) + const contentLength = response.headers?.get('Content-Length') + const size = contentLength ? +contentLength : undefined + const filename = replayFileUrl.split('/').pop() + + const contents = await response.text() + openFile({ + contents, + filename, + filesize: size + }) + return true + } + const mapUrlDir = appQueryParamsArray.mapDir ?? [] const mapUrlDirGuess = appQueryParams.mapDirGuess const mapUrlDirBaseUrl = appQueryParams.mapDirBaseUrl diff --git a/src/dragndrop.ts b/src/dragndrop.ts index 1f4b0e2b..6c8af856 100644 --- a/src/dragndrop.ts +++ b/src/dragndrop.ts @@ -6,6 +6,7 @@ import { versions } from 'minecraft-data' import { openWorldDirectory, openWorldZip } from './browserfs' import { isGameActive } from './globalState' import { showNotification } from './react/NotificationProvider' +import { openFile, VALID_REPLAY_EXTENSIONS } from './packetsReplay/replayPackets' const parseNbt = promisify(nbt.parse) const simplifyNbt = nbt.simplify @@ -53,10 +54,19 @@ async function handleDroppedFile (file: File) { alert('Rar files are not supported yet!') return } + if (VALID_REPLAY_EXTENSIONS.some(ext => file.name.endsWith(ext)) || file.name.startsWith('packets-replay')) { + const contents = await file.text() + openFile({ + contents, + filename: file.name, + filesize: file.size + }) + return + } if (file.name.endsWith('.mca')) { const tempPath = '/data/temp.mca' try { - await fs.promises.writeFile(tempPath, Buffer.from(await file.arrayBuffer())) + await fs.promises.writeFile(tempPath, Buffer.from(await file.arrayBuffer()) as any) const region = new RegionFile(tempPath) await region.initialize() const chunks: Record = {} diff --git a/src/index.ts b/src/index.ts index bac2593e..def72d2e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,7 +45,7 @@ import * as THREE from 'three' import MinecraftData from 'minecraft-data' import debug from 'debug' import { defaultsDeep } from 'lodash-es' -import initializePacketsReplay from './packetsReplay' +import initializePacketsReplay from './packetsReplay/packetsReplayLegacy' import { initVR } from './vr' import { @@ -111,6 +111,9 @@ import { states } from 'minecraft-protocol' import { initMotionTracking } from './react/uiMotion' import { UserError } from './mineflayer/userError' import ping from './mineflayer/plugins/ping' +import { LocalServer } from './customServer' +import { startLocalReplayServer } from './packetsReplay/replayPackets' +import { localRelayServerPlugin } from './mineflayer/plugins/localRelay' window.debug = debug window.THREE = THREE @@ -397,6 +400,7 @@ export async function connect (connectOptions: ConnectOptions) { const renderDistance = singleplayer ? renderDistanceSingleplayer : multiplayerRenderDistance let updateDataAfterJoin = () => { } let localServer + let localReplaySession: ReturnType | undefined try { const serverOptions = defaultsDeep({}, connectOptions.serverOverrides ?? {}, options.localServerOptions, defaultServerOptions) Object.assign(serverOptions, connectOptions.serverOverridesFlat ?? {}) @@ -436,6 +440,16 @@ export async function connect (connectOptions: ConnectOptions) { let finalVersion = connectOptions.botVersion || (singleplayer ? serverOptions.version : undefined) + if (connectOptions.worldStateFileContents) { + try { + localReplaySession = startLocalReplayServer(connectOptions.worldStateFileContents) + } catch (err) { + console.error(err) + throw new UserError(`Failed to start local replay server: ${err}`) + } + finalVersion = localReplaySession.version + } + if (singleplayer) { // SINGLEPLAYER EXPLAINER: // Note 1: here we have custom sync communication between server Client (flying-squid) and game client (mineflayer) @@ -484,6 +498,8 @@ export async function connect (connectOptions: ConnectOptions) { initialLoadingText = `Connecting to server ${server.host}:${server.port ?? 25_565} with version ${finalVersion}` } else if (connectOptions.viewerWsConnect) { initialLoadingText = `Connecting to Mineflayer WebSocket server ${connectOptions.viewerWsConnect}` + } else if (connectOptions.worldStateFileContents) { + initialLoadingText = `Loading local replay server` } else { initialLoadingText = 'We have no idea what to do' } @@ -543,7 +559,7 @@ export async function connect (connectOptions: ConnectOptions) { ...clientDataStream ? { stream: clientDataStream as any, } : {}, - ...singleplayer || p2pMultiplayer ? { + ...singleplayer || p2pMultiplayer || localReplaySession ? { keepAlive: false, } : {}, ...singleplayer ? { @@ -551,6 +567,10 @@ export async function connect (connectOptions: ConnectOptions) { connect () { }, Client: CustomChannelClient as any, } : {}, + ...localReplaySession ? { + connect () { }, + Client: CustomChannelClient as any, + } : {}, onMsaCode (data) { signInMessageState.code = data.user_code signInMessageState.link = data.verification_uri @@ -613,15 +633,17 @@ export async function connect (connectOptions: ConnectOptions) { void handleCustomChannel() } customEvents.emit('mineflayerBotCreated') - if (singleplayer || p2pMultiplayer) { - // in case of p2pMultiplayer there is still flying-squid on the host side - const _supportFeature = bot.supportFeature - bot.supportFeature = ((feature) => { - if (unsupportedLocalServerFeatures.includes(feature)) { - return false - } - return _supportFeature(feature) - }) as typeof bot.supportFeature + if (singleplayer || p2pMultiplayer || localReplaySession) { + if (singleplayer || p2pMultiplayer) { + // in case of p2pMultiplayer there is still flying-squid on the host side + const _supportFeature = bot.supportFeature + bot.supportFeature = ((feature) => { + if (unsupportedLocalServerFeatures.includes(feature)) { + return false + } + return _supportFeature(feature) + }) as typeof bot.supportFeature + } bot.emit('inject_allowed') bot._client.emit('connect') @@ -670,6 +692,9 @@ export async function connect (connectOptions: ConnectOptions) { if (connectOptions.server) { bot.loadPlugin(ping) } + if (!localReplaySession) { + bot.loadPlugin(localRelayServerPlugin) + } if (!bot) return const p2pConnectTimeout = p2pMultiplayer ? setTimeout(() => { throw new UserError('Spawn timeout. There might be error on the other side, check console.') }, 20_000) : undefined diff --git a/src/mineflayer/plugins/localRelay.ts b/src/mineflayer/plugins/localRelay.ts new file mode 100644 index 00000000..e7d304a7 --- /dev/null +++ b/src/mineflayer/plugins/localRelay.ts @@ -0,0 +1,32 @@ +import { viewerConnector } from 'mcraft-fun-mineflayer' +import { PACKETS_REPLAY_FILE_EXTENSION, WORLD_STATE_FILE_EXTENSION } from 'mcraft-fun-mineflayer/build/worldState' +import { Bot } from 'mineflayer' + +export const localRelayServerPlugin = (bot: Bot) => { + bot.loadPlugin( + viewerConnector({ + tcpEnabled: false, + websocketEnabled: false, + }) + ) + + bot.downloadCurrentWorldState = () => { + const worldState = bot.webViewer._unstable.createStateCaptureFile() + const a = document.createElement('a') + const textContents = worldState.contents + const blob = new Blob([textContents], { type: 'text/plain' }) + const url = URL.createObjectURL(blob) + a.href = url + // add readable timestamp to filename + const timestamp = new Date().toISOString().replaceAll(/[-:Z]/g, '') + a.download = `${bot.username}-world-state-${timestamp}.${WORLD_STATE_FILE_EXTENSION}` + a.click() + URL.revokeObjectURL(url) + } +} + +declare module 'mineflayer' { + interface Bot { + downloadCurrentWorldState: () => void + } +} diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index bbc04fa6..8b994507 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -11,7 +11,7 @@ import { getScreenRefreshRate } from './utils' import { setLoadingScreenStatus } from './appStatus' import { openFilePicker, resetLocalStorageWithoutWorld } from './browserfs' import { completeTexturePackInstall, getResourcePackNames, resourcePackState, uninstallResourcePack } from './resourcePack' -import { downloadPacketsReplay, packetsReplaceSessionState } from './packetsReplay' +import { downloadPacketsReplay, packetsReplaceSessionState } from './packetsReplay/packetsReplayLegacy' import { showOptionsModal } from './react/SelectOption' import supportedVersions from './supportedVersions.mjs' import { getVersionAutoSelect } from './connect' diff --git a/src/packetsReplay.ts b/src/packetsReplay/packetsReplayLegacy.ts similarity index 94% rename from src/packetsReplay.ts rename to src/packetsReplay/packetsReplayLegacy.ts index 6b99e665..0db97012 100644 --- a/src/packetsReplay.ts +++ b/src/packetsReplay/packetsReplayLegacy.ts @@ -1,6 +1,6 @@ import { proxy } from 'valtio' -import { PacketsLogger } from './packetsReplayBase' -import { options } from './optionsStorage' +import { PacketsLogger } from 'mcraft-fun-mineflayer/build/packetsLogger' +import { options } from '../optionsStorage' export const packetsReplaceSessionState = proxy({ active: options.packetsReplayAutoStart, diff --git a/src/packetsReplay/replayPackets.ts b/src/packetsReplay/replayPackets.ts new file mode 100644 index 00000000..1af819f6 --- /dev/null +++ b/src/packetsReplay/replayPackets.ts @@ -0,0 +1,274 @@ +/* eslint-disable no-await-in-loop */ +import { createServer, ServerClient } from 'minecraft-protocol' +import { parseReplayContents } from 'mcraft-fun-mineflayer/build/packetsLogger' +import { WorldStateHeader, PACKETS_REPLAY_FILE_EXTENSION, WORLD_STATE_FILE_EXTENSION } from 'mcraft-fun-mineflayer/build/worldState' +import { LocalServer } from '../customServer' +import { UserError } from '../mineflayer/userError' +import { packetsReplayState } from '../react/state/packetsReplayState' +import { getFixedFilesize } from '../react/simpleUtils' +import { appQueryParams } from '../appParams' + +const SUPPORTED_FORMAT_VERSION = 1 + +type ReplayDefinition = { + minecraftVersion: string + replayAgainst?: 'client' | 'server' + serverIp?: string +} + +interface OpenFileOptions { + contents: string + filename?: string + filesize?: number +} + +export function openFile ({ contents, filename = 'unnamed', filesize }: OpenFileOptions) { + packetsReplayState.replayName = `${filename} (${getFixedFilesize(filesize ?? contents.length)})` + packetsReplayState.isOpen = true + packetsReplayState.isPlaying = false + + const connectOptions = { + worldStateFileContents: contents, + username: 'replay' + } + dispatchEvent(new CustomEvent('connect', { detail: connectOptions })) +} + +export const startLocalReplayServer = (contents: string) => { + const lines = contents.split('\n') + if (!lines[0]) { + throw new UserError('No header line found. Cannot parse replay definition.') + } + let def: WorldStateHeader | ReplayDefinition + try { + def = JSON.parse(lines[0]) + } catch (err) { + throw new UserError(`Invalid JSON in file header: ${String(err)}`) + } + const packetsRaw = lines.slice(1).join('\n') + const replayData = parseReplayContents(packetsRaw) + + packetsReplayState.packetsPlayback = [] + packetsReplayState.isOpen = true + packetsReplayState.isPlaying = true + packetsReplayState.progress = { + current: 0, + total: replayData.packets.filter(packet => packet.isFromServer).length + } + packetsReplayState.speed = 1 + packetsReplayState.replayName ||= `local ${getFixedFilesize(contents.length)}` + packetsReplayState.replayName = `${def.minecraftVersion} ${packetsReplayState.replayName}` + + if ('formatVersion' in def && def.formatVersion !== SUPPORTED_FORMAT_VERSION) { + throw new UserError(`Unsupported format version: ${def.formatVersion}`) + } + if ('replayAgainst' in def && def.replayAgainst === 'server') { + throw new Error('not supported') + } + + const server = createServer({ + Server: LocalServer as any, + version: def.minecraftVersion, + 'online-mode': false + }) + + server.on('login', async client => { + await mainPacketsReplayer( + client, + replayData, + appQueryParams.replayValidateClient === 'true' ? true : undefined + ) + }) + + return { + server, + version: def.minecraftVersion + } +} + +// time based packets +const FLATTEN_CLIENT_PACKETS = new Set(['position', 'position_look']) + +const positions = { + client: 0, + server: 0 +} +const addPacketToReplayer = (name: string, data, isFromClient: boolean, wasUpcoming = false) => { + const side = isFromClient ? 'client' : 'server' + + if (wasUpcoming) { + const lastUpcoming = packetsReplayState.packetsPlayback.findLast(p => p.isUpcoming && p.name === name) + if (lastUpcoming) { + lastUpcoming.isUpcoming = false + } + } else { + packetsReplayState.packetsPlayback.push({ + name, + data, + isFromClient, + position: ++positions[side]!, + isUpcoming: false, + timestamp: Date.now() + }) + } + + if (!isFromClient && !wasUpcoming) { + packetsReplayState.progress.current++ + } +} + +const IGNORE_SERVER_PACKETS = new Set([ + 'kick_disconnect', +]) + +const mainPacketsReplayer = async (client: ServerClient, replayData: ReturnType, ignoreClientPacketsWait: string[] | true = []) => { + const writePacket = (name: string, data: any) => { + data = restoreData(data) + client.write(name, data) + } + + const playPackets = replayData.packets.filter(p => p.state === 'play') + + let clientPackets = [] as Array<{ name: string, params: any }> + const clientsPacketsWaiter = createPacketsWaiter({ + unexpectedPacketReceived (name, params) { + console.log('unexpectedPacketReceived', name, params) + addPacketToReplayer(name, params, true) + }, + expectedPacketReceived (name, params) { + console.log('expectedPacketReceived', name, params) + addPacketToReplayer(name, params, true, true) + } + }) + bot._client.on('writePacket' as any, (name, params) => { + console.log('writePacket', name, params) + clientsPacketsWaiter.addPacket(name, params) + }) + + console.log('start replaying!') + for (const [i, packet] of playPackets.entries()) { + if (packet.isFromServer) { + writePacket(packet.name, packet.params) + addPacketToReplayer(packet.name, packet.params, false) + await new Promise(resolve => { + setTimeout(resolve, packet.diff * packetsReplayState.speed) + }) + } else if (ignoreClientPacketsWait !== true && !ignoreClientPacketsWait.includes(packet.name)) { + clientPackets.push({ name: packet.name, params: packet.params }) + if (playPackets[i + 1]?.isFromServer) { + // eslint-disable-next-line @typescript-eslint/no-loop-func + clientPackets = clientPackets.filter((p, index) => { + return !FLATTEN_CLIENT_PACKETS.has(p.name) || index === clientPackets.findIndex(clientPacket => clientPacket.name === p.name) + }) + for (const packet of clientPackets) { + packetsReplayState.packetsPlayback.push({ + name: packet.name, + data: packet.params, + isFromClient: true, + position: positions.client++, + timestamp: Date.now(), + isUpcoming: true, + }) + } + + await clientsPacketsWaiter.waitForPackets(clientPackets.map(p => p.name)) + clientPackets = [] + } + } + } +} + +interface PacketsWaiterOptions { + unexpectedPacketReceived?: (name: string, params: any) => void + expectedPacketReceived?: (name: string, params: any) => void +} + +interface PacketsWaiter { + addPacket(name: string, params: any): void + waitForPackets(packets: string[]): Promise +} + +const createPacketsWaiter = (options: PacketsWaiterOptions = {}): PacketsWaiter => { + let packetHandler: ((data: any, name: string) => void) | null = null + const queuedPackets: Array<{ name: string, params: any }> = [] + let isWaiting = false + + const handlePacket = (data: any, name: string, waitingPackets: string[], resolve: () => void) => { + if (waitingPackets.includes(name)) { + waitingPackets.splice(waitingPackets.indexOf(name), 1) + options.expectedPacketReceived?.(name, data) + } else { + options.unexpectedPacketReceived?.(name, data) + } + + if (waitingPackets.length === 0) { + resolve() + } + } + + return { + addPacket (name: string, params: any) { + if (packetHandler) { + packetHandler(params, name) + } else { + queuedPackets.push({ name, params }) + } + }, + + async waitForPackets (packets: string[]) { + if (isWaiting) { + throw new Error('Already waiting for packets') + } + isWaiting = true + + try { + await new Promise(resolve => { + const waitingPackets = [...packets] + + packetHandler = (data: any, name: string) => { + handlePacket(data, name, waitingPackets, resolve) + } + + // Process any queued packets + for (const packet of queuedPackets) { + handlePacket(packet.params, packet.name, waitingPackets, resolve) + } + queuedPackets.length = 0 + }) + } finally { + isWaiting = false + packetHandler = null + } + } + } +} + +const isArrayEqual = (a: any[], b: any[]) => { + if (a.length !== b.length) return false + for (const [i, element] of a.entries()) { + if (element !== b[i]) return false + } + return true +} + +const restoreData = (json: any) => { + const keys = Object.keys(json) + + if (isArrayEqual(keys.sort(), ['data', 'type'].sort())) { + if (json.type === 'Buffer') { + return Buffer.from(json.data) + } + } + + if (typeof json === 'object' && json) { + for (const [key, value] of Object.entries(json)) { + if (typeof value === 'object') { + json[key] = restoreData(value) + } + } + } + + return json +} + +export const VALID_REPLAY_EXTENSIONS = [`.${PACKETS_REPLAY_FILE_EXTENSION}`, `.${WORLD_STATE_FILE_EXTENSION}`] diff --git a/src/packetsReplayBase.ts b/src/packetsReplayBase.ts deleted file mode 100644 index fda41ae2..00000000 --- a/src/packetsReplayBase.ts +++ /dev/null @@ -1,60 +0,0 @@ -export class PacketsLogger { - lastPacketTime = -1 - contents = '' - logOnly = [] as string[] - skip = [] as string[] - - logStr (str: string) { - this.contents += `${str}\n` - } - - log (isFromServer: boolean, packet: { name; state }, data: any) { - if (this.logOnly.length > 0 && !this.logOnly.includes(packet.name)) { - return - } - if (this.skip.length > 0 && this.skip.includes(packet.name)) { - return - } - if (this.lastPacketTime === -1) { - this.lastPacketTime = Date.now() - } - - const diff = `+${Date.now() - this.lastPacketTime}` - // serialize bigint - const str = `${isFromServer ? 'S' : 'C'} ${packet.state}:${packet.name} ${diff} ${JSON.stringify(data, (key, value) => { - if (typeof value === 'bigint') return value.toString() - return value - })}` - this.logStr(str) - this.lastPacketTime = Date.now() - } -} - -export type ParsedReplayPacket = { - name: string - params: any - state: string - diff: number - isFromServer: boolean -} -export function parseReplayContents (contents: string) { - const lines = contents.split('\n') - - const packets = [] as ParsedReplayPacket[] - for (let line of lines) { - line = line.trim() - if (!line || line.startsWith('#')) continue - const [side, nameState, diff, ...data] = line.split(' ') - const parsed = JSON.parse(data.join(' ')) - const [state, name] = nameState.split(':') - packets.push({ - name, - state, - params: parsed, - isFromServer: side.toUpperCase() === 'S', - diff: Number.parseInt(diff.slice(1), 10), - }) - } - - return packets -} diff --git a/src/react/AppStatusProvider.tsx b/src/react/AppStatusProvider.tsx index dfb1b9bf..a484bc35 100644 --- a/src/react/AppStatusProvider.tsx +++ b/src/react/AppStatusProvider.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react' import { activeModalStack, activeModalStacks, hideModal, insertActiveModalStack, miscUiState } from '../globalState' import { guessProblem } from '../errorLoadingScreenHelpers' import type { ConnectOptions } from '../connect' -import { downloadPacketsReplay, packetsReplaceSessionState, replayLogger } from '../packetsReplay' +import { downloadPacketsReplay, packetsReplaceSessionState, replayLogger } from '../packetsReplay/packetsReplayLegacy' import { getProxyDetails } from '../microsoftAuthflow' import AppStatus from './AppStatus' import DiveTransition from './DiveTransition' @@ -89,6 +89,7 @@ export default () => { useEffect(() => { const controller = new AbortController() window.addEventListener('keyup', (e) => { + if ('input textarea select'.split(' ').includes((e.target as HTMLElement).tagName?.toLowerCase() ?? '')) return if (activeModalStack.at(-1)?.reactType !== 'app-status') return if (e.code !== 'KeyR' || !lastConnectOptions.value) return reconnect() diff --git a/src/react/Book.stories.tsx b/src/react/Book.stories.tsx index a90762c2..f772122d 100644 --- a/src/react/Book.stories.tsx +++ b/src/react/Book.stories.tsx @@ -2,7 +2,7 @@ import { Meta, Story } from '@storybook/react' import Book, { BookProps } from './Book' export default { - title: 'Components/Book', + title: 'Book', component: Book, } as Meta diff --git a/src/react/GameInteractionOverlay.tsx b/src/react/GameInteractionOverlay.tsx index bdfdb859..503ca8b4 100644 --- a/src/react/GameInteractionOverlay.tsx +++ b/src/react/GameInteractionOverlay.tsx @@ -2,10 +2,10 @@ import { useRef, useEffect } from 'react' import { subscribe, useSnapshot } from 'valtio' import { useUtilsEffect } from '@zardoy/react-util' import { options } from '../optionsStorage' -import { activeModalStack, gameAdditionalState, isGameActive, miscUiState } from '../globalState' +import { activeModalStack, isGameActive, miscUiState } from '../globalState' import worldInteractions from '../worldInteractions' import { onCameraMove, CameraMoveEvent } from '../cameraRotationControls' -import { pointerLock } from '../utils' +import { pointerLock, isInRealGameSession } from '../utils' import { handleMovementStickDelta, joystickPointer } from './TouchAreasControls' /** after what time of holding the finger start breaking the block */ @@ -291,7 +291,7 @@ export default function GameInteractionOverlay ({ zIndex }: { zIndex: number }) subscribe(activeModalStack, () => { if (activeModalStack.length === 0) { - if (isGameActive(false) && !gameAdditionalState.viewerConnection) { + if (isInRealGameSession()) { void pointerLock.requestPointerLock() } } else { diff --git a/src/react/HotbarRenderApp.tsx b/src/react/HotbarRenderApp.tsx index 2f581f9a..af7d1387 100644 --- a/src/react/HotbarRenderApp.tsx +++ b/src/react/HotbarRenderApp.tsx @@ -7,8 +7,10 @@ import { activeModalStack, isGameActive, miscUiState } from '../globalState' import { currentScaling } from '../scaleInterface' import { watchUnloadForCleanup } from '../gameUnload' import { getItemNameRaw } from '../mineflayer/items' +import { isInRealGameSession } from '../utils' import MessageFormattedString from './MessageFormattedString' import SharedHudVars from './SharedHudVars' +import { packetsReplayState } from './state/packetsReplayState' const ItemName = ({ itemKey }: { itemKey: string }) => { @@ -147,7 +149,7 @@ const HotbarInner = () => { bot.on('heldItemChanged' as any, heldItemChanged) document.addEventListener('wheel', (e) => { - if (!isGameActive(true)) return + if (!isInRealGameSession()) return e.preventDefault() const newSlot = ((bot.quickBarSlot + Math.sign(e.deltaY)) % 9 + 9) % 9 setSelectedSlot(newSlot) @@ -157,7 +159,7 @@ const HotbarInner = () => { }) document.addEventListener('keydown', (e) => { - if (!isGameActive(true)) return + if (!isInRealGameSession()) return const numPressed = +((/Digit(\d)/.exec(e.code))?.[1] ?? -1) if (numPressed < 1 || numPressed > 9) return setSelectedSlot(numPressed - 1) diff --git a/src/react/NotificationProvider.tsx b/src/react/NotificationProvider.tsx index 73a73d1b..4c9661e0 100644 --- a/src/react/NotificationProvider.tsx +++ b/src/react/NotificationProvider.tsx @@ -61,7 +61,7 @@ export default () => { const scale = useAppScale() return
{ + if (!loadedData) return + return { + client: Object.keys(loadedData.protocol.play.toClient.types).filter(a => a.startsWith('packet_')).map(a => a.slice('packet_'.length)), + server: Object.keys(loadedData.protocol.play.toServer.types).filter(a => a.startsWith('packet_')).map(a => a.slice('packet_'.length)) + } + }, [gameLoaded]) + + if (!state.isOpen) return null + + return ( +
+ { + packetsReplayState.isPlaying = isPlaying + }} + onRestart={() => { + window.location.reload() + }} + onSpeedChange={(speed) => { + packetsReplayState.speed = speed + updateQsParam('replaySpeed', speed === 1 ? undefined : speed.toString()) + }} + onFilterChange={(filter) => { + updateQsParam('replayFilter', filter) + }} + onCustomButtonToggle={(button) => { + packetsReplayState.customButtons[button] = !state.customButtons[button] + }} + /> +
+ ) +} diff --git a/src/react/ReplayPanel.stories.tsx b/src/react/ReplayPanel.stories.tsx new file mode 100644 index 00000000..132c8011 --- /dev/null +++ b/src/react/ReplayPanel.stories.tsx @@ -0,0 +1,106 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { proxy, useSnapshot } from 'valtio' +import { useEffect } from 'react' +import ReplayPanel, { PacketData } from './ReplayPanel' + +const meta: Meta = { + component: ReplayPanel, + title: 'ReplayPanel' +} + +export default meta +type Story = StoryObj + +const mockPackets = proxy([ + { + name: 'position', + data: { x: 100.123, y: 64.456, z: -200.789 }, + isFromClient: true, + isUpcoming: false, + position: 1, + timestamp: 1_234_567_890 + }, + { + name: 'chat', + data: { message: 'Hello, world!' }, + isFromClient: true, + isUpcoming: false, + position: 2, + timestamp: 1_234_567_890 + }, + { + name: 'block_change', + data: { blockId: 1, position: { x: 100, y: 64, z: -200 } }, + isFromClient: false, + isUpcoming: true, + position: 3, + timestamp: 1_234_567_890 + }, + { + name: 'entity_move', + data: { entityId: 1, x: 100, y: 64, z: -200 }, + isFromClient: false, + isUpcoming: false, + actualVersion: { x: 101, y: 64, z: -201 }, + position: 4, + timestamp: 1_234_567_890 + } +] satisfies PacketData[]) + +const ReplayPanelWithToggle = (props: Parameters[0]) => { + const packets = useSnapshot(mockPackets) + + useEffect(() => { + const interval = setInterval(() => { + for (const [index, packet] of mockPackets.entries()) { + packet.isUpcoming = !packet.isUpcoming + } + }, 3000) + + return () => clearInterval(interval) + }, []) + + return +} + +export const Primary: Story = { + render: () => ( + {}} + onRestart={() => {}} + onSpeedChange={() => {}} + onCustomButtonToggle={() => {}} + onFilterChange={() => {}} + packets={mockPackets} + /> + ) +} + +export const Playing: Story = { + render: () => ( + {}} + onRestart={() => {}} + onSpeedChange={() => {}} + onCustomButtonToggle={() => {}} + onFilterChange={() => {}} + packets={mockPackets} + /> + ) +} diff --git a/src/react/ReplayPanel.tsx b/src/react/ReplayPanel.tsx new file mode 100644 index 00000000..fbc4ea6a --- /dev/null +++ b/src/react/ReplayPanel.tsx @@ -0,0 +1,172 @@ +import { useState, useEffect } from 'react' +import { filterPackets } from './packetsFilter' +import { DARK_COLORS } from './components/replay/constants' +import FilterInput from './components/replay/FilterInput' +import PacketList from './components/replay/PacketList' +import ProgressBar from './components/replay/ProgressBar' + +interface Props { + replayName: string + packets: readonly PacketData[] + isPlaying: boolean + progress: { current: number; total: number } + speed: number + defaultFilter?: string + customButtons: { button1: boolean; button2: boolean } + onPlayPause?: (isPlaying: boolean) => void + onRestart?: () => void + onSpeedChange?: (speed: number) => void + onFilterChange: (filter: string) => void + onCustomButtonToggle: (button: 'button1' | 'button2') => void + clientPacketsAutocomplete: string[] + serverPacketsAutocomplete: string[] +} + +export default function ReplayPanel ({ + replayName, + packets, + isPlaying, + progress, + speed, + defaultFilter = '', + customButtons, + onPlayPause, + onRestart, + onSpeedChange, + onFilterChange, + onCustomButtonToggle, + clientPacketsAutocomplete, + serverPacketsAutocomplete +}: Props) { + const [filter, setFilter] = useState(defaultFilter) + const { filtered: filteredPackets, hiddenCount } = filterPackets(packets.slice(-500), filter) + + useEffect(() => { + onFilterChange(filter) + }, [filter, onFilterChange]) + + return ( +
+
{replayName || 'Unnamed Replay'}
+ + setFilter('')} + clientPacketsAutocomplete={clientPacketsAutocomplete} + serverPacketsAutocomplete={serverPacketsAutocomplete} + /> + + + +
+ + + +
+ +
+ + + onSpeedChange?.(Number(e.target.value))} + onContextMenu={e => { + e.preventDefault() + onSpeedChange?.(1) + }} + step={0.1} + min={0.1} + style={{ + width: '60px', + padding: '4px', + border: `1px solid ${DARK_COLORS.border}`, + borderRadius: '4px', + background: DARK_COLORS.input, + color: DARK_COLORS.text + }} + /> + + {[1, 2].map(num => ( + + ))} +
+
+ ) +} + +export interface PacketData { + name: string + data: any + isFromClient: boolean + isUpcoming: boolean + actualVersion?: any + position: number + timestamp: number +} diff --git a/src/react/components/replay/FilterInput.tsx b/src/react/components/replay/FilterInput.tsx new file mode 100644 index 00000000..0e241c8f --- /dev/null +++ b/src/react/components/replay/FilterInput.tsx @@ -0,0 +1,159 @@ +import { useEffect, useRef, useState } from 'react' +import { DARK_COLORS } from './constants' + +interface Props { + value: string + onChange: (value: string) => void + hiddenCount: number + shownCount: number + onClearFilter: () => void + clientPacketsAutocomplete: string[] + serverPacketsAutocomplete: string[] +} + +export default function FilterInput ({ + value, + onChange, + hiddenCount, + shownCount, + onClearFilter, + clientPacketsAutocomplete, + serverPacketsAutocomplete +}: Props) { + const inputRef = useRef(null) + const [showAutocomplete, setShowAutocomplete] = useState(false) + const [selectedIndex, setSelectedIndex] = useState(0) + + const allSuggestions = [ + ...clientPacketsAutocomplete.map(name => ({ name, isClient: true })), + ...serverPacketsAutocomplete.map(name => ({ name, isClient: false })) + ].sort((a, b) => a.name.localeCompare(b.name)) + + const currentWord = value.split(/,\s*/).pop() || '' + const filteredSuggestions = allSuggestions.filter( + ({ name }) => name.toLowerCase().includes(currentWord.toLowerCase().replace(/^\$/, '')) + ) + + useEffect(() => { + setSelectedIndex(0) + }, [currentWord]) + + const acceptSuggestion = (suggestion: string) => { + const parts = value.split(/,\s*/) + parts[parts.length - 1] = suggestion + onChange(parts.join(', ')) + setShowAutocomplete(false) + inputRef.current?.focus() + } + + return ( +
+
+ onChange(e.target.value)} + onFocus={() => setShowAutocomplete(true)} + onKeyDown={e => { + if (!showAutocomplete) { + if (e.key === 'Tab') { + e.preventDefault() + setShowAutocomplete(true) + } + return + } + + if (e.key === 'ArrowDown') { + e.preventDefault() + setSelectedIndex(i => (i + 1) % filteredSuggestions.length) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSelectedIndex(i => (i - 1 + filteredSuggestions.length) % filteredSuggestions.length) + } else if (e.key === 'Enter' && filteredSuggestions.length > 0) { + e.preventDefault() + acceptSuggestion(filteredSuggestions[selectedIndex].name) + } else if (e.key === 'Escape') { + e.preventDefault() + setShowAutocomplete(false) + } + }} + placeholder="Filter packets (e.g. entity, $block_display, !position)" + style={{ + width: '100%', + padding: '8px', + border: `1px solid ${DARK_COLORS.border}`, + borderRadius: '4px', + background: DARK_COLORS.input, + color: DARK_COLORS.text + }} + /> + {showAutocomplete && filteredSuggestions.length > 0 && ( +
+ {filteredSuggestions.map(({ name, isClient }, index) => ( +
acceptSuggestion(name)} + style={{ + padding: '4px 8px', + cursor: 'pointer', + background: index === selectedIndex ? DARK_COLORS.hover : 'transparent', + color: isClient ? DARK_COLORS.client : DARK_COLORS.server + }} + > + {name} +
+ ))} +
+ )} +
+
+ Showing: {shownCount} + + Hidden: {hiddenCount} + +
+ + +
+ +
+
+ ) +} diff --git a/src/react/components/replay/PacketList.tsx b/src/react/components/replay/PacketList.tsx new file mode 100644 index 00000000..c481ad52 --- /dev/null +++ b/src/react/components/replay/PacketList.tsx @@ -0,0 +1,141 @@ +import { useRef, useState } from 'react' +import { PacketData } from '../../ReplayPanel' +import { useScrollBehavior } from '../../hooks/useScrollBehavior' +import { DARK_COLORS } from './constants' + +const formatters: Record string> = { + position: (data) => `x:${data.x.toFixed(2)} y:${data.y.toFixed(2)} z:${data.z.toFixed(2)}`, + chat: (data) => data.message, + // Add more formatters as needed +} + +const getPacketIcon = (name: string): string => { + if (name.includes('position')) return '📍' + if (name.includes('chat')) return '💬' + if (name.includes('block') || name.includes('chunk') || name.includes('light')) return '📦' + if (name.includes('entity') || name.includes('player') || name.includes('passenger')) return '🎯' + return '📄' +} + +interface Props { + packets: PacketData[] + filter: string + maxHeight?: number +} + +const ROW_HEIGHT = 24 +const EXPANDED_HEIGHT = 120 + +function formatTimeDiff (current: number, prev: number | null): string { + if (prev === null) return '' + const diff = current - prev + return `+${Math.floor(diff / 1000)}` +} + +const styles = { + packetRow: { + height: ROW_HEIGHT, + padding: '0 8px', + fontSize: '12px', + display: 'flex', + alignItems: 'center', + gap: '8px', + whiteSpace: 'nowrap', + overflow: 'hidden', + cursor: 'pointer', + transition: 'background-color 0.1s' + } as const, + expandedPacket: { + height: EXPANDED_HEIGHT, + padding: '8px', + background: DARK_COLORS.input, + fontSize: '12px', + overflow: 'auto', + borderBottom: `1px solid ${DARK_COLORS.border}` + } as const +} + +export default function PacketList ({ packets, filter, maxHeight = 300 }: Props) { + const listRef = useRef(null) + const [expandedPacket, setExpandedPacket] = useState(null) + const { scrollToBottom } = useScrollBehavior(listRef, { messages: packets, opened: true }) + + let prevTimestamp: number | null = null + + return ( + <> + +
+
+ {packets.map((packet, index) => { + const timeDiff = formatTimeDiff(packet.timestamp, prevTimestamp) + prevTimestamp = packet.timestamp + return ( +
+
setExpandedPacket(expandedPacket === packet.position ? null : packet.position)} + style={{ + ...styles.packetRow, + background: packet.isFromClient ? DARK_COLORS.client : DARK_COLORS.server, + opacity: packet.isUpcoming ? 0.5 : 1 + }} + > + {getPacketIcon(packet.name)} + + #{packet.position} + {timeDiff && {timeDiff}} + + {filter && ( + #{index + 1} + )} + + {packet.name} + + + {formatters[packet.name]?.(packet.data) ?? JSON.stringify(packet.data)} + +
+ {expandedPacket === packet.position && ( +
+
+ Data: +
+                        {JSON.stringify(packet.data, null, 2)}
+                      
+
+ {packet.actualVersion && ( +
+ Actual Version: +
+                          {JSON.stringify(packet.actualVersion, null, 2)}
+                        
+
+ )} +
+ )} +
+ ) + })} +
+
+ + ) +} diff --git a/src/react/components/replay/ProgressBar.tsx b/src/react/components/replay/ProgressBar.tsx new file mode 100644 index 00000000..7bf5f88b --- /dev/null +++ b/src/react/components/replay/ProgressBar.tsx @@ -0,0 +1,36 @@ +import { DARK_COLORS } from './constants' + +interface Props { + current: number + total: number +} + +function padNumber (num: number): string { + return String(num).padStart(3, '0') +} + +export default function ProgressBar ({ current, total }: Props) { + return ( +
+
+ {padNumber(current)}/{padNumber(total)} +
+
+
+
+
+ ) +} diff --git a/src/react/components/replay/constants.ts b/src/react/components/replay/constants.ts new file mode 100644 index 00000000..54beb7cb --- /dev/null +++ b/src/react/components/replay/constants.ts @@ -0,0 +1,11 @@ +export const DARK_COLORS = { + bg: '#1e1e1e', + input: '#2d2d2d', + text: '#ffffff', + textDim: '#888888', + client: '#144218', + server: '#750200', + modified: '#f57f17', + border: '#333333', + hover: '#404040' +} diff --git a/src/react/packetsFilter.ts b/src/react/packetsFilter.ts new file mode 100644 index 00000000..e89621cf --- /dev/null +++ b/src/react/packetsFilter.ts @@ -0,0 +1,55 @@ +import { PacketData } from './ReplayPanel' + +function wildcardToRegExp (pattern: string): RegExp { + const escaped = pattern.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') + return new RegExp(`^${escaped.replaceAll('\\*', '.*')}$`) +} + +function patternToRegExp (pattern: string): RegExp { + if (pattern.startsWith('$')) { + return new RegExp(`^${pattern.slice(1)}$`) + } + return wildcardToRegExp(`*${pattern}*`) +} + +export function parseFilterString (filter: string): { include: RegExp[]; exclude: RegExp[] } { + const parts = filter.split(/,\s*/) + const include: RegExp[] = [] + const exclude: RegExp[] = [] + + for (const part of parts) { + if (!part) continue + if (part.startsWith('!')) { + exclude.push(patternToRegExp(part.slice(1))) + } else { + include.push(patternToRegExp(part)) + } + } + + return { include, exclude } +} + +export function filterPackets (packets: PacketData[], filter: string): { filtered: PacketData[]; hiddenCount: number } { + if (!filter.trim()) { + return { filtered: packets, hiddenCount: 0 } + } + + const { include, exclude } = parseFilterString(filter) + const filtered = packets.filter(packet => { + // If packet matches any exclude pattern, filter it out + if (exclude.some(pattern => pattern.test(packet.name))) { + return false + } + // If there are include patterns, packet must match at least one + if (include.length > 0) { + return include.some(pattern => pattern.test(packet.name)) + } + // If no include patterns, keep the packet (unless it was excluded) + return true + }) + + return { + filtered, + hiddenCount: packets.length - filtered.length + } +} diff --git a/src/react/state/packetsReplayState.ts b/src/react/state/packetsReplayState.ts new file mode 100644 index 00000000..102a0ba1 --- /dev/null +++ b/src/react/state/packetsReplayState.ts @@ -0,0 +1,19 @@ +import { proxy } from 'valtio' +import type { PacketData } from '../ReplayPanel' +import { appQueryParams } from '../../appParams' + +export const packetsReplayState = proxy({ + packetsPlayback: [] as PacketData[], + isOpen: false, + replayName: '', + isPlaying: false, + progress: { + current: 0, + total: 0 + }, + speed: appQueryParams.replaySpeed ? parseFloat(appQueryParams.replaySpeed) : 1, + customButtons: { + button1: false, + button2: false + } +}) diff --git a/src/reactUi.tsx b/src/reactUi.tsx index 5c294282..b6d4b6d2 100644 --- a/src/reactUi.tsx +++ b/src/reactUi.tsx @@ -51,6 +51,7 @@ import MineflayerPluginHud from './react/MineflayerPluginHud' import MineflayerPluginConsole from './react/MineflayerPluginConsole' import { UIProvider } from './react/UIProvider' import { useAppScale } from './scaleInterface' +import PacketsReplayProvider from './react/PacketsReplayProvider' const RobustPortal = ({ children, to }) => { return createPortal({children}, to) @@ -205,6 +206,7 @@ const App = () => { +
diff --git a/src/utils.ts b/src/utils.ts index a5867a41..8039d403 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,7 @@ -import { isGameActive, miscUiState } from './globalState' +import { gameAdditionalState, isGameActive, miscUiState } from './globalState' import { options } from './optionsStorage' import { notificationProxy, showNotification } from './react/NotificationProvider' +import { packetsReplayState } from './react/state/packetsReplayState' export const goFullscreen = async (doToggle = false) => { if (!document.fullscreenElement) { @@ -64,6 +65,10 @@ export const pointerLock = { } } +export const isInRealGameSession = () => { + return isGameActive(true) && !packetsReplayState.isOpen && !gameAdditionalState.viewerConnection +} + window.getScreenRefreshRate = getScreenRefreshRate /**