| """将会话序列化为前端视图模型(含简体中文标签)。""" |
| 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", |
| |
| "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 "上架仅限「营业前」整理货架" |
| ), |
| }, |
| } |
|
|