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:
Zihao Xu
2026-02-06 06:03:41 -08:00
committed by GitHub
parent ef926594c7
commit 67b559ac5a
10 changed files with 1607 additions and 73 deletions

View File

@@ -25,6 +25,7 @@
- 事件实时写入SQLiteJSON中的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,
}
# 构建世界数据