"""将会话序列化为前端视图模型(含简体中文标签)。""" from __future__ import annotations import json from functools import lru_cache from pathlib import Path from typing import Any, Dict, List, Optional from game.sim.session import GameSession from game.webapi.i18n import ( atm_zh, light_zh, marker_zh, pace_zh, phase_zh, state_zh, ) ROOT = Path(__file__).resolve().parents[2] PHASE_BG = { "PREP": "assets/shop/interior_morning_01.jpg", "MORNING": "assets/shop/interior_morning_01.jpg", "AFTERNOON": "assets/shop/interior_afternoon_02.jpg", "DUSK": "assets/shop/interior_dusk_01.jpg", "CLOSING": "assets/shop/interior_golden_hour_honest_01.jpg", "NIGHT": "assets/shop/interior_night_01.jpg", } PHASE_ICON = { "PREP": "assets/ui/phase_morning_01.jpg", "MORNING": "assets/ui/phase_morning_01.jpg", "AFTERNOON": "assets/ui/phase_afternoon_01.jpg", "DUSK": "assets/ui/phase_dusk_01.jpg", "CLOSING": "assets/ui/phase_closing_01.jpg", "NIGHT": "assets/ui/phase_night_01.jpg", } KIND_ZH = {"regular": "熟客", "passerby": "路人"} @lru_cache(maxsize=1) def _manifest() -> Dict[str, str]: path = ROOT / "assets/manifest/assets.json" if not path.is_file(): return {} try: data = json.loads(path.read_text(encoding="utf-8")) except Exception: return {} return dict(data.get("assets") or {}) def _exists(rel: Optional[str]) -> Optional[str]: if not rel: return None p = ROOT / rel return rel if p.is_file() else None def _resolve_product_sprite(product_id: Optional[str], db) -> Optional[str]: if not product_id: return None prod = (db.products or {}).get(product_id) or {} for key in ( prod.get("asset"), f"assets/products/{product_id}_01.jpg", _manifest().get(f"product.{product_id}"), ): hit = _exists(key) if hit: return hit return None def _resolve_furniture_sprite(furniture_id: str, db) -> Optional[str]: fdef = (db.furniture or {}).get(furniture_id) or {} for key in ( fdef.get("asset"), f"assets/furniture/{furniture_id}_01.jpg", _manifest().get(f"furniture.{furniture_id}"), ): hit = _exists(key) if hit: return hit return None def _resolve_npc_portrait(npc_id: str, db) -> Optional[str]: npc = (db.npcs or {}).get(npc_id) or {} for key in ( npc.get("portrait"), npc.get("asset"), f"assets/characters/{npc_id}_portrait_01.jpg", f"assets/characters/{npc_id}_01.jpg", _manifest().get(f"character.{npc_id}"), _manifest().get("character.regular_lan") if "lan" in npc_id else None, _manifest().get("character.regular_mori") if "mori" in npc_id else None, ): hit = _exists(key) if hit: return hit for name in ( "assets/characters/passerby_student_01.jpg", "assets/characters/passerby_commuter_02.jpg", "assets/characters/passerby_elder_01.jpg", ): hit = _exists(name) if hit: return hit return None def _npc_name(npc_id: str, db) -> str: npc = (db.npcs or {}).get(npc_id) or {} return str(npc.get("name") or npc.get("display_name") or npc_id) def _product_name(product_id: str, db) -> str: prod = (db.products or {}).get(product_id) or {} return str(prod.get("name") or product_id) def build_view(session: GameSession, log: Optional[List[str]] = None) -> Dict[str, Any]: db = session.db phase = session.day.phase.value markers = {k: [int(v[0]), int(v[1])] for k, v in session.space.markers.items()} pace_val = session.day.pace.value if hasattr(session.day.pace, "value") else str(session.day.pace) light = getattr(session.atmosphere, "light", "warm") furniture: List[dict] = [] for item in session.state.get("shop", {}).get("layout") or []: fid = item.get("furniture_id") or "" fdef = (db.furniture or {}).get(fid) or {} furniture.append( { "id": item.get("instance_id"), "fid": fid, "name": fdef.get("name") or fid, "x": int(item.get("x", 0)), "y": int(item.get("y", 0)), "rot90": bool(item.get("rot90", False)), "sprite": _resolve_furniture_sprite(fid, db), "slots": list(fdef.get("display_slots") or []), } ) displays: List[dict] = [] raw_disp = session.state.get("shop", {}).get("displays") or {} for slot, info in raw_disp.items(): info = info or {} pid = info.get("product_id") displays.append( { "slot": slot, "slot_label": slot.replace("_", " "), "product_id": pid, "name": _product_name(pid, db) if pid else "空", "qty": int(info.get("qty") or 0), "sprite": _resolve_product_sprite(pid, db) if pid else None, } ) inventory: List[dict] = [] inv = session.state.get("shop", {}).get("inventory") or {} for pid, qty in inv.items(): inventory.append( { "product_id": pid, "name": _product_name(pid, db), "qty": int(qty), "price": int(((db.products or {}).get(pid) or {}).get("price") or 0), "sprite": _resolve_product_sprite(pid, db), } ) inventory.sort(key=lambda x: (-x["qty"], x["name"])) agents: List[dict] = [] for agent in list(session.director.agents): marker = agent.current_marker() xy = markers.get(marker) or markers.get("door") or [5, 9] st = agent.state.value if hasattr(agent.state, "value") else str(agent.state) agents.append( { "agent_id": agent.agent_id, "npc_id": agent.npc_id, "name": _npc_name(agent.npc_id, db), "kind": agent.kind, "kind_zh": KIND_ZH.get(agent.kind, agent.kind), "state": st, "state_zh": state_zh(st), "marker": marker, "marker_zh": marker_zh(marker), "xy": xy, "path": list(agent.path or []), "path_index": int(agent.path_index), "portrait": _resolve_npc_portrait(agent.npc_id, db), "bark": agent.last_bark, "target_product": agent.target_product, "target_name": _product_name(agent.target_product, db) if agent.target_product else None, } ) atm = dict(session.atmosphere.snapshot or {}) dims_list = [] for k, v in atm.items(): if isinstance(v, (int, float)): dims_list.append( { "key": k, "label": atm_zh(k), "value": round(float(v), 3), "pct": int(max(0, min(100, round(float(v) * 100)))), } ) night = None if phase == "NIGHT": try: raw_night = session.night_summary() night = { "mood": raw_night.get("mood"), "diary": raw_night.get("diary"), "sales": raw_night.get("sales"), "revenue": raw_night.get("revenue"), "enters": raw_night.get("enters"), "top_dims": [atm_zh(x) if isinstance(x, str) else x for x in (raw_night.get("top_dims") or [])], } except Exception: night = None catalog: List[dict] = [] for pid, prod in list((db.products or {}).items())[:40]: catalog.append( { "product_id": pid, "name": prod.get("name") or pid, "price": int(prod.get("price") or 0), "sprite": _resolve_product_sprite(pid, db), } ) phases = [ {"id": p, "label": phase_zh(p), "active": p == phase} for p in ("PREP", "MORNING", "AFTERNOON", "DUSK", "CLOSING", "NIGHT") ] return { "locale": "zh-CN", "meta": { "day_index": int(session.day.day_index), "phase": phase, "phase_zh": phase_zh(phase), "phase_elapsed": round(float(session.day.phase_elapsed), 2), "paused": bool(getattr(session.day, "paused", False)), "pace": pace_val, "pace_zh": pace_zh(pace_val), "day_label": f"第 {int(session.day.day_index) + 1} 天", }, "phases": phases, "wallet": int(session.economy.wallet), "atmosphere": { "dims": dims_list, "mood_line": session.atmosphere.mood_line(), "light": light, "light_zh": light_zh(light), "bgm": getattr(session.atmosphere, "bgm", "quiet"), }, "grid": { "w": int(session.space.width), "h": int(session.space.height), "markers": markers, "markers_zh": {k: marker_zh(k) for k in markers}, "cell_tags": { f"{x},{y}": sorted(tags) for (x, y), tags in session.space.cell_tags.items() if tags - {"floor"} }, }, "furniture": furniture, "displays": displays, "inventory": inventory[:24], "catalog": catalog, "agents": agents, "log": list(log or session.phase_log)[-40:], "stats": { "enters": int(session.director.stats.get("enters", 0)), "sales": int(session.sales_count), "revenue": int(session.revenue), "barks": int(session.director.stats.get("barks", 0)), }, "art": { "shop_bg": _exists(PHASE_BG.get(phase)) or _exists("assets/shop/interior_alley_flower_shop_01.jpg"), "phase_icon": _exists(PHASE_ICON.get(phase)), "open_sign": _exists("assets/ui/open_sign_01.jpg"), }, "night": night, "ui": { "can_open": phase == "PREP", "can_tick": phase not in ("PREP", "NIGHT"), "can_sleep": phase == "NIGHT", "can_prep_actions": phase == "PREP", # 与 Economy 规则一致:进货 PREP/NIGHT,上架仅 PREP "can_restock": phase in ("PREP", "NIGHT"), "can_stock_display": phase == "PREP", "restock_hint": ( "现在可以进货" if phase in ("PREP", "NIGHT") else "进货仅限「营业前」与「夜晚」(营业中请专心待客)" ), "stock_hint": ( "现在可以上架" if phase == "PREP" else "上架仅限「营业前」整理货架" ), }, }