Files
HeXi/hexi/plugins/nonebot_plugin_helldivers_tools/utils/war_renderer.py
T
sansenhoshiandClaude 9371a28e35 refactor: restructure plugins per MTSS standard, consolidate assets to res/
Per docs/plugin-audit-report.md (plugins normalized to
Trigger(handlers) → Service(services) → Model(repository/models) + utils):

- Split monolithic __init__.py into handlers/services/utils across
  dailywife, deer_pipe, dice, galgame_card, helldivers_tools,
  huoziyinshua, learning_chat, makeaquote, mc_server_status,
  ncm_saying, picfinder_take, picstatus, random_jm_code, regif,
  steam_info, video_analysis, group_tools
- Move static assets under res/: deer_pipe font/img, makeaquote font,
  helldivers img/templates, huoziyinshua HuoZiYinShua
- Add config.py + register_config_items to ncm_saying, random_jm_code,
  group_tools; learning_chat unified config bridge
- Remove deprecated: voice_trans plugin, bf_bot/test.py, dead code in
  dailywife/deer_pipe, empty dirs, debug scripts under helldivers temp
- Disable brash_general_supercredits_tools (stub comment only)
- bot.py: optional stdout/stderr redirect to log file for Web log viewer,
  force ANSI colorize on non-TTY sinks
- Move runtime data (jm_code.json) out of plugin dir into hexi/data
- Docs: plugin-audit-report.md; README reflects removed plugins

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-03 00:44:38 +08:00

910 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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().parents[1]
img_path = basic_path / "res" / "img"
data_path = basic_path / "data"
template_path = basic_path / "res" / "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 + 亮色阵营色