refactor player data storage completely

This commit is contained in:
Chipmunk 2024-05-26 19:38:43 -04:00
parent 69966ec746
commit f82cbb736c
10 changed files with 297 additions and 63 deletions

View file

@ -35,7 +35,7 @@ module.exports = {
node.permissionLevel = 0
},
sendCommand (context) {
async sendCommand (context) {
const source = context.source
const bot = source.bot
const player = source.getPlayerOrThrow()
@ -43,7 +43,13 @@ module.exports = {
const username = context.getArgument('username')
const message = context.getArgument('message')
bot.sendMail(player.username, username, message)
try {
await bot.sendMail(player.username, username, message)
} catch (error) {
source.sendError('Unable to send mail: ' + error)
return
}
bot.tellraw([
{ text: 'Sent ', ...bot.styles.primary },
{ text: message, ...bot.styles.secondary },
@ -56,16 +62,17 @@ module.exports = {
const source = context.source
const bot = source.bot
const player = source.getPlayerOrThrow()
const playerData = bot.playerData[player.uuid]
const messages = bot.mail[player.username]
if (!messages || messages.length < 1) {
const messages = playerData.data?.mail
if (!messages || !messages.length) {
bot.tellraw({ text: 'You have no mail', ...bot.styles.primary }, createUuidSelector(player.uuid))
return
}
const msg = [{ text: 'Mail:\n', ...bot.styles.primary }]
messages.forEach((message) => {
msg.push(`${message.sender} (from ${message.host}): `)
msg.push(`${message.sender} (from ${message.host}:${message.port}): `)
msg.push({ text: `${message.message}\n`, ...bot.styles.secondary })
})
msg[msg.length - 1].text = msg[msg.length - 1].text.slice(0, -1)

View file

@ -1,5 +1,6 @@
const { literal, argument, string, DynamicCommandExceptionType } = require('brigadier-commands')
const TextMessage = require('../util/command/text_message')
const NEVER_SEEN_ERROR = new DynamicCommandExceptionType(username => new TextMessage([username, ' was never seen']))
module.exports = {
@ -16,14 +17,26 @@ module.exports = {
node.permissionLevel = 0
},
seenCommand (context) {
async seenCommand (context) {
const source = context.source
const bot = source.bot
const username = context.getArgument('username')
if (bot.seen[username] == null) throw NEVER_SEEN_ERROR.create(username)
const { first, last } = bot.seen[username]
let playerData = Object.values(bot.playerData).find(playerData => playerData.data.username === username)
if (!playerData) {
try {
playerData = await bot.loadPlayerData(username)
} catch (error) {
source.sendError('Unable to load player data: ' + error)
return
}
}
if (!playerData.data?.seen) throw NEVER_SEEN_ERROR.create()
const { first, last } = playerData.data.seen
source.sendFeedback([
{ text: '', ...bot.styles.primary },
{ text: username, ...bot.styles.secondary },

View file

@ -1,27 +1,19 @@
const fs = require('fs')
const path = require('path')
const filepath = path.resolve('persistent', 'mail.json')
// load the blacklist
let mail = {}
try {
mail = require(filepath)
} catch (e) {
console.log('An error occured while loading the mail.')
}
// save it every 5 minutes
setInterval(() => {
fs.writeFileSync(filepath, JSON.stringify(mail))
}, 5 * 6000)
// make the bot uuid ban blacklisted players
function inject (bot) {
bot.mail = mail
bot.sendMail = (sender, reciever, message) => {
if (!mail[reciever]) mail[reciever] = []
mail[reciever].push({ sender: sender, message, host: bot.host })
bot.sendMail = sendMail
async function sendMail (sender, receiver, message) {
let playerData = Object.values(bot.playerData).find(playerData => playerData.data.username === receiver)
let loadedManually = false
if (!playerData) {
playerData = await bot.loadPlayerData(receiver)
loadedManually = true
}
playerData.data.mail ??= []
playerData.data.mail.push({ sender: sender, message, host: bot.host, port: bot.port })
if (loadedManually) await playerData.save()
}
}

View file

@ -31,7 +31,7 @@ function inject (bot, options) {
}
bot.matrix.client.on('Room.timeline', (event, room, toStartOfTimeline) => {
if (event.getRoomId() !== bot.matrix.roomId || event.getType() !== 'm.room.message' || event.getTs() < startTime || event.sender.userId === bot.matrix.client.getUserId()) return
if (event.getRoomId() !== bot.matrix.roomId || event.getType() !== 'm.room.message' || event.getTs() < startTime || event.sender.userId === bot.matrix.client.getUserId() || !bot.loggedIn) return
const content = event.getContent()
const permissionLevel = event.sender.powerLevelNorm

92
plugins/player_data.js Normal file
View file

@ -0,0 +1,92 @@
const fs = require('fs/promises')
const path = require('path')
const nbt = require('prismarine-nbt')
const PlayerData = require('../util/player_data')
const fileExists = require('../util/file_exists')
const getOfflineUUID = require('../util/offline_player_uuid')
const playerData = {}
async function inject (bot) {
const persistentDir = bot.paths.persistent
const playerDataDir = path.join(persistentDir, 'playerdata')
if (!await fileExists(playerDataDir)) await fs.mkdir(playerDataDir)
bot.playerData = playerData
bot.loadPlayerData = loadPlayerData
if (bot.loggedIn && bot.players) {
for (const player of bot.players) {
// If we logged in while the async file operations were happening (highly unlikely but possible), load the player data for the players on the server
handlePlayerAdded(player)
}
}
bot.on('player_added', handlePlayerAdded)
async function handlePlayerAdded (player) {
if (player.uuid === bot.uuid) return
let data = playerData[player.uuid]
if (data) {
data._bots.add(bot)
return
}
data = await loadPlayerData(player.username)
data._bots.add(bot)
playerData[player.uuid] = data
data.data.username = player.username
bot.emit('player_data_loaded', player, data)
}
async function loadPlayerData (uuid) {
if (uuid.length <= 16) {
// Usernames
uuid = getOfflineUUID(uuid)
}
const data = new PlayerData(path.join(playerDataDir, uuid + '.dat'))
await data.load()
return data
}
bot.on('player_removed', async player => {
const data = playerData[player.uuid]
if (!data) return
data._bots.delete(bot)
if (data._bots.size) return
bot.emit('player_data_unloading', player, data)
await data.unload(true)
delete bot.playerData[player.uuid]
})
}
async function saveAll () {
console.log('Saving player data...')
for (const uuid in playerData) {
const data = playerData[uuid]
if (!data?.data) {
// We _somehow_ found some unloaded data
delete playerData[uuid]
continue
}
try {
await data.save()
} catch (error) {
console.error('Unable to write data for player %s:', data?.data?.username, error)
}
}
console.log('Saved!')
}
setInterval(saveAll, 60 * 3 * 1000)
module.exports = inject

View file

@ -1,42 +1,22 @@
const fs = require('fs')
const path = require('path')
const filepath = path.resolve('persistent', 'seen.json')
// load the seen data
let seen = {}
try {
seen = require(filepath)
} catch (e) {
console.log('An error occured while loading seen players.')
}
// save it every 5 minutes
setInterval(() => {
fs.writeFileSync(filepath, JSON.stringify(seen))
}, 5 * 6000)
// expose the data to the bot
function inject (bot) {
bot.seen = seen
bot.on('player_data_loaded', (player, data) => {
data.data.seen ??= {}
const seenData = data.data.seen
bot.on('player_added', player => {
if (player.uuid === bot.uuid) return
seen[player.username] ??= {}
if (seen[player.username].first == null) {
seen[player.username].first = new Date()
bot.core.run('minecraft:tellraw @a ' + JSON.stringify([
if (seenData.first == null) {
seenData.first = new Date()
bot.tellraw([
{ text: 'Welcome ', ...bot.styles.primary },
{ text: player.username, ...bot.styles.secondary },
' to the server!'
]))
], '@a')
}
seen[player.username].last = new Date()
seenData.last = new Date()
})
bot.on('player_removed', player => {
if (seen[player.username] != null) seen[player.username].last = new Date()
bot.on('player_data_unloading', (player, data) => {
const seenData = data.data.seen
if (seenData != null) seenData.last = new Date()
})
}

BIN
test.nbt Normal file

Binary file not shown.

View file

@ -0,0 +1,19 @@
const crypto = require('crypto')
function getOfflineUUID (username) {
// Hash the UUID
const md5 = crypto.createHash('md5')
md5.update('OfflinePlayer:' + username, 'utf-8')
const hash = md5.digest()
// From https://github.com/openjdk/jdk/blob/master/src/java.base/share/classes/java/util/UUID.java#L182-L185
hash[6] &= 0x0f /* clear version */
hash[6] |= 0x30 /* set to version 3 */
hash[8] &= 0x3f /* clear variant */
hash[8] |= 0x80 /* set to IETF variant */
const hex = hash.toString('hex')
return `${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20, 32)}`
}
module.exports = getOfflineUUID

60
util/persistent_data.js Normal file
View file

@ -0,0 +1,60 @@
const fs = require('fs/promises')
const nbt = require('prismarine-nbt')
const fileExists = require('./file_exists')
class PersistentData {
#endianness
data = null
constructor (filepath) {
this.filepath = filepath
this.loaded = false
}
async load () {
const { parsed, type } = await this.#loadData(this.filepath, true)
this.data = this.parse(parsed)
this.#endianness = type
this.loaded = true
}
async #loadData (filepath, tryBackup) {
if (!await fileExists(filepath)) {
return { parsed: nbt.comp({}), type: 'big' }
}
const buffer = await fs.readFile(filepath)
try {
return nbt.parse(buffer)
} catch (error) {
if (tryBackup) return this.#loadData(filepath + '_old', false)
throw error
}
}
async save () {
const data = this.unparse(this.data)
if (await fileExists(this.filepath)) await fs.copyFile(this.filepath, this.filepath + '_old') // Back up our data before a save
await fs.writeFile(this.filepath, nbt.writeUncompressed(data, this.#endianness))
}
async unload (save) {
if (save) await this.save()
this.data = null
this.#endianness = undefined
this.loaded = false
}
parse (data) {
return data
}
unparse (data) {
return data
}
}
module.exports = PersistentData

71
util/player_data.js Normal file
View file

@ -0,0 +1,71 @@
const PersistentData = require('./persistent_data')
const nbt = require('prismarine-nbt')
class PlayerData extends PersistentData {
_bots = new Set()
parse (data) {
const parsed = {}
data = data.value // * Convenient since I don't need to write data.value a bunch of times
if (data.username) parsed.username = data.username?.value
if (data.seen?.value) {
parsed.seen = {}
if (data.seen.value.first?.value) parsed.seen.first = new Date(Number(data.seen.value.first?.value))
if (data.seen.value.last?.value) parsed.seen.last = new Date(Number(data.seen.value.last?.value))
}
if (data.mail?.value?.value && Array.isArray(data.mail.value.value)) {
parsed.mail = data.mail.value.value.map(this.#parseMail)
}
return parsed
}
#parseMail (mail) {
const signedPort = Number(mail?.port?.value ?? 25565)
const uint16Array = new Uint16Array(1)
uint16Array[0] = signedPort
const unsignedPort = uint16Array[0]
return {
sender: nbt.comp({ uuid: mail?.sender?.uuid }),
message: String(mail?.message?.value),
host: String(mail?.host?.value),
port: unsignedPort
}
}
unparse (parsed) {
const data = {}
if (parsed.username) data.username = nbt.string(parsed.username)
if (parsed.seen) {
data.seen = nbt.comp({ first: nbt.long(BigInt(parsed.seen.first.getTime())), last: nbt.long(BigInt(parsed.seen.last.getTime())) })
}
if (parsed.mail) {
data.mail = nbt.list(nbt.comp(parsed.mail.map(this.#unparseMail)))
}
return nbt.comp(data)
}
#unparseMail (mail) {
const unsignedPort = mail.port ?? 25565
const int16Array = new Int16Array(1)
int16Array[0] = unsignedPort
const signedPort = int16Array[0]
return {
sender: nbt.string(mail.sender),
message: nbt.string(mail.message),
host: nbt.string(mail.host),
port: nbt.short(signedPort)
}
}
}
module.exports = PlayerData