diff --git a/EN_README.md b/EN_README.md index ee09f4a..e8902d3 100644 --- a/EN_README.md +++ b/EN_README.md @@ -29,7 +29,7 @@ > **An AI-driven cultivation world simulator that aims to create a truly living, immersive xianxia world.**

- + Featured|HelloGitHub

diff --git a/README.md b/README.md index 22b7065..d78d114 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ > **一个AI驱动的修仙世界模拟器,旨在创造一个真正活着的、有沉浸感的仙侠世界。**

- + Featured|HelloGitHub

diff --git a/docs/VUE_PERFORMANCE_GUIDE.md b/docs/VUE_PERFORMANCE_GUIDE.md new file mode 100644 index 0000000..b368624 --- /dev/null +++ b/docs/VUE_PERFORMANCE_GUIDE.md @@ -0,0 +1,66 @@ +# Vue 3 Performance Best Practices + +This document outlines performance optimization strategies for the Vue 3 frontend in the Cultivation World Simulator project. + +## 1. Handling Large Objects (`shallowRef`) + +### Context +When receiving large, deeply nested JSON objects from the backend (e.g., full avatar details, game state snapshots), Vue's default reactivity system (`ref` or `reactive`) converts every single property at every level into a Proxy. This process is synchronous and runs on the main thread. + +For complex objects like `AvatarDetail` which may contain lists of relations, materials, and effects, this deep conversion can take significant time (10ms - 100ms+), causing noticeable UI freezes during assignment. + +### Solution: `shallowRef` +Use `shallowRef` instead of `ref` for these large, read-only data structures. + +```typescript +import { shallowRef } from 'vue'; + +// BAD: Deep conversion, slow for large objects +const bigData = ref(null); + +// GOOD: Only tracks .value changes, no deep conversion +const bigData = shallowRef(null); +``` + +### When to Use +- **Read-Only Display Data**: Data fetched from the API that is primarily for display and not modified field-by-field in the frontend. +- **Large Lists/Trees**: Game state, logs, inventory lists, relation graphs. + +### Important Trade-offs +With `shallowRef`, **deep mutations do NOT trigger updates**. + +```typescript +const data = shallowRef({ count: 1, nested: { name: 'foo' } }); + +// ❌ This will NOT update the UI +data.value.count++; +data.value.nested.name = 'bar'; + +// ✅ This WILL update the UI (replace the entire object) +data.value = { ...data.value, count: data.value.count + 1 }; +// OR assignment from API response +data.value = apiResponse; +``` + +### Project Usage Example +See `web/src/stores/ui.ts`: +```typescript +// Used for the Info Panel detail data which can be very large +const detailData = shallowRef(null); +``` + +--- + +## 2. Component Rendering + +### Virtual Scrolling +For lists that can grow indefinitely (e.g., event logs, entity lists), avoid `v-for` rendering all items. Use virtual scrolling (rendering only visible items) to keep the DOM light. + +### Memoization +Use `computed` for expensive derived state rather than methods or inline expressions in templates. + +## 3. Reactivity Debugging +If UI operations feel sluggish: +1. Check the "Scripting" time in Chrome DevTools Performance tab. +2. If high, look for large object assignments to `ref` or `reactive`. +3. Switch to `shallowRef` if deep reactivity is not strictly required. diff --git a/src/classes/avatar_manager.py b/src/classes/avatar_manager.py index f9e7dd9..c727617 100644 --- a/src/classes/avatar_manager.py +++ b/src/classes/avatar_manager.py @@ -97,6 +97,38 @@ class AvatarManager: """辅助方法:遍历所有角色(活人+死者)""" return itertools.chain(self.avatars.values(), self.dead_avatars.values()) + def cleanup_long_dead_avatars(self, current_time: "MonthStamp", threshold_years: int = 20) -> int: + """ + 清理长期已故的角色。 + + Args: + current_time: 当前时间戳 + threshold_years: 死亡超过多少年则清理 (默认20年) + + Returns: + 清理的角色数量 + """ + if not self.dead_avatars: + return 0 + + to_remove = [] + for aid, avatar in self.dead_avatars.items(): + if avatar.death_info: + death_time = avatar.death_info.get("time") # int 类型的时间戳 + if death_time is not None: + # 计算时间差 (MonthStamp 本质是 int, 表示总月数) + elapsed_months = int(current_time) - death_time + elapsed_years = elapsed_months // 12 + + if elapsed_years >= threshold_years: + to_remove.append(aid) + + # 批量删除 + if to_remove: + self.remove_avatars(to_remove) + + return len(to_remove) + def remove_avatar(self, avatar_id: str) -> None: """ 从管理器中彻底删除一个 avatar(无论是死是活),并清理所有与其相关的双向关系。 diff --git a/src/classes/cultivation.py b/src/classes/cultivation.py index 255a681..6ab34a0 100644 --- a/src/classes/cultivation.py +++ b/src/classes/cultivation.py @@ -288,6 +288,10 @@ class CultivationProgress: """ 检查是否可以突破 """ + # 动态检测目前最高级别的修为:如果已经是最高境界的最高等级,则不能突破 + max_level = len(REALM_ORDER) * LEVELS_PER_REALM + if self.level >= max_level: + return False return self.is_in_bottleneck() def can_cultivate(self) -> bool: diff --git a/src/server/main.py b/src/server/main.py index cdbb741..1f9cde4 100644 --- a/src/server/main.py +++ b/src/server/main.py @@ -7,6 +7,8 @@ import time import threading import signal import random +import re +import logging from omegaconf import OmegaConf from contextlib import asynccontextmanager @@ -38,8 +40,7 @@ from src.classes.alignment import Alignment from src.classes.event import Event from src.classes.celestial_phenomenon import celestial_phenomena_by_id from src.classes.long_term_objective import set_user_long_term_objective, clear_user_long_term_objective -from src.sim.save.save_game import save_game, list_saves -from src.sim.load.load_game import load_game +from src.sim import save_game, list_saves, load_game, get_events_db_path, check_save_compatibility from src.utils import protagonist as prot_utils from src.utils.llm.client import test_connectivity from src.utils.llm.config import LLMConfig, LLMMode @@ -132,6 +133,14 @@ def resolve_avatar_action_emoji(avatar) -> str: # 简易的命令行参数检查 (不使用 argparse 以避免冲突和时序问题) IS_DEV_MODE = "--dev" in sys.argv +class EndpointFilter(logging.Filter): + """ + Log filter to hide successful /api/init-status requests (polling) + to reduce console noise. + """ + def filter(self, record: logging.LogRecord) -> bool: + return record.getMessage().find("GET /api/init-status") == -1 + class ConnectionManager: def __init__(self): self.active_connections: list[WebSocket] = [] @@ -369,7 +378,7 @@ async def init_game_async(): # 初始化 SQLite 事件数据库 from datetime import datetime - from src.sim.load_game import get_events_db_path + from src.sim import get_events_db_path timestamp = datetime.now().strftime("%Y%m%d_%H%M") save_name = f"save_{timestamp}" @@ -628,6 +637,9 @@ def ensure_npm_dependencies(web_dir: str) -> bool: @asynccontextmanager async def lifespan(app: FastAPI): + # Filter out health check / polling logs + logging.getLogger("uvicorn.access").addFilter(EndpointFilter()) + # 初始化语言设置 from src.utils.config import update_paths_for_language from src.utils.df import reload_game_configs @@ -1685,8 +1697,20 @@ async def save_llm_config(req: LLMConfigDTO): # --- 存档系统 API --- +def validate_save_name(name: str) -> bool: + """验证存档名称是否合法。""" + if not name or len(name) > 50: + return False + # 只允许中文、字母、数字和下划线。 + pattern = r'^[\w\u4e00-\u9fff]+$' + return bool(re.match(pattern, name)) + + class SaveGameRequest(BaseModel): - filename: Optional[str] = None + custom_name: Optional[str] = None # 自定义存档名称 + +class DeleteSaveRequest(BaseModel): + filename: str class LoadGameRequest(BaseModel): filename: str @@ -1695,14 +1719,22 @@ class LoadGameRequest(BaseModel): def get_saves(): """获取存档列表""" saves_list = list_saves() - # 转换 Path 为 str,并整理格式 + # 转换 Path 为 str,并整理格式。 result = [] for path, meta in saves_list: result.append({ "filename": path.name, "save_time": meta.get("save_time", ""), "game_time": meta.get("game_time", ""), - "version": meta.get("version", "") + "version": meta.get("version", ""), + # 新增字段。 + "language": meta.get("language", ""), + "avatar_count": meta.get("avatar_count", 0), + "alive_count": meta.get("alive_count", 0), + "dead_count": meta.get("dead_count", 0), + "protagonist_name": meta.get("protagonist_name"), + "custom_name": meta.get("custom_name"), + "event_count": meta.get("event_count", 0), }) return {"saves": result} @@ -1714,20 +1746,58 @@ def api_save_game(req: SaveGameRequest): if not world or not sim: raise HTTPException(status_code=503, detail="Game not initialized") - # 尝试从 world 属性获取(如果以后添加了) + # 尝试从 world 属性获取(如果以后添加了)。 existed_sects = getattr(world, "existed_sects", []) if not existed_sects: - # fallback: 所有 sects + # fallback: 所有 sects. existed_sects = list(sects_by_id.values()) - # 使用当前存档路径(保持 SQLite 数据库关联) - current_save_path = game_instance.get("current_save_path") - success, filename = save_game(world, sim, existed_sects, save_path=current_save_path) + # 名称验证。 + custom_name = req.custom_name + if custom_name and not validate_save_name(custom_name): + raise HTTPException( + status_code=400, + detail="Invalid save name" + ) + + # 新存档(不使用 current_save_path,每次创建新文件)。 + success, filename = save_game(world, sim, existed_sects, custom_name=custom_name) if success: return {"status": "ok", "filename": filename} else: raise HTTPException(status_code=500, detail="Save failed") +@app.post("/api/game/delete") +def api_delete_game(req: DeleteSaveRequest): + """删除存档及其关联文件""" + # 安全检查 + if ".." in req.filename or "/" in req.filename or "\\" in req.filename: + raise HTTPException(status_code=400, detail="Invalid filename") + + try: + saves_dir = CONFIG.paths.saves + target_path = saves_dir / req.filename + + # 1. 删除 JSON 存档文件 + if target_path.exists(): + os.remove(target_path) + + # 2. 删除对应的 SQL 数据库文件 + events_db_path = get_events_db_path(target_path) + if os.path.exists(events_db_path): + try: + os.remove(events_db_path) + except Exception as e: + print(f"[Warning] Failed to delete db file {events_db_path}: {e}") + + # 3. 删除可能存在的其他关联文件(如果有) + + return {"status": "ok", "message": "Save deleted"} + except Exception as e: + import traceback + traceback.print_exc() + raise HTTPException(status_code=500, detail=f"Delete failed: {str(e)}") + @app.post("/api/game/load") async def api_load_game(req: LoadGameRequest): """加载游戏(异步,支持进度更新)。""" @@ -1743,7 +1813,7 @@ async def api_load_game(req: LoadGameRequest): raise HTTPException(status_code=404, detail="File not found") # --- 语言环境自动切换 --- - from src.sim.save.save_game import get_save_info + from src.sim import get_save_info save_meta = get_save_info(target_path) if save_meta: save_lang = save_meta.get("language") diff --git a/src/sim/__init__.py b/src/sim/__init__.py new file mode 100644 index 0000000..67e67f3 --- /dev/null +++ b/src/sim/__init__.py @@ -0,0 +1,17 @@ +""" +Simulator module +""" +# 延迟导入 Simulator 以避免循环导入 +# from .simulator import Simulator + +def __getattr__(name): + if name == "Simulator": + from .simulator import Simulator + return Simulator + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + +# 导出常用的 save/load 函数,方便外部调用 +from .save.save_game import save_game, list_saves, get_save_info +from .load.load_game import load_game, get_events_db_path, check_save_compatibility + +__all__ = ["Simulator", "save_game", "list_saves", "get_save_info", "load_game", "get_events_db_path", "check_save_compatibility"] diff --git a/src/sim/load/load_game.py b/src/sim/load/load_game.py index 8fa6ba2..1b26c9d 100644 --- a/src/sim/load/load_game.py +++ b/src/sim/load/load_game.py @@ -234,9 +234,18 @@ def load_game(save_path: Optional[Path] = None) -> Tuple["World", "Simulator", L # 第一阶段:重建所有Avatar(不含relations) avatars_data = save_data.get("avatars", []) all_avatars = {} + living_avatars = {} + dead_avatars = {} + for avatar_data in avatars_data: avatar = Avatar.from_save_dict(avatar_data, world) all_avatars[avatar.id] = avatar + + # 分流:生者与死者 + if avatar.is_dead: + dead_avatars[avatar.id] = avatar + else: + living_avatars[avatar.id] = avatar # 第二阶段:重建relations(需要所有avatar都已加载) for avatar_data in avatars_data: @@ -251,7 +260,8 @@ def load_game(save_path: Optional[Path] = None) -> Tuple["World", "Simulator", L avatar.relations[other_avatar] = relation # 将所有avatar添加到world - world.avatar_manager.avatars = all_avatars + world.avatar_manager.avatars = living_avatars + world.avatar_manager.dead_avatars = dead_avatars # 恢复洞府主人关系 cultivate_regions_hosts = world_data.get("cultivate_regions_hosts", {}) diff --git a/src/sim/load_game.py b/src/sim/load_game.py deleted file mode 100644 index 489c1e0..0000000 --- a/src/sim/load_game.py +++ /dev/null @@ -1,159 +0,0 @@ -""" -读档功能模块 -""" -import json -from pathlib import Path -from typing import Tuple, List, Optional - -from src.classes.world import World -from src.classes.map import Map -from src.classes.calendar import MonthStamp -from src.classes.avatar import Avatar -from src.classes.event import Event -from src.classes.sect import sects_by_id, Sect -from src.classes.relation.relation import Relation -from src.sim.simulator import Simulator -from src.run.load_map import load_cultivation_world_map -from src.utils.config import CONFIG - - -def get_events_db_path(save_path: Path) -> Path: - """ - 根据存档路径计算事件数据库路径。 - - 例如:save_20260105_1423.json -> save_20260105_1423_events.db - """ - return save_path.with_suffix("").with_name(save_path.stem + "_events.db") - - -def load_game(save_path: Optional[Path] = None) -> Tuple[World, Simulator, List[Sect]]: - """ - 从文件加载游戏状态 - - Args: - save_path: 存档路径,默认为saves/save.json - - Returns: - (world, simulator, existed_sects) - - Raises: - FileNotFoundError: 如果存档文件不存在 - Exception: 如果加载失败 - """ - # 确定加载路径 - if save_path is None: - saves_dir = CONFIG.paths.saves - save_path = saves_dir / "save.json" - else: - save_path = Path(save_path) - - if not save_path.exists(): - raise FileNotFoundError(f"存档文件不存在: {save_path}") - - try: - # 读取存档文件 - with open(save_path, "r", encoding="utf-8") as f: - save_data = json.load(f) - - # 读取元信息 - meta = save_data.get("meta", {}) - print(f"正在加载存档 (版本: {meta.get('version', 'unknown')}, " - f"游戏时间: {meta.get('game_time', 'unknown')})") - - # 重建地图(地图本身不变,只需重建宗门总部位置) - game_map = load_cultivation_world_map() - - # 读取世界数据 - world_data = save_data.get("world", {}) - month_stamp = MonthStamp(world_data["month_stamp"]) - - # 计算事件数据库路径 - events_db_path = get_events_db_path(save_path) - - # 重建World对象(使用 SQLite 事件存储) - world = World.create_with_db( - map=game_map, - month_stamp=month_stamp, - events_db_path=events_db_path, - ) - - # 获取本局启用的宗门 - existed_sect_ids = world_data.get("existed_sect_ids", []) - existed_sects = [sects_by_id[sid] for sid in existed_sect_ids if sid in sects_by_id] - - # 第一阶段:重建所有Avatar(不含relations) - avatars_data = save_data.get("avatars", []) - all_avatars = {} - for avatar_data in avatars_data: - avatar = Avatar.from_save_dict(avatar_data, world) - all_avatars[avatar.id] = avatar - - # 第二阶段:重建relations(需要所有avatar都已加载) - for avatar_data in avatars_data: - avatar_id = avatar_data["id"] - avatar = all_avatars[avatar_id] - relations_dict = avatar_data.get("relations", {}) - - for other_id, relation_value in relations_dict.items(): - if other_id in all_avatars: - other_avatar = all_avatars[other_id] - relation = Relation(relation_value) - avatar.relations[other_avatar] = relation - - # 将所有avatar添加到world - world.avatar_manager.avatars = all_avatars - - # 检查是否需要从 JSON 迁移事件(向后兼容) - db_event_count = world.event_manager.count() - events_data = save_data.get("events", []) - - if db_event_count == 0 and len(events_data) > 0: - # SQLite 数据库是空的,但 JSON 中有事件,执行迁移 - print(f"正在从 JSON 迁移 {len(events_data)} 条事件到 SQLite...") - for event_data in events_data: - event = Event.from_dict(event_data) - world.event_manager.add_event(event) - print("事件迁移完成") - else: - print(f"已从 SQLite 加载 {db_event_count} 条事件") - - # 重建Simulator - simulator_data = save_data.get("simulator", {}) - simulator = Simulator(world) - simulator.awakening_rate = simulator_data.get("awakening_rate", simulator_data.get("birth_rate", CONFIG.game.npc_awakening_rate_per_month)) - - print(f"存档加载成功!共加载 {len(all_avatars)} 个角色") - return world, simulator, existed_sects - - except Exception as e: - print(f"加载游戏失败: {e}") - import traceback - traceback.print_exc() - raise - - -def check_save_compatibility(save_path: Path) -> Tuple[bool, str]: - """ - 检查存档兼容性 - - Args: - save_path: 存档路径 - - Returns: - (是否兼容, 错误信息) - """ - try: - with open(save_path, "r", encoding="utf-8") as f: - save_data = json.load(f) - - meta = save_data.get("meta", {}) - save_version = meta.get("version", "unknown") - current_version = CONFIG.meta.version - - # 当前不做版本兼容性检查,直接返回兼容 - # 未来可以在这里添加版本比较逻辑 - return True, "" - - except Exception as e: - return False, f"无法读取存档文件: {e}" - diff --git a/src/sim/save/save_game.py b/src/sim/save/save_game.py index e2fe0fc..9148495 100644 --- a/src/sim/save/save_game.py +++ b/src/sim/save/save_game.py @@ -25,6 +25,7 @@ - 事件实时写入SQLite,JSON中的events字段仅用于旧存档迁移 """ import json +import re from pathlib import Path from datetime import datetime from typing import List, Optional, TYPE_CHECKING @@ -38,22 +39,45 @@ from src.utils.config import CONFIG from src.classes.language import language_manager from src.sim.load.load_game import get_events_db_path +# 主角特质 ID: 穿越者=30, 气运之子=31. +PROTAGONIST_PERSONA_IDS = {30, 31} + + +def sanitize_save_name(name: str) -> str: + """清理存档名称,只保留安全字符。""" + # 移除文件系统不允许的字符。 + safe_name = re.sub(r'[\\/:*?"<>|]', '', name) + # 只保留中文、字母、数字和下划线。 + safe_name = re.sub(r'[^\w\u4e00-\u9fff]', '_', safe_name) + return safe_name[:50] if safe_name else "save" + + +def find_protagonist_name(world: "World") -> Optional[str]: + """查找主角名字(具有气运之子或穿越者特质的存活角色)。""" + for avatar in world.avatar_manager.avatars.values(): + persona_ids = [p.id for p in avatar.personas] if avatar.personas else [] + if any(pid in PROTAGONIST_PERSONA_IDS for pid in persona_ids): + return avatar.name + return None + def save_game( world: "World", simulator: "Simulator", existed_sects: List["Sect"], - save_path: Optional[Path] = None + save_path: Optional[Path] = None, + custom_name: Optional[str] = None ) -> tuple[bool, Optional[str]]: """ 保存游戏状态到文件 - + Args: world: 世界对象 simulator: 模拟器对象 existed_sects: 本局启用的宗门列表 save_path: 保存路径,默认为saves/时间戳_游戏时间.json - + custom_name: 用户自定义的存档名称 + Returns: (保存是否成功, 保存的文件名) """ @@ -62,15 +86,21 @@ def save_game( if save_path is None: saves_dir = CONFIG.paths.saves saves_dir.mkdir(parents=True, exist_ok=True) - - # 生成友好的文件名:20251111_193000_Y100M1.json + + # 生成友好的文件名。 now = datetime.now() time_str = now.strftime("%Y%m%d_%H%M%S") year = world.month_stamp.get_year() month = world.month_stamp.get_month().value game_time_str = f"Y{year}M{month}" - - filename = f"{time_str}_{game_time_str}.json" + + # 处理自定义名称。 + if custom_name: + safe_name = sanitize_save_name(custom_name) + filename = f"{safe_name}_{time_str}.json" + else: + filename = f"{time_str}_{game_time_str}.json" + save_path = saves_dir / filename else: save_path = Path(save_path) @@ -95,6 +125,12 @@ def save_game( else: print(f"警告: 当前事件数据库不存在: {current_db_path}") + # 计算角色统计。 + alive_count = len(world.avatar_manager.avatars) + dead_count = len(world.avatar_manager.dead_avatars) + total_count = alive_count + dead_count + protagonist_name = find_protagonist_name(world) + # 构建元信息 meta = { "version": CONFIG.meta.version, @@ -104,6 +140,12 @@ def save_game( # SQLite 事件数据库信息。 "events_db": str(events_db_path.name), "event_count": world.event_manager.count(), + # 新增元数据。 + "avatar_count": total_count, + "alive_count": alive_count, + "dead_count": dead_count, + "protagonist_name": protagonist_name, + "custom_name": custom_name, } # 构建世界数据 diff --git a/src/sim/save_game.py b/src/sim/save_game.py deleted file mode 100644 index a5708ed..0000000 --- a/src/sim/save_game.py +++ /dev/null @@ -1,139 +0,0 @@ -""" -存档功能模块 -""" -import json -from pathlib import Path -from datetime import datetime -from typing import List, Optional - -from src.classes.world import World -from src.sim.simulator import Simulator -from src.classes.sect import Sect -from src.utils.config import CONFIG -from src.sim.load_game import get_events_db_path - - -def save_game( - world: World, - simulator: Simulator, - existed_sects: List[Sect], - save_path: Optional[Path] = None -) -> tuple[bool, str]: - """ - 保存游戏状态到文件 - - Args: - world: 世界对象 - simulator: 模拟器对象 - existed_sects: 本局启用的宗门列表 - save_path: 保存路径,默认为saves/save.json - - Returns: - (是否成功, 文件名) - """ - try: - # 确定保存路径 - if save_path is None: - saves_dir = CONFIG.paths.saves - saves_dir.mkdir(parents=True, exist_ok=True) - save_path = saves_dir / "save.json" - else: - save_path = Path(save_path) - save_path.parent.mkdir(parents=True, exist_ok=True) - - # 构建元信息 - meta = { - "version": CONFIG.meta.version, - "save_time": datetime.now().isoformat(), - "game_time": f"{world.month_stamp.get_year()}年{world.month_stamp.get_month().value}月" - } - - # 构建世界数据 - world_data = { - "month_stamp": int(world.month_stamp), - "existed_sect_ids": [sect.id for sect in existed_sects] - } - - # 保存所有Avatar(第一阶段:不含relations) - avatars_data = [] - for avatar in world.avatar_manager.avatars.values(): - avatars_data.append(avatar.to_save_dict()) - - # 事件已实时写入 SQLite,不再保存到 JSON。 - # 记录事件数据库路径到元信息中(供参考)。 - events_db_path = get_events_db_path(save_path) - meta["events_db"] = str(events_db_path.name) - meta["event_count"] = world.event_manager.count() - - # 保存模拟器数据 - simulator_data = { - "awakening_rate": simulator.awakening_rate - } - - # 组装完整的存档数据(不含 events,事件在 SQLite 中) - save_data = { - "meta": meta, - "world": world_data, - "avatars": avatars_data, - "simulator": simulator_data - } - - # 写入文件 - with open(save_path, "w", encoding="utf-8") as f: - json.dump(save_data, f, ensure_ascii=False, indent=2) - - print(f"游戏已保存到: {save_path}") - print(f"事件数据库: {events_db_path} ({meta['event_count']} 条事件)") - return True, save_path.name - - except Exception as e: - print(f"保存游戏失败: {e}") - import traceback - traceback.print_exc() - return False, "" - - -def get_save_info(save_path: Path) -> Optional[dict]: - """ - 读取存档文件的元信息(不加载完整数据) - - Args: - save_path: 存档路径 - - Returns: - 存档元信息字典,如果读取失败返回None - """ - try: - with open(save_path, "r", encoding="utf-8") as f: - data = json.load(f) - return data.get("meta", {}) - except Exception: - return None - - -def list_saves(saves_dir: Optional[Path] = None) -> List[tuple[Path, dict]]: - """ - 列出所有存档文件及其元信息 - - Args: - saves_dir: 存档目录,默认为config中的saves目录 - - Returns: - [(存档路径, 元信息字典), ...] - """ - if saves_dir is None: - saves_dir = CONFIG.paths.saves - - if not saves_dir.exists(): - return [] - - saves = [] - for save_file in saves_dir.glob("*.json"): - info = get_save_info(save_file) - if info is not None: - saves.append((save_file, info)) - - # 按保存时间倒序排列 - saves.sort(key=lambda x: x[1].get("save_time", ""), reverse=True) - return saves - diff --git a/src/sim/simulator.py b/src/sim/simulator.py index d284e66..2804049 100644 --- a/src/sim/simulator.py +++ b/src/sim/simulator.py @@ -479,6 +479,14 @@ class Simulator: # 15. (每年1月) 更新计算关系 (二阶关系) self._phase_update_calculated_relations() + + # 15.5 (每年1月) 清理由于时间久远而被遗忘的死者 + if self.world.month_stamp.get_month() == Month.JANUARY: + # 20年写死或者做成 CONFIG.game.dead_cleanup_years + cleaned_count = self.world.avatar_manager.cleanup_long_dead_avatars(self.world.month_stamp, 20) + if cleaned_count > 0: + # 记录日志,但不产生游戏内事件 + get_logger().logger.info(f"Cleaned up {cleaned_count} long-dead avatars.") # 16. 归档与时间推进 return self._finalize_step(events) diff --git a/static/config.yml b/static/config.yml index 98739f7..a6ed912 100644 --- a/static/config.yml +++ b/static/config.yml @@ -1,5 +1,5 @@ meta: - version: "1.4.1" + version: "1.4.2" llm: default_modes: diff --git a/tests/test_breakthrough_logic.py b/tests/test_breakthrough_logic.py new file mode 100644 index 0000000..d36a7e7 --- /dev/null +++ b/tests/test_breakthrough_logic.py @@ -0,0 +1,81 @@ +import pytest +from src.classes.cultivation import CultivationProgress, Realm, Stage, REALM_ORDER, LEVELS_PER_REALM + +def test_breakthrough_normal(dummy_avatar): + """ + 测试正常突破逻辑:从练气圆满突破到筑基前期 + """ + # 练气圆满是 30 级 (LEVELS_PER_REALM * 1) + qi_max_level = 30 + + # 设置角色状态 + cp = CultivationProgress(level=qi_max_level, exp=0) + dummy_avatar.cultivation_progress = cp + + # 验证当前状态 + assert cp.realm == Realm.Qi_Refinement + assert cp.is_in_bottleneck() is True + assert cp.can_break_through() is True + + # 执行突破 + cp.break_through() + + # 验证突破后状态 + # 应该升级到 31 级 + assert cp.level == 31 + # 境界应该是 筑基 + assert cp.realm == Realm.Foundation_Establishment + # 阶段应该是 前期 + assert cp.stage == Stage.Early_Stage + # 不再处于瓶颈 + assert cp.is_in_bottleneck() is False + +def test_breakthrough_max_realm_limit(dummy_avatar): + """ + 测试最高境界限制:元婴圆满(目前最高等级)不能再突破 + """ + # 计算最高等级 + # 目前有 4 个境界,每个 30 级,最高 120 级 + max_level = len(REALM_ORDER) * LEVELS_PER_REALM + + # 设置角色状态为最高等级 + cp = CultivationProgress(level=max_level, exp=0) + dummy_avatar.cultivation_progress = cp + + # 验证当前状态 + assert cp.realm == Realm.Nascent_Soul + # 理论上它是 30/60/90/120,模 30 为 0,所以 is_in_bottleneck 会返回 True + assert cp.is_in_bottleneck() is True + + # 关键测试点:can_break_through 应该返回 False,因为已经封顶了 + assert cp.can_break_through() is False + +def test_not_in_bottleneck(dummy_avatar): + """ + 测试非瓶颈期不能突破 + """ + # 随便设一个中间等级,比如 15 级(练气中期) + cp = CultivationProgress(level=15, exp=0) + dummy_avatar.cultivation_progress = cp + + assert cp.is_in_bottleneck() is False + assert cp.can_break_through() is False + +def test_breakthrough_intermediate_bottleneck(dummy_avatar): + """ + 测试中间境界的突破:筑基圆满 -> 金丹 + """ + # 筑基圆满是 60 级 + foundation_max_level = 60 + + cp = CultivationProgress(level=foundation_max_level, exp=0) + dummy_avatar.cultivation_progress = cp + + assert cp.realm == Realm.Foundation_Establishment + assert cp.can_break_through() is True + + cp.break_through() + + assert cp.level == 61 + assert cp.realm == Realm.Core_Formation + assert cp.stage == Stage.Early_Stage diff --git a/tests/test_save_custom_name.py b/tests/test_save_custom_name.py new file mode 100644 index 0000000..a60f0ca --- /dev/null +++ b/tests/test_save_custom_name.py @@ -0,0 +1,680 @@ +""" +Tests for custom save name and enhanced metadata features (Issue #95). +""" +import pytest +import json +from pathlib import Path +from unittest.mock import patch, MagicMock + +from src.classes.world import World +from src.classes.map import Map +from src.classes.tile import TileType +from src.classes.calendar import Month, Year, create_month_stamp +from src.classes.avatar import Avatar, Gender +from src.classes.age import Age +from src.classes.cultivation import Realm +from src.classes.persona import personas_by_id +from src.sim.simulator import Simulator +from src.sim.save.save_game import ( + save_game, + sanitize_save_name, + find_protagonist_name, + PROTAGONIST_PERSONA_IDS, +) +from src.sim.load.load_game import load_game, get_events_db_path +from src.utils.id_generator import get_avatar_id + + +def create_test_map(): + """Create a simple test map.""" + m = Map(width=10, height=10) + for x in range(10): + for y in range(10): + m.create_tile(x, y, TileType.PLAIN) + return m + + +@pytest.fixture +def temp_save_dir(tmp_path): + d = tmp_path / "saves" + d.mkdir() + return d + + +# ============================================================================= +# Tests for sanitize_save_name function +# ============================================================================= + + +class TestSanitizeSaveName: + """Tests for the sanitize_save_name helper function.""" + + def test_chinese_characters_allowed(self): + """Test that Chinese characters are preserved.""" + result = sanitize_save_name("我的存档") + assert result == "我的存档" + + def test_english_characters_allowed(self): + """Test that English characters are preserved.""" + result = sanitize_save_name("MyFirstSave") + assert result == "MyFirstSave" + + def test_numbers_allowed(self): + """Test that numbers are preserved.""" + result = sanitize_save_name("Save123") + assert result == "Save123" + + def test_underscores_allowed(self): + """Test that underscores are preserved.""" + result = sanitize_save_name("my_save_file") + assert result == "my_save_file" + + def test_mixed_content(self): + """Test mixed Chinese, English, and numbers.""" + result = sanitize_save_name("我的Save存档_123") + assert result == "我的Save存档_123" + + def test_special_characters_replaced(self): + """Test that special characters are replaced with underscores.""" + result = sanitize_save_name("Save!@#$%^&*()") + assert "!" not in result + assert "@" not in result + assert result.replace("_", "").isalnum() or result == "Save__________" + + def test_path_separators_removed(self): + """Test that path separators are removed.""" + result = sanitize_save_name("path/to\\save") + assert "/" not in result + assert "\\" not in result + + def test_dangerous_chars_removed(self): + """Test that dangerous filesystem characters are removed.""" + result = sanitize_save_name('save:*?"<>|name') + assert ":" not in result + assert "*" not in result + assert "?" not in result + assert '"' not in result + assert "<" not in result + assert ">" not in result + assert "|" not in result + + def test_length_limit(self): + """Test that names are truncated to 50 characters.""" + long_name = "a" * 100 + result = sanitize_save_name(long_name) + assert len(result) <= 50 + + def test_empty_string_returns_default(self): + """Test that empty string returns 'save'.""" + result = sanitize_save_name("") + assert result == "save" + + def test_only_special_chars_returns_default(self): + """Test that a name with only special chars returns 'save'.""" + # After replacing all special chars with underscores, if nothing left, return 'save'. + # But underscores are kept, so "!!!" becomes "___" which is not empty. + result = sanitize_save_name("!!!") + # Should be "___" or similar, not "save". + assert len(result) > 0 + + def test_spaces_replaced(self): + """Test that spaces are replaced with underscores.""" + result = sanitize_save_name("my save file") + assert " " not in result + assert "_" in result + + +# ============================================================================= +# Tests for find_protagonist_name function +# ============================================================================= + + +class TestFindProtagonistName: + """Tests for the find_protagonist_name helper function.""" + + def test_no_protagonists(self, temp_save_dir): + """Test with no protagonist avatars.""" + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + + # Add regular avatar without protagonist traits. + avatar = Avatar( + world=world, + name="RegularAvatar", + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY), + age=Age(20, Realm.Qi_Refinement), + gender=Gender.MALE, + ) + avatar.personas = [] + world.avatar_manager.avatars[avatar.id] = avatar + + result = find_protagonist_name(world) + assert result is None + + def test_finds_protagonist_with_trait_30(self, temp_save_dir): + """Test finding protagonist with trait ID 30 (穿越者).""" + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + + # Add protagonist avatar with trait 30. + avatar = Avatar( + world=world, + name="穿越者主角", + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY), + age=Age(20, Realm.Qi_Refinement), + gender=Gender.MALE, + ) + # Mock persona with ID 30. + mock_persona = MagicMock() + mock_persona.id = 30 + avatar.personas = [mock_persona] + world.avatar_manager.avatars[avatar.id] = avatar + + result = find_protagonist_name(world) + assert result == "穿越者主角" + + def test_finds_protagonist_with_trait_31(self, temp_save_dir): + """Test finding protagonist with trait ID 31 (气运之子).""" + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + + # Add protagonist avatar with trait 31. + avatar = Avatar( + world=world, + name="气运之子", + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY), + age=Age(20, Realm.Qi_Refinement), + gender=Gender.MALE, + ) + mock_persona = MagicMock() + mock_persona.id = 31 + avatar.personas = [mock_persona] + world.avatar_manager.avatars[avatar.id] = avatar + + result = find_protagonist_name(world) + assert result == "气运之子" + + def test_finds_first_protagonist_when_multiple(self, temp_save_dir): + """Test that it returns one protagonist when multiple exist.""" + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + + # Add two protagonist avatars. + for i, name in enumerate(["主角一", "主角二"]): + avatar = Avatar( + world=world, + name=name, + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY), + age=Age(20, Realm.Qi_Refinement), + gender=Gender.MALE, + ) + mock_persona = MagicMock() + mock_persona.id = 30 + avatar.personas = [mock_persona] + world.avatar_manager.avatars[avatar.id] = avatar + + result = find_protagonist_name(world) + assert result in ["主角一", "主角二"] + + +# ============================================================================= +# Tests for save_game with custom name +# ============================================================================= + + +class TestSaveGameWithCustomName: + """Tests for save_game function with custom_name parameter.""" + + def test_save_with_custom_name(self, temp_save_dir): + """Test that custom name is used in filename.""" + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + sim = Simulator(world) + + success, filename = save_game( + world, sim, [], + save_path=None, + custom_name="我的测试存档" + ) + + # Check save succeeded. + assert success + + # Filename should start with the sanitized custom name. + assert filename.startswith("我的测试存档_") + assert filename.endswith(".json") + + def test_save_without_custom_name(self, temp_save_dir): + """Test that default naming is used when no custom name provided.""" + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + sim = Simulator(world) + + # Patch CONFIG.paths.saves to use temp dir. + with patch.object(__import__('src.utils.config', fromlist=['CONFIG']).CONFIG.paths, 'saves', temp_save_dir): + success, filename = save_game( + world, sim, [], + save_path=None, + custom_name=None + ) + + assert success + # Default filename format: YYYYMMDD_HHMMSS_Y{year}M{month}.json. + assert "_Y100M1.json" in filename or filename.endswith(".json") + + def test_custom_name_stored_in_meta(self, temp_save_dir): + """Test that custom_name is stored in save metadata.""" + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + sim = Simulator(world) + + save_path = temp_save_dir / "test_custom_meta.json" + success, _ = save_game( + world, sim, [], + save_path=save_path, + custom_name="我的存档" + ) + + assert success + + # Read and verify meta. + with open(save_path, "r", encoding="utf-8") as f: + data = json.load(f) + + assert data["meta"]["custom_name"] == "我的存档" + + def test_null_custom_name_in_meta(self, temp_save_dir): + """Test that null custom_name is stored when not provided.""" + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + sim = Simulator(world) + + save_path = temp_save_dir / "test_null_meta.json" + success, _ = save_game( + world, sim, [], + save_path=save_path, + custom_name=None + ) + + assert success + + with open(save_path, "r", encoding="utf-8") as f: + data = json.load(f) + + assert data["meta"]["custom_name"] is None + + +# ============================================================================= +# Tests for enhanced metadata +# ============================================================================= + + +class TestEnhancedMetadata: + """Tests for enhanced save metadata (avatar counts, protagonist).""" + + def test_avatar_counts_in_meta(self, temp_save_dir): + """Test that avatar counts are correctly stored.""" + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + + # Add living avatars. + for i in range(5): + avatar = Avatar( + world=world, + name=f"Avatar{i}", + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY), + age=Age(20, Realm.Qi_Refinement), + gender=Gender.MALE, + ) + world.avatar_manager.avatars[avatar.id] = avatar + + # Add dead avatars. + for i in range(3): + avatar = Avatar( + world=world, + name=f"DeadAvatar{i}", + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY), + age=Age(20, Realm.Qi_Refinement), + gender=Gender.MALE, + ) + world.avatar_manager.dead_avatars[avatar.id] = avatar + + sim = Simulator(world) + save_path = temp_save_dir / "test_counts.json" + success, _ = save_game(world, sim, [], save_path) + + assert success + + with open(save_path, "r", encoding="utf-8") as f: + data = json.load(f) + + meta = data["meta"] + assert meta["alive_count"] == 5 + assert meta["dead_count"] == 3 + assert meta["avatar_count"] == 8 # total + + def test_protagonist_name_in_meta(self, temp_save_dir): + """Test that protagonist name is stored in metadata.""" + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + + # Add protagonist avatar. + avatar = Avatar( + world=world, + name="林动", + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY), + age=Age(20, Realm.Qi_Refinement), + gender=Gender.MALE, + ) + mock_persona = MagicMock() + mock_persona.id = 31 # 气运之子 + avatar.personas = [mock_persona] + world.avatar_manager.avatars[avatar.id] = avatar + + sim = Simulator(world) + save_path = temp_save_dir / "test_protagonist.json" + success, _ = save_game(world, sim, [], save_path) + + assert success + + with open(save_path, "r", encoding="utf-8") as f: + data = json.load(f) + + assert data["meta"]["protagonist_name"] == "林动" + + def test_no_protagonist_in_meta(self, temp_save_dir): + """Test that protagonist_name is None when no protagonist exists.""" + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + + # Add regular avatar. + avatar = Avatar( + world=world, + name="普通人", + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY), + age=Age(20, Realm.Qi_Refinement), + gender=Gender.MALE, + ) + avatar.personas = [] + world.avatar_manager.avatars[avatar.id] = avatar + + sim = Simulator(world) + save_path = temp_save_dir / "test_no_prot.json" + success, _ = save_game(world, sim, [], save_path) + + assert success + + with open(save_path, "r", encoding="utf-8") as f: + data = json.load(f) + + assert data["meta"]["protagonist_name"] is None + + +# ============================================================================= +# Tests for API endpoints +# ============================================================================= + + +class TestSaveApiWithCustomName: + """Tests for /api/game/save endpoint with custom name.""" + + def test_api_save_with_custom_name(self, temp_save_dir): + """Test API save endpoint with custom name.""" + from fastapi.testclient import TestClient + from src.server import main + from src.utils.config import CONFIG + + # Setup game instance. + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + sim = Simulator(world) + + original_state = main.game_instance.copy() + main.game_instance["world"] = world + main.game_instance["sim"] = sim + + with patch.object(CONFIG.paths, "saves", temp_save_dir): + client = TestClient(main.app) + response = client.post( + "/api/game/save", + json={"custom_name": "我的API存档"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert "我的API存档" in data["filename"] + + # Cleanup. + main.game_instance.update(original_state) + + def test_api_save_without_custom_name(self, temp_save_dir): + """Test API save endpoint without custom name.""" + from fastapi.testclient import TestClient + from src.server import main + from src.utils.config import CONFIG + + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + sim = Simulator(world) + + original_state = main.game_instance.copy() + main.game_instance["world"] = world + main.game_instance["sim"] = sim + + with patch.object(CONFIG.paths, "saves", temp_save_dir): + client = TestClient(main.app) + response = client.post( + "/api/game/save", + json={} + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert data["filename"].endswith(".json") + + main.game_instance.update(original_state) + + def test_api_save_invalid_name_rejected(self, temp_save_dir): + """Test that invalid save names are rejected.""" + from fastapi.testclient import TestClient + from src.server import main + from src.utils.config import CONFIG + + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + sim = Simulator(world) + + original_state = main.game_instance.copy() + main.game_instance["world"] = world + main.game_instance["sim"] = sim + + with patch.object(CONFIG.paths, "saves", temp_save_dir): + client = TestClient(main.app) + # Name with only special characters - should be rejected. + response = client.post( + "/api/game/save", + json={"custom_name": "!!!@@@###"} + ) + + # Should be rejected with 400. + assert response.status_code == 400 + + main.game_instance.update(original_state) + + def test_api_save_name_too_long_rejected(self, temp_save_dir): + """Test that names over 50 chars are rejected.""" + from fastapi.testclient import TestClient + from src.server import main + from src.utils.config import CONFIG + + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + sim = Simulator(world) + + original_state = main.game_instance.copy() + main.game_instance["world"] = world + main.game_instance["sim"] = sim + + with patch.object(CONFIG.paths, "saves", temp_save_dir): + client = TestClient(main.app) + long_name = "a" * 51 + response = client.post( + "/api/game/save", + json={"custom_name": long_name} + ) + + assert response.status_code == 400 + + main.game_instance.update(original_state) + + +class TestSavesListApiWithMetadata: + """Tests for /api/saves endpoint returning enhanced metadata.""" + + def test_api_saves_returns_new_fields(self, temp_save_dir): + """Test that /api/saves returns new metadata fields.""" + from fastapi.testclient import TestClient + from src.server import main + from src.utils.config import CONFIG + + # Create a save file with metadata. + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + + # Add avatars. + for i in range(3): + avatar = Avatar( + world=world, + name=f"Avatar{i}", + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY), + age=Age(20, Realm.Qi_Refinement), + gender=Gender.MALE, + ) + world.avatar_manager.avatars[avatar.id] = avatar + + sim = Simulator(world) + save_path = temp_save_dir / "test_list.json" + save_game(world, sim, [], save_path, custom_name="列表测试") + + with patch.object(CONFIG.paths, "saves", temp_save_dir): + client = TestClient(main.app) + response = client.get("/api/saves") + + assert response.status_code == 200 + data = response.json() + + assert len(data["saves"]) >= 1 + save_item = data["saves"][0] + + # Verify new fields are present. + assert "avatar_count" in save_item + assert "alive_count" in save_item + assert "dead_count" in save_item + assert "protagonist_name" in save_item + assert "custom_name" in save_item + assert "event_count" in save_item + assert "language" in save_item + + # Verify values. + assert save_item["custom_name"] == "列表测试" + assert save_item["alive_count"] == 3 + assert save_item["avatar_count"] == 3 + + def test_api_saves_old_save_compatibility(self, temp_save_dir): + """Test that old saves without new fields return defaults.""" + from fastapi.testclient import TestClient + from src.server import main + from src.utils.config import CONFIG + + # Create a "legacy" save file without new metadata fields. + legacy_save = { + "meta": { + "version": "1.0", + "save_time": "2026-01-01T12:00:00", + "game_time": "100年1月", + }, + "world": {"month_stamp": 1200}, + "avatars": [], + "events": [], + "simulator": {}, + } + + save_path = temp_save_dir / "legacy.json" + with open(save_path, "w", encoding="utf-8") as f: + json.dump(legacy_save, f) + + with patch.object(CONFIG.paths, "saves", temp_save_dir): + client = TestClient(main.app) + response = client.get("/api/saves") + + assert response.status_code == 200 + data = response.json() + + # Find our legacy save. + legacy_item = None + for save in data["saves"]: + if save["filename"] == "legacy.json": + legacy_item = save + break + + assert legacy_item is not None + + # New fields should have default values. + assert legacy_item["avatar_count"] == 0 + assert legacy_item["alive_count"] == 0 + assert legacy_item["dead_count"] == 0 + assert legacy_item["protagonist_name"] is None + assert legacy_item["custom_name"] is None + assert legacy_item["event_count"] == 0 + + +# ============================================================================= +# Tests for validate_save_name function +# ============================================================================= + + +class TestValidateSaveName: + """Tests for the validate_save_name function in main.py.""" + + def test_valid_chinese_name(self): + from src.server.main import validate_save_name + assert validate_save_name("我的存档") is True + + def test_valid_english_name(self): + from src.server.main import validate_save_name + assert validate_save_name("MySave") is True + + def test_valid_mixed_name(self): + from src.server.main import validate_save_name + assert validate_save_name("我的Save_123") is True + + def test_empty_name_invalid(self): + from src.server.main import validate_save_name + assert validate_save_name("") is False + + def test_too_long_name_invalid(self): + from src.server.main import validate_save_name + assert validate_save_name("a" * 51) is False + + def test_special_chars_invalid(self): + from src.server.main import validate_save_name + assert validate_save_name("save!@#") is False + + def test_space_invalid(self): + from src.server.main import validate_save_name + assert validate_save_name("my save") is False + + def test_exactly_50_chars_valid(self): + from src.server.main import validate_save_name + assert validate_save_name("a" * 50) is True diff --git a/tests/test_save_load_death.py b/tests/test_save_load_death.py new file mode 100644 index 0000000..5073611 --- /dev/null +++ b/tests/test_save_load_death.py @@ -0,0 +1,84 @@ +import pytest +import os +from pathlib import Path +from src.sim.save.save_game import save_game +from src.sim.load.load_game import load_game +from src.classes.avatar import Avatar +from src.classes.death_reason import DeathReason, DeathType +from src.classes.calendar import MonthStamp, Month, Year, create_month_stamp + +def test_dead_avatar_stays_dead_after_load(base_world, dummy_avatar): + """ + 测试死亡的角色在读档后是否仍然被正确归类为死者, + 而不是复活出现在活人列表中。 + """ + # 1. 准备环境 + dummy_avatar.weapon = None + base_world.avatar_manager.register_avatar(dummy_avatar) + + assert dummy_avatar.id in base_world.avatar_manager.avatars + assert dummy_avatar.id not in base_world.avatar_manager.dead_avatars + assert not dummy_avatar.is_dead + + # 2. 杀死角色 + death_time = base_world.month_stamp + dummy_avatar.set_dead("Test Death", death_time) + base_world.avatar_manager.handle_death(dummy_avatar.id) + + assert dummy_avatar.is_dead + assert dummy_avatar.id not in base_world.avatar_manager.avatars + assert dummy_avatar.id in base_world.avatar_manager.dead_avatars + + # 3. 保存游戏 + from src.sim.simulator import Simulator + simulator = Simulator(base_world) + + success, save_filename = save_game(base_world, simulator, existed_sects=[]) + assert success + + from src.utils.config import CONFIG + save_path = CONFIG.paths.saves / save_filename + + # 4. 读取游戏 + loaded_world, loaded_sim, _ = load_game(save_path) + + # 5. 验证读档后的状态 + loaded_avatar = loaded_world.avatar_manager.get_avatar(dummy_avatar.id) + assert loaded_avatar is not None + assert loaded_avatar.is_dead + + assert loaded_avatar.id not in loaded_world.avatar_manager.avatars, "死者不应出现在活人列表 avatars 中" + assert loaded_avatar.id in loaded_world.avatar_manager.dead_avatars, "死者应该出现在死者列表 dead_avatars 中" + + living_ids = [a.id for a in loaded_world.avatar_manager.get_living_avatars()] + assert loaded_avatar.id not in living_ids, "死者不应被 get_living_avatars() 返回" + +def test_cleanup_long_dead_avatars(base_world, dummy_avatar): + """ + 测试清理死亡超过20年的角色逻辑 + """ + # 1. 准备环境 + base_world.avatar_manager.register_avatar(dummy_avatar) + + # 2. 模拟角色死亡(死亡时间为 Year 1, Month 1) + death_time = create_month_stamp(Year(1), Month.JANUARY) + dummy_avatar.set_dead("Old Age", death_time) + base_world.avatar_manager.handle_death(dummy_avatar.id) + + assert dummy_avatar.id in base_world.avatar_manager.dead_avatars + + # 3. 未满20年,不应清理 + # Year 20, Month 1 (经过19年) + current_time_19y = create_month_stamp(Year(20), Month.JANUARY) + cleaned_count = base_world.avatar_manager.cleanup_long_dead_avatars(current_time_19y, threshold_years=20) + assert cleaned_count == 0 + assert dummy_avatar.id in base_world.avatar_manager.dead_avatars + + # 4. 满20年,应清理 + # Year 21, Month 1 (经过20年) + current_time_20y = create_month_stamp(Year(21), Month.JANUARY) + cleaned_count = base_world.avatar_manager.cleanup_long_dead_avatars(current_time_20y, threshold_years=20) + + assert cleaned_count == 1 + assert dummy_avatar.id not in base_world.avatar_manager.dead_avatars + assert base_world.avatar_manager.get_avatar(dummy_avatar.id) is None diff --git a/web/src/__tests__/components/panels/SaveLoadPanel.test.ts b/web/src/__tests__/components/panels/SaveLoadPanel.test.ts new file mode 100644 index 0000000..2acc5c3 --- /dev/null +++ b/web/src/__tests__/components/panels/SaveLoadPanel.test.ts @@ -0,0 +1,494 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import SaveLoadPanel from '@/components/game/panels/system/SaveLoadPanel.vue' +import type { SaveFileDTO } from '@/types/api' + +// Use real timers for this test file since we need async operations. +beforeEach(() => { + vi.useRealTimers() +}) + +afterEach(() => { + vi.useFakeTimers() +}) + +// Mock naive-ui components. +vi.mock('naive-ui', () => ({ + NModal: { + name: 'NModal', + template: '
', + props: ['show', 'title', 'preset', 'maskClosable', 'closable'], + }, + NInput: { + name: 'NInput', + template: '', + props: ['value', 'placeholder', 'status', 'disabled'], + emits: ['update:value'], + }, + NButton: { + name: 'NButton', + template: '', + props: ['type', 'loading', 'disabled'], + emits: ['click'], + }, + NSpin: { + name: 'NSpin', + template: '
', + props: ['size'], + }, + NTooltip: { + name: 'NTooltip', + template: '
', + props: ['trigger'], + }, + useMessage: () => ({ + success: vi.fn(), + error: vi.fn(), + }), +})) + +// Mock stores. +vi.mock('@/stores/world', () => ({ + useWorldStore: () => ({ + reset: vi.fn(), + initialize: vi.fn().mockResolvedValue(undefined), + }), +})) + +vi.mock('@/stores/ui', () => ({ + useUiStore: () => ({ + clearSelection: vi.fn(), + }), +})) + +// Mock API. +vi.mock('@/api', () => ({ + systemApi: { + fetchSaves: vi.fn(), + saveGame: vi.fn(), + loadGame: vi.fn(), + }, +})) + +import { systemApi } from '@/api' + +// Create i18n instance for tests. +const i18n = createI18n({ + legacy: false, + locale: 'en-US', + messages: { + 'en-US': { + save_load: { + loading: 'Loading...', + new_save: 'New Save', + new_save_desc: 'Save with custom name', + quick_save: 'Quick Save', + quick_save_desc: 'Use auto-generated name', + empty: 'No saves found', + game_time: 'Game Time: {time}', + avatar_count: 'Characters: {alive}/{total}', + event_count: '{count} events', + protagonist_tooltip: 'Protagonist', + load: 'Load', + save_success: 'Saved: {filename}', + save_failed: 'Save failed', + load_confirm: 'Load {filename}?', + load_success: 'Loaded', + load_failed: 'Load failed', + fetch_failed: 'Fetch failed', + save_modal_title: 'Save Game', + save_confirm: 'Save', + name_hint: 'Enter save name (optional)', + name_placeholder: 'Enter name...', + name_tip: 'Leave empty for auto name', + name_too_long: 'Name too long', + name_invalid_chars: 'Invalid characters', + }, + common: { + cancel: 'Cancel', + }, + }, + }, +}) + +const createMockSave = (overrides: Partial = {}): SaveFileDTO => ({ + filename: 'test_save.json', + save_time: '2026-01-01T12:00:00', + game_time: '100年1月', + version: '1.0.0', + language: 'zh-CN', + avatar_count: 10, + alive_count: 8, + dead_count: 2, + protagonist_name: null, + custom_name: null, + event_count: 50, + ...overrides, +}) + +// Helper to wait for promises. +const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0)) + +describe('SaveLoadPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(window, 'confirm').mockReturnValue(true) + }) + + describe('Save Mode', () => { + it('should render save actions in save mode', async () => { + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'save' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + + expect(wrapper.find('.new-save-card').exists()).toBe(true) + expect(wrapper.find('.quick-save-card').exists()).toBe(true) + expect(wrapper.text()).toContain('New Save') + expect(wrapper.text()).toContain('Quick Save') + }) + + it('should open save modal when clicking new save', async () => { + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'save' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + await wrapper.find('.new-save-card').trigger('click') + await flushPromises() + + expect(wrapper.find('.n-modal').exists()).toBe(true) + }) + + it('should call saveGame without name on quick save', async () => { + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] }) + vi.mocked(systemApi.saveGame).mockResolvedValue({ status: 'ok', filename: 'auto_save.json' }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'save' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + await wrapper.find('.quick-save-card').trigger('click') + await flushPromises() + + expect(systemApi.saveGame).toHaveBeenCalled() + expect(systemApi.saveGame).toHaveBeenCalledWith() + }) + }) + + describe('Load Mode', () => { + it('should render save list in load mode', async () => { + const mockSaves = [ + createMockSave({ filename: 'save1.json', custom_name: '我的存档' }), + createMockSave({ filename: 'save2.json', game_time: '200年6月' }), + ] + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'load' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + + expect(wrapper.findAll('.save-item')).toHaveLength(2) + expect(wrapper.text()).toContain('我的存档') + expect(wrapper.text()).toContain('Load') + }) + + it('should not render save actions in load mode', async () => { + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'load' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + + expect(wrapper.find('.new-save-card').exists()).toBe(false) + expect(wrapper.find('.quick-save-card').exists()).toBe(false) + }) + + it('should call loadGame when clicking save item', async () => { + const mockSaves = [createMockSave({ filename: 'test.json' })] + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves }) + vi.mocked(systemApi.loadGame).mockResolvedValue({ status: 'ok', message: 'loaded' }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'load' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + await wrapper.find('.save-item').trigger('click') + await flushPromises() + + expect(systemApi.loadGame).toHaveBeenCalledWith('test.json') + }) + + it('should not load if user cancels confirm', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(false) + const mockSaves = [createMockSave({ filename: 'test.json' })] + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'load' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + await wrapper.find('.save-item').trigger('click') + await flushPromises() + + expect(systemApi.loadGame).not.toHaveBeenCalled() + }) + }) + + describe('Save Display', () => { + it('should display custom name when available', async () => { + const mockSaves = [createMockSave({ custom_name: '自定义名称', filename: 'test.json' })] + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'load' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + + expect(wrapper.find('.save-name').text()).toBe('自定义名称') + }) + + it('should display filename when no custom name', async () => { + const mockSaves = [createMockSave({ custom_name: null, filename: '20260101_120000.json' })] + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'load' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + + expect(wrapper.find('.save-name').text()).toBe('20260101_120000') + }) + + it('should display protagonist badge when protagonist exists', async () => { + const mockSaves = [createMockSave({ protagonist_name: '林动' })] + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'load' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + + expect(wrapper.find('.protagonist-badge').exists()).toBe(true) + expect(wrapper.text()).toContain('林动') + }) + + it('should not display protagonist badge when no protagonist', async () => { + const mockSaves = [createMockSave({ protagonist_name: null })] + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'load' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + + expect(wrapper.find('.protagonist-badge').exists()).toBe(false) + }) + + it('should display avatar counts', async () => { + const mockSaves = [createMockSave({ alive_count: 15, avatar_count: 20 })] + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'load' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + + expect(wrapper.find('.avatar-count').text()).toContain('15') + expect(wrapper.find('.avatar-count').text()).toContain('20') + }) + + it('should display event count', async () => { + const mockSaves = [createMockSave({ event_count: 100 })] + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'load' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + + expect(wrapper.find('.event-count').text()).toContain('100') + }) + }) + + describe('Name Validation', () => { + it('should show error for name over 50 chars', async () => { + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'save' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + await wrapper.find('.new-save-card').trigger('click') + await flushPromises() + + const input = wrapper.find('.n-input') + await input.setValue('a'.repeat(51)) + await flushPromises() + + expect(wrapper.find('.error-text').exists()).toBe(true) + expect(wrapper.text()).toContain('Name too long') + }) + + it('should show error for invalid characters', async () => { + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'save' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + await wrapper.find('.new-save-card').trigger('click') + await flushPromises() + + const input = wrapper.find('.n-input') + await input.setValue('name!@#$') + await flushPromises() + + expect(wrapper.find('.error-text').exists()).toBe(true) + expect(wrapper.text()).toContain('Invalid characters') + }) + + it('should allow valid Chinese name', async () => { + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'save' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + await wrapper.find('.new-save-card').trigger('click') + await flushPromises() + + const input = wrapper.find('.n-input') + await input.setValue('我的存档') + await flushPromises() + + expect(wrapper.find('.error-text').exists()).toBe(false) + }) + + it('should allow empty name', async () => { + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'save' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + await wrapper.find('.new-save-card').trigger('click') + await flushPromises() + + const input = wrapper.find('.n-input') + await input.setValue('') + await flushPromises() + + expect(wrapper.find('.error-text').exists()).toBe(false) + expect(wrapper.find('.tip-text').exists()).toBe(true) + }) + }) + + describe('Empty State', () => { + it('should show empty message when no saves', async () => { + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'load' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + + expect(wrapper.find('.empty').exists()).toBe(true) + expect(wrapper.text()).toContain('No saves found') + }) + }) + + describe('Error Handling', () => { + it('should handle fetchSaves error gracefully', async () => { + vi.mocked(systemApi.fetchSaves).mockRejectedValue(new Error('Network error')) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'load' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + + // Should not crash, saves should be empty. + expect(wrapper.findAll('.save-item')).toHaveLength(0) + }) + + it('should handle saveGame error gracefully', async () => { + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] }) + vi.mocked(systemApi.saveGame).mockRejectedValue(new Error('Save error')) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'save' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + await wrapper.find('.quick-save-card').trigger('click') + await flushPromises() + + // Should not crash. + expect(wrapper.exists()).toBe(true) + }) + }) + + describe('Mode Switching', () => { + it('should refetch saves when mode changes', async () => { + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'save' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + expect(systemApi.fetchSaves).toHaveBeenCalledTimes(1) + + await wrapper.setProps({ mode: 'load' }) + await flushPromises() + + expect(systemApi.fetchSaves).toHaveBeenCalledTimes(2) + }) + }) +}) diff --git a/web/src/api/modules/system.ts b/web/src/api/modules/system.ts index 2882731..0cfb78c 100644 --- a/web/src/api/modules/system.ts +++ b/web/src/api/modules/system.ts @@ -19,8 +19,15 @@ export const systemApi = { return httpClient.get<{ saves: SaveFileDTO[] }>('/api/saves'); }, - saveGame(filename?: string) { - return httpClient.post<{ status: string; filename: string }>('/api/game/save', { filename }); + saveGame(customName?: string) { + return httpClient.post<{ status: string; filename: string }>( + '/api/game/save', + { custom_name: customName } + ); + }, + + deleteSave(filename: string) { + return httpClient.post<{ status: string; message: string }>('/api/game/delete', { filename }); }, loadGame(filename: string) { diff --git a/web/src/components/game/panels/system/SaveLoadPanel.vue b/web/src/components/game/panels/system/SaveLoadPanel.vue index 36c5603..8402910 100644 --- a/web/src/components/game/panels/system/SaveLoadPanel.vue +++ b/web/src/components/game/panels/system/SaveLoadPanel.vue @@ -1,5 +1,6 @@