Commit f3747995 authored by Nemo Ma's avatar Nemo Ma

fix: retire dead bots and support probabilistic respawn

parent e5c2446b
<?php
define('CURSCRIPT', 'revbotservice');
include './include/common.inc.php';
include GAME_ROOT . './include/game.func.php';
include GAME_ROOT . './bot/revbot.func.php';
$gameRoot = dirname(__DIR__).DIRECTORY_SEPARATOR;
if(is_dir($gameRoot)) {
chdir($gameRoot);
}
require_once $gameRoot.'include/common.inc.php';
require_once GAME_ROOT.'./include/game.func.php';
require_once GAME_ROOT.'./bot/revbot.func.php';
$bot_respawn_chance = isset($_GET['respawn_chance']) ? (int)$_GET['respawn_chance'] : 35;
if($bot_respawn_chance < 0) $bot_respawn_chance = 0;
if($bot_respawn_chance > 100) $bot_respawn_chance = 100;
# 注意:因为进程锁的存在,运行bot脚本时必须确保游戏处于未开始状态
# 否则请先中止游戏,并手动清空lock目录下所有文件,然后确保游戏正处于未开始状态下运行脚本
......@@ -11,6 +22,9 @@ include GAME_ROOT . './bot/revbot.func.php';
bot_prepare_flag:
$id = 0;
$dir = GAME_ROOT.'./bot/lock/';
if(!is_dir($dir)) {
mkdir($dir, 0777, true);
}
$scdir = scandir($dir);
# 为进程创建对应编号的进程锁
$process_id = $scdir ? count($scdir)+1 : 1;
......@@ -67,18 +81,33 @@ while($id)
{
$flag = bot_acts($id);
if ($flag == 0) {
unset($gamevars['botid'][array_search($botid, $gamevars['botid'])]);
$index = array_search($id, $gamevars['botid']);
if($index !== false) unset($gamevars['botid'][$index]);
$roll = mt_rand(1,100);
if($gamestate > 10 && $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}\n";
} else {
echo "BOT:{$id} 已死亡;不加入重生队列。roll={$roll}, chance={$bot_respawn_chance}\n";
}
save_gameinfo();
save_combatinfo();
if (empty($gamevars['botid'])) break;
ob_end_flush();
break;
}
echo "\nBOT:{$id} 行动完成\n";
ob_end_flush();
}
else
{
echo "BOT:{$id} 不在活动队列,进程退出。\n";
ob_end_flush();
break;
}
sleep(1);
}
else
{
goto bot_prepare_flag;
}
}
\ No newline at end of file
}
# bothost
独立于 PHPDTS 本体的 BOT 宿主程序。它运行在服务器 A,通过 HTTP 长连接托管多个目标 PHPDTS 站点的 `bot/revbotservice.php` 进程。
## 设计要点
- **不改 PHPDTS 主体代码**:只新增 `bothost/`
- **不依赖目标机 shell**:不需要在目标机执行 `bot_enable.sh`
- **多站点**:一个 bothost 可同时接入多个 PHPDTS 站点。
- **多 bot 并发**:每个站点可设多个 worker(等价多 BOT 守护连接)。
- **自动恢复**:连接中断/超时会自动重连。
## 使用方式
1. 准备配置:
```bash
cp bothost/config.example.json bothost/config.json
# 修改 bothost/config.json
```
2. 启动:
```bash
python bothost/main.py -c bothost/config.json
```
3. 停止:
- `Ctrl+C`,或发送 `SIGTERM`
## 配置说明
- `report_interval_sec`:状态汇总打印间隔。
- `targets[]`:目标站点列表。
- `name`:站点名。
- `revbotservice_url`:目标站点的 `.../bot/revbotservice.php` 完整 URL。
- `workers`:并发 worker 数(通常对应可并发 bot 初始化/行动进程数)。
- `connect_timeout_sec`:连接超时。
- `read_timeout_sec`:读取超时,超时会断开并重连。
- `restart_delay_sec`:重连等待秒数。
- `disable_env_proxy`:是否禁用环境变量中的 HTTP/HTTPS 代理(默认 true,建议保持)。
- `insecure_skip_tls_verify`:是否跳过 TLS 证书校验(默认 false,仅测试环境临时使用)。
- `headers`:额外请求头。
- `query`:附加查询参数。可加入 `respawn_chance`(0-100)控制 BOT 死亡后的随机补位概率。
## 注意事项
1. `revbotservice.php` 是长生命周期脚本,受目标 PHP 运行时参数影响(如 `max_execution_time`)。
2. 若目标前置代理(Nginx/CDN)对长连接有限制,需要放宽超时。
3. bothost 仅负责远程托管与状态监测;BOT 行为逻辑仍由 PHPDTS 原生 `revbot` 代码执行。
## 故障诊断
- 状态中 `err` 持续增长时,查看汇总下方 `last_error`,可直接看到最近一次 HTTP/网络错误详情。
- 若出现类似 `Tunnel connection failed: 403 Forbidden`,通常是环境代理劫持导致,请确认 `disable_env_proxy=true`
- 若出现证书错误,可先确认站点证书链;仅在临时测试中可设 `insecure_skip_tls_verify=true`
-`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 任务量化清单
> 目标:在**不修改 PHPDTS 主体代码**的前提下,在独立目录 `bothost/` 内实现可部署在服务器 A 的远程 BOT 守护方案。
## 任务点(量化)
1. **需求拆分与边界确认(1 点)**
- 明确方案仅新增 `bothost/`,不改动仓库其他目录。
- 确认通过 HTTP 直接拉起 `bot/revbotservice.php`,避免依赖目标机 shell 脚本。
2. **配置模型设计(2 点)**
- 支持多目标服务器(`targets[]`)。
- 每个目标支持 BOT 并发数(`workers`)与网络超时、重连、请求头等参数。
3. **远程拉起与守护(3 点)**
- 每个 worker 长连接到目标 `revbotservice.php`
- 连接断开、超时、异常后自动重连。
- 支持信号退出(SIGINT/SIGTERM)并停止所有 worker。
4. **状态检测与生命周期管理(3 点)**
- 解析输出中的“当前游戏状态/BOT 初始化/行动完成/等待中”等关键行。
- 维护每个 worker 的状态:连接态、重启次数、错误次数、最近输出。
- 周期打印汇总,便于实时观测 bot 存活与游戏状态。
5. **可用性交付(2 点)**
- 提供 `config.example.json`
- 提供运行文档(启动/停止/部署建议/限制说明)。
总计:**11 个任务点**
## 关于“是否改动 PHPDTS 主体代码”
本实现**没有**改动 PHPDTS 主体(仓库根目录既有逻辑)。
说明:
- 通过复用已存在的 `bot/revbotservice.php` 机制实现远程触发与守护,不需要新增 PHP 接口。
- 若目标站点限制直接访问 `bot/revbotservice.php`(如 WAF、鉴权、执行时长限制),才需要在目标环境做运维层调整(非本仓库代码变更)。
{
"report_interval_sec": 15,
"targets": [
{
"name": "demo-server",
"revbotservice_url": "https://example.com/path/to/phpdts/bot/revbotservice.php",
"workers": 2,
"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"
},
"query": {
"respawn_chance": "35"
}
}
]
}
#!/usr/bin/env python3
"""bothost: 远程托管 PHPDTS bot/revbotservice.php 的轻量守护程序。"""
from __future__ import annotations
import argparse
import json
import signal
import socket
import threading
import time
import ssl
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional
@dataclass
class WorkerState:
worker_id: int
status: str = "idle"
last_line: str = ""
last_seen_ts: float = 0.0
current_game_state: Optional[int] = None
bot_id: Optional[int] = None
restarts: int = 0
errors: int = 0
last_error: str = ""
@dataclass
class TargetConfig:
name: str
revbotservice_url: str
workers: int
connect_timeout_sec: int = 10
read_timeout_sec: int = 30
restart_delay_sec: int = 2
headers: Dict[str, str] = field(default_factory=dict)
query: Dict[str, str] = field(default_factory=dict)
disable_env_proxy: bool = True
insecure_skip_tls_verify: bool = False
class TargetRuntime:
def __init__(self, config: TargetConfig):
self.config = config
self.states: Dict[int, WorkerState] = {
i: WorkerState(worker_id=i) for i in range(1, config.workers + 1)
}
self.lock = threading.Lock()
self.threads: List[threading.Thread] = []
class BotHost:
def __init__(self, targets: List[TargetConfig], report_interval_sec: int = 15):
self.targets = [TargetRuntime(t) for t in targets]
self.stop_event = threading.Event()
self.report_interval_sec = report_interval_sec
self.report_thread: Optional[threading.Thread] = None
def run(self) -> None:
for target in self.targets:
for worker_id in range(1, target.config.workers + 1):
t = threading.Thread(
target=self._worker_loop,
args=(target, worker_id),
name=f"{target.config.name}-w{worker_id}",
daemon=True,
)
target.threads.append(t)
t.start()
self.report_thread = threading.Thread(
target=self._report_loop,
name="reporter",
daemon=True,
)
self.report_thread.start()
while not self.stop_event.is_set():
time.sleep(0.5)
def shutdown(self) -> None:
self.stop_event.set()
for target in self.targets:
for t in target.threads:
t.join(timeout=1.0)
if self.report_thread:
self.report_thread.join(timeout=1.0)
def _worker_loop(self, target: TargetRuntime, worker_id: int) -> None:
cfg = target.config
while not self.stop_event.is_set():
self._set_state(target, worker_id, status="connecting")
try:
self._stream_worker(target, worker_id)
except urllib.error.HTTPError as exc:
self._record_http_error(target, worker_id, exc)
except urllib.error.URLError as exc:
self._record_error(target, worker_id, f"URLError: {exc.reason}")
except Exception as exc: # noqa: BLE001
self._record_error(target, worker_id, f"{type(exc).__name__}: {exc}")
self._increment_restart(target, worker_id)
self._set_state(target, worker_id, status="restarting")
if self.stop_event.wait(cfg.restart_delay_sec):
break
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")
handlers: List[Any] = []
if cfg.disable_env_proxy:
handlers.append(urllib.request.ProxyHandler({}))
if cfg.insecure_skip_tls_verify:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
handlers.append(urllib.request.HTTPSHandler(context=ctx))
opener = urllib.request.build_opener(*handlers) if handlers else urllib.request.build_opener()
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:
raw._sock.settimeout(cfg.read_timeout_sec)
while not self.stop_event.is_set():
try:
line = response.readline()
except socket.timeout:
self._set_state(target, worker_id, status="read_timeout")
break
if not line:
self._set_state(target, worker_id, status="disconnected")
break
text = line.decode("utf-8", errors="ignore").strip()
if not text:
continue
self._consume_line(target, worker_id, text)
def _consume_line(self, target: TargetRuntime, worker_id: int, line: str) -> None:
now = time.time()
state = target.states[worker_id]
with target.lock:
state.last_line = line
state.last_seen_ts = now
if "当前游戏状态:" in line:
try:
gs = int(line.split("当前游戏状态:", 1)[1])
state.current_game_state = gs
except ValueError:
pass
if "BOT初始化完成,id:" in line:
try:
bot_id = int(line.split("BOT初始化完成,id:", 1)[1].split()[0])
state.bot_id = bot_id
state.status = "bot_spawned"
except ValueError:
pass
if "行动完成" in line:
state.status = "running"
if "等待中" in line:
state.status = "waiting_lock"
if "已死亡;已加入重生队列" in line:
state.status = "bot_dead_queued"
state.bot_id = None
if "已死亡;不加入重生队列" in line:
state.status = "bot_dead_retired"
state.bot_id = None
if "不在活动队列,进程退出" in line:
state.status = "bot_retired"
state.bot_id = None
low = line.lower()
if "fatal error" in low or "warning:" in low or "uncaught error" in low:
state.last_error = line
state.status = "remote_php_error"
def _set_state(self, target: TargetRuntime, worker_id: int, status: str) -> None:
with target.lock:
target.states[worker_id].status = status
target.states[worker_id].last_seen_ts = time.time()
def _record_error(self, target: TargetRuntime, worker_id: int, err: str) -> None:
with target.lock:
state = target.states[worker_id]
state.errors += 1
state.last_line = err
state.last_error = err
state.last_seen_ts = time.time()
state.status = "error"
def _record_http_error(self, target: TargetRuntime, worker_id: int, err: urllib.error.HTTPError) -> None:
body = ""
try:
body = err.read(300).decode("utf-8", errors="ignore").strip()
except Exception: # noqa: BLE001
body = ""
detail = f"HTTPError {err.code}: {err.reason}"
if body:
detail += f" | body={body}"
self._record_error(target, worker_id, detail)
def _increment_restart(self, target: TargetRuntime, worker_id: int) -> None:
with target.lock:
target.states[worker_id].restarts += 1
def _report_loop(self) -> None:
while not self.stop_event.wait(self.report_interval_sec):
self.print_report()
def print_report(self) -> None:
now = time.time()
print(f"\n[{datetime.now().isoformat(timespec='seconds')}] bothost 状态汇总")
for target in self.targets:
with target.lock:
rows = []
for worker_id in sorted(target.states):
st = target.states[worker_id]
age = int(now - st.last_seen_ts) if st.last_seen_ts else -1
rows.append(
f"w{worker_id}: status={st.status}, game={st.current_game_state}, "
f"bot={st.bot_id}, restart={st.restarts}, err={st.errors}, age={age}s"
)
print(f"- {target.config.name}: {', '.join(rows)}")
for worker_id in sorted(target.states):
st = target.states[worker_id]
if st.last_error:
print(f" - w{worker_id} last_error: {st.last_error}")
def load_config(path: str) -> Dict[str, Any]:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def parse_targets(config: Dict[str, Any]) -> List[TargetConfig]:
targets: List[TargetConfig] = []
for raw in config.get("targets", []):
targets.append(
TargetConfig(
name=raw["name"],
revbotservice_url=raw["revbotservice_url"],
workers=int(raw.get("workers", 1)),
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)),
headers=dict(raw.get("headers", {})),
query=dict(raw.get("query", {})),
disable_env_proxy=bool(raw.get("disable_env_proxy", True)),
insecure_skip_tls_verify=bool(raw.get("insecure_skip_tls_verify", False)),
)
)
return targets
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Remote bot host for PHPDTS revbotservice")
p.add_argument("-c", "--config", default="bothost/config.json", help="配置文件路径")
return p
def main() -> int:
parser = build_parser()
args = parser.parse_args()
cfg = load_config(args.config)
targets = parse_targets(cfg)
if not targets:
raise SystemExit("配置中没有 targets")
host = BotHost(targets, report_interval_sec=int(cfg.get("report_interval_sec", 15)))
def _signal_handler(signum: int, _frame: Any) -> None: # noqa: ANN401
print(f"接收到信号 {signum},准备退出...")
host.shutdown()
signal.signal(signal.SIGINT, _signal_handler)
signal.signal(signal.SIGTERM, _signal_handler)
try:
host.run()
except KeyboardInterrupt:
host.shutdown()
return 0
if __name__ == "__main__":
raise SystemExit(main())
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