feat: improve save/load interface with custom names and metadata (#128)
* feat: improve save/load interface with custom names and metadata - Add custom save name support with input validation - Extend save metadata with avatar counts, protagonist info, and event count - Add quick save button alongside named save option - Enhance save list display with richer information - Add sanitize_save_name and find_protagonist_name helpers - Update API endpoints to support new features - Add i18n translations for new UI elements Closes #95 * test: add comprehensive tests for save custom name feature - Add 37 tests for sanitize_save_name, find_protagonist_name - Add tests for custom name API endpoints - Add tests for enhanced metadata - Fix unused NIcon import in SaveLoadPanel - Add zh-TW translations for new save_load keys * test(frontend): add SaveLoadPanel component tests - Add 21 tests for SaveLoadPanel component - Cover save mode, load mode, display, validation - Mock naive-ui components, stores, and API
This commit is contained in:
@@ -7,6 +7,7 @@ import time
|
||||
import threading
|
||||
import signal
|
||||
import random
|
||||
import re
|
||||
from omegaconf import OmegaConf
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@@ -1684,8 +1685,17 @@ 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 LoadGameRequest(BaseModel):
|
||||
filename: str
|
||||
@@ -1694,14 +1704,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}
|
||||
|
||||
@@ -1713,15 +1731,22 @@ 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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
# 构建世界数据
|
||||
|
||||
Reference in New Issue
Block a user