235 lines
8.8 KiB
Python
235 lines
8.8 KiB
Python
from __future__ import annotations
|
||
|
||
import re
|
||
from copy import deepcopy
|
||
from typing import Any
|
||
|
||
|
||
DEFAULT_ROLE_BADGES: list[dict[str, Any]] = [
|
||
{
|
||
"id": "detective",
|
||
"name": "侦探徽章",
|
||
"description": "擅长抽丝剥茧,在悬疑/推理类剧本中可获得额外线索优势。",
|
||
"unlock_metric": "plays",
|
||
"unlock_threshold": 3,
|
||
"genre_keywords": ["悬疑", "推理", "调查", "mystery", "detective"],
|
||
"effect_item": "侦探工具包",
|
||
"effect_clue": "【侦探直觉】你提前注意到一处可疑细节。",
|
||
"special_choice": "侦探徽章:进行现场勘验",
|
||
},
|
||
{
|
||
"id": "negotiator",
|
||
"name": "交涉者徽章",
|
||
"description": "擅长沟通博弈,在社交/阵营类剧本中可获得额外交涉筹码。",
|
||
"unlock_metric": "wins",
|
||
"unlock_threshold": 2,
|
||
"genre_keywords": ["社交", "阵营", "心理", "博弈", "negotiation", "social"],
|
||
"effect_item": "交涉筹码",
|
||
"effect_clue": "【交涉洞察】你看穿了对方一句话中的破绽。",
|
||
"special_choice": "交涉者徽章:发起高压谈判",
|
||
},
|
||
{
|
||
"id": "infiltrator",
|
||
"name": "潜行者徽章",
|
||
"description": "擅长隐秘行动,在生存/探索类剧本中可获得额外渗透手段。",
|
||
"unlock_metric": "best_streak",
|
||
"unlock_threshold": 2,
|
||
"genre_keywords": ["潜行", "生存", "探索", "渗透", "stealth", "survival", "explore"],
|
||
"effect_item": "潜行工具",
|
||
"effect_clue": "【潜行本能】你发现一条更隐蔽的行动路径。",
|
||
"special_choice": "潜行者徽章:尝试隐秘渗透",
|
||
},
|
||
]
|
||
|
||
ALLOWED_UNLOCK_METRICS = {"plays", "wins", "best_streak"}
|
||
|
||
|
||
def _safe_int(value: Any, default: int = 0) -> int:
|
||
try:
|
||
return int(value)
|
||
except (TypeError, ValueError):
|
||
return int(default)
|
||
|
||
|
||
def _parse_text_list(raw_value: Any, *, limit: int = 12) -> list[str]:
|
||
if isinstance(raw_value, list):
|
||
rows = [str(item).strip() for item in raw_value if str(item).strip()]
|
||
else:
|
||
rows = [row.strip() for row in re.split(r"[\n,,]", str(raw_value or "")) if row.strip()]
|
||
return rows[:limit]
|
||
|
||
|
||
def default_role_badges() -> list[dict[str, Any]]:
|
||
return deepcopy(DEFAULT_ROLE_BADGES)
|
||
|
||
|
||
def normalize_badge_definitions(
|
||
raw_value: Any,
|
||
*,
|
||
default_to_builtin: bool = True,
|
||
limit: int = 12,
|
||
) -> list[dict[str, Any]]:
|
||
rows = raw_value if isinstance(raw_value, list) else []
|
||
normalized: list[dict[str, Any]] = []
|
||
seen_ids: set[str] = set()
|
||
|
||
for idx, row in enumerate(rows[: max(1, int(limit))]):
|
||
if not isinstance(row, dict):
|
||
continue
|
||
raw_id = str(row.get("id", "")).strip().lower()
|
||
seed_id = raw_id or f"badge_{idx+1}"
|
||
badge_id = re.sub(r"[^a-z0-9_-]+", "_", seed_id).strip("_")[:36]
|
||
if not badge_id or badge_id in seen_ids:
|
||
continue
|
||
seen_ids.add(badge_id)
|
||
|
||
name = str(row.get("name", "")).strip()[:24]
|
||
if not name:
|
||
continue
|
||
|
||
unlock_metric = str(row.get("unlock_metric", "plays")).strip().lower()
|
||
if unlock_metric not in ALLOWED_UNLOCK_METRICS:
|
||
unlock_metric = "plays"
|
||
|
||
threshold = max(1, _safe_int(row.get("unlock_threshold", 1), 1))
|
||
normalized.append(
|
||
{
|
||
"id": badge_id,
|
||
"name": name,
|
||
"description": str(row.get("description", "")).strip()[:180],
|
||
"unlock_metric": unlock_metric,
|
||
"unlock_threshold": threshold,
|
||
"genre_keywords": _parse_text_list(row.get("genre_keywords", []), limit=16),
|
||
"effect_item": str(row.get("effect_item", "")).strip()[:48],
|
||
"effect_clue": str(row.get("effect_clue", "")).strip()[:180],
|
||
"special_choice": str(row.get("special_choice", "")).strip()[:48],
|
||
}
|
||
)
|
||
|
||
if not normalized and default_to_builtin:
|
||
return default_role_badges()
|
||
return normalized
|
||
|
||
|
||
def _resolve_badges(badge_definitions: Any = None) -> list[dict[str, Any]]:
|
||
if badge_definitions is None:
|
||
return default_role_badges()
|
||
return normalize_badge_definitions(badge_definitions, default_to_builtin=False)
|
||
|
||
|
||
def _badge_map(badge_definitions: Any = None) -> dict[str, dict[str, Any]]:
|
||
rows = _resolve_badges(badge_definitions)
|
||
return {str(row.get("id", "")).strip(): row for row in rows if str(row.get("id", "")).strip()}
|
||
|
||
|
||
def normalize_badge_id(value: Any, badge_definitions: Any = None) -> str:
|
||
badge_id = str(value or "").strip().lower()
|
||
if not badge_id:
|
||
return ""
|
||
if badge_id in _badge_map(badge_definitions):
|
||
return badge_id
|
||
return ""
|
||
|
||
|
||
def build_badge_progress(
|
||
stats: dict[str, Any],
|
||
active_badge: Any = "",
|
||
badge_definitions: Any = None,
|
||
) -> dict[str, Any]:
|
||
role_badges = _resolve_badges(badge_definitions)
|
||
plays = max(0, _safe_int(stats.get("played_games", 0), 0))
|
||
wins = max(0, _safe_int(stats.get("wins", 0), 0))
|
||
best_streak = max(0, _safe_int(stats.get("best_streak", 0), 0))
|
||
metric_values = {
|
||
"plays": plays,
|
||
"wins": wins,
|
||
"best_streak": best_streak,
|
||
}
|
||
|
||
rows: list[dict[str, Any]] = []
|
||
unlocked_ids: list[str] = []
|
||
for badge in role_badges:
|
||
badge_id = str(badge["id"])
|
||
metric_key = str(badge.get("unlock_metric", "plays"))
|
||
threshold = max(1, _safe_int(badge.get("unlock_threshold", 1), 1))
|
||
current = max(0, _safe_int(metric_values.get(metric_key, 0), 0))
|
||
unlocked = current >= threshold
|
||
if unlocked:
|
||
unlocked_ids.append(badge_id)
|
||
progress_value = min(current, threshold)
|
||
progress_percent = min(100, int(round((progress_value / threshold) * 100)))
|
||
rows.append(
|
||
{
|
||
"id": badge_id,
|
||
"name": str(badge.get("name", "")).strip()[:24],
|
||
"description": str(badge.get("description", "")).strip()[:180],
|
||
"unlock_metric": metric_key,
|
||
"unlock_threshold": threshold,
|
||
"unlock_current": current,
|
||
"progress_value": progress_value,
|
||
"progress_percent": progress_percent,
|
||
"unlocked": unlocked,
|
||
"genre_keywords": _parse_text_list(badge.get("genre_keywords", []), limit=16),
|
||
"special_choice": str(badge.get("special_choice", "")).strip()[:48],
|
||
"effect_item": str(badge.get("effect_item", "")).strip()[:48],
|
||
"effect_clue": str(badge.get("effect_clue", "")).strip()[:180],
|
||
}
|
||
)
|
||
|
||
normalized_active = normalize_badge_id(active_badge, role_badges)
|
||
if normalized_active and normalized_active not in unlocked_ids:
|
||
normalized_active = ""
|
||
if not normalized_active and unlocked_ids:
|
||
normalized_active = unlocked_ids[0]
|
||
|
||
return {
|
||
"active_badge": normalized_active,
|
||
"badges": rows,
|
||
"unlocked_ids": unlocked_ids,
|
||
"stats_snapshot": metric_values,
|
||
}
|
||
|
||
|
||
def badge_matches_card(badge_id: Any, card: dict[str, Any] | None, badge_definitions: Any = None) -> bool:
|
||
badge_map = _badge_map(badge_definitions)
|
||
normalized = str(badge_id or "").strip().lower()
|
||
if normalized not in badge_map:
|
||
return False
|
||
badge = badge_map.get(normalized, {})
|
||
keywords = [str(row).strip().lower() for row in badge.get("genre_keywords", []) if str(row).strip()]
|
||
if not keywords:
|
||
return False
|
||
|
||
payload = card if isinstance(card, dict) else {}
|
||
content_pack = payload.get("content_pack", {})
|
||
if not isinstance(content_pack, dict):
|
||
content_pack = {}
|
||
genre_tags = content_pack.get("genre_tags", [])
|
||
if not isinstance(genre_tags, list):
|
||
genre_tags = []
|
||
|
||
corpus = []
|
||
corpus.extend(str(row).strip().lower() for row in genre_tags if str(row).strip())
|
||
corpus.append(str(payload.get("title", "")).strip().lower())
|
||
corpus.append(str(payload.get("subtitle", "")).strip().lower())
|
||
corpus.append(str(payload.get("summary", "")).strip().lower())
|
||
text = " ".join(row for row in corpus if row)
|
||
if not text:
|
||
return False
|
||
return any(keyword in text for keyword in keywords)
|
||
|
||
|
||
def badge_effect_payload(badge_id: Any, badge_definitions: Any = None) -> dict[str, str]:
|
||
badge_map = _badge_map(badge_definitions)
|
||
normalized = str(badge_id or "").strip().lower()
|
||
if normalized not in badge_map:
|
||
return {"id": "", "name": "", "item": "", "clue": "", "special_choice": ""}
|
||
badge = badge_map.get(normalized, {})
|
||
return {
|
||
"id": normalized,
|
||
"name": str(badge.get("name", "")).strip()[:24],
|
||
"item": str(badge.get("effect_item", "")).strip()[:48],
|
||
"clue": str(badge.get("effect_clue", "")).strip()[:180],
|
||
"special_choice": str(badge.get("special_choice", "")).strip()[:48],
|
||
}
|