#!/usr/bin/env python3 """ 生成 MCSManager 自定义市场 JSON —— 为每个 Squad 实例预排好端口。 为什么需要自建市场:官方市场自带的 Squad Docker 模板有三个问题, 在「无法访问 Docker Hub + 多实例」场景下必然失败。详见 PANEL.md §2。 用法: ./gen-market.py # 默认 24 个模板 ./gen-market.py 48 # 生成 48 个 ./gen-market.py 24 - # 输出到 stdout 而非写文件 SQUAD_IMAGE=<私有仓库>/steam-game-runtime:latest ./gen-market.py 端口排布(每实例 +10): 游戏 7787 + 10(N-1) 并占用 +1 查询 27165 + 10(N-1) 并占用 +1 Beacon 15000 + 10(N-1) RCON 21114 + 10(N-1) 间隔取 10 是因为游戏端口和查询端口各占 N 和 N+1 两个,只差 1 会重叠。 实例数上限 606 —— 四组端口各自从常规基址递增,最先相撞的是 RCON(21114 起)追上查询基址(27165):(27165-21114)/10 + 1 = 606。 超过这个数就必须重新规划基址,否则不同实例的不同服务会抢同一端口。 """ import json import os import sys BASE = {"port": 7787, "query": 27165, "beacon": 15000, "rcon": 21114} STEP = 10 # 相邻两组基址的最小间距决定上限:RCON(21114) 追上 query(27165) MAX_INSTANCES = (BASE["query"] - BASE["rcon"]) // STEP + 1 # = 606 IMAGE = os.environ.get( "SQUAD_IMAGE", "squaddocker.azurecr.cn/steam-game-runtime:latest") DATA_ROOT = os.environ.get("SQUAD_DATA_ROOT", "/srv/squad") MEM_MB = int(os.environ.get("SQUAD_MEM_MB", "6144")) MAXPLAYERS = int(os.environ.get("SQUAD_MAXPLAYERS", "50")) UPDATE_CMD = ('/home/steam/steamcmd/steamcmd.sh -net_ipv4 ' '+force_install_dir "/data" +login anonymous ' '"+app_update 403240 validate" +quit') def ports_for(n): off = (n - 1) * STEP return {k: v + off for k, v in BASE.items()} def package(n): p = ports_for(n) cwd = f"{DATA_ROOT}/inst{n}" return { "title": f"Squad 实例 {n:02d}", "language": "zh_cn", "platform": "Linux", "description": ( f"游戏 {p['port']}/{p['port']+1} · 查询 {p['query']}/{p['query']+1} · " f"Beacon {p['beacon']} · RCON {p['rcon']} · 目录 {cwd}。" f"端口已预排,可与其他实例并存。镜像走私有仓库(本机访问不了 Docker Hub);" f"网络 host(Squad 硬要求,bridge 下游戏内浏览器搜不到服务器)。" f"创建后需 ① mkdir -p {cwd} && chown 1000:1000 " f"② 首启下载约 14GB ③ 下载完在 ServerConfig/Rcon.cfg 设 Password," f"否则服务端不启动 RCON。" ), "image": "http://mcsmanager.com/package-images/squad.jpg", "gameType": "Squad", # 分组:每 8 个一档,免得市场列表里一长条没法找 "category": f"实例 {((n - 1) // 8) * 8 + 1:02d}-{((n - 1) // 8 + 1) * 8:02d}", "runtime": "Linux", "hardware": "每实例 1 物理核 + 6GB 内存 + 20GB 磁盘", "size": "约 14GB(游戏本体运行时下载)", "targetLink": "", "author": "自建 — 修正 MCSManager 官方 Squad 模板", "tags": ["Docker", "host网络", f"实例{n:02d}"], "setupInfo": { # 官方模板这里是裸的 ./SquadGameServer.sh,不带端口参数, # 多开时第二个实例会抢第一个的端口 "startCommand": ( f"./SquadGameServer.sh Port={p['port']} QueryPort={p['query']} " f"RCONPORT={p['rcon']} beaconport={p['beacon']} " f"FIXEDMAXPLAYERS={MAXPLAYERS} FIXEDMAXTICKRATE=50"), "stopCommand": "^C", # Squad 无 stop 命令,只能靠信号 "updateCommand": UPDATE_CMD, # Linux 上必须 utf8;官方模板在中文 Windows 环境下用 gbk,会乱码 "ie": "utf8", "oe": "utf8", "fileCode": "utf8", "type": "steam/universal", "tag": ["Squad"], "processType": "docker", "runAs": "steam", "crlf": 1, "cwd": cwd, "enableRcon": True, "rconPort": p["rcon"], "rconIp": "127.0.0.1", # 官方模板默认 25565 是 Minecraft 的,Squad 要指向 Steam 查询端口 "pingConfig": {"ip": "127.0.0.1", "port": p["query"], "type": 1}, "eventTask": {"autoStart": False, "autoRestart": True, "ignore": False}, "terminalOption": {"haveColor": False, "pty": True}, "docker": { "image": IMAGE, "updateCommandImage": IMAGE, "networkMode": "host", # 官方模板是 bridge,Squad 不工作 "ports": [], # host 网络下端口映射无意义 "memory": MEM_MB, # 0=无上限,小内存机会被 OOM 拖垮整机 "changeWorkdir": True, "workingDir": "/data", "env": [], "extraVolumes": [], "networkAliases": [], "cpusetCpus": "", "cpuUsage": 0, "maxSpace": None, "network": None, "io": None, }, }, } def main(): count = int(sys.argv[1]) if len(sys.argv) > 1 else 24 if not 1 <= count <= MAX_INSTANCES: sys.exit(f"实例数须在 1..{MAX_INSTANCES} 之间。\n" f"上限来自端口排布:RCON({BASE['rcon']}) 每实例 +{STEP}," f"到第 {MAX_INSTANCES+1} 个就会撞上查询端口基址 {BASE['query']}。") market = { "languages": [{"label": "中文", "value": "zh_cn", "path": ""}], "packages": [package(n) for n in range(1, count + 1)], } out = json.dumps(market, ensure_ascii=False, indent=2) + "\n" # 默认写入仓库根的 mcsm-market.json;传 - 则输出到 stdout if "-" in sys.argv[1:]: sys.stdout.write(out) else: import pathlib p = pathlib.Path(__file__).resolve().parent.parent / "mcsm-market.json" p.write_text(out) last = ports_for(count) print(f"已写入 {p}") print(f" 模板数 {count}(上限 {MAX_INSTANCES}) {p.stat().st_size} 字节") print(f" 实例 1 游戏 {BASE['port']} 查询 {BASE['query']} " f"Beacon {BASE['beacon']} RCON {BASE['rcon']}") print(f" 实例 {count} 游戏 {last['port']} 查询 {last['query']} " f"Beacon {last['beacon']} RCON {last['rcon']}") print(f" 镜像 {IMAGE}") if __name__ == "__main__": main()