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