-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
258 lines (220 loc) · 11.8 KB
/
Copy pathmain.py
File metadata and controls
258 lines (220 loc) · 11.8 KB
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
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
"""easy-qqbot-game 入口:python main.py [--smoke-test]
启动链:config.ini -> SQLite -> 游戏引擎 -> 内置指令 -> 加载 plugins/ 下的 mod
-> 启动自检(浏览器渲染内核缺失则阻塞下载安装;财富榜条数超限警告)
-> 客户端身份自检(阻塞:无 token 则 input() 注册客户端 id → 服务器校验
id+token → 离线数据更新,任一步失败阻止主进程启动)
-> 全球财富校验(/verify 对比本地/云端 id-QQ 绑定,冲突则 5 分钟后停机)
-> OneBot v11 正向 WebSocket(默认 ws://127.0.0.1:12981)。
日志:统一 UTC+8 时间戳(不受机器时区影响);指令消息与数据库操作(SQL)均输出到后台。
"""
import argparse
import asyncio
import json
import logging
import sys
import urllib.request
from config import GameConfig
from core.bot.commands import (BuyCommand, ChangeIdCommand, GlobalLeaderboardCommand,
GroupLeaderboardCommand, HarvestCommand, HelpCommand,
InsuranceCommand, InventoryCommand, LeaderboardCommand, MeCommand,
PlantAllCommand, PlantCommand, PlotsCommand, RegisterCommand,
SellCommand, ShopCommand, SignCommand, StealCommand,
UnlockCommand, UpgradeVaultCommand, UseCommand, VaultCommand,
FertilizeAllCommand, GetIdCommand, RecoverAccountCommand,
UnlockGoldPlotCommand, GoldPlotsCommand)
from core.bot.engine import BotEngine
from core.bot import help_image
from core.cloud.sync import CloudSync, ensure_client_identity
from core.db.database import GameDatabase
from core.game.service import GameService
from core.onebot.server import OneBotWsServer
from core.plugin.api import PluginContext
from core.plugin.loader import PluginLoader
from core.plugin.registry import CommandRegistry
log = logging.getLogger("main")
# 云端通讯:心跳/重连等参数见 core/cloud/sync.py;REPORT_INTERVAL_SECONDS 已废弃
# id 冲突停机延迟:5 分钟
CONFLICT_SHUTDOWN_SECONDS = 5 * 60
def build_app(config: GameConfig):
"""组装:DB + 引擎 + 命令注册表 + mod 上下文 + 路由引擎。"""
db = GameDatabase(config.db_path)
game = GameService(db, config)
commands = CommandRegistry()
context = PluginContext(config, db, game, commands, game.crops, game.items, game.events)
engine = BotEngine(context)
builtin = [
RegisterCommand(engine), ChangeIdCommand(), GetIdCommand(), RecoverAccountCommand(),
HelpCommand(engine), SignCommand(),
MeCommand(), InventoryCommand(), ShopCommand(), BuyCommand(engine), PlantCommand(),
PlantAllCommand(), PlotsCommand(), HarvestCommand(), SellCommand(), UnlockCommand(),
UseCommand(), FertilizeAllCommand(), InsuranceCommand(engine), VaultCommand(),
UpgradeVaultCommand(), StealCommand(), LeaderboardCommand(), GroupLeaderboardCommand(),
GlobalLeaderboardCommand(),
UnlockGoldPlotCommand(), GoldPlotsCommand(),
]
for cmd in builtin:
commands.register(cmd)
return db, game, context, engine
def _post_json(url: str, payload: dict, timeout: int = 10) -> dict:
"""同步 HTTP POST(在 asyncio.to_thread 中调用,避免阻塞事件循环)。"""
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
request = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(request, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
async def verify_bindings(config: GameConfig, db: GameDatabase) -> list:
"""启动时校验本地与云端 id/QQ 绑定一致性。
返回冲突列表 [{"id", "local_qq", "cloud_qq"}, ...];云端不可达视为无冲突
(记录 warning,不阻塞启动——首次上报会兜底校验)。
"""
if not config.global_enabled:
log.info("全球财富校验已跳过(global_enabled = false)")
return []
url = config.global_server_url.rstrip("/") + "/verify"
players = db.all_players()
if not players:
log.info("本地无玩家,跳过 id/QQ 一致性校验")
return []
payload = {"players": [{"id": p.id, "qq": p.qq_id} for p in players]}
try:
result = await asyncio.to_thread(_post_json, url, payload)
except Exception as exc: # noqa: BLE001
log.warning("启动校验失败(云端不可达,继续启动): %s", exc)
return []
conflicts = result.get("conflicts") or []
return conflicts
async def ensure_client_booted(config: GameConfig, db: GameDatabase) -> tuple:
"""启动自检(阻塞):客户端身份注册/校验 + 离线数据更新。
流程(每一步未完成则阻止主进程启动):
1. 确认本地是否已有客户端身份(data/client_token.json);
没有则阻塞 input() 让管理员输入客户端 id → 服务器生成唯一 token → 本地保存
2. 连接服务器发送 id + token 校验(client_boot 协议)
3. 服务器把该客户端持有副本的所有用户最新权威数据下发给本地(离线数据更新)
4. 本地全量上报用户(sync_users),服务器补建缺失用户并标记来源
返回 (client_id, token, is_super);任一环节失败返回 (None, None, False)。
is_super:服务器 config.json 的 superClient 匹配本客户端(激活 webUI 编辑面板)。
"""
client_id, token = await ensure_client_identity(config)
if not token:
return None, None, False
sync = CloudSync(config, db)
sync.client_id = client_id
sync.token = token
if not await sync.bootstrap():
return None, None, False
return client_id, token, sync.super_client
async def run(config: GameConfig, smoke: bool) -> int:
db, game, context, engine = build_app(config)
# 启动自检:财富榜条数硬上限(>15 警告并强制改回)
if config.leaderboard_top_n > GameConfig.MAX_LEADERBOARD_TOP_N:
log.warning("leaderboard_top_n = %d 超过硬上限 %d,已强制改为 %d",
config.leaderboard_top_n, GameConfig.MAX_LEADERBOARD_TOP_N,
GameConfig.MAX_LEADERBOARD_TOP_N)
config.leaderboard_top_n = GameConfig.MAX_LEADERBOARD_TOP_N
loader = PluginLoader(config.plugins_dir)
plugins = loader.load_all(context)
log.info("已加载 %d 个 mod:%s", len(plugins), "、".join(p.name for p in plugins) or "无")
# 启动自检:浏览器渲染内核缺失时阻塞下载安装(带进度日志),完成后预渲染帮助图片
help_image.ensure_ready(config, context.commands.all())
# 启动自检(阻塞):客户端身份注册/校验 + 离线数据更新。
# 未完成则阻止主进程启动(smoke 模式跳过,避免阻塞等待输入)
client_id = None
token = None
if config.global_enabled:
if smoke:
log.info("冒烟模式:跳过客户端身份自检与云端同步")
else:
client_id, token, is_super = await ensure_client_booted(config, db)
if not token:
log.error("客户端启动自检未通过:无法完成云端注册/校验或离线数据更新,"
"阻止主进程启动。请检查云端服务器(global_ws_url)与 "
"data/client_token.json。")
db.close()
return 1
# 启动一致性校验:云端对比本地/云端 id-QQ 绑定,冲突则 5 分钟后停机
shutdown_event = asyncio.Event()
background_tasks = []
conflicts = await verify_bindings(config, db)
if conflicts:
for c in conflicts:
log.error("id/QQ 冲突: id=%s 本地绑定 QQ=%s,云端绑定 QQ=%s",
c.get("id"), c.get("local_qq"), c.get("cloud_qq"))
log.error("服务器与本地 id 存在冲突,程序无法运行,请检查本地数据库。"
"%d 秒后自动退出。", CONFLICT_SHUTDOWN_SECONDS)
background_tasks.append(asyncio.create_task(_delayed_shutdown(shutdown_event)))
else:
log.info("启动校验通过:本地与云端 id/QQ 绑定一致")
# 通讯重构:WebSocket 持久连接(启动自检已认证;此处启动常驻同步循环)
# 取代旧的 20 分钟定时财富上报
if config.global_enabled and token:
sync = CloudSync(config, db)
sync.client_id = client_id
sync.token = token
background_tasks.append(asyncio.create_task(sync.run()))
log.info("云端实时同步已启动(client_id=%s,token 就绪)", client_id)
# superClient(服务器 config.json):本地 13059 端口开 webUI 编辑面板,
# 浏览器打开 http://127.0.0.1:<webui_port> 直改服务器玩家数据库
if is_super:
from core.webui.server import WebUiServer
webui = WebUiServer(config, client_id, token)
background_tasks.append(
asyncio.create_task(asyncio.to_thread(webui.serve_forever)))
log.info("superClient:webUI 编辑面板已启动 http://127.0.0.1:%d "
"(编辑服务器玩家数据库,保存后自动同步所有客户端)",
config.webui_port)
elif config.global_enabled and not smoke:
log.warning("客户端身份未就绪:云端实时同步未启动")
server = OneBotWsServer(config.ws_host, config.ws_port, engine.handle_message)
serve_task = asyncio.create_task(server.start())
try:
await asyncio.sleep(0.3)
log.info("🌾 种菜小农场已启动,等待 OneBot 客户端接入 ws://%s:%s(数据库: %s)",
config.ws_host, config.ws_port, config.db_path)
if smoke:
await asyncio.sleep(3)
log.info("冒烟测试完成,正常退出。")
return 0
await shutdown_event.wait() # 常驻运行;id 冲突时 5 分钟后返回 1
return 1 if shutdown_event.is_set() else 0
finally:
for t in background_tasks:
t.cancel()
await server.close()
loader.unload_all()
db.close()
return 0
async def _delayed_shutdown(event: asyncio.Event) -> None:
"""冲突停机:延迟后置位事件(run 的主循环据此退出)。"""
await asyncio.sleep(CONFLICT_SHUTDOWN_SECONDS)
log.error("id 冲突未解决,程序退出。")
event.set()
def _setup_logging() -> None:
"""日志统一 UTC+8 时间戳(不受机器时区影响)。"""
from datetime import datetime, timedelta, timezone
_UTC8 = timezone(timedelta(hours=8))
def _utc8_converter(secs):
return datetime.fromtimestamp(secs, tz=_UTC8).timetuple()
# 全局生效:所有 logging.Formatter 的时间戳都按 UTC+8 转换
logging.Formatter.converter = staticmethod(_utc8_converter)
logging.basicConfig(
level=logging.INFO,
format="[%(asctime)s] %(levelname)-7s %(name)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S +08:00",
encoding="utf-8",
)
def main() -> None:
parser = argparse.ArgumentParser(description="easy-qqbot-game 种菜小农场(OneBot v11 正向 WS)")
parser.add_argument("--smoke-test", action="store_true",
help="启动 3 秒后自动退出,用于验证完整启动链")
args = parser.parse_args()
_setup_logging()
# Windows 控制台 UTF-8 输出(配合 start.bat 的 chcp 65001)
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8")
except Exception: # noqa: BLE001
pass
config = GameConfig.load("config.ini")
code = asyncio.run(run(config, args.smoke_test))
sys.exit(code)
if __name__ == "__main__":
main()