Commit 7d154693 authored by Nemo Ma's avatar Nemo Ma Committed by GitHub

Merge pull request #221 from amarillonmc/codex/create-bot-control-system-in-bothost-5plv4f

fix: 采用 oneshot 模式避免 revbot 长连接占用全局锁导致超时/卡服
parents b955be6b 12a84154
...@@ -15,9 +15,69 @@ $bot_respawn_chance = isset($_GET['respawn_chance']) ? (int)$_GET['respawn_chanc ...@@ -15,9 +15,69 @@ $bot_respawn_chance = isset($_GET['respawn_chance']) ? (int)$_GET['respawn_chanc
if($bot_respawn_chance < 0) $bot_respawn_chance = 0; if($bot_respawn_chance < 0) $bot_respawn_chance = 0;
if($bot_respawn_chance > 100) $bot_respawn_chance = 100; if($bot_respawn_chance > 100) $bot_respawn_chance = 100;
$oneshot = isset($_GET['oneshot']) ? (int)$_GET['oneshot'] : 0;
$oneshot = $oneshot ? 1 : 0;
# 注意:因为进程锁的存在,运行bot脚本时必须确保游戏处于未开始状态 # 注意:因为进程锁的存在,运行bot脚本时必须确保游戏处于未开始状态
# 否则请先中止游戏,并手动清空lock目录下所有文件,然后确保游戏正处于未开始状态下运行脚本 # 否则请先中止游戏,并手动清空lock目录下所有文件,然后确保游戏正处于未开始状态下运行脚本
# 单次执行模式:执行一次初始化或一次行动后立即退出,避免长连接占用游戏锁
if($oneshot)
{
load_gameinfo();
echo "oneshot=1
";
echo "当前游戏状态:{$gamestate}
";
if($gamestate <= 10) {
echo "游戏未开始,跳过。
";
exit();
}
if (!empty($gamevars['botplayer']))
{
$ids = bot_player_valid(1);
$id = $ids[0];
$gamevars['botid'][] = $id;
$gamevars['botplayer'] --;
save_gameinfo();
echo "BOT初始化完成,id:" . ($id) . "
剩余待初始化bot数量:{$gamevars['botplayer']}
";
exit();
}
if (!empty($gamevars['botid']))
{
$id = $gamevars['botid'][array_rand($gamevars['botid'])];
$flag = bot_acts($id);
if ($flag == 0) {
$index = array_search($id, $gamevars['botid']);
if($index !== false) unset($gamevars['botid'][$index]);
$roll = mt_rand(1,100);
if($bot_respawn_chance > 0 && $roll <= $bot_respawn_chance) {
$gamevars['botplayer'] = isset($gamevars['botplayer']) ? (int)$gamevars['botplayer'] + 1 : 1;
echo "BOT:{$id} 已死亡;已加入重生队列。roll={$roll}, chance={$bot_respawn_chance}
";
} else {
echo "BOT:{$id} 已死亡;不加入重生队列。roll={$roll}, chance={$bot_respawn_chance}
";
}
save_gameinfo();
save_combatinfo();
exit();
}
echo "BOT:{$id} 行动完成
";
exit();
}
echo "当前无可行动BOT。
";
exit();
}
# 进程初始化 # 进程初始化
bot_prepare_flag: bot_prepare_flag:
$id = 0; $id = 0;
......
...@@ -36,9 +36,11 @@ python bothost/main.py -c bothost/config.json ...@@ -36,9 +36,11 @@ python bothost/main.py -c bothost/config.json
- `name`:站点名。 - `name`:站点名。
- `revbotservice_url`:目标站点的 `.../bot/revbotservice.php` 完整 URL。 - `revbotservice_url`:目标站点的 `.../bot/revbotservice.php` 完整 URL。
- `workers`:并发 worker 数(通常对应可并发 bot 初始化/行动进程数)。 - `workers`:并发 worker 数(通常对应可并发 bot 初始化/行动进程数)。
- `mode``oneshot`(推荐,默认)或 `stream``oneshot` 每次请求只执行一次并立即释放服务端锁。
- `loop_interval_sec`:仅 `oneshot` 下生效,请求间隔(建议 >=2 秒)。
- `connect_timeout_sec`:连接超时。 - `connect_timeout_sec`:连接超时。
- `read_timeout_sec`:读取超时,超时会断开并重连。 - `read_timeout_sec`:读取超时,超时会断开并重连(主要用于 `stream` 模式)
- `restart_delay_sec`重连等待秒数。 - `restart_delay_sec`异常后的重试等待秒数。
- `disable_env_proxy`:是否禁用环境变量中的 HTTP/HTTPS 代理(默认 true,建议保持)。 - `disable_env_proxy`:是否禁用环境变量中的 HTTP/HTTPS 代理(默认 true,建议保持)。
- `insecure_skip_tls_verify`:是否跳过 TLS 证书校验(默认 false,仅测试环境临时使用)。 - `insecure_skip_tls_verify`:是否跳过 TLS 证书校验(默认 false,仅测试环境临时使用)。
- `headers`:额外请求头。 - `headers`:额外请求头。
...@@ -46,9 +48,11 @@ python bothost/main.py -c bothost/config.json ...@@ -46,9 +48,11 @@ python bothost/main.py -c bothost/config.json
## 注意事项 ## 注意事项
1. `revbotservice.php` 是长生命周期脚本,受目标 PHP 运行时参数影响(如 `max_execution_time`)。 1. 强烈建议使用 `mode=oneshot`:每次请求执行一次动作后退出,可显著降低对 PHPDTS 全局锁的占用,避免游戏页面“卡死无响应”。
2. 若目标前置代理(Nginx/CDN)对长连接有限制,需要放宽超时。 2. `stream` 模式仅用于兼容旧行为,若长时间运行会持续占用进程并可能放大锁争用。
3. bothost 仅负责远程托管与状态监测;BOT 行为逻辑仍由 PHPDTS 原生 `revbot` 代码执行。 3. `revbotservice.php` 受目标 PHP 运行时参数影响(如 `max_execution_time`)。
4. 若目标前置代理(Nginx/CDN)对长连接有限制,需要放宽超时。
5. bothost 仅负责远程托管与状态监测;BOT 行为逻辑仍由 PHPDTS 原生 `revbot` 代码执行。
## 故障诊断 ## 故障诊断
...@@ -60,3 +64,5 @@ python bothost/main.py -c bothost/config.json ...@@ -60,3 +64,5 @@ python bothost/main.py -c bothost/config.json
-`last_error` 中包含 `include(...common.inc.php): failed to open stream` 等报错,通常是目标站 `bot/revbotservice.php` 在 Web 环境下工作目录不正确;本仓库已修复为基于脚本目录计算 GAME_ROOT。 -`last_error` 中包含 `include(...common.inc.php): failed to open stream` 等报错,通常是目标站 `bot/revbotservice.php` 在 Web 环境下工作目录不正确;本仓库已修复为基于脚本目录计算 GAME_ROOT。
- 当目标端输出“已死亡;已加入重生队列 / 不加入重生队列”时,bothost 会将该 worker 标记为 `bot_dead_queued` / `bot_dead_retired`,并等待连接退出后自动重连。 - 当目标端输出“已死亡;已加入重生队列 / 不加入重生队列”时,bothost 会将该 worker 标记为 `bot_dead_queued` / `bot_dead_retired`,并等待连接退出后自动重连。
- 若出现“游戏本体整体无响应”,通常是长连接脚本长期占用全局锁;请切换到 `mode=oneshot` 并调大 `loop_interval_sec`
...@@ -5,13 +5,15 @@ ...@@ -5,13 +5,15 @@
"name": "demo-server", "name": "demo-server",
"revbotservice_url": "https://example.com/path/to/phpdts/bot/revbotservice.php", "revbotservice_url": "https://example.com/path/to/phpdts/bot/revbotservice.php",
"workers": 2, "workers": 2,
"mode": "oneshot",
"loop_interval_sec": 2.0,
"connect_timeout_sec": 10, "connect_timeout_sec": 10,
"read_timeout_sec": 30, "read_timeout_sec": 30,
"restart_delay_sec": 2, "restart_delay_sec": 2,
"disable_env_proxy": true, "disable_env_proxy": true,
"insecure_skip_tls_verify": false, "insecure_skip_tls_verify": false,
"headers": { "headers": {
"User-Agent": "bothost/0.3" "User-Agent": "bothost/0.4"
}, },
"query": { "query": {
"respawn_chance": "35" "respawn_chance": "35"
......
...@@ -5,11 +5,12 @@ from __future__ import annotations ...@@ -5,11 +5,12 @@ from __future__ import annotations
import argparse import argparse
import json import json
import random
import signal import signal
import socket import socket
import ssl
import threading import threading
import time import time
import ssl
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
...@@ -39,6 +40,8 @@ class TargetConfig: ...@@ -39,6 +40,8 @@ class TargetConfig:
connect_timeout_sec: int = 10 connect_timeout_sec: int = 10
read_timeout_sec: int = 30 read_timeout_sec: int = 30
restart_delay_sec: int = 2 restart_delay_sec: int = 2
loop_interval_sec: float = 2.0
mode: str = "oneshot" # oneshot / stream
headers: Dict[str, str] = field(default_factory=dict) headers: Dict[str, str] = field(default_factory=dict)
query: Dict[str, str] = field(default_factory=dict) query: Dict[str, str] = field(default_factory=dict)
disable_env_proxy: bool = True disable_env_proxy: bool = True
...@@ -74,11 +77,7 @@ class BotHost: ...@@ -74,11 +77,7 @@ class BotHost:
target.threads.append(t) target.threads.append(t)
t.start() t.start()
self.report_thread = threading.Thread( self.report_thread = threading.Thread(target=self._report_loop, name="reporter", daemon=True)
target=self._report_loop,
name="reporter",
daemon=True,
)
self.report_thread.start() self.report_thread.start()
while not self.stop_event.is_set(): while not self.stop_event.is_set():
...@@ -94,9 +93,18 @@ class BotHost: ...@@ -94,9 +93,18 @@ class BotHost:
def _worker_loop(self, target: TargetRuntime, worker_id: int) -> None: def _worker_loop(self, target: TargetRuntime, worker_id: int) -> None:
cfg = target.config cfg = target.config
jitter = random.uniform(0, max(0.2, cfg.loop_interval_sec * 0.2))
self.stop_event.wait(jitter)
while not self.stop_event.is_set(): while not self.stop_event.is_set():
self._set_state(target, worker_id, status="connecting") self._set_state(target, worker_id, status="connecting")
try: try:
if cfg.mode == "oneshot":
self._oneshot_worker(target, worker_id)
self._set_state(target, worker_id, status="idle")
if self.stop_event.wait(cfg.loop_interval_sec):
break
continue
self._stream_worker(target, worker_id) self._stream_worker(target, worker_id)
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
self._record_http_error(target, worker_id, exc) self._record_http_error(target, worker_id, exc)
...@@ -112,13 +120,7 @@ class BotHost: ...@@ -112,13 +120,7 @@ class BotHost:
self._set_state(target, worker_id, status="stopped") self._set_state(target, worker_id, status="stopped")
def _stream_worker(self, target: TargetRuntime, worker_id: int) -> None: def _build_opener(self, cfg: TargetConfig) -> urllib.request.OpenerDirector:
cfg = target.config
full_url = cfg.revbotservice_url
if cfg.query:
full_url += "?" + urllib.parse.urlencode(cfg.query)
request = urllib.request.Request(full_url, headers=cfg.headers, method="GET")
handlers: List[Any] = [] handlers: List[Any] = []
if cfg.disable_env_proxy: if cfg.disable_env_proxy:
handlers.append(urllib.request.ProxyHandler({})) handlers.append(urllib.request.ProxyHandler({}))
...@@ -127,12 +129,41 @@ class BotHost: ...@@ -127,12 +129,41 @@ class BotHost:
ctx.check_hostname = False ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE ctx.verify_mode = ssl.CERT_NONE
handlers.append(urllib.request.HTTPSHandler(context=ctx)) handlers.append(urllib.request.HTTPSHandler(context=ctx))
return urllib.request.build_opener(*handlers) if handlers else urllib.request.build_opener()
opener = urllib.request.build_opener(*handlers) if handlers else urllib.request.build_opener() def _build_url(self, cfg: TargetConfig) -> str:
q = dict(cfg.query)
if cfg.mode == "oneshot":
q.setdefault("oneshot", "1")
full_url = cfg.revbotservice_url
if q:
full_url += "?" + urllib.parse.urlencode(q)
return full_url
def _oneshot_worker(self, target: TargetRuntime, worker_id: int) -> None:
cfg = target.config
request = urllib.request.Request(self._build_url(cfg), headers=cfg.headers, method="GET")
opener = self._build_opener(cfg)
with opener.open(request, timeout=cfg.connect_timeout_sec) as response:
self._set_state(target, worker_id, status="connected")
raw = response.read().decode("utf-8", errors="ignore")
any_line = False
for line in raw.splitlines():
text = line.strip()
if not text:
continue
any_line = True
self._consume_line(target, worker_id, text)
if not any_line:
self._set_state(target, worker_id, status="empty_response")
def _stream_worker(self, target: TargetRuntime, worker_id: int) -> None:
cfg = target.config
request = urllib.request.Request(self._build_url(cfg), headers=cfg.headers, method="GET")
opener = self._build_opener(cfg)
with opener.open(request, timeout=cfg.connect_timeout_sec) as response: with opener.open(request, timeout=cfg.connect_timeout_sec) as response:
self._set_state(target, worker_id, status="connected") self._set_state(target, worker_id, status="connected")
# response.fp 是底层文件对象,可设置读超时,防止永远阻塞。
if hasattr(response, "fp") and hasattr(response.fp, "raw"): if hasattr(response, "fp") and hasattr(response.fp, "raw"):
raw = response.fp.raw raw = response.fp.raw
if hasattr(raw, "_sock") and raw._sock: if hasattr(raw, "_sock") and raw._sock:
...@@ -150,10 +181,8 @@ class BotHost: ...@@ -150,10 +181,8 @@ class BotHost:
break break
text = line.decode("utf-8", errors="ignore").strip() text = line.decode("utf-8", errors="ignore").strip()
if not text: if text:
continue self._consume_line(target, worker_id, text)
self._consume_line(target, worker_id, text)
def _consume_line(self, target: TargetRuntime, worker_id: int, line: str) -> None: def _consume_line(self, target: TargetRuntime, worker_id: int, line: str) -> None:
now = time.time() now = time.time()
...@@ -261,6 +290,8 @@ def parse_targets(config: Dict[str, Any]) -> List[TargetConfig]: ...@@ -261,6 +290,8 @@ def parse_targets(config: Dict[str, Any]) -> List[TargetConfig]:
connect_timeout_sec=int(raw.get("connect_timeout_sec", 10)), connect_timeout_sec=int(raw.get("connect_timeout_sec", 10)),
read_timeout_sec=int(raw.get("read_timeout_sec", 30)), read_timeout_sec=int(raw.get("read_timeout_sec", 30)),
restart_delay_sec=int(raw.get("restart_delay_sec", 2)), restart_delay_sec=int(raw.get("restart_delay_sec", 2)),
loop_interval_sec=float(raw.get("loop_interval_sec", 2.0)),
mode=str(raw.get("mode", "oneshot")).strip().lower() or "oneshot",
headers=dict(raw.get("headers", {})), headers=dict(raw.get("headers", {})),
query=dict(raw.get("query", {})), query=dict(raw.get("query", {})),
disable_env_proxy=bool(raw.get("disable_env_proxy", True)), disable_env_proxy=bool(raw.get("disable_env_proxy", True)),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment