Files
squaddocker/scripts/gen-market.py
T
gongzhiyongandClaude Opus 5 af87e416ce feat: 市场模板改为脚本生成,预置 24 个实例
新增 scripts/gen-market.py 生成市场 JSON,替代手写。模板数可传参调整。

端口排布与容量上限:
四组端口各自从常规基址每实例 +10。最先相撞的是 RCON(21114 起)追上
查询端口基址(27165),故实例数硬上限为 606,超出会让不同实例的不同
服务抢同一端口。脚本对超限直接报错并说明原因。

已校验:24 个模板占用 144 个端口(24×6)零冲突;RCON 端口与 rconPort
字段一致;pingConfig 指向查询端口而非模板默认的 Minecraft 25565;
networkMode 均为 host;memory 均非 0;镜像均非 Docker Hub;编码均 utf8。
边界用例 607 被拒、606 可生成且末个 RCON 恰好不越界。

模板按每 8 个分组,避免市场列表过长难找。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 21:53:28 +08:00

156 lines
6.5 KiB
Python
Executable File

#!/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()