File size: 13,088 Bytes
b04c42c 968c7dd b04c42c 968c7dd b04c42c df19590 968c7dd b04c42c 968c7dd b04c42c 968c7dd b04c42c 968c7dd df19590 968c7dd df19590 b04c42c 968c7dd b04c42c 968c7dd b04c42c df19590 968c7dd b04c42c 968c7dd b04c42c 968c7dd b04c42c 968c7dd b04c42c 968c7dd b04c42c 968c7dd b04c42c 968c7dd b04c42c df19590 b04c42c 968c7dd df19590 968c7dd b04c42c 968c7dd df19590 b04c42c 968c7dd df19590 968c7dd df19590 b04c42c 968c7dd df19590 968c7dd 9ad9b76 b04c42c 9ad9b76 968c7dd df19590 b04c42c 968c7dd 9ad9b76 968c7dd b04c42c 968c7dd 9ad9b76 968c7dd df19590 968c7dd df19590 968c7dd b04c42c 968c7dd df19590 b04c42c 968c7dd df19590 968c7dd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 | """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")
|