"""FastAPI:可玩网页演示(Vue 前端 + 中文 + 演示向节奏)。""" from __future__ import annotations import secrets import time from pathlib import Path from typing import Any, Dict, List, Optional from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field from game.sim.day_cycle import Pace from game.sim.session import GameSession from game.webapi.demo_tune import guest_card, tune_session_for_web from game.webapi.i18n import light_zh, phase_zh from game.webapi.view import build_view ROOT = Path(__file__).resolve().parents[2] WEB_PLAY = ROOT / "web" / "play" ASSETS = ROOT / "assets" app = FastAPI(title="小镇角落小店", version="0.4.0", description="可玩花店模拟 · 网页演示") _SESSIONS: Dict[str, Dict[str, Any]] = {} _MAX_SESSIONS = 40 _TTL_SEC = 60 * 60 class NewSessionBody(BaseModel): seed: int = 7 pace: str = "Brisk" class TickBody(BaseModel): steps: int = Field(default=1, ge=1, le=200) delta: float = Field(default=0.25, gt=0, le=5) # 跳过无人时段,避免干等一分钟空店 skip_idle: bool = True max_skip: int = Field(default=80, ge=0, le=400) class RestockBody(BaseModel): product_id: str qty: int = Field(default=1, ge=1, le=20) class StockBody(BaseModel): product_id: str slot_id: str qty: int = Field(default=1, ge=1, le=20) class LightBody(BaseModel): mode: str class PauseBody(BaseModel): paused: bool = True def _purge() -> None: now = time.time() dead = [k for k, v in _SESSIONS.items() if now - v["created"] > _TTL_SEC] for k in dead: _SESSIONS.pop(k, None) while len(_SESSIONS) > _MAX_SESSIONS: oldest = min(_SESSIONS.items(), key=lambda kv: kv[1]["created"])[0] _SESSIONS.pop(oldest, None) def _get(sid: str) -> Dict[str, Any]: _purge() bag = _SESSIONS.get(sid) if not bag: raise HTTPException(404, "找不到游戏存档,请点击「新开一局」") return bag def _append_log(bag: Dict[str, Any], line: str) -> None: bag["log"].append(line) if len(bag["log"]) > 80: bag["log"] = bag["log"][-80:] def _npc_name(s: GameSession, npc_id: str) -> str: return str((s.db.npcs.get(npc_id) or {}).get("name") or npc_id) def _prod_name(s: GameSession, pid: str) -> str: return str((s.db.products.get(pid) or {}).get("name") or pid) def _remember_guest(bag: Dict[str, Any], card: dict) -> None: guests: List[dict] = bag.setdefault("guests_today", []) # 更新同 id 或追加 for i, g in enumerate(guests): if g.get("agent_id") == card.get("agent_id"): guests[i] = {**g, **card} break else: guests.append(card) if len(guests) > 24: bag["guests_today"] = guests[-24:] def _pack(bag: Dict[str, Any], events: Optional[List[dict]] = None) -> dict: view = build_view(bag["session"], bag["log"]) view["guests_today"] = list(bag.get("guests_today") or []) view["toasts"] = list(bag.pop("pending_toasts", []) or []) view["events"] = list(events or []) # 引导阶段 phase = view["meta"]["phase"] if phase == "PREP": guide = "整理好货架后,点击「开始营业」迎接客人。" elif phase == "NIGHT": guide = "一天结束了。看看夜记,然后「入睡」进入下一天。" else: guide = "时光自动流逝。留意进店的客人与成交提示。" view["guide"] = guide return {"session_id": bag["id"], "view": view} def _note_toast(bag: Dict[str, Any], text: str, kind: str = "info") -> None: bag.setdefault("pending_toasts", []).append({"text": text, "kind": kind}) @app.get("/api/health") def health(): return {"ok": True, "sessions": len(_SESSIONS), "locale": "zh-CN", "playable": True} @app.post("/api/session") def create_session(body: NewSessionBody = NewSessionBody()): _purge() pace_name = body.pace if body.pace in ("Relaxed", "Normal", "Brisk") else "Brisk" pace_map = {"Relaxed": Pace.RELAXED, "Normal": Pace.NORMAL, "Brisk": Pace.BRISK} pace = pace_map.get(pace_name, Pace.BRISK) sid = secrets.token_hex(8) session = GameSession(rng_seed=int(body.seed), pace=pace) tune_session_for_web(session) from game.webapi.i18n import pace_zh bag = { "id": sid, "session": session, "log": [ f"新开一局 · 种子 {body.seed}", "提示:先在「营业前」整理货架,再点开始营业。", "演示节奏已加速,客人会更频繁光临。", ], "created": time.time(), "seed": int(body.seed), "pace": pace_name, "guests_today": [], "pending_toasts": [ {"text": "欢迎来到小镇角落小店", "kind": "info"}, {"text": "先整理店面,再开始营业", "kind": "info"}, ], } _SESSIONS[sid] = bag return _pack(bag) @app.get("/api/session/{sid}/view") def get_view(sid: str): return _pack(_get(sid)) def _one_tick(bag: Dict[str, Any], delta: float) -> List[dict]: """推进 1 步并收集事件。""" s: GameSession = bag["session"] events: List[dict] = [] before_phase = s.day.phase.value before_sales = s.sales_count before_wallet = s.economy.wallet before_ids = {a.agent_id for a in s.director.agents} if s.day.phase.value in ("NIGHT", "PREP") or s.day.paused: return events s.tick(float(delta)) if s.day.phase.value != before_phase: line = f"时辰流转 → {phase_zh(s.day.phase.value)}" _append_log(bag, line) events.append({"type": "phase", "text": line}) _note_toast(bag, line, "phase") if s.sales_count > before_sales: delta_w = s.economy.wallet - before_wallet line = f"成交!+¥{delta_w}" _append_log(bag, f"成交一笔!收入 ¥{delta_w}(今日销量 {s.sales_count})") events.append({"type": "sale", "text": line, "amount": delta_w}) _note_toast(bag, line, "sale") after = {a.agent_id: a for a in s.director.agents} for aid, a in after.items(): if aid not in before_ids: kind = "熟客" if a.kind == "regular" else "路人" name = _npc_name(s, a.npc_id) line = f"{name} 推门进店了({kind})" _append_log(bag, line) card = guest_card(s, a) _remember_guest(bag, card) events.append({"type": "enter", "text": line, "guest": card}) _note_toast(bag, line, "enter") else: _remember_guest(bag, guest_card(s, a)) if a.last_bark: line = f"{_npc_name(s, a.npc_id)}:「{a.last_bark}」" if not bag["log"] or bag["log"][-1] != line: _append_log(bag, line) events.append({"type": "bark", "text": line}) # 离开的客人 for aid in before_ids - set(after): # 标记历史 for g in bag.get("guests_today") or []: if g.get("agent_id") == aid: g["state_zh"] = "已离开" g["left"] = True return events @app.post("/api/session/{sid}/tick") def tick(sid: str, body: TickBody = TickBody()): bag = _get(sid) s: GameSession = bag["session"] all_events: List[dict] = [] steps = int(body.steps) for _ in range(steps): if s.day.phase.value in ("NIGHT", "PREP"): break all_events.extend(_one_tick(bag, body.delta)) # 跳过空闲:店里没人且暂无事件时,快进到有客/换时辰 if body.skip_idle and s.day.phase.value not in ("NIGHT", "PREP") and not s.day.paused: idle = 0 while ( idle < int(body.max_skip) and not s.director.agents and s.day.phase.value not in ("NIGHT", "PREP") ): before_phase = s.day.phase.value ev = _one_tick(bag, body.delta) all_events.extend(ev) idle += 1 if ev or s.director.agents or s.day.phase.value != before_phase: break return _pack(bag, all_events) @app.post("/api/session/{sid}/open") def open_shop(sid: str): bag = _get(sid) s: GameSession = bag["session"] try: s.open_shop() _append_log(bag, "翻开营业牌 —— 欢迎光临小镇角落小店") _note_toast(bag, "开始营业!客人很快就会来", "info") bag["guests_today"] = [] except Exception as e: raise HTTPException(400, f"无法开店:{e}") from e return _pack(bag) @app.post("/api/session/{sid}/sleep") def sleep(sid: str): bag = _get(sid) s: GameSession = bag["session"] try: rep = s.sleep() _append_log(bag, f"入睡。迎来第 {s.day.day_index + 1} 天 · 现金 ¥{rep.wallet_end}") _note_toast(bag, f"新的一天 · 现金 ¥{rep.wallet_end}", "info") bag["guests_today"] = [] except Exception as e: raise HTTPException(400, f"无法入睡:{e}") from e return _pack(bag) def _friendly_err(action: str, e: Exception) -> str: msg = str(e) if "仅营业前/夜晚" in msg or "not allowed in phase" in msg: if "stock" in msg.lower() and "restock" not in msg.lower(): return f"{action}失败:上架只能在「营业前」" return f"{action}失败:进货只能在「营业前」或「夜晚」(营业中请专心待客)" if "only in PREP" in msg: return f"{action}失败:上架只能在「营业前」" if "insufficient wallet" in msg or "现金不足" in msg: return f"{action}失败:现金不够了" if "not enough warehouse" in msg or "仓库存货不足" in msg: return f"{action}失败:仓库存货不足,请先进货" if "inventory cap" in msg or "库存上限" in msg: return f"{action}失败:仓库已满" if "slot occupied" in msg or "货架位" in msg: return f"{action}失败:货架位已被占用" if "display capacity" in msg or "货架容量" in msg: return f"{action}失败:货架已满" return f"{action}失败:{msg}" @app.post("/api/session/{sid}/restock") def restock(sid: str, body: RestockBody): bag = _get(sid) s: GameSession = bag["session"] try: before = s.economy.wallet s.restock(body.product_id, body.qty) cost = before - s.economy.wallet name = _prod_name(s, body.product_id) _append_log(bag, f"进货「{name}」×{body.qty}(花费 ¥{cost})") _note_toast(bag, f"进货 {name} −¥{cost}", "info") except Exception as e: raise HTTPException(400, _friendly_err("进货", e)) from e return _pack(bag) @app.post("/api/session/{sid}/stock_display") def stock_display(sid: str, body: StockBody): bag = _get(sid) s: GameSession = bag["session"] try: s.stock_display(body.product_id, body.slot_id, body.qty) name = _prod_name(s, body.product_id) _append_log(bag, f"上架「{name}」→ {body.slot_id} ×{body.qty}") _note_toast(bag, f"已上架 {name}", "info") except Exception as e: raise HTTPException(400, _friendly_err("上架", e)) from e return _pack(bag) @app.post("/api/session/{sid}/set_light") def set_light(sid: str, body: LightBody): bag = _get(sid) s: GameSession = bag["session"] try: s.set_light(body.mode) _append_log(bag, f"灯光切换为「{light_zh(body.mode)}」") except Exception as e: raise HTTPException(400, f"调灯失败:{e}") from e return _pack(bag) @app.post("/api/session/{sid}/pause") def pause(sid: str, body: PauseBody): bag = _get(sid) s: GameSession = bag["session"] s.day.set_paused(bool(body.paused)) _append_log(bag, "已暂停" if body.paused else "继续营业") return _pack(bag) @app.post("/api/session/{sid}/run_to_night") def run_to_night(sid: str): bag = _get(sid) s: GameSession = bag["session"] try: if s.day.phase.value == "PREP": s.open_shop() _append_log(bag, "翻开营业牌 —— 欢迎光临小镇角落小店") bag["guests_today"] = [] # 逐步推进以记录客人 guard = 0 while s.day.phase.value != "NIGHT" and guard < 5000: _one_tick(bag, 0.25) guard += 1 _append_log(bag, "这一天过去了,夜幕降临") _note_toast(bag, "夜幕降临", "phase") except Exception as e: raise HTTPException(400, f"推进失败:{e}") from e return _pack(bag) if ASSETS.is_dir(): app.mount("/assets", StaticFiles(directory=str(ASSETS)), name="assets") if WEB_PLAY.is_dir(): app.mount("/play", StaticFiles(directory=str(WEB_PLAY), html=True), name="play") @app.get("/") def root(): index = WEB_PLAY / "index.html" if index.is_file(): return FileResponse(index) return RedirectResponse("/docs")