| """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", []) |
| |
| 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") |
|
|