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: """返回相对插件目录的正斜杠相对路径(配合 解析;可移植、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 膨胀导致渲染慢(配合 解析) return _rel(logo) def _svg_inline(path: Path, color: str, em: str = "1.2em") -> str: """把任意 SVG 文件改成 currentColor,返回内联 。""" 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*]*>", "", raw, count=1) inner = re.sub(r"\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'' f"{inner}" ) def _icon_svg(name: str, color: str, em: str = "1.2em") -> str: """读取 img/assets/icons/{name}.svg,返回内联 ; 原始游戏 SVG(含XML声明/DOCTYPE/嵌套svg)内联会破坏HTML结构导致文字不显示,改用 原样嵌入。""" p = img_path / "assets" / "icons" / f"{name}.svg" if not p.exists(): return "" raw = p.read_text(encoding="utf-8") if " 1: # 原始游戏 SVG 内联会破坏HTML结构导致文字不显示;用相对路径链接而非 base64 return ( f"" ) 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"" ) 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 更小、渲染更快;配合 ) 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""" 超级地球战况简报 Galactic War Briefing · {t} """ def _render_major_order(data: dict[str, Any]) -> str: assignments = data.get("assignments") or [] if not assignments: return "今日暂无重要指令" 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"" f"" f"{pname}{bar_html}" f"{pct_text}" ) ov_html = ( '' '指令目标' + "".join(rows) + "" ) return f""" {_icon_svg('misc-major-order', '#FFE710')}{title} 剩余:{remain} {description or "无任务描述"} {ov_html} 奖励:{reward_text or "—"} """ 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"{v}{k}" for k, v in items ) return f""" 星系战况 {cells} """ # ---- 星球效果(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"" f"{_icon_any(EFFECT_ICONS.get(gid, ''), c)} {label}" ) return "".join(spans) def _ui_icon(name: str, color: str, em: str = "1em") -> str: """内联小图标(fill/stroke 用 currentColor,可配色),用于单卡信息/战役/特征行。""" icons = { "globe": '', "diver": '', "mountain": '', "cloud": '', "crosshair": '', "clock": '', "warn": '', "grid": '', } d = icons.get(name, "") if not d: return "" return f"{d}" 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"" if i < n1 else f"" for i in range(segs) ) return f"{cells}" def _info_row(icon: str, label: str, value: str, color: str) -> str: return ( f"" f"{_ui_icon(icon, color, '15px')}" f"{label}" f"{value or '—'}" ) 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 = [] # [[icon]label] 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"{icon}{b}") else: if b in seen_i: continue seen_i.add(b) inherent.append(b) icon = _icon_any("effect-dss", "#6bb7ea", "16px") # 两行:一行=带图标的特殊行动(无·连接),一行=固有效果(单独) actions_line = f"{''.join(actions)}" if actions else "" inherent_line = f"{' · '.join(inherent)}" if inherent else "" return ( f"" f"{icon}{name}" f"{inherent_line}{actions_line}" ) 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""" {cd['name']} {_icon_any(FACTION_ICON.get(owner_cn, ''), color)}{owner_cn} · {cd['kind']} {cd['pct_text']} {cd['progress_label']} """ remain = ( f"剩余时间:{cd['remain']}" 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"" f"{_ui_icon('cloud', color, '15px')}" f"环境事件" f"{cd.get('hazard_html') or '—'}" ), ] ) feat_rows = "" if cd.get("effects_html"): feat_rows = ( f"{_ui_icon('grid', color)}星球效果" f"{cd['effects_html']}" ) return f""" {rail_icon} {cd['kind_label']} {heading} {cd.get('dss_html') or ''} {cd['bar_html']} {remain} {info} {feat_rows} """ 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 "当前无进行中的战役" 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"{icon}{cn}") hazard_html = "".join(haz_items) if haz_items else "无" # 剩余时间(事件倒计时;无事件则空) 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""" {icon}{race} · {len(items)}玩家 {_fmt_num(total_players)} {rows} """) 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""" 进行中的战役 {rows} """ return f""" 进行中的战役 {''.join(sections)} """ 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"{_icon_any(FACTION_ICON.get(cn, ''), color)} {cn} {counts[cn]}" ) if not chips: return "" return f""" 星系控制 {''.join(chips)} """ 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}) # 图片/字体用相对路径 + 解析;写临时 .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 + 亮色阵营色