910 lines
39 KiB
Python
910 lines
39 KiB
Python
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 + 亮色阵营色
|