前言 2023年的暑假,在我百般聊赖之际,网上冲浪时刷到了一个 mc服务器的视频。于是呼,心里萌发了一个想法——建立一个属于自己的服务器。在考虑的各种因素后,我选择了“纯生存”这个主题(之前都是玩命令的,很少沉下心来好好玩玩生存,也算是圆个心愿吧)。然后嘛,路走窄了,因为时机不恰当,稳定性欠佳,玩法单调
等致命负面因素,好嘛,它没了 (还活着,但在线玩家数量≤3罢了)。毕竟玩家数量跟投入的预算完全不成正比,没啥动力开,让我不禁想起了之前和酷安上一个老哥「Ifkn_271 」合作,结果也是这样消失在互联网茫茫大海之中。
不过话说回来,还是有投入了很多精力在上面的,时至今日,随着「go-cqhttp」项目寿终正寝,加上自己硬盘爆炸带来的数据库丢失,目前只剩下核心的游戏服务端还能用了,今天写一篇文章来追悼 怀念一下吧。
内容索引MCDR插件及部分服务端配置 Dynmap设置MySQL存储及使用独立Web服务器 在线服务平台程序解析及部分代码片段 QQ机器人中自动添加离线用户白名单、聊天信息同步及图片上传 杂项 介绍 关于服务器的信息在这里 可以看 当然,在上面没有涉及到技术部分。总的来说,一共涉及到了「服务端主体及相应插件」,「网页前端、后端」 ,「QQ机器人」, 「Dynmap」这几个部分,大体架构如下
以上,听我慢慢道来
游戏服务端这方面大家各有不同,我这也不是最优选,就一笔带过
外围服务
由于当时没找到使用于fabric的登录插件,被迫增加了BC和一个独立的登录服务端。在前者我用了「Geyser」和「PixelMOTD」,一个用于转换be版于java版的通信协议,另一个用来处理motd请求,看起来更加高大上。后者用了雪之樱的整合包,并加了BungeeAutoJoinServer
模组,在Authme
登录事件结束后自动进入服务器。
虽然臃肿了点,但也有好处,比如防假人压测,motd攻击
之类。
Geyser PixelMOTD BungeeAutoJoinServer MCDR
不懂Java,但又得对服务端运行做出干涉,这就得请出MCDReforged
了
MCDReforged
清除掉落物 \mcdr_server\plugins\cleandrops.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 PLUGIN_METADATA = { 'id' : 'cleanitem' , 'version' : '1.0.0' , 'name' : '扫地僧' } from mcdreforged.api.all import *import timedef clear_drops (server: PluginServerInterface ): server.execute('tellraw @a {"text":"30秒后清除掉落物","color":"yellow"}' ) time.sleep(30 ) server.execute('kill @e[type=item]' ) server.execute('tellraw @a {"text":"掉落物已清除!","color":"yellow"}' ) def on_info (server: PluginServerInterface, info: Info ): if info.content == '!!cleardrops' : clear_drops(server) @new_thread(PLUGIN_METADATA['id' ] ) def on_load (server, old ): while True : clear_drops(server) time.sleep(86400 )
插件运行在新的线程中,可以放心睡 。 当然,最好还是用「ClearDespawn 」
聊天记录统计 \mcdr_server\plugins\talk.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 PLUGIN_METADATA = { 'id' : 'talksave' , 'version' : '1.0.0' , 'name' : '记录玩家聊天记录' } DATABASE = { 'host' : '127.0.0.1' , 'user' : 'user' , 'password' : 'password' , 'database' : 'database' } import mysql.connectorfrom mcdreforged.api.all import *def on_user_info (server: PluginServerInterface, info: Info ): player = info.player message = info.content if player is None or message is None : return if message.startswith('!!MCDR' ) or message.startswith('!!plp' ) or message.startswith('!!day' ) or message =="stop" or message =="" or message.startswith('!!qb' ) or message.startswith('tellraw' ) or message.startswith('list' ): return conn= mysql.connector.connect(**DATABASE) cursor = conn.cursor() sql = "INSERT INTO chat (playername, msg, time) VALUES (%s, %s, NOW())" val = (player, message) cursor.execute(sql, val) conn.commit() cursor.close()
使用sudo pip install mysql-connector-python
安装依赖
1 2 3 4 5 6 7 CREATE TABLE `mcserve`.`chat` ( `id` int NOT NULL, `playername` varchar(255) NULL, `msg` varchar(255) NULL, `time` datetime NULL, PRIMARY KEY (`id`) );
记录聊天记录到数据库,日后可以查询
「银行」 用下界合金为货币,存储到数据库中,本想和商店搭配使用的,没写完就不放了
服务端本体
既然是生存,必然绕不开一些生电类机器,无脑投入carpet的怀抱
Tab玩家列表用styledplayerlist
微调了下
\server\config\styledplayerlist\styles\default.json 1 2 3 4 5 6 7 8 9 10 11 12 13 14 { "style_name" : "Default" , "update_tick_time" : 20 , "list_header" : [ "<gr:#ff6c00:#ff6c00><bold>峰间云海</bold></gr>" , "<color:#555555><strikethrough> </strikethrough>[ </color><color:#FF5555>%server:online%<color:#6666676>/</color>%server:max_players%</color><color:#555555> ]<strikethrough> </strikethrough></color>" ] , "list_footer" : [ "<color:#555555><strikethrough> </strikethrough></color>" , "<gray>TPS: %server:tps_colored% <dark_gray>|</dark_gray><gray> MSPT: %server:mspt_colored% <dark_gray>|</dark_gray> <gray>RAM: <color:#54fc54>%server:used_ram%/%server:max_ram%MB</color> <dark_gray>|</dark_gray> <gray>Ping: <color:#ffba26>%player:ping%ms</color>" , "<gray>游戏时间:%world:time% <dark_gray>|</dark_gray> <gray>现实时间:%server:time% <dark_gray>|</dark_gray> <gray>实体数量:%world:mob_count%" ] , "hidden_in_commands" : false }
Simple Voice Chat 平时还有几条frp线路备用,而SVC默认使用当前连接的ip+24454
进行连接,导致使用frp线路时无法使用,只好在配置里指定连接host
\config\voicechat\voicechat-server.properties:29 1 2 3 4 5 voice_host =mcvoice.hzchu.top\:24454
需开放UDP协议!
其它就没什么改动了,添加的插件列表在这里
外围应用Dynmap 如官方简介里说的一样,这是一个Google Maps-like map for your Minecraft server
,可以把服务器存档渲染成像谷歌地图般的在线网页供其他人查看
通常情况下,大部分人装好就直接用了,可能再反代一下默认的8123端口,定个自定义路径访问。不过聪明的你从上面的架构图中应该已经发现了不同。没看清?再看一次
flowchart LR
A[User] <-->|visit or talk| B[Dynmap Forestage]
B <--> C(MySQL)
C <--> D[Dynmap Backstage] 为了实现上述访问流程,需要对原有设置做出一定调整
我参考了这篇文章,国内好像还没人写过,我就当个搬运工,绝对不是水字数 。
首先先新建一个数据库,如果能设置允许访问范围的话最好只包括本机和服务端的ip
修改以下配置
更改前
\dynmap\configuration.txt:28 1 2 3 4 5 6 7 8 9 10 11 12 13 14 storage: type: filetree
更改后
\dynmap\configuration.txt:28 1 2 3 4 5 6 7 8 9 10 11 12 13 14 storage: type: mysql hostname: <mysql_ip> port: <mysql_port> database: <mysql_database> userid: <dynmap_mysql_user> password: <dynmap_mysql_password> prefix: ""
如果使用SQLite取消对应注释即可,路径可写绝对路径,把数据库放到硬盘空间充足的地方
随后,注释掉- class: org.dynmap.InternalClientUpdateComponent
所有内容并取消 注释- class: org.dynmap.JsonFileClientUpdateComponent
所有内容
所修改内容 更改前
\dynmap\configuration.txt:54 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 - class: org.dynmap.InternalClientUpdateComponent sendhealth: true sendposition: true allowwebchat: true webchat-interval: 5 hidewebchatip: true trustclientname: false includehiddenplayers: false use-name-colors: false use-player-login-ip: true require-player-login-ip: false block-banned-player-chat: true webchat-requires-login: false webchat-permissions: false chatlengthlimit: 256 hideifsneaking: false protected-player-info: false hide-if-invisiblity-potion: true hidenames: false
更改后
\dynmap\configuration.txt:54 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 - class: org.dynmap.JsonFileClientUpdateComponent writeinterval: 1 sendhealth: true sendposition: true allowwebchat: true webchat-interval: 5 hidewebchatip: false includehiddenplayers: false use-name-colors: false use-player-login-ip: false require-player-login-ip: false block-banned-player-chat: true hideifshadow: 0 hideifundercover: 0 hideifsneaking: false webchat-requires-login: false webchat-permissions: false chatlengthlimit: 256 hide-if-invisiblity-potion: true hidenames: false
然后找到disable-webserver
并将值从false
更改为 true
以禁用内部网页服务器
再修改相应的请求地址(url:
)
更改前
\dynmap\configuration.txt:441 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 url:
MySQL
\dynmap\configuration.txt:441 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 url: configuration: "standalone/MySQL_configuration.php" update: "standalone/MySQL_update.php?world={world}&ts={timestamp}" sendmessage: "standalone/MySQL_sendmessage.php" login: "standalone/MySQL_login.php" register: "standalone/MySQL_register.php" tiles: "standalone/MySQL_tiles.php?tile=" markers: "standalone/MySQL_markers.php?marker="
SQLite
\dynmap\configuration.txt:441 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 url: configuration: "standalone/configuration.php" update: "standalone/update.php?world={world}&ts={timestamp}" sendmessage: "standalone/sendmessage.php" login: "standalone/login.php" register: "standalone/register.php" tiles: "standalone/tiles.php?tile=" markers: "standalone/markers.php?marker="
Dynmap还支持MariaDB、PostgreSQL及S3存储。使用MariaDB的话将
type: mysql
改为 type: mariadb
,PostgreSQL 改为 type: postgres
并将 url:
中的 MySQL_
替换为 PostgreSQL_
。至于S3,太豪气了应该没人用吧,自建当我没说(小声bb
最后修改一下配置文件中前端的地址(会在游戏中显示)
\dynmap\configuration.txt:478 1 2 3 publicURL: https://yourdomain.com/
安装用于MySQL的java驱动包 下载后放入 mods
文件夹
试运行 开启服务端,观察日志输出中有无报错,同时检查数据库中是否有数据表生成
成功运行后配置网页前端。先安装PHP-7.4(实测8.0会存在bug无法登录),将...\dynmap\web
下所有文件复制到网页根目录即可。(模组在启动后会自动完成配置,以配置mysql为存储为例,配置会写入 \standalone\config.js
和 \web\standalone\MySQL_config.php
)如果数据库无法访问可尝试手动设置$dbhost
为127.0.0.1
打开看看吧~ 哦?一片漆黑,因为这时候还没有执行渲染命令,dynmap还没工作 ,因此我们可以执行 /dynmap radiusrender world 0 0 10
来对主世界出生点周围进行渲染,并查看中有无数据写入到tiles
表中
初步测试完成后就可以执行 /dynmap fullrender world
渲染整个世界了。
前端我做了一定修改,有兴趣可以下载看看:
注意:该地图会对性能及带宽占用上造成一定影响,请参考网上其他调优资料,根据自身实际情况调整
在线服务
峰间云海 (hzchu.top)
与其叫做官网,倒不如说是一个 综合服务平台
, 当初想着与其整个花里胡哨的页面,不如整个有用的。虽然事实证明还是花哨的好。
前端 因为当时赶工期, 从头学各种框架来不及,用Py写了个拼凑html的程序,基本能用,后面MRUI也延续了这套,后续如果有空改进下。
基本是开箱即用了
功能在后面混着讲
后端 **首页:**状态检测使用了mcstatus
库,具体操作可以参考mcstatus · PyPI 。公告用了Artalk,歪门邪道
聊天记录:
main.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 @app.route('/user/msg' , methods=['GET' ] ) def getmsg (): num = request.args.get('num' , default=0 , type =int ) if num > 160 : messages = [] message = { 'id' : 0 , 'playername' : "Null" , 'msg' : "200条后不予查看" , 'time' : "Null" } messages.append(message) return jsonify(messages), 200 conn= mysql.connector.connect(**DATABASE) cursor = conn.cursor() sql = "SELECT id, playername, msg, time FROM chat ORDER BY id DESC LIMIT %s, %s" cursor.execute(sql, (num, 40 )) result = cursor.fetchall() cursor.close() messages = [] for row in result: message = { 'id' : row[0 ], 'playername' : row[1 ], 'msg' : row[2 ], 'time' : row[3 ].strftime('%Y-%m-%d %H:%M:%S' ) } messages.append(message) return jsonify(messages), 200
在前端页面中,我是直接用了表格的形式显示,不够好看,原草稿大概是这样,可惜没时间
用于当时忘了xss处理,要在前端补上
1 2 3 4 5 6 7 8 9 10 11 function dexss (str, kwargs ) { return ('' + str) .replace (/&/g , '&' ) .replace (/</g , '<' ) .replace (/>/g , '>' ) .replace (/"/g , '"' ) .replace (/'/g , ''' ) .replace (/\//g , '/' ); };
相册:
这部分与前者差不多,前端网上随便找了个改了改,现在想想直接用Lsky pro的画廊就行了
此部分仅为读取,写入的部分由QQ机器人负责
main.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 @app.route('/user/photo' , methods=['GET' ] ) def get_data (): num = request.args.get('num' , default=0 , type =int ) conn= mysql.connector.connect(**DATABASE) cursor = conn.cursor() try : query = "SELECT id, nickname, author, time, url FROM photo ORDER BY id DESC LIMIT %s, %s" cursor.execute(query, (num, 24 )) results = cursor.fetchall() data = [] for row in results: data.append({ 'id' : row[0 ], 'nickname' : row[1 ], 'author' : row[2 ], 'time' : row[3 ], 'url' : row[4 ] }) return jsonify(data) except Exception as e: return jsonify({'error' : str (e)}) finally : cursor.close() conn.close()
坐标&反馈: 缩水了,用Artalk替代了,只能说与我计划中差的有亿点多
排行榜:
这就有点意思了,为了实现这些数据统计,我在游戏中新建了若干的计分板
+一些在常加载区块里的命令方块
,随后每隔一段时间读取存档里的存储着计分板数据的文件(\world\data\scoreboard.dat),将数值计入数据库。(起初使用Rcon执行命令显示该玩家所有计分板数据并使用正则表达式提取再存入,前期还好,到后来对性能影响太大被迫改进)
游戏里的命令就不说了,需求也不一定相同,网上一大把教程
在实际程序中,为了简化主程序的复杂程度(拉侧边滚动条都拉出火花了),我把新定义的函数放在extra.py
里
extra.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 import mysql.connectorimport python_nbt.nbt as nbtimport jsonimport timedef get_scoreboard (scoreboard_name ): start_time = time.time() file = nbt.read_from_nbt_file("D:\MCC\mcdr_server\server\world\data\scoreboard.dat" ) string_data = str (file) python_object = ast.literal_eval(string_data) json_data = json.dumps(python_object) data = json.loads(json_data) data = data["value" ]["data" ]["value" ]["PlayerScores" ]["value" ] for i in data: if i["Objective" ]["value" ] == scoreboard_name: name = i["Name" ]["value" ] score = i["Score" ]["value" ] conn= mysql.connector.connect(**DATABASE) cursor = conn.cursor() try : insert_query = "INSERT INTO scoreboard (player, {}) VALUES (%s, %s) ON DUPLICATE KEY UPDATE {} = %s" .format (scoreboard_name, scoreboard_name) data = (name, score, score) cursor.execute(insert_query, data) conn.commit() except Exception as e: print (str (e)) finally : cursor.close() conn.close() end_time = time.time() execution_time = end_time - start_time return (execution_time)
该函数回返回执行时间,不过没用错误处理,虽然一般情况下不会发生错误,不过最好也改进下再用
main.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 import threadingimport timeimport extradef updatabase (): time_used = extra.get_scoreboard("pickaxe_total" ) time_used = time_used + extra.get_scoreboard("gametime" ) time_used = time_used + extra.get_scoreboard("Kills" ) time_used = time_used + extra.get_scoreboard("fish" ) time_used = time_used + extra.get_scoreboard("xp" ) time_used =" {:.3f}" .format (time_used) def run_timer (): while True : time.sleep(2 * 60 * 60 ) threading.Thread(target=updatabase).start()
通过以下路由返回数据
main.py 1 2 3 4 5 6 7 8 9 @app.route('/user/scoreboard' ) def get_scoreboard (): cnx = mysql.connector.connect(**DATABASE) cursor = cnx.cursor() query = "SELECT * FROM scoreboard" cursor.execute(query) data = [{'playername' : row[0 ], 'pickaxe_total' : row[1 ], 'gametime' : row[2 ], 'Kills' : row[3 ], 'fish' : row[4 ], 'xp' : row[5 ]} for row in cursor.fetchall()] cursor.close() return jsonify(data)
前端用了AI帮忙,太折磨人了。
默认折叠 list.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 const scoreboardUrl = 'https://***************/user/scoreboard' ;const scoreboardBodyPickaxe = document .getElementById ('scoreboard-body-pickaxe' );const scoreboardBodyKills = document .getElementById ('scoreboard-body-kills' );const scoreboardBodyFish = document .getElementById ('scoreboard-body-fish' );const scoreboardBodyGametime = document .getElementById ('scoreboard-body-gametime' );const scoreboardBodyxp = document .getElementById ('scoreboard-body-xp' );fetch (scoreboardUrl) .then (response => response.json ()) .then (scoreboard => { function compareByPickaxe (a, b ) { return b.pickaxe_total - a.pickaxe_total ; } function compareByKills (a, b ) { return b.Kills - a.Kills ; } function compareByFish (a, b ) { return b.fish - a.fish ; } function compareByGametime (a, b ) { return b.gametime - a.gametime ; } function compareByxp (a, b ) { return b.xp - a.xp ; } function filterByPickaxe (a ) { return a.pickaxe_total > 0 ; } function filterByKills (a ) { return a.Kills != null ; } function filterByFish (a ) { return a.fish != null ; } function filterByGametime (a ) { return a.gametime != null ; } function filterByxp (a ) { return a.xp != null ; } scoreboard = scoreboard.filter (filterByPickaxe); let scoreboardByPickaxe = [...scoreboard]; scoreboardByPickaxe.sort (compareByPickaxe); console .log (scoreboardByPickaxe) let scoreboardByKills = [...scoreboard]; scoreboardByKills.sort (compareByKills); scoreboardByKills = scoreboardByKills.filter (filterByKills); let scoreboardByFish = [...scoreboard]; scoreboardByFish.sort (compareByFish); scoreboardByFish = scoreboardByFish.filter (filterByFish); let scoreboardByGametime = [...scoreboard]; scoreboardByGametime.sort (compareByGametime); scoreboardByGametime = scoreboardByGametime.filter (filterByGametime); let scoreboardByXp = [...scoreboard]; scoreboardByXp.sort (compareByxp); scoreboardByXp = scoreboardByXp.filter (filterByxp); scoreboardByPickaxe.forEach ((player, index ) => { const row = document .createElement ('tr' ); const rank = document .createElement ('td' ); const playerName = document .createElement ('td' ); const pickaxeTotal = document .createElement ('td' ); rank.textContent = index + 1 ; playerName.textContent = player.playername ; pickaxeTotal.textContent = player.pickaxe_total ; row.appendChild (rank); row.appendChild (playerName); row.appendChild (pickaxeTotal); scoreboardBodyPickaxe.appendChild (row); }); scoreboardByKills.forEach ((player, index ) => { const row = document .createElement ('tr' ); const rank = document .createElement ('td' ); const playerName = document .createElement ('td' ); const kills = document .createElement ('td' ); rank.textContent = index + 1 ; playerName.textContent = player.playername ; kills.textContent = player.Kills ; row.appendChild (rank); row.appendChild (playerName); row.appendChild (kills); scoreboardBodyKills.appendChild (row); }); scoreboardByFish.forEach ((player, index ) => { const row = document .createElement ('tr' ); const rank = document .createElement ('td' ); const playerName = document .createElement ('td' ); const fish = document .createElement ('td' ); rank.textContent = index + 1 ; playerName.textContent = player.playername ; fish.textContent = player.fish ; row.appendChild (rank); row.appendChild (playerName); row.appendChild (fish); scoreboardBodyFish.appendChild (row); }); scoreboardByGametime.forEach ((player, index ) => { const row = document .createElement ('tr' ); const rank = document .createElement ('td' ); const playerName = document .createElement ('td' ); const gametime = document .createElement ('td' ); rank.textContent = index + 1 ; playerName.textContent = player.playername ; gametime.textContent = Math .floor (player.gametime / 60 / 60 ) + "h" ; row.appendChild (rank); row.appendChild (playerName); row.appendChild (gametime); scoreboardBodyGametime.appendChild (row); }); scoreboardByXp.forEach ((player, index ) => { const row = document .createElement ('tr' ); const rank = document .createElement ('td' ); const playerName = document .createElement ('td' ); const xp = document .createElement ('td' ); rank.textContent = index + 1 ; playerName.textContent = player.playername ; xp.textContent = getLevel (player.xp ); row.appendChild (rank); row.appendChild (playerName); row.appendChild (xp); scoreboardBodyxp.appendChild (row); }); }); function getLevel (exp ) { let level = 0 ; let expToNext = 0 ; while (true ) { if (level <= 15 ) { expToNext = 2 * level + 7 ; } else if (level <= 30 ) { expToNext = 5 * level - 38 ; } else { expToNext = 9 * level - 158 ; } if (exp >= expToNext) { exp -= expToNext; level++; } else { break ; } } return level; }
管理:
如果内建账号密码登录,要增加一些工作量,所以我借鉴了一下之前某个AIGC平台的做法,向qq机器人发信息以获取有效期一周的token用于登录,这样安全性大大提高,而且写起来也不用顾虑太多。 在初次打开时网页会发送鉴权请求判断是否有效,后续请求也都带上这个token。以下是部分代码片段
封禁玩家 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @app.route('/admin/banplayer' , methods=['POST' ] ) def banplayer (): playername = request.json.get('playername' ) reason = request.json.get('reason' ) ... if reason == "" : reason = "未记录原因的封禁,请联系管理员" command = 'kick ' + playername + ' ' + reason response = extra.sendcommand(command) command = 'ban ' + playername + ' ' + reason response = extra.sendcommand(command) conn = mysql.connector.connect(**DATABASE) ... return jsonify({'code' :0 ,'response' : response}), 200
extra.py 1 2 3 4 5 6 7 8 9 10 11 import mcrconclient = mcrcon.MCRcon('127.0.0.1' , '*-*-*-*' , 2 ****5 ) client.connect() def sendcommand (command ): if not command: return {'error' : 'No command provided' } response = client.command(command) return response
解除封禁 1 2 3 4 5 6 7 8 9 @app.route('/admin/debanplayer' , methods=['POST' ] ) def debanplayer (): playername = request.json.get('playername' ) ... command = 'pardon ' + playername response = extra.sendcommand(command) conn = mysql.connector.connect(**DATABASE) ... return jsonify({'code' :0 ,'response' : response}), 200
获取用户信息 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 import ipdb@app.route('/admin/getaccount' , methods=['POST' ] ) def getaccount (): accountinfo = request.json.get('accountinfo' ) mode = int (request.json.get('mode' )) ... conn = mysql.connector.connect(**DATABASE) c = conn.cursor() if mode == 1 : c.execute("SELECT * FROM `qq_user` where qq_id = %s" , (accountinfo,)) elif mode == 2 : c.execute("SELECT * FROM `qq_user` where user_name = %s" , (accountinfo,)) elif mode == 3 : c.execute("SELECT * FROM `qq_user` where be_name = %s" , (accountinfo,)) else : c.execute("SELECT * FROM `qq_user` where qq_id = %s" , (accountinfo,)) acc_result = c.fetchall() if acc_result: if acc_result[0 ][5 ] is None : be_name = "!未绑定!" else : be_name = acc_result[0 ][5 ] playername = acc_result[0 ][1 ] c.execute("SELECT * FROM `authme` where realname = %s or username = %s" , (playername,playername,)) ip_result = c.fetchall() conn.close() if ip_result: ip = ip_result[0 ][4 ] if ipaddress.ip_address(ip).is_private: ip = "内网地址" country="南极洲" city="中山站" else : ipinfo=db.find_map(ip, "CN" ) country=ipinfo["country_name" ] city= ipinfo["region_name" ]+"," +ipinfo["city_name" ] else : ip = "!未记录在数据库!" country="北极点" city="鹦鹉螺号" return jsonify({'code' :0 ,'qq' : acc_result[0 ][0 ],'user_name' : playername,'bind_time' : acc_result[0 ][2 ],'group' : acc_result[0 ][3 ],'status' : acc_result[0 ][4 ],'be_name' : be_name,'ip' : ip,'country' :country,'city' :city}), 200 else : conn.close() return jsonify({'code' :1000 ,'msg' : '没有相应玩家消息' }), 200
QQ-BOT 在整个规划中,它承担了相当重要的责任,譬如自动添加白名单(绑定)
**绑定:**一开始我使用了一个极为简单的绑定方式:通过Rcon连接到服务器执行 whitelist add playername
,按理来说这没问题,但是后面来的玩家基本都在抱怨进不去服务器。错误截图都一样,不在白名单内。最后发现uuid不一致,有些人的离线用户名与某些正版用户名重复了,导致服务端直接沿用正版用户的uuid,过不去后续校验。所以我这里就直接计算离线用户的uuid写入到whitelist.json
内
mchelper\__init__.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 @bind.handle() async def handle_first_receive (matcher: Matcher, args: Message = CommandArg( ) ): plain_text = args.extract_plain_text() if plain_text: matcher.set_arg("username" , args) @bind.got("username" , prompt="请输入你的用户名" ) async def handle_username (event: GroupMessageEvent, username: Message = Arg( ), username_str: str = ArgPlainText("username" ) ): username = str (username) user_id = event.user_id pattern = r'^[a-zA-Z0-9_]+$' if re.match (pattern, username): pass else : extra.settimeout() await bind.finish(f"用户名只可以包括英文字母,数字,下划线" ) ... binduser(username) ... await bind.finish(f"{username} 绑定成功(若仍无法进入服务器,可尝试发送“正常流程绑定”)" ) def binduser (playername ): offline_uuid = str (extra.touuid(playername)) whitelist_file_path = r"Z:\server\whitelist.json" with open (whitelist_file_path, "r" , encoding="utf-8" ) as f: data = json.load(f) found = False for player in data: if player["name" ] == playername: player["uuid" ] = offline_uuid found = True break if not found: data.append({"name" : playername, "uuid" : offline_uuid}) with open (whitelist_file_path, "w" , encoding="utf-8" ) as f: json.dump(data, f) client.connect() command = "whitelist reload" response = client.command(command) client.disconnect()
mchelper\extra.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import hashlibimport uuiddef touuid (name ): name = "OfflinePlayer:" + name md5 = hashlib.md5() md5.update(name.encode('utf-8' )) md5_bytes = md5.digest() md5_bytes = bytearray (md5_bytes) md5_bytes[6 ] &= 0x0f md5_bytes[6 ] |= 0x30 md5_bytes[8 ] &= 0x3f md5_bytes[8 ] |= 0x80 return uuid.UUID(bytes =bytes (md5_bytes))
**解绑:**其实差不了多少
mchelper\__init__.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 def debinduser (playername ): offline_uuid = str (extra.touuid(playername)) whitelist_file_path = r"Z:\server\whitelist.json" with open (whitelist_file_path, "r" , encoding="utf-8" ) as f: data = json.load(f) for player in data: if player["name" ] == playername: data.remove(player) with open (whitelist_file_path, "w" , encoding="utf-8" ) as f: json.dump(data, f) client.connect() command = "whitelist reload" response = client.command(command) client.disconnect()
图片上传:
flowchart LR
A[User] -->|"@BOT+图片"| B["nb2-mchelper"]
B <-->|"upgit"| C("pic.hzchu.top(lysk pro)")
B --> D["MySQL"] mchelper\__init__.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 upphoto = on_message(rule=to_me(), priority=5 , block=True ) @upphoto.handle() async def handle_upphoto (bot: Bot, event: GroupMessageEvent, state: T_State ): user_id = event.user_id message = str (event.get_message()) conn = mysql.connector.connect(**DATABASE) if message: if matches: pattern = r"\[CQ:image,file=\w+\.image,subType=\d+,url=(https?://\S+?)]" matches = re.findall(pattern, message) current_time = time.strftime("%Y-%m-%d %H:%M:%S" , time.localtime(time.time())) for match in matches: file_name = str (user_id) + str (time.time() * 1000 )+".jpg" save_path = './tmp/' + file_name response = requests.get(match , stream=True ) if response.status_code == 200 : with open (save_path, 'wb' ) as file: for chunk in response.iter_content(1024 ): file.write(chunk) path = "Z:\\nb2\\mc\\tmp\\" + file_name response = extra.upload_image(path) if response.startswith("https://" ): url = response c = conn.cursor() c.execute("INSERT INTO photo VALUES (%s,%s,%s,%s,%s)" , (None , str (event.sender.card),user_id, current_time, url)) conn.commit() else : conn.disconnect() await upphoto.finish("上传失败(调用上传工具错误)" ) os.remove(path) else : conn.disconnect() await upphoto.finish("上传失败(请求图片错误)" ) conn.disconnect() await upphoto.finish("上传成功" ) else : conn.disconnect() return
聊天记录同步:
send\__init__.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 from nonebot import on_message, loggerfrom nonebot.adapters.onebot.v11 import GroupMessageEvent, Bot,MessageSegmentfrom nonebot.rule import startswithfrom nonebot import get_driverimport mcrconimport threadingimport timeimport remsg_matcher = on_message(priority=10 , block=False ) client = mcrcon.MCRcon('127.0.0.1' , '******' , 2 ***5 ) def sendcommand (command ): response = client.command(command) return response @msg_matcher.handle() async def _ (bot: Bot, event: GroupMessageEvent ): if event.group_id == 790118052 : nick = str (event.sender.card) if nick == "" : nick = str (event.sender.nickname) msg = nick+":" +replace(event.message) qqqq = "[Q群]" msg =f'[{{"text":"{qqqq} ","color":"gold"}},{{"text":"{msg} ","color":"white"}}]' command=f"tellraw @a [{msg} ]" sendcommand(command) def replace (text ): text = str (text) image_pattern = r"\[CQ:image,file=.+?image,subType=0,url=.+?\]" at_pattern = r"\[CQ:at,qq=\d+\]" picface_pattern = r"\[CQ:image,file=.+?image,subType=1,url=.+?\]" face_pattern = r"\[CQ:face,id=.+?]" record_pattern = r"\[CQ:record,file=.+?amr,url=.+?\]" redbag_pattern = r"\[CQ:redbag,title=.+?\]" shit_pattern = r"请使用最新版手机QQ体验新功能" result = re.sub(image_pattern, "[图片]" , text) result = re.sub(picface_pattern, "[图片表情]" , result) result = re.sub(face_pattern, "[表情]" , result) result = re.sub(at_pattern, "@" , result) result = re.sub(record_pattern, "[语音]" , result) result = re.sub(redbag_pattern, "[红包]" , result) result = re.sub(shit_pattern, "不支持的信息类型" , result) result = result.replace("<" , "《" ).replace(">" , "》" ) return result def connectagain (): client.connect() def run_timer (): while True : time.sleep(5 * 60 ) threading.Thread(target=connectagain).start() connectagain() timer_thread = threading.Thread(target=run_timer) timer_thread.daemon = True timer_thread.start()
杂项 备份相关: 由于之前存档炸过一次,相关的备份措施也随之建立起来。可分为主动式和被动式。主动式嘛,好说,不就是手动关掉自动保存然后压缩到一个独立硬盘嘛
被动的话就是用Syncthing进行单向同步,目前,我设置了2台设备用于备份
flowchart LR
A[Game Server] -->|"有更改时同步"| B("B Server")
A[Game Server] -->|"2h全部同步"| C
C("A Server")-->|"有更改时同步"|B 也用过git,但同步太耗性能了,遂放弃
自动节能 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 import subprocessimport psutilimport timefrom mcstatus import JavaServerimport redisBALANCE = '381b4222-f694-41f0-9685-ff5bb260df2e' HIGH_PERFORMANCE = '8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c' ENERGY_SAVER = 'a1841308-3541-4fab-bc81-f71556f20b4a' redis_client = redis.Redis(host='localhost' , port=6379 , db=1 ) server = JavaServer.lookup("127.0.0.1:26565" ) people = 1 def change_power_plan (plan ): subprocess.run(f'powercfg /S {plan} ' ) def get_power_plan (): output = subprocess.run("powercfg /GETACTIVESCHEME" , shell=True , stdout=subprocess.PIPE).stdout.decode("GBK" ) return output.split(" " )[-3 ] def what_power_plan (plan ): if plan == BALANCE: return "BALANCE" elif plan == ENERGY_SAVER: return "ENERGY_SAVER" elif plan == HIGH_PERFORMANCE: return "HIGH_PERFORMANCE" else : return "Unknown" def what_power_planid (plan ): if plan == "BALANCE" : return BALANCE elif plan == "ENERGY_SAVER" : return ENERGY_SAVER elif plan == "HIGH_PERFORMANCE" : return HIGH_PERFORMANCE else : return "Unknown" while True : current_time = time.strftime("%H:%M:%S" , time.localtime()) plan = get_power_plan() mcserve_plan = redis_client.get('powerplan' ) if mcserve_plan is None : cpu_percent = psutil.cpu_percent() status = server.status() hour = int (current_time.split(":" )[0 ]) if hour >= 0 and hour <= 5 : if plan != ENERGY_SAVER: print (str (current_time) + ":切换到节能模式" ) change_power_plan(ENERGY_SAVER) elif status.players.online > 3 : if plan != HIGH_PERFORMANCE: print (str (current_time) + ":多人,切换到高性能模式" ) change_power_plan(HIGH_PERFORMANCE) elif status.players.online > people: if plan != BALANCE: print (str (current_time) + ":有人,切换到平衡模式" ) change_power_plan(BALANCE) elif cpu_percent > 70 : if plan != HIGH_PERFORMANCE: print (str (current_time) + ":CPU占用过高,切换到平衡模式" ) change_power_plan(BALANCE) elif status.players.online <= people: if plan != ENERGY_SAVER: print (str (current_time) + ":无人,切换到节能模式" ) change_power_plan(ENERGY_SAVER) else : if plan != BALANCE: print (str (current_time) + ":切换到平衡模式" ) change_power_plan(BALANCE) if mcserve_plan is not None : decoded_mcserve_plan = mcserve_plan.decode('utf-8' ) if what_power_plan(plan) == decoded_mcserve_plan: continue else : power_plan_id = what_power_planid(decoded_mcserve_plan) change_power_plan(power_plan_id) print (str (current_time) + ":MCSERVE覆盖配置,切换到" + decoded_mcserve_plan + "计划" ) time.sleep(300 )
graph TB
A["开始"]
B{"获取mcserve_plan"}
C{"mcserve_plan为空"}
D["获取当前时间、电源计划、CPU占用率和服务器状态"]
E{"时间在0-5点"}
F{"在线人数大于3"}
G{"在线人数大于1"}
H{"CPU占用率大于70%"}
I{"在线人数小于等于1"}
J["切换到节能模式"]
K["切换到高性能模式"]
L["切换到平衡模式"]
M["切换到节能模式"]
N["切换到平衡模式"]
O{"mcserve_plan不为空"}
Q["根据mcserve_plan切换电源计划"]
R["结束"]
A-->B
B-->C
C--是-->D
D-->E
E--是-->J
E--否-->F
F--是-->K
F--否-->G
G--是-->L
G--否-->H
H--"是(后来临时修改)"-->L
H--否-->I
I--是-->M
I--否-->N
C--否-->O
O--是-->R
O--否-->Q
Q-->R 注:在部分情况下(如E5)使用高性能模式会使单核最高睿频下降,反而不利,可自行抉择
未开发完成功能 [WEB]小黑屋 :在实行白名单
后破坏性外挂直接绝迹,直接鸽了。[WEB]假人管理 :后来人也绝迹了,鸽[WEB]成就 :鸽[WEB]自检 :用于检查一开始结构图中各个部分的运行情况,后来发现都正常的很,遂鸽.[MCDR]实体追踪器 :鸽 结语 逝去的事物,只是换了个形式陪伴在你我身边。
相册(多图预警)
THEOSMANTHUSWINE()_1687446921569.9314.webp
H_Skippy(原Yangyyx)()_1687447421090.0547.webp
H_Skippy(原Yangyyx)()_1687447425470.0432.webp
H_Skippy(原Yangyyx)()_1687447430099.4773.webp
H_Skippy(原Yangyyx)()_1687447433416.5522.webp
H_Skippy(原Yangyyx)()_1687447436391.867.webp
H_Skippy(原Yangyyx)()_1687447439140.172.webp
H_Skippy(原Yangyyx)()_1687447442236.669.webp
2644.webp
726.webp
821.webp
192.webp
1228.webp
5107.webp
2917.webp
8945.webp
1045.webp
9756.webp