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,
|
||||
}
|
||||
|
||||
# 构建世界数据
|
||||
|
||||
680
tests/test_save_custom_name.py
Normal file
680
tests/test_save_custom_name.py
Normal file
@@ -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
|
||||
494
web/src/__tests__/components/panels/SaveLoadPanel.test.ts
Normal file
494
web/src/__tests__/components/panels/SaveLoadPanel.test.ts
Normal file
@@ -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: '<div class="n-modal" v-if="show"><slot /><slot name="footer" /></div>',
|
||||
props: ['show', 'title', 'preset', 'maskClosable', 'closable'],
|
||||
},
|
||||
NInput: {
|
||||
name: 'NInput',
|
||||
template: '<input class="n-input" :value="value" @input="$emit(\'update:value\', $event.target.value)" :placeholder="placeholder" :disabled="disabled" />',
|
||||
props: ['value', 'placeholder', 'status', 'disabled'],
|
||||
emits: ['update:value'],
|
||||
},
|
||||
NButton: {
|
||||
name: 'NButton',
|
||||
template: '<button class="n-button" :disabled="disabled || loading" @click="$emit(\'click\')"><slot /></button>',
|
||||
props: ['type', 'loading', 'disabled'],
|
||||
emits: ['click'],
|
||||
},
|
||||
NSpin: {
|
||||
name: 'NSpin',
|
||||
template: '<div class="n-spin"></div>',
|
||||
props: ['size'],
|
||||
},
|
||||
NTooltip: {
|
||||
name: 'NTooltip',
|
||||
template: '<div class="n-tooltip"><slot name="trigger" /><slot /></div>',
|
||||
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> = {}): 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -19,8 +19,11 @@ 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 }
|
||||
);
|
||||
},
|
||||
|
||||
loadGame(filename: string) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { NModal, NInput, NButton, NSpin, NTooltip } from 'naive-ui'
|
||||
import { systemApi } from '../../../../api'
|
||||
import type { SaveFileDTO } from '../../../../types/api'
|
||||
import { useWorldStore } from '../../../../stores/world'
|
||||
@@ -23,6 +24,25 @@ const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const saves = ref<SaveFileDTO[]>([])
|
||||
|
||||
// 保存对话框状态
|
||||
const showSaveModal = ref(false)
|
||||
const saveName = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
// 名称验证
|
||||
const nameError = computed(() => {
|
||||
if (!saveName.value) return ''
|
||||
if (saveName.value.length > 50) {
|
||||
return t('save_load.name_too_long')
|
||||
}
|
||||
// 只允许中文、字母、数字和下划线
|
||||
const pattern = /^[\w\u4e00-\u9fff]+$/
|
||||
if (!pattern.test(saveName.value)) {
|
||||
return t('save_load.name_invalid_chars')
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
async function fetchSaves() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -35,8 +55,15 @@ async function fetchSaves() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
loading.value = true
|
||||
// 打开保存对话框
|
||||
function openSaveModal() {
|
||||
saveName.value = ''
|
||||
showSaveModal.value = true
|
||||
}
|
||||
|
||||
// 快速保存(不输入名称)
|
||||
async function handleQuickSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await systemApi.saveGame()
|
||||
message.success(t('save_load.save_success', { filename: res.filename }))
|
||||
@@ -44,7 +71,26 @@ async function handleSave() {
|
||||
} catch (e) {
|
||||
message.error(t('save_load.save_failed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 带名称保存
|
||||
async function handleSaveWithName() {
|
||||
if (nameError.value) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const customName = saveName.value.trim() || undefined
|
||||
const res = await systemApi.saveGame(customName)
|
||||
message.success(t('save_load.save_success', { filename: res.filename }))
|
||||
showSaveModal.value = false
|
||||
saveName.value = ''
|
||||
await fetchSaves()
|
||||
} catch (e) {
|
||||
message.error(t('save_load.save_failed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +112,26 @@ async function handleLoad(filename: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化保存时间
|
||||
function formatSaveTime(isoTime: string): string {
|
||||
if (!isoTime) return ''
|
||||
try {
|
||||
const date = new Date(isoTime)
|
||||
return date.toLocaleString()
|
||||
} catch {
|
||||
return isoTime
|
||||
}
|
||||
}
|
||||
|
||||
// 获取存档显示名称
|
||||
function getSaveDisplayName(save: SaveFileDTO): string {
|
||||
if (save.custom_name) {
|
||||
return save.custom_name
|
||||
}
|
||||
// 从文件名提取时间部分
|
||||
return save.filename.replace('.json', '')
|
||||
}
|
||||
|
||||
watch(() => props.mode, () => {
|
||||
fetchSaves()
|
||||
})
|
||||
@@ -77,32 +143,103 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div :class="mode === 'save' ? 'save-panel' : 'load-panel'">
|
||||
<div v-if="loading && saves.length === 0" class="loading">{{ t('save_load.loading') }}</div>
|
||||
|
||||
<!-- Save Mode: New Save Button -->
|
||||
<div v-if="loading && saves.length === 0" class="loading">
|
||||
<NSpin size="medium" />
|
||||
<span>{{ t('save_load.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Save Mode: Action Buttons -->
|
||||
<template v-if="mode === 'save'">
|
||||
<div class="new-save-card" @click="handleSave">
|
||||
<div class="icon">+</div>
|
||||
<div>{{ t('save_load.new_save') }}</div>
|
||||
<div class="sub">{{ t('save_load.new_save_desc') }}</div>
|
||||
<div class="save-actions">
|
||||
<div class="new-save-card" @click="openSaveModal">
|
||||
<div class="icon">+</div>
|
||||
<div>{{ t('save_load.new_save') }}</div>
|
||||
<div class="sub">{{ t('save_load.new_save_desc') }}</div>
|
||||
</div>
|
||||
<div class="quick-save-card" @click="handleQuickSave">
|
||||
<div class="icon">
|
||||
<NSpin v-if="saving" size="small" />
|
||||
<span v-else>⚡</span>
|
||||
</div>
|
||||
<div>{{ t('save_load.quick_save') }}</div>
|
||||
<div class="sub">{{ t('save_load.quick_save_desc') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Save List -->
|
||||
<div v-if="!loading && saves.length === 0" class="empty">{{ t('save_load.empty') }}</div>
|
||||
<div
|
||||
v-for="save in saves"
|
||||
:key="save.filename"
|
||||
class="save-item"
|
||||
@click="mode === 'load' ? handleLoad(save.filename) : null"
|
||||
>
|
||||
<div class="save-info">
|
||||
<div class="save-time">{{ save.save_time }}</div>
|
||||
<div class="game-time">{{ t('save_load.game_time', { time: save.game_time }) }}</div>
|
||||
<div class="filename">{{ save.filename }}</div>
|
||||
|
||||
<div class="saves-list">
|
||||
<div
|
||||
v-for="save in saves"
|
||||
:key="save.filename"
|
||||
class="save-item"
|
||||
@click="mode === 'load' ? handleLoad(save.filename) : null"
|
||||
>
|
||||
<div class="save-info">
|
||||
<div class="save-header">
|
||||
<span class="save-name">{{ getSaveDisplayName(save) }}</span>
|
||||
<NTooltip v-if="save.protagonist_name" trigger="hover">
|
||||
<template #trigger>
|
||||
<span class="protagonist-badge">★ {{ save.protagonist_name }}</span>
|
||||
</template>
|
||||
{{ t('save_load.protagonist_tooltip') }}
|
||||
</NTooltip>
|
||||
</div>
|
||||
<div class="save-meta">
|
||||
<span class="game-time">{{ t('save_load.game_time', { time: save.game_time }) }}</span>
|
||||
<span class="divider">|</span>
|
||||
<span class="avatar-count">{{ t('save_load.avatar_count', { alive: save.alive_count, total: save.avatar_count }) }}</span>
|
||||
<span class="divider">|</span>
|
||||
<span class="event-count">{{ t('save_load.event_count', { count: save.event_count }) }}</span>
|
||||
</div>
|
||||
<div class="save-footer">
|
||||
<span class="save-time">{{ formatSaveTime(save.save_time) }}</span>
|
||||
<span class="version">v{{ save.version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="mode === 'load'" class="load-btn">{{ t('save_load.load') }}</div>
|
||||
</div>
|
||||
<div v-if="mode === 'load'" class="load-btn">{{ t('save_load.load') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Modal -->
|
||||
<NModal
|
||||
v-model:show="showSaveModal"
|
||||
preset="card"
|
||||
:title="t('save_load.save_modal_title')"
|
||||
style="width: 400px;"
|
||||
:mask-closable="!saving"
|
||||
:closable="!saving"
|
||||
>
|
||||
<div class="save-modal-content">
|
||||
<p class="hint">{{ t('save_load.name_hint') }}</p>
|
||||
<NInput
|
||||
v-model:value="saveName"
|
||||
:placeholder="t('save_load.name_placeholder')"
|
||||
:status="nameError ? 'error' : undefined"
|
||||
:disabled="saving"
|
||||
@keyup.enter="handleSaveWithName"
|
||||
/>
|
||||
<p v-if="nameError" class="error-text">{{ nameError }}</p>
|
||||
<p v-else class="tip-text">{{ t('save_load.name_tip') }}</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<NButton :disabled="saving" @click="showSaveModal = false">
|
||||
{{ t('common.cancel') }}
|
||||
</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
:disabled="!!nameError"
|
||||
@click="handleSaveWithName"
|
||||
>
|
||||
{{ t('save_load.save_confirm') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -115,12 +252,18 @@ onMounted(() => {
|
||||
|
||||
.save-panel {
|
||||
align-items: center;
|
||||
padding-top: 3em;
|
||||
padding-top: 2em;
|
||||
}
|
||||
|
||||
.new-save-card {
|
||||
width: 15em;
|
||||
height: 11em;
|
||||
.save-actions {
|
||||
display: flex;
|
||||
gap: 1.5em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
.new-save-card, .quick-save-card {
|
||||
width: 12em;
|
||||
height: 9em;
|
||||
border: 2px dashed #444;
|
||||
border-radius: 0.5em;
|
||||
display: flex;
|
||||
@@ -130,44 +273,56 @@ onMounted(() => {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: #888;
|
||||
margin-bottom: 3em;
|
||||
}
|
||||
|
||||
.new-save-card:hover {
|
||||
.new-save-card:hover, .quick-save-card:hover {
|
||||
border-color: #666;
|
||||
background: #222;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.new-save-card .icon {
|
||||
font-size: 3em;
|
||||
.quick-save-card {
|
||||
border-color: #3a5a3a;
|
||||
}
|
||||
|
||||
.quick-save-card:hover {
|
||||
border-color: #4a7a4a;
|
||||
background: #1a2a1a;
|
||||
}
|
||||
|
||||
.new-save-card .icon, .quick-save-card .icon {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 0.2em;
|
||||
}
|
||||
|
||||
.new-save-card .sub {
|
||||
font-size: 0.85em;
|
||||
.new-save-card .sub, .quick-save-card .sub {
|
||||
font-size: 0.75em;
|
||||
color: #666;
|
||||
margin-top: 0.4em;
|
||||
margin-top: 0.3em;
|
||||
}
|
||||
|
||||
.saves-list {
|
||||
width: 100%;
|
||||
max-width: 50em;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.save-item {
|
||||
background: #222;
|
||||
border: 1px solid #333;
|
||||
padding: 0.8em;
|
||||
margin-bottom: 0.8em;
|
||||
border-radius: 0.3em;
|
||||
padding: 0.8em 1em;
|
||||
margin-bottom: 0.6em;
|
||||
border-radius: 0.4em;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
width: 100%;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.save-panel .save-item {
|
||||
cursor: default;
|
||||
width: 100%;
|
||||
max-width: 45em;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.save-item:hover {
|
||||
@@ -175,21 +330,65 @@ onMounted(() => {
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.save-info .save-time {
|
||||
.save-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.save-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6em;
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
|
||||
.save-name {
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
font-size: 1em;
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
.save-info .game-time {
|
||||
color: #4a9eff;
|
||||
font-size: 0.9em;
|
||||
margin: 0.3em 0;
|
||||
.protagonist-badge {
|
||||
background: linear-gradient(135deg, #5a4a2a 0%, #3a2a1a 100%);
|
||||
color: #ffd700;
|
||||
padding: 0.15em 0.5em;
|
||||
border-radius: 0.3em;
|
||||
font-size: 0.8em;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.save-info .filename {
|
||||
color: #666;
|
||||
.save-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
margin-bottom: 0.3em;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.game-time {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.avatar-count {
|
||||
color: #7acc7a;
|
||||
}
|
||||
|
||||
.event-count {
|
||||
color: #cc9a7a;
|
||||
}
|
||||
|
||||
.divider {
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.save-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
font-size: 0.8em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.version {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
@@ -200,12 +399,59 @@ onMounted(() => {
|
||||
padding: 0.4em 1em;
|
||||
border-radius: 0.3em;
|
||||
font-size: 0.9em;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.loading, .empty {
|
||||
.load-btn:hover {
|
||||
background: #444;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.8em;
|
||||
color: #888;
|
||||
padding: 3em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
padding: 3em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Save Modal */
|
||||
.save-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8em;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #aaa;
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #e55;
|
||||
margin: 0;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.tip-text {
|
||||
color: #888;
|
||||
margin: 0;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.8em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,16 +28,28 @@
|
||||
"save_load": {
|
||||
"loading": "Loading...",
|
||||
"new_save": "New Save",
|
||||
"new_save_desc": "Click to create a new save file",
|
||||
"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 (Child of Destiny/Transmigrator)",
|
||||
"load": "Load",
|
||||
"save_success": "Saved successfully: {filename}",
|
||||
"save_failed": "Failed to save",
|
||||
"load_confirm": "Are you sure you want to load save {filename}? Unsaved progress will be lost.",
|
||||
"load_success": "Loaded successfully",
|
||||
"load_failed": "Failed to load",
|
||||
"fetch_failed": "Failed to fetch save list"
|
||||
"fetch_failed": "Failed to fetch save list",
|
||||
"save_modal_title": "Save Game",
|
||||
"save_confirm": "Save",
|
||||
"name_hint": "Enter a name for this save (optional)",
|
||||
"name_placeholder": "Enter save name...",
|
||||
"name_tip": "Leave empty to use auto-generated name",
|
||||
"name_too_long": "Name cannot exceed 50 characters",
|
||||
"name_invalid_chars": "Name can only contain letters, numbers, Chinese characters and underscores"
|
||||
},
|
||||
"llm": {
|
||||
"loading": "Loading...",
|
||||
|
||||
@@ -28,16 +28,28 @@
|
||||
"save_load": {
|
||||
"loading": "加载中...",
|
||||
"new_save": "新建存档",
|
||||
"new_save_desc": "点击创建一个新的存档文件",
|
||||
"new_save_desc": "输入自定义名称保存",
|
||||
"quick_save": "快速保存",
|
||||
"quick_save_desc": "使用自动生成的名称",
|
||||
"empty": "暂无存档",
|
||||
"game_time": "游戏时间: {time}",
|
||||
"avatar_count": "角色: {alive}/{total}",
|
||||
"event_count": "{count} 条事件",
|
||||
"protagonist_tooltip": "主角(气运之子/穿越者)",
|
||||
"load": "加载",
|
||||
"save_success": "存档成功: {filename}",
|
||||
"save_failed": "存档失败",
|
||||
"load_confirm": "确定要加载存档 {filename} 吗?当前未保存的进度将丢失。",
|
||||
"load_success": "读档成功",
|
||||
"load_failed": "读档失败",
|
||||
"fetch_failed": "获取存档列表失败"
|
||||
"fetch_failed": "获取存档列表失败",
|
||||
"save_modal_title": "保存游戏",
|
||||
"save_confirm": "保存",
|
||||
"name_hint": "为存档输入一个名称(可选)",
|
||||
"name_placeholder": "输入存档名称...",
|
||||
"name_tip": "留空将使用自动生成的名称",
|
||||
"name_too_long": "名称不能超过50个字符",
|
||||
"name_invalid_chars": "名称只能包含中文、字母、数字和下划线"
|
||||
},
|
||||
"llm": {
|
||||
"loading": "加载中...",
|
||||
|
||||
@@ -28,16 +28,28 @@
|
||||
"save_load": {
|
||||
"loading": "載入中...",
|
||||
"new_save": "新增存檔",
|
||||
"new_save_desc": "點擊創建一個新的存檔檔案",
|
||||
"new_save_desc": "輸入自訂名稱儲存",
|
||||
"quick_save": "快速儲存",
|
||||
"quick_save_desc": "使用自動生成的名稱",
|
||||
"empty": "暫無存檔",
|
||||
"game_time": "遊戲時間: {time}",
|
||||
"avatar_count": "角色: {alive}/{total}",
|
||||
"event_count": "{count} 條事件",
|
||||
"protagonist_tooltip": "主角(氣運之子/穿越者)",
|
||||
"load": "載入",
|
||||
"save_success": "存檔成功: {filename}",
|
||||
"save_failed": "存檔失敗",
|
||||
"load_confirm": "確定要載入存檔 {filename} 嗎?當前未儲存的進度將丟失。",
|
||||
"load_success": "讀檔成功",
|
||||
"load_failed": "讀檔失敗",
|
||||
"fetch_failed": "獲取存檔列表失敗"
|
||||
"fetch_failed": "獲取存檔列表失敗",
|
||||
"save_modal_title": "儲存遊戲",
|
||||
"save_confirm": "儲存",
|
||||
"name_hint": "為存檔輸入一個名稱(可選)",
|
||||
"name_placeholder": "輸入存檔名稱...",
|
||||
"name_tip": "留空將使用自動生成的名稱",
|
||||
"name_too_long": "名稱不能超過50個字元",
|
||||
"name_invalid_chars": "名稱只能包含中文、字母、數字和底線"
|
||||
},
|
||||
"llm": {
|
||||
"loading": "載入中...",
|
||||
|
||||
@@ -66,6 +66,14 @@ export interface SaveFileDTO {
|
||||
save_time: string;
|
||||
game_time: string;
|
||||
version: string;
|
||||
// 新增字段。
|
||||
language: string;
|
||||
avatar_count: number;
|
||||
alive_count: number;
|
||||
dead_count: number;
|
||||
protagonist_name: string | null;
|
||||
custom_name: string | null;
|
||||
event_count: number;
|
||||
}
|
||||
|
||||
// --- Game Data Metadata ---
|
||||
|
||||
Reference in New Issue
Block a user