feat: Replay packets server functionality! (#287)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
parent
81a692272c
commit
1387cb036b
31 changed files with 1232 additions and 103 deletions
|
|
@ -144,6 +144,7 @@ General:
|
|||
|
||||
- **`?setting=<setting_name>:<setting_value>`** - 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=<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=<url>` - 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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
47
pnpm-lock.yaml
generated
47
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<AppQsParams>({} as AppQsParams, {
|
||||
get (target, property) {
|
||||
if (typeof property !== 'string') {
|
||||
return null
|
||||
return undefined
|
||||
}
|
||||
return qsParams.get(property)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string, any> = {}
|
||||
|
|
|
|||
47
src/index.ts
47
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<typeof startLocalReplayServer> | 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
|
||||
|
|
|
|||
32
src/mineflayer/plugins/localRelay.ts
Normal file
32
src/mineflayer/plugins/localRelay.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
274
src/packetsReplay/replayPackets.ts
Normal file
274
src/packetsReplay/replayPackets.ts
Normal file
|
|
@ -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<typeof parseReplayContents>, 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<void>
|
||||
}
|
||||
|
||||
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<void>(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}`]
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export default () => {
|
|||
const scale = useAppScale()
|
||||
|
||||
return <div style={{
|
||||
scale,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'top right',
|
||||
}}>
|
||||
<Notification
|
||||
|
|
|
|||
66
src/react/PacketsReplayProvider.tsx
Normal file
66
src/react/PacketsReplayProvider.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { useSnapshot } from 'valtio'
|
||||
import { useMemo } from 'react'
|
||||
import { appQueryParams } from '../appParams'
|
||||
import { miscUiState } from '../globalState'
|
||||
import { packetsReplayState } from './state/packetsReplayState'
|
||||
import ReplayPanel from './ReplayPanel'
|
||||
|
||||
function updateQsParam (name: string, value: string | undefined) {
|
||||
const url = new URL(window.location.href)
|
||||
if (value) {
|
||||
url.searchParams.set(name, value)
|
||||
} else {
|
||||
url.searchParams.delete(name)
|
||||
}
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
}
|
||||
|
||||
export default function PacketsReplayProvider () {
|
||||
const state = useSnapshot(packetsReplayState)
|
||||
const { gameLoaded } = useSnapshot(miscUiState)
|
||||
|
||||
const autocomplete = useMemo(() => {
|
||||
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 (
|
||||
<div style={{
|
||||
transform: 'scale(0.5)',
|
||||
transformOrigin: 'top right'
|
||||
}}>
|
||||
<ReplayPanel
|
||||
replayName={state.replayName}
|
||||
packets={state.packetsPlayback}
|
||||
isPlaying={state.isPlaying}
|
||||
progress={state.progress}
|
||||
speed={state.speed}
|
||||
defaultFilter={appQueryParams.replayFilter ?? ''}
|
||||
clientPacketsAutocomplete={autocomplete?.client ?? []}
|
||||
serverPacketsAutocomplete={autocomplete?.server ?? []}
|
||||
customButtons={state.customButtons}
|
||||
onPlayPause={(isPlaying) => {
|
||||
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]
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
106
src/react/ReplayPanel.stories.tsx
Normal file
106
src/react/ReplayPanel.stories.tsx
Normal file
|
|
@ -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<typeof ReplayPanel> = {
|
||||
component: ReplayPanel,
|
||||
title: 'ReplayPanel'
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof ReplayPanel>
|
||||
|
||||
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<typeof ReplayPanel>[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 <ReplayPanel {...props} packets={packets} />
|
||||
}
|
||||
|
||||
export const Primary: Story = {
|
||||
render: () => (
|
||||
<ReplayPanelWithToggle
|
||||
replayName="Test Replay"
|
||||
clientPacketsAutocomplete={[]}
|
||||
serverPacketsAutocomplete={[]}
|
||||
isPlaying={false}
|
||||
progress={{ current: 0, total: 100 }}
|
||||
speed={1}
|
||||
defaultFilter=""
|
||||
customButtons={{ button1: false, button2: false }}
|
||||
onPlayPause={() => {}}
|
||||
onRestart={() => {}}
|
||||
onSpeedChange={() => {}}
|
||||
onCustomButtonToggle={() => {}}
|
||||
onFilterChange={() => {}}
|
||||
packets={mockPackets}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playing: Story = {
|
||||
render: () => (
|
||||
<ReplayPanelWithToggle
|
||||
replayName="Test Replay"
|
||||
clientPacketsAutocomplete={[]}
|
||||
serverPacketsAutocomplete={[]}
|
||||
isPlaying={true}
|
||||
progress={{ current: 50, total: 100 }}
|
||||
speed={1}
|
||||
defaultFilter=""
|
||||
customButtons={{ button1: false, button2: false }}
|
||||
onPlayPause={() => {}}
|
||||
onRestart={() => {}}
|
||||
onSpeedChange={() => {}}
|
||||
onCustomButtonToggle={() => {}}
|
||||
onFilterChange={() => {}}
|
||||
packets={mockPackets}
|
||||
/>
|
||||
)
|
||||
}
|
||||
172
src/react/ReplayPanel.tsx
Normal file
172
src/react/ReplayPanel.tsx
Normal file
|
|
@ -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 (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 18,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
background: DARK_COLORS.bg,
|
||||
padding: '16px',
|
||||
borderRadius: '0 0 8px 0',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
width: '400px',
|
||||
maxHeight: '80vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
color: DARK_COLORS.text
|
||||
}}>
|
||||
<div style={{ fontSize: '12px', fontWeight: 'bold' }}>{replayName || 'Unnamed Replay'}</div>
|
||||
|
||||
<FilterInput
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
hiddenCount={hiddenCount}
|
||||
shownCount={filteredPackets.length}
|
||||
onClearFilter={() => setFilter('')}
|
||||
clientPacketsAutocomplete={clientPacketsAutocomplete}
|
||||
serverPacketsAutocomplete={serverPacketsAutocomplete}
|
||||
/>
|
||||
|
||||
<PacketList
|
||||
packets={filteredPackets}
|
||||
filter={filter}
|
||||
maxHeight={300}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => onPlayPause?.(!isPlaying)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
color: DARK_COLORS.text
|
||||
}}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
{isPlaying ? (
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
||||
) : (
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<ProgressBar current={progress.current} total={progress.total} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button
|
||||
onClick={onRestart}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
border: `1px solid ${DARK_COLORS.border}`,
|
||||
background: DARK_COLORS.input,
|
||||
color: DARK_COLORS.text,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="number"
|
||||
value={speed}
|
||||
onChange={e => 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 => (
|
||||
<button
|
||||
key={num}
|
||||
onClick={() => onCustomButtonToggle(`button${num}` as 'button1' | 'button2')}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
border: `1px solid ${DARK_COLORS.border}`,
|
||||
background: customButtons[`button${num}`]
|
||||
? (num === 1 ? DARK_COLORS.client : DARK_COLORS.server)
|
||||
: DARK_COLORS.input,
|
||||
color: DARK_COLORS.text,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{num}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface PacketData {
|
||||
name: string
|
||||
data: any
|
||||
isFromClient: boolean
|
||||
isUpcoming: boolean
|
||||
actualVersion?: any
|
||||
position: number
|
||||
timestamp: number
|
||||
}
|
||||
159
src/react/components/replay/FilterInput.tsx
Normal file
159
src/react/components/replay/FilterInput.tsx
Normal file
|
|
@ -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<HTMLInputElement>(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 (
|
||||
<div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={e => 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 && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: DARK_COLORS.bg,
|
||||
border: `1px solid ${DARK_COLORS.border}`,
|
||||
borderRadius: '4px',
|
||||
marginTop: '4px',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
zIndex: 1
|
||||
}}>
|
||||
{filteredSuggestions.map(({ name, isClient }, index) => (
|
||||
<div
|
||||
key={name}
|
||||
onClick={() => acceptSuggestion(name)}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
background: index === selectedIndex ? DARK_COLORS.hover : 'transparent',
|
||||
color: isClient ? DARK_COLORS.client : DARK_COLORS.server
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
marginTop: '4px',
|
||||
fontSize: '12px',
|
||||
color: DARK_COLORS.textDim,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}>
|
||||
<span>Showing: {shownCount}</span>
|
||||
<span>•</span>
|
||||
<span>Hidden: {hiddenCount}</span>
|
||||
|
||||
<div style={{
|
||||
opacity: value ? 1 : 0,
|
||||
gap: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<span>•</span>
|
||||
<button
|
||||
onClick={onClearFilter}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
color: DARK_COLORS.text,
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
clear all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
141
src/react/components/replay/PacketList.tsx
Normal file
141
src/react/components/replay/PacketList.tsx
Normal file
|
|
@ -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, (data: any) => 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<HTMLDivElement>(null)
|
||||
const [expandedPacket, setExpandedPacket] = useState<number | null>(null)
|
||||
const { scrollToBottom } = useScrollBehavior(listRef, { messages: packets, opened: true })
|
||||
|
||||
let prevTimestamp: number | null = null
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
.packet-row:hover {
|
||||
background: ${DARK_COLORS.hover} !important;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div
|
||||
ref={listRef}
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
height: maxHeight,
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<div style={{ minHeight: '100%' }}>
|
||||
{packets.map((packet, index) => {
|
||||
const timeDiff = formatTimeDiff(packet.timestamp, prevTimestamp)
|
||||
prevTimestamp = packet.timestamp
|
||||
return (
|
||||
<div key={`${packet.timestamp}-${packet.position}`}>
|
||||
<div
|
||||
className="packet-row"
|
||||
onClick={() => 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
|
||||
}}
|
||||
>
|
||||
<span>{getPacketIcon(packet.name)}</span>
|
||||
<span style={{ color: DARK_COLORS.textDim }}>
|
||||
#{packet.position}
|
||||
{timeDiff && <span style={{ marginLeft: '4px' }}>{timeDiff}</span>}
|
||||
</span>
|
||||
{filter && (
|
||||
<span style={{ color: DARK_COLORS.textDim }}>#{index + 1}</span>
|
||||
)}
|
||||
<span style={{
|
||||
color: packet.actualVersion ? DARK_COLORS.modified : DARK_COLORS.text,
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{packet.name}
|
||||
</span>
|
||||
<span style={{ color: DARK_COLORS.textDim, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{formatters[packet.name]?.(packet.data) ?? JSON.stringify(packet.data)}
|
||||
</span>
|
||||
</div>
|
||||
{expandedPacket === packet.position && (
|
||||
<div style={styles.expandedPacket}>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<strong>Data:</strong>
|
||||
<pre style={{ margin: '4px 0', color: DARK_COLORS.textDim }}>
|
||||
{JSON.stringify(packet.data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
{packet.actualVersion && (
|
||||
<div>
|
||||
<strong>Actual Version:</strong>
|
||||
<pre style={{ margin: '4px 0', color: DARK_COLORS.textDim }}>
|
||||
{JSON.stringify(packet.actualVersion, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
36
src/react/components/replay/ProgressBar.tsx
Normal file
36
src/react/components/replay/ProgressBar.tsx
Normal file
|
|
@ -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 (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flex: 1 }}>
|
||||
<div style={{ color: DARK_COLORS.textDim, fontSize: '12px', minWidth: '70px' }}>
|
||||
{padNumber(current)}/{padNumber(total)}
|
||||
</div>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
height: '4px',
|
||||
background: DARK_COLORS.border,
|
||||
borderRadius: '2px'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: `${(current / total) * 100}%`,
|
||||
height: '100%',
|
||||
background: DARK_COLORS.text,
|
||||
borderRadius: '2px',
|
||||
transition: 'width 0.2s'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
src/react/components/replay/constants.ts
Normal file
11
src/react/components/replay/constants.ts
Normal file
|
|
@ -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'
|
||||
}
|
||||
55
src/react/packetsFilter.ts
Normal file
55
src/react/packetsFilter.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
19
src/react/state/packetsReplayState.ts
Normal file
19
src/react/state/packetsReplayState.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -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(<PerComponentErrorBoundary>{children}</PerComponentErrorBoundary>, to)
|
||||
|
|
@ -205,6 +206,7 @@ const App = () => {
|
|||
<TouchAreasControlsProvider />
|
||||
<SignInMessageProvider />
|
||||
<NoModalFoundProvider />
|
||||
<PacketsReplayProvider />
|
||||
</RobustPortal>
|
||||
<RobustPortal to={document.body}>
|
||||
<div className='overlay-top-scaled'>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue