Add HeXi bot codebase: custom plugins, web frontends, tests

- hexi core: message handling, rate limiting, cooldown, plugin manager
- Custom plugins: BF stats, daily check-in, quotes, persona cards, etc.
- Community plugins vendored under hexi/plugins with local fixes
- Web admin frontends (learning-chat, persona-admin), unified hexi/web
- Tests for rate_limit/cooldown/memes/persona; poetry.lock

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-01 13:13:40 +08:00
co-authored by Claude
parent 1783c60afa
commit b61d09f09f
3201 changed files with 160436 additions and 171 deletions
@@ -0,0 +1,909 @@
import json
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from nonebot import require
# 顺序很重要:先 require 声明依赖,再 import(与 nonebot_plugin_picstatus 一致)
require("nonebot_plugin_htmlrender")
from nonebot_plugin_htmlrender import get_new_page # noqa: E402
basic_path = Path(__file__).resolve().parent
img_path = basic_path / "img"
data_path = basic_path / "data"
template_path = basic_path / "templates"
def _rel(p: Path) -> str:
"""返回相对插件目录的正斜杠相对路径(配合 <base href> 解析;可移植、file:// 同目录树可加载)。"""
return p.relative_to(basic_path).as_posix()
def _load_template(name: str) -> str:
"""读取 templates/ 下的模板文件(UTF-8)。每次渲染实时读取,便于直接改模板即时生效。"""
return (template_path / name).read_text(encoding="utf-8")
# ---- 常量 ----
FACTIONS: dict[str, tuple[str, str]] = {
"Super Earth": ("超级地球", "#6bb7ea"),
"Humans": ("超级地球", "#6bb7ea"), # API 阵营名为 Humans
"Terminids": ("终结族", "#ff9900"),
"Automaton": ("机器人", "#fe6d6a"), # API 用单数 Automaton
"Automatons": ("机器人", "#fe6d6a"),
"Illuminate": ("光能族", "#CF64F8"),
}
REWARD_TYPES: dict[int, str] = {
1: "奖章",
2: "征用券",
3: "超级货币",
4: "补助",
5: "经验",
}
_name_map: dict[str, str] | None = None
# ---- 数据工具 ----
def _load_name_map() -> dict[str, str]:
global _name_map
if _name_map is None:
# 统一从 data/i18n.json 的 planet 字段取(已合并 plantes_mix.json 的星球/文案翻译)
_name_map = _load_i18n().get("planet", {})
return _name_map
def _localized(value: Any) -> str:
"""提取本地化字段。API 在指定语言下返回字符串,invarants 模式下可能是 {lang: text} 字典。"""
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, dict):
for key in ("zh-Hans", "zh-CN", "zh-Hant", "zh"):
if value.get(key):
return str(value[key])
for v in value.values():
if v:
return str(v)
return ""
return str(value)
def _is_cjk(text: str) -> bool:
return any("\u4e00" <= ch <= "\u9fff" for ch in text)
def _strict_lookup(mapping: dict[str, str], value: str) -> str | None:
"""严格匹配:整串精确匹配(先原样,再整串大小写不敏感),不做子串/模糊匹配;未命中返回 None。"""
if not value:
return None
if value in mapping:
return mapping[value]
low = value.lower()
for k, v in mapping.items():
if k.lower() == low:
return v
return None
def _translate(text: str) -> str:
"""严格翻译:整串能精确匹配到 i18n.json 的 planet 表才译,否则原样返回(不做模糊/乱配)。"""
s = text or ""
if _is_cjk(s):
return s
return _strict_lookup(_load_name_map(), s) or s
def _sector(value: Any) -> str:
"""星系本地化:优先用 API 已返回的中文;否则去掉可能的 "Sector" 后缀后严格查 sector 表。"""
s = _localized(value)
if not s:
return ""
if _is_cjk(s):
return s
key = re.sub(r"\s*SECTOR\s*$", "", s.strip(), flags=re.IGNORECASE)
return _strict_lookup(_load_i18n().get("sector", {}), key) or _translate(s)
def _fmt_num(value: Any) -> str:
try:
n = int(value)
except (TypeError, ValueError):
return str(value or "—")
if n >= 100_000_000:
return f"{n / 100_000_000:.2f}亿"
if n >= 10_000:
return f"{n / 10_000:.1f}万"
return f"{n:,}"
def _fmt_hud_num(value: Any) -> str:
"""战情数字使用游戏 HUD 常见的 K/M/B 单位,避免中文单位挤占空间。"""
try:
n = float(value)
except (TypeError, ValueError):
return str(value or "—")
if abs(n) >= 1_000_000_000:
return f"{n / 1_000_000_000:.1f}B"
if abs(n) >= 1_000_000:
return f"{n / 1_000_000:.1f}M"
if abs(n) >= 1_000:
return f"{n / 1_000:.1f}K"
return f"{int(n):,}" if n.is_integer() else f"{n:.1f}"
def _fmt_pct(value: Any) -> str:
try:
v = float(value)
except (TypeError, ValueError):
return "—"
if v <= 1:
v *= 100
return f"{max(0, min(100, round(v)))}%"
def _remaining(expiration: Any, now_ts: int) -> str:
if not expiration:
return "?"
try:
s = str(expiration).strip().replace("Z", "+00:00")
# Python 3.10 的 fromisoformat 最多支持 6 位小数秒,超长微秒需截断到 6 位
s = re.sub(r"\.(\d{6})\d+", r".\1", s)
dt = datetime.fromisoformat(s)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
secs = (dt - datetime.fromtimestamp(now_ts, tz=timezone.utc)).total_seconds()
except (ValueError, TypeError):
return "?"
if secs <= 0:
return "已结束"
h, m = int(secs // 3600), int((secs % 3600) // 60)
return f"{h}小时{m}分"
def _faction(name: str) -> tuple[str, str]:
key = (name or "").strip().title()
if key in FACTIONS:
return FACTIONS[key]
return (name or "未知"), "#cccccc"
def _logo_data_uri() -> str:
logo = img_path / "super earth.png"
if not logo.exists():
return ""
# 用相对路径链接而非 base64,避免 HTML 膨胀导致渲染慢(配合 <base href> 解析)
return _rel(logo)
def _svg_inline(path: Path, color: str, em: str = "1.2em") -> str:
"""把任意 SVG 文件改成 currentColor,返回内联 <svg>。"""
if not path or not Path(path).exists():
return ""
raw = Path(path).read_text(encoding="utf-8")
vb = re.search(r'viewBox="([^"]+)"', raw)
vb = vb.group(1) if vb else "0 0 1000 1000"
inner = re.sub(r"^\s*<svg[^>]*>", "", raw, count=1)
inner = re.sub(r"</svg>\s*$", "", inner)
inner = re.sub(r'fill="#[0-9a-fA-F]+"', 'fill="currentColor"', inner)
inner = re.sub(r'stroke="#[0-9a-fA-F]+"', 'stroke="currentColor"', inner)
return (
f'<svg class="ic" viewBox="{vb}" '
f'style="width:{em};height:{em};color:{color};vertical-align:middle;">'
f"{inner}</svg>"
)
def _icon_svg(name: str, color: str, em: str = "1.2em") -> str:
"""读取 img/assets/icons/{name}.svg,返回内联 <svg>;
原始游戏 SVG(含XML声明/DOCTYPE/嵌套svg)内联会破坏HTML结构导致文字不显示,改用 <img> 原样嵌入。"""
p = img_path / "assets" / "icons" / f"{name}.svg"
if not p.exists():
return ""
raw = p.read_text(encoding="utf-8")
if "<?xml" in raw or "<!DOCTYPE" in raw or raw.count("<svg") > 1:
# 原始游戏 SVG 内联会破坏HTML结构导致文字不显示;用相对路径链接而非 base64
return (
f"<img class='ic' src='{_rel(p)}' "
f"style='width:{em};height:{em};color:{color};vertical-align:middle;'>"
)
return _svg_inline(p, color, em)
def _icon_any(name: str, color: str, em: str = "1.2em") -> str:
"""优先用 PNG/webp 位图(用户转好的固定尺寸图标);无位图再用 SVG(currentColor 可变色)。"""
# 图标用相对路径链接而非 base64(HTML 更小、渲染更快)
for ext in (".png", ".webp"):
p = img_path / "assets" / "icons" / f"{name}{ext}"
if p.exists():
return (
f"<img class='ic' src='{_rel(p)}' "
f"style='width:{em};height:{em};color:{color};vertical-align:middle;'>"
)
svg = img_path / "assets" / "icons" / f"{name}.svg"
if svg.exists():
return _icon_svg(name, color, em)
return ""
# 阵营中文名 -> 图标
FACTION_ICON: dict[str, str] = {
"超级地球": "faction-super-earth",
"终结族": "faction-terminid",
"机器人": "faction-automaton",
"光能族": "faction-illuminate",
}
# 星球效果 -> 图标
EFFECT_ICONS: dict[str, str] = {
"1187": "effect-gloom", "1188": "effect-gloom", "1190": "effect-gloom", "1193": "effect-gloom",
"1243": "effect-predator-strain", "1245": "effect-predator-strain",
"1244": "effect-spore-burst-strain", "1386": "effect-spore-burst-strain",
"1303": "effect-rupture-strain", "1310": "effect-rupture-strain",
"1306": "effect-dragonroach", "1309": "effect-dragonroach",
"1307": "effect-hive-lord", "1308": "effect-hive-lord",
"1311": "effect-hive-world",
"1202": "effect-jet-brigade", "1203": "effect-jet-brigade",
"1209": "effect-dss-eagle", "1212": "effect-dss-eagle", "1216": "effect-dss-eagle",
"1210": "effect-dss-blockade", "1213": "effect-dss-blockade", "1215": "effect-dss-blockade",
"1211": "effect-dss-heavy-ordnance", "1214": "effect-dss-heavy-ordnance",
"1232": "effect-factory-hub",
"1238": "effect-dss",
"1239": "effect-jet-brigade-factory",
"1248": "effect-incineration-corps", "1249": "effect-incineration-corps",
"1360": "effect-cyborgs", "1361": "effect-cyborgs",
"1379": "effect-appropriators", "1380": "effect-appropriators",
"1377": "effect-mindless-masses", "1378": "effect-mindless-masses",
"1402": "effect-vote-snatchers", "1403": "effect-vote-snatchers",
"1413": "effect-invasion-fleet", "1414": "effect-invasion-fleet",
"1395": "effect-tcs-plus",
"1400": "effect-heavy-seaf", "1401": "effect-heavy-seaf",
"1324": "effect-mine",
}
# 子派系/种族事件效果 -> 种族主题色(效果徽章按种族着色,不再统一金色)
EFFECT_RACE_COLOR: dict[str, str] = {
# 终结族 (橙)
"1187": "#ff9900", "1188": "#ff9900", "1190": "#ff9900", "1193": "#ff9900",
"1243": "#ff9900", "1245": "#ff9900", "1244": "#ff9900", "1386": "#ff9900",
"1303": "#ff9900", "1310": "#ff9900", "1306": "#ff9900", "1309": "#ff9900",
"1307": "#ff9900", "1308": "#ff9900", "1311": "#ff9900",
# 机器人 (红)
"1202": "#fe6d6a", "1203": "#fe6d6a", "1239": "#fe6d6a", "1248": "#fe6d6a",
"1249": "#fe6d6a", "1360": "#fe6d6a", "1361": "#fe6d6a",
# 光能族 (紫)
"1377": "#CF64F8", "1378": "#CF64F8", "1379": "#CF64F8", "1380": "#CF64F8",
"1402": "#CF64F8", "1403": "#CF64F8", "1413": "#CF64F8", "1414": "#CF64F8",
# 超级地球 (蓝) —— DSS 行动/设施
"1209": "#6bb7ea", "1216": "#6bb7ea", "1210": "#6bb7ea", "1215": "#6bb7ea",
"1211": "#6bb7ea", "1217": "#6bb7ea", "1232": "#6bb7ea", "1234": "#6bb7ea",
"1236": "#6bb7ea", "1238": "#6bb7ea", "1261": "#6bb7ea", "1353": "#6bb7ea",
"1262": "#6bb7ea", "1279": "#6bb7ea", "1286": "#6bb7ea", "1287": "#6bb7ea",
"1291": "#6bb7ea", "1292": "#6bb7ea", "1400": "#6bb7ea", "1401": "#6bb7ea",
}
# 环境危害(英名 -> 图标);API 里的 hazard.name 为英文,ID 化/未知时退化为无图标。
HAZARD_ICON: dict[str, str] = {
"Acid Storms": "hazard-acid-storms",
"Blizzards": "hazard-blizzards",
"Extreme Cold": "hazard-extreme-cold",
"Fire Tornadoes": "hazard-fire-tornados",
"Intense Heat": "hazard-intense-heat",
"Ion Storms": "hazard-ion-storms",
"Meteor Storms": "hazard-meteor-storms",
"Rainstorms": "hazard-rainstorms",
"Sandstorms": "hazard-sandstorms",
"Thick Fog": "hazard-thick-fog",
"Tremors": "hazard-tremors",
"Volcanic Activity": "hazard-volcanic-activity",
}
# ---- 星球缩略图 ----
_bg_index: dict[str, Path] | None = None
def _planet_bg(index: Any, planet_english: dict[Any, Any]) -> str:
"""按星球 index 查英文名,匹配 planets/ 下的高清地形图,返回 CSS background-image 值;没图返回空。"""
global _bg_index
if _bg_index is None:
_bg_index = {}
d = img_path / "assets" / "planets"
if d.exists():
for f in d.glob("*.png"):
_bg_index[f.stem] = f
eng = _localized(planet_english.get(index)) if index is not None else ""
if not eng:
return ""
slug = re.sub(r"[^a-z0-9]+", "-", eng.lower()).strip("-")
f = _bg_index.get(slug)
if f is None:
return ""
# 背景大图用相对路径链接而非 base64(HTML 更小、渲染更快;配合 <base href>)
return f"url({_rel(f)})"
_font_cache: str | None = None
def _font_faces() -> str:
"""内嵌 @font-face:拉丁/数字用 HD2 游戏字体(FS/HC Sinclair);中文按用途分配——
星球名=爱点风雅黑,星系名=造字工房尚黑,重要指令=创客贴金刚体,正文=一品创享体。"""
global _font_cache
if _font_cache is not None:
return _font_cache
spec = (
(400, "HCSinclair-Normal.woff2", "woff2", "HC Sinclair"),
(500, "HCSinclair-Medium.woff2", "woff2", "HC Sinclair"),
(700, "FSSinclair-Bold.ttf", "ttf", "FS Sinclair"),
)
faces = []
for weight, fname, fmt, family in spec:
p = data_path / "font" / fname
if p.exists():
# 字体用文件路径链接而非 base64(HTML 更小、渲染更快)
faces.append(
f"@font-face{{font-family:'{family}';"
f"src:url({_rel(p)}) format('{fmt}');"
f"font-weight:{weight};font-style:normal;font-display:swap;}}"
)
# 中文字体(按用途分配):星球名/星系名/重要指令/正文
pkg = data_path / "font" / "HD2S字体包"
chinese = (
("HD2 Planet", "商用免费/爱点风雅黑/爱点风雅黑.ttf"),
("HD2 Sector", "商用授权/造字工坊尚黑/造字工房尚黑G0v1常规体.otf"),
("HD2 Display", "商用免费/创客贴金刚体/ChuangKeTieJinGangTi-2.otf"),
("HD2 Body", "商用免费/一品创享体/一品创享体 1.20.ttf"),
("HD2 Sans", "商用免费/思源黑体/SourceHanSansCN-Regular.otf"),
)
for family, rel in chinese:
p = pkg / rel
if p.exists():
fmt = "opentype" if p.suffix.lower() == ".otf" else "truetype"
faces.append(
f"@font-face{{font-family:'{family}';"
f"src:url({_rel(p)}) format('{fmt}');"
"font-weight:100 900;font-style:normal;font-display:swap;}"
)
_font_cache = "\n".join(faces)
return _font_cache
# ---- 各板块渲染 ----
def _render_header(fetched_at: int) -> str:
t = datetime.fromtimestamp(fetched_at).strftime("%Y-%m-%d %H:%M")
return f"""
<div class="header">
<img class="logo" src="{_logo_data_uri()}" alt="logo" />
<div class="title">超级地球战况简报</div>
<div class="subtitle">Galactic War Briefing · {t}</div>
</div>
"""
def _render_major_order(data: dict[str, Any]) -> str:
assignments = data.get("assignments") or []
if not assignments:
return "<div class='empty'>今日暂无重要指令</div>"
a = assignments[0]
title = _translate(_localized(a.get("title"))) or "重要指令"
description = _translate(_localized(a.get("briefing") or a.get("description")))
remain = _remaining(a.get("expiration"), data["fetched_at"])
rewards = a.get("rewards")
if rewards is None:
rewards = [a["reward"]] if a.get("reward") else []
reward_text = ""
for r in rewards or []:
amount = r.get("amount") if isinstance(r, dict) else r
rtype = r.get("type") if isinstance(r, dict) else None
label = REWARD_TYPES.get(int(rtype), "") if rtype is not None else ""
reward_text += f"{_fmt_num(amount)}{label} "
if reward_text:
reward_text = reward_text.strip()
# 指令目标(ORDER OVERVIEW):从 tasks 提取目标星球 index(valueType=12 / 取最大值),
# 用 /planets 英名翻译成中文,匹配 campaign 的 health/maxHealth 取解放进度
eng = data.get("planet_english") or {}
camp_by_idx = {c.get("planet", {}).get("index"): c for c in (data.get("campaigns") or [])}
targets = []
for task in (a.get("tasks") or []):
values = task.get("values") or []
vtypes = task.get("valueTypes") or []
idx = None
for i, v in enumerate(values):
vt = vtypes[i] if i < len(vtypes) else None
if vt == 12:
idx = v
break
if idx is None and values:
idx = max(values)
if idx is None:
continue
ename = eng.get(idx)
pname = _translate(ename) if ename else f"星球{idx}"
pct_text = "—"
bar_html = ""
dot_color = "#FFE710"
camp = camp_by_idx.get(idx)
if camp:
p = camp.get("planet") or {}
owner_cn, owner_color = _faction(p.get("currentOwner"))
is_defense = owner_cn == "超级地球"
health = float(p.get("health") or 0)
max_health = float(p.get("maxHealth") or 0)
if is_defense:
# 防守:蓝=剩余血(超级地球控制);敌色=入侵进度(侵占蓝条)
se_pct = max(0, min(100, round(health / max_health * 100))) if max_health else 0
inv_pct = 100 - se_pct
enemy_color = _faction(camp.get("faction") or p.get("currentOwner"))[1]
bar_html = _segbar_duo(enemy_color, inv_pct, "#6bb7ea", cls="otseg")
pct_text = f"{inv_pct}%"
dot_color = enemy_color
else:
# 解放:蓝=解放进度;敌色=残余
lib_pct = max(0, min(100, round((max_health - health) / max_health * 100))) if max_health else 0
bar_html = _segbar_duo("#6bb7ea", lib_pct, owner_color, cls="otseg")
pct_text = f"{lib_pct}%"
dot_color = "#6bb7ea"
targets.append((pname, pct_text, bar_html, dot_color))
ov_html = ""
if targets:
rows = []
for pname, pct_text, bar_html, dot_color in targets:
rows.append(
f"<div class='order-target'>"
f"<span class='ot-dot' style='background:{dot_color}'></span>"
f"<span class='ot-main'><span class='ot-name'>{pname}</span>{bar_html}</span>"
f"<span class='ot-pct'>{pct_text}</span></div>"
)
ov_html = (
'<div class="order-ov">'
'<div class="order-ov-title">指令目标</div>'
+ "".join(rows)
+ "</div>"
)
return f"""
<div class="box order">
<div class="order-head">
<span class="order-title2"><span class="dot"></span>{_icon_svg('misc-major-order', '#FFE710')}{title}</span>
<span class="order-remain">剩余:{remain}</span>
</div>
<div class="order-desc">{description or "无任务描述"}</div>
{ov_html}
<div class="order-meta"><span>奖励:{reward_text or "—"}</span></div>
</div>
"""
def _render_stats(war: Any, campaign_count: int = 0) -> str:
stats = (war or {}).get("statistics") or {}
items = [
("参战潜兵", _fmt_hud_num(stats.get("playerCount"))),
("任务完成率", _fmt_pct(stats.get("missionSuccessRate"))),
("当前战役数", _fmt_hud_num(campaign_count)),
("终结族总击杀", _fmt_hud_num(stats.get("terminidKills"))),
("机器人总击杀", _fmt_hud_num(stats.get("automatonKills"))),
("光能族总击杀", _fmt_hud_num(stats.get("illuminateKills"))),
("潜兵总阵亡", _fmt_hud_num(stats.get("deaths"))),
("任务完成总数", _fmt_hud_num(stats.get("missionsWon"))),
]
cells = "".join(
f"<div class='stat'><div class='v'>{v}</div><div class='k'>{k}</div></div>"
for k, v in items
)
return f"""
<div class="box">
<div class="box-title"><span class="dot"></span>星系战况</div>
<div class="stats">{cells}</div>
</div>
"""
# ---- 星球效果(planetActiveEffects) ----
_i18n_cache: dict | None = None
def _load_i18n() -> dict:
"""读取 data/i18n.json(规范化翻译数据):{biome, hazard, effect, planet} 各为 {key: 中文}。"""
global _i18n_cache
if _i18n_cache is None:
p = data_path / "i18n.json"
_i18n_cache = json.loads(p.read_text(encoding="utf-8")) if p.exists() else {}
return _i18n_cache
def _planet_effects_html(index: Any, planet_effects: dict[Any, Any], color: str) -> str:
"""星球卡「效果插槽」:把这个星球当前所有的 planetActiveEffect 都列出来(事件/特征一视同仁,
不分类),没有就返回空;没了自然消失。最多展示 6 个。"""
i18n_eff = _load_i18n().get("effect", {})
seen: list[tuple[str, str]] = []
seen_keys: set[str] = set()
for gid in (planet_effects.get(index) or []):
label = i18n_eff.get(str(gid), "")
if label and label not in seen_keys:
seen_keys.add(label)
seen.append((label, str(gid)))
if not seen:
return ""
spans = []
for label, gid in seen[:6]: # 最多6个
c = EFFECT_RACE_COLOR.get(gid, "#e0c46a")
spans.append(
f"<span class='efx' style='background:{c}1f;border-color:{c}66;color:{c}'>"
f"{_icon_any(EFFECT_ICONS.get(gid, ''), c)} {label}</span>"
)
return "".join(spans)
def _ui_icon(name: str, color: str, em: str = "1em") -> str:
"""内联小图标(fill/stroke 用 currentColor,可配色),用于单卡信息/战役/特征行。"""
icons = {
"globe": '<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M3 12h18M12 3c2.6 2.4 4 5.6 4 9s-1.4 6.6-4 9c-2.6-2.4-4-5.6-4-9s1.4-6.6 4-9Z" fill="none" stroke="currentColor" stroke-width="1.5"/>',
"diver": '<path d="M12 2c2.5 0 4.7 2 4.7 4.6 0 1.6-.7 3-1.8 3.9 4.8 1.5 7.3 5 7.3 8.9v.6h-2.4v-.6c0-2.7-1.7-5.2-5-6.6.3.6.5 1.3.5 2 0 .5-.3.7-.7.4-.5-.3-1.2-.8-1.9-.8s-1.4.5-1.9.8c-.4.3-.7.1-.7-.4 0-.7.2-1.4.5-2-3.3 1.4-5 3.9-5 6.6v.6H3v-.6c0-3.9 2.5-7.4 7.3-8.9-1.1-.9-1.8-2.3-1.8-3.9C8.5 4 9.5 2 12 2Z" fill="currentColor"/>',
"mountain": '<path d="M3 19 9 7l4 7 3-4 5 9H3Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><circle cx="9.5" cy="5.5" r="1.4" fill="currentColor"/>',
"cloud": '<path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.6-1.5A4.5 4.5 0 0 1 17.5 18H7Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>',
"crosshair": '<circle cx="12" cy="12" r="6.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 1.5v4M12 18.5v4M1.5 12h4M18.5 12h4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>',
"clock": '<circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 7.5V12l3 2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>',
"warn": '<path d="M12 3 21.5 19.5h-19L12 3Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M12 9v4.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="12" cy="16.4" r="1" fill="currentColor"/>',
"grid": '<rect x="2.5" y="2.5" width="8" height="8" rx="1.2" fill="none" stroke="currentColor" stroke-width="1.4"/><rect x="13.5" y="2.5" width="8" height="8" rx="1.2" fill="none" stroke="currentColor" stroke-width="1.4"/><rect x="2.5" y="13.5" width="8" height="8" rx="1.2" fill="none" stroke="currentColor" stroke-width="1.4"/><rect x="13.5" y="13.5" width="8" height="8" rx="1.2" fill="none" stroke="currentColor" stroke-width="1.4"/>',
}
d = icons.get(name, "")
if not d:
return ""
return f"<svg class='ic' viewBox='0 0 24 24' style='width:{em};height:{em};color:{color};vertical-align:middle;'>{d}</svg>"
def _segbar_duo(first_color: str, first_pct: float, second_color: str, segs: int = 28, cls: str = "") -> str:
"""双色交战进度条:前一色占 first_pct 的段数,其余为第二色;谁段多谁占优。
用于表达“蓝色(超级地球) vs 敌方种族色”两股势力在进度条上争地盘。"""
first_pct = max(0, min(100, first_pct))
n1 = round(first_pct / 100 * segs)
cells = "".join(
f"<i style='background:{first_color}'></i>" if i < n1
else f"<i style='background:{second_color}'></i>"
for i in range(segs)
)
return f"<div class='segbar segbar-duo {cls}'>{cells}</div>"
def _info_row(icon: str, label: str, value: str, color: str) -> str:
return (
f"<div class='irow'>"
f"<span class='irow-ic'>{_ui_icon(icon, color, '15px')}</span>"
f"<span class='irow-label'>{label}</span>"
f"<span class='irow-val'>{value or '—'}</span></div>"
)
def _dss_badge(planet_index: Any, space_stations: list[Any] | None) -> str:
"""民主空间站(DSS)——看作一个“会动的星球”:它驻扎在哪个星球,就给那个星球带来增益 buff。
性质与插槽里的“事件/特征”不同,因此做成独立的「民主空间站」元素展示。"""
st = next((s for s in (space_stations or []) if s.get("planetIndex") == planet_index), None)
if not st:
return ""
eff = _load_i18n().get("effect", {})
name = eff.get("1217", "") or "民主空间站"
# 分类:带图标的特殊行动 vs 固有效果(去 (Marker) 后缀、去重、跳过无标签)
actions = [] # [<span class='dss-b'>[icon]label</span>]
inherent = [] # [label]
seen_a: set[str] = set()
seen_i: set[str] = set()
for g in (st.get("activeEffectIds") or []):
gid = str(g)
b = re.sub(r"\s*\(Marker\)\s*$", "", eff.get(gid, "")).strip()
if not b:
continue
icon_name = EFFECT_ICONS.get(gid, "")
if icon_name in ("effect-dss-eagle", "effect-dss-blockade", "effect-dss-heavy-ordnance"):
if b in seen_a:
continue
seen_a.add(b)
icon = _icon_any(icon_name, "#6bb7ea", "13px")
actions.append(f"<span class='dss-b'>{icon}{b}</span>")
else:
if b in seen_i:
continue
seen_i.add(b)
inherent.append(b)
icon = _icon_any("effect-dss", "#6bb7ea", "16px")
# 两行:一行=带图标的特殊行动(无·连接),一行=固有效果(单独)
actions_line = f"<div class='dss-actions'>{''.join(actions)}</div>" if actions else ""
inherent_line = f"<div class='dss-inherent'>{' · '.join(inherent)}</div>" if inherent else ""
return (
f"<div class='dss'>"
f"<div class='dss-head'><span class='dss-ic'>{icon}</span><span class='dss-tx'>{name}</span></div>"
f"{inherent_line}{actions_line}</div>"
)
def _camp_card_html(cd: dict) -> str:
"""单张战役卡片(新设计:左侧军种竖栏 + 行星背景 + 分段进度 + 信息行 + 主要战役 + 特征/事件)。"""
bg = cd.get("bg", "")
style = f"background-image:{bg};" if bg else ""
color = cd.get("color", "#cccccc")
owner_cn = cd.get("owner_cn", "")
rail_icon = _icon_any(FACTION_ICON.get(owner_cn, ""), "#fbfaf5", "24px")
heading = f"""
<div class="camp-head">
<div class="camp-title">
<div class="planet">{cd['name']}</div>
<div class="camp-faction" style="color:{color}">{_icon_any(FACTION_ICON.get(owner_cn, ''), color)}{owner_cn} · {cd['kind']}</div>
</div>
<div class="camp-prog">
<div class="camp-pct">{cd['pct_text']}</div>
<div class="camp-prog-label">{cd['progress_label']}</div>
</div>
</div>"""
remain = (
f"<div class='camp-remain'>剩余时间:<b>{cd['remain']}</b></div>"
if cd.get("remain")
else ""
)
info = "".join(
[
_info_row("globe", "所属星系", cd.get("sector"), color),
_info_row("diver", "部署潜兵", cd.get("players_fmt"), color),
_info_row("mountain", "地形特征", cd.get("biome"), color),
(
f"<div class='irow'>"
f"<span class='irow-ic'>{_ui_icon('cloud', color, '15px')}</span>"
f"<span class='irow-label'>环境事件</span>"
f"<span class='irow-val haz-val'>{cd.get('hazard_html') or '—'}</span></div>"
),
]
)
feat_rows = ""
if cd.get("effects_html"):
feat_rows = (
f"<div class='feat-row'><span class='feat-label'>{_ui_icon('grid', color)}星球效果</span>"
f"<span class='feat-tags'>{cd['effects_html']}</span></div>"
)
return f"""
<div class="camp" style="{style}">
<div class="camp-veil"></div>
<div class="camp-rail" style="--rc:{color}">
<span class="rail-icon">{rail_icon}</span>
<span class="rail-text">{cd['kind_label']}</span>
<span class="rail-dots"><i></i><i></i><i></i></span>
</div>
<div class="camp-body">
{heading}
{cd.get('dss_html') or ''}
{cd['bar_html']}
{remain}
<div class="info-rows">{info}</div>
{feat_rows}
</div>
</div>"""
def _render_campaigns(campaigns: list[Any], planet_english: dict[Any, Any], planet_effects: dict[Any, Any], space_stations: list[Any] | None = None, top_per_race: int | None = None) -> str:
if not campaigns:
return "<div class='empty'>当前无进行中的战役</div>"
now = int(datetime.now(timezone.utc).timestamp())
# 空间站所在星球(民主空间站 DSS 等):独立于插槽事件,作为增益 buff 标识展示
station_planets = {int(st.get("planetIndex")) for st in (space_stations or []) if st.get("planetIndex") is not None}
i18n = _load_i18n()
biome_map = i18n.get("biome", {})
hazard_map = i18n.get("hazard", {})
# 预处理每个战役
enriched = []
for c in campaigns:
p = c.get("planet") or {}
# 星球名:优先用英文名(i18n.json 简体)再翻译;API zH-Hans 是繁体,不用
en_name = _localized(planet_english.get(p.get("index")))
name = _translate(en_name) if en_name else (_translate(_localized(p.get("name"))) or "未知星球")
sector = _sector(p.get("sector"))
owner_cn, color = _faction(p.get("currentOwner"))
is_defense = owner_cn == "超级地球"
kind = "防守" if is_defense else "解放"
kind_label = "保卫战" if is_defense else "解放战"
# 进度用“双色交战条”:蓝色=超级地球,另一色=敌方种族;谁段多谁占优。
# 解放=蓝领先(解放进度);防守=反过来(敌方入侵领先),单卡高度可控。
max_health = float(p.get("maxHealth") or 0)
health = float(p.get("health") or 0)
if is_defense:
# 防守:入侵/受害进度 = 进攻事件剩余血量%(主要战役框里那个终结族进攻进度)。
# 蓝色条被敌方颜色侵占;被击退则 event.health 下降、蓝条恢复。
ev = p.get("event") or {}
event_mh = float(ev.get("maxHealth") or 0)
inv_pct = max(0, min(100, round(float(ev.get("health") or 0) / event_mh * 100))) if event_mh else 0
enemy_color = _faction(c.get("faction") or p.get("currentOwner"))[1]
else:
# 解放:蓝色=解放进度;敌方=残余;owner 即敌方,color 即敌方色
inv_pct = max(0, min(100, round((max_health - health) / max_health * 100))) if max_health else 0
enemy_color = color
# 双色交战条:解放=蓝领先(解放进度);防守=反过来(敌色=入侵进度,侵占蓝条)
bar_html = (
_segbar_duo(enemy_color, inv_pct, "#6bb7ea")
if is_defense
else _segbar_duo("#6bb7ea", inv_pct, enemy_color)
)
if is_defense:
progress_label = "入侵进度"
pct_text = f"{inv_pct}%"
pct_num = inv_pct
else:
progress_label = "解放进度"
pct_text = f"{inv_pct}%"
pct_num = inv_pct
players = (p.get("statistics") or {}).get("playerCount") or 0
# 地形特征
raw_biome = _localized((p.get("biome") or {}).get("name"))
biome = _strict_lookup(biome_map, raw_biome) or _translate(raw_biome)
# 环境事件(危害):一颗星球可能同时出现多个,做一个可换行、右对齐的图标+名称列表。
# 空间够则一行放下,不够自动换行。
haz_icon = {k.strip().lower(): v for k, v in HAZARD_ICON.items()}
haz_items = []
for h in (p.get("hazards") or []):
raw = _localized(h.get("name"))
if not raw or raw.lower() in ("none", "unknown"):
continue
cn = _strict_lookup(hazard_map, raw) or _translate(raw)
icon = _icon_any(haz_icon.get(raw.strip().lower(), ""), color, "13px")
haz_items.append(f"<span class='hzt'>{icon}{cn}</span>")
hazard_html = "".join(haz_items) if haz_items else "<span class='hzt'>无</span>"
# 剩余时间(事件倒计时;无事件则空)
ev = p.get("event") or {}
has_event = bool(ev.get("faction")) and bool(ev.get("maxHealth"))
remaining = _remaining(ev.get("endTime"), now) if has_event else ""
# 归属种族:防守(超级地球被攻)取进攻方 campaign.faction,解放(星球被该种族占据)取 currentOwner
if is_defense:
race_cn, _ = _faction(c.get("faction") or p.get("currentOwner"))
else:
race_cn = owner_cn
effects_html = _planet_effects_html(p.get("index"), planet_effects, color)
bg = _planet_bg(p.get("index"), planet_english)
dss_html = _dss_badge(p.get("index"), space_stations) if p.get("index") in station_planets else ""
enriched.append(
{
"race_cn": race_cn,
"owner_cn": owner_cn,
"name": name,
"sector": sector or "—",
"color": color,
"kind": kind,
"kind_label": kind_label,
"progress_label": progress_label,
"pct_text": pct_text,
"pct_num": pct_num,
"bar_html": bar_html,
"players": players,
"players_fmt": _fmt_num(players),
"biome": biome or "—",
"hazard_html": hazard_html,
"remain": remaining,
"effects_html": effects_html,
"dss_html": dss_html,
"bg": bg,
}
)
# 三大种族各一个板块,板块内按玩家人数降序
RACES = ("终结族", "机器人", "光能族")
name_to_color = {cn: color for cn, color in FACTIONS.values()}
sections = []
for race in RACES:
items = [e for e in enriched if e["race_cn"] == race]
if not items:
continue
# 玩家数多者优先,进度仅作并列次项
items.sort(key=lambda e: (e["players"], e["pct_num"]), reverse=True)
total_players = sum(e["players"] for e in items)
shown = items[:top_per_race] if top_per_race else items
rows = "".join(_camp_card_html(e) for e in shown)
color = name_to_color.get(race, "#cccccc")
icon = _icon_any(FACTION_ICON.get(race, ""), color)
sections.append(f"""
<div class="campsec">
<div class="campsec-title" style="color:{color}">{icon}{race} · {len(items)}<span class="campsec-sub">玩家 {_fmt_num(total_players)}</span></div>
<div class="camps">{rows}</div>
</div>""")
if not sections:
# 兜底:全部战役仍按玩家数降序铺开
remaining = sorted(enriched, key=lambda e: (e["players"], e["pct_num"]), reverse=True)
rows = "".join(_camp_card_html(e) for e in remaining)
return f"""
<div class="box">
<div class="box-title"><span class="dot"></span>进行中的战役</div>
<div class="camps">{rows}</div>
</div>
"""
return f"""
<div class="box">
<div class="box-title"><span class="dot"></span>进行中的战役</div>
{''.join(sections)}
</div>
"""
def _render_control(planets: list[Any]) -> str:
if not planets:
return ""
name_to_color = {cn: color for cn, color in FACTIONS.values()}
counts: dict[str, int] = {}
for p in planets:
owner = (p.get("currentOwner") or "").strip()
cn, _ = _faction(owner)
counts[cn] = counts.get(cn, 0) + 1
chips = []
for cn in ("超级地球", "终结族", "机器人", "光能族"):
if cn in counts:
color = name_to_color.get(cn, "#cccccc")
chips.append(
f"<span class='chip' style='border-color:{color};color:{color}'>{_icon_any(FACTION_ICON.get(cn, ''), color)} {cn} {counts[cn]}</span>"
)
if not chips:
return ""
return f"""
<div class="box">
<div class="box-title"><span class="dot"></span>星系控制</div>
<div class="chips">{''.join(chips)}</div>
</div>
"""
def _build_html(data: dict[str, Any], top_per_race: int | None = None) -> str:
# 模板骨架 + 样式在 templates/ 里,此处按需加载并填充;CSS 含花括号,不用 .format,改用 replace
return (
_load_template("brief_card.html")
.replace("@@BASE@@", basic_path.as_uri() + "/")
.replace("@@FONT_FACES@@", _font_faces())
.replace("@@CSS@@", _load_template("brief_card.css"))
.replace("@@HEADER@@", _render_header(data["fetched_at"]))
.replace("@@MAJOR_ORDER@@", _render_major_order(data))
.replace("@@STATS@@", _render_stats(data.get("war"), len(data.get("campaigns") or [])))
.replace(
"@@CAMPAIGNS@@",
_render_campaigns(
data.get("campaigns") or [],
data.get("planet_english") or {},
data.get("planet_effects") or {},
data.get("space_stations") or [],
top_per_race,
),
)
.replace("@@CONTROL@@", _render_control(data.get("planets") or []))
)
async def _html_to_png(html: str, width: int = 782) -> bytes:
import secrets
async with get_new_page() as page:
await page.set_viewport_size({"width": width, "height": 800})
# 图片/字体用相对路径 + <base href> 解析;写临时 .html 到插件根目录,
# 使页面与 img/、data/ 同目录树,file:// 同源即可加载(无需额外标志)。
tmp = basic_path / f"_render_{secrets.token_hex(6)}.html"
tmp.write_text(html, encoding="utf-8")
try:
await page.goto(tmp.as_uri(), wait_until="networkidle")
return await page.screenshot(full_page=True, type="png")
finally:
try:
tmp.unlink()
except OSError:
pass
async def render_war_briefing(data: dict[str, Any], top_per_race: int | None = None) -> bytes:
"""把战况数据渲染成一张图,返回 PNG bytes。top_per_race 非 None 时每板块只画该种族人数前 N 张卡。"""
html = _build_html(data, top_per_race)
return await _html_to_png(html)
# 样式与 HTML 骨架已抽离到 templates/(brief_card.css / brief_card.html),
# 由 _build_html 里的 _load_template(...) 按需加载并填充。
# 风格参考 Helldivers Companion:近黑底 + 金 #FFE710 主色 + 奶油白 #fbfaf5 + 亮色阵营色