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