Merge remote-tracking branch 'yuan/main' into plus
This commit is contained in:
@@ -29,7 +29,7 @@
|
||||
> **An AI-driven cultivation world simulator that aims to create a truly living, immersive xianxia world.**
|
||||
|
||||
<p align="center">
|
||||
<a href="https://hellogithub.com/repository/AI-Cultivation/cultivation-world-simulator" target="_blank">
|
||||
<a href="https://hellogithub.com/repository/4thfever/cultivation-world-simulator" target="_blank">
|
||||
<img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d0d75240fb95445bba1d7af7574d8420&claim_uid=DogxfCROM1PBL89" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
> **一个AI驱动的修仙世界模拟器,旨在创造一个真正活着的、有沉浸感的仙侠世界。**
|
||||
|
||||
<p align="center">
|
||||
<a href="https://hellogithub.com/repository/AI-Cultivation/cultivation-world-simulator" target="_blank">
|
||||
<a href="https://hellogithub.com/repository/4thfever/cultivation-world-simulator" target="_blank">
|
||||
<img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d0d75240fb95445bba1d7af7574d8420&claim_uid=DogxfCROM1PBL89" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
66
docs/VUE_PERFORMANCE_GUIDE.md
Normal file
66
docs/VUE_PERFORMANCE_GUIDE.md
Normal file
@@ -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<ComplexType | null>(null);
|
||||
|
||||
// GOOD: Only tracks .value changes, no deep conversion
|
||||
const bigData = shallowRef<ComplexType | null>(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<AvatarDetail | RegionDetail | SectDetail | null>(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.
|
||||
@@ -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(无论是死是活),并清理所有与其相关的双向关系。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
17
src/sim/__init__.py
Normal file
17
src/sim/__init__.py
Normal file
@@ -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"]
|
||||
@@ -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", {})
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
# 构建世界数据
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
meta:
|
||||
version: "1.4.1"
|
||||
version: "1.4.2"
|
||||
|
||||
llm:
|
||||
default_modes:
|
||||
|
||||
81
tests/test_breakthrough_logic.py
Normal file
81
tests/test_breakthrough_logic.py
Normal file
@@ -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
|
||||
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
|
||||
84
tests/test_save_load_death.py
Normal file
84
tests/test_save_load_death.py
Normal file
@@ -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
|
||||
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,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) {
|
||||
|
||||
@@ -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,41 @@ async function handleLoad(filename: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(filename: string) {
|
||||
if (!confirm(t('save_load.delete_confirm', { filename }))) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await systemApi.deleteSave(filename)
|
||||
message.success(t('save_load.delete_success'))
|
||||
await fetchSaves()
|
||||
} catch (e) {
|
||||
message.error(t('save_load.delete_failed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化保存时间
|
||||
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 +158,118 @@ 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-actions">
|
||||
<NButton
|
||||
type="error"
|
||||
size="small"
|
||||
secondary
|
||||
@click.stop="handleDelete(save.filename)"
|
||||
>
|
||||
{{ t('save_load.delete') }}
|
||||
</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
@click.stop="handleLoad(save.filename)"
|
||||
>
|
||||
{{ t('save_load.load') }}
|
||||
</NButton>
|
||||
</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>
|
||||
|
||||
@@ -113,14 +280,20 @@ onMounted(() => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.save-panel {
|
||||
.save-panel, .load-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 +303,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,37 +360,119 @@ 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;
|
||||
}
|
||||
|
||||
.load-btn {
|
||||
background: #333;
|
||||
color: #ddd;
|
||||
border: 1px solid #444;
|
||||
padding: 0.4em 1em;
|
||||
border-radius: 0.3em;
|
||||
font-size: 0.9em;
|
||||
.load-actions {
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loading, .empty {
|
||||
.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>
|
||||
|
||||
@@ -40,12 +40,12 @@ export function useGameInit(options: UseGameInitOptions = {}) {
|
||||
// 初始化世界状态
|
||||
await worldStore.initialize()
|
||||
|
||||
systemStore.setInitialized(true)
|
||||
console.log('[GameInit] Game initialized.')
|
||||
|
||||
// 重新加载纹理以确保新生成的角色头像被加载
|
||||
console.log('[GameInit] Reloading textures for new avatars...')
|
||||
await loadBaseTextures()
|
||||
|
||||
systemStore.setInitialized(true)
|
||||
console.log('[GameInit] Game initialized.')
|
||||
}
|
||||
|
||||
async function pollInitStatus() {
|
||||
|
||||
@@ -28,16 +28,32 @@
|
||||
"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",
|
||||
"delete": "Delete",
|
||||
"delete_confirm": "Are you sure you want to permanently delete save {filename}? This action cannot be undone.",
|
||||
"delete_success": "Save deleted",
|
||||
"delete_failed": "Failed to delete"
|
||||
},
|
||||
"llm": {
|
||||
"loading": "Loading...",
|
||||
|
||||
@@ -28,16 +28,32 @@
|
||||
"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": "名称只能包含中文、字母、数字和下划线",
|
||||
"delete": "删除",
|
||||
"delete_confirm": "确定要彻底删除存档 {filename} 及其所有数据吗?此操作无法撤销。",
|
||||
"delete_success": "存档已删除",
|
||||
"delete_failed": "删除失败"
|
||||
},
|
||||
"llm": {
|
||||
"loading": "加载中...",
|
||||
|
||||
@@ -28,16 +28,32 @@
|
||||
"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": "名稱只能包含中文、字母、數字和底線",
|
||||
"delete": "刪除",
|
||||
"delete_confirm": "確定要徹底刪除存檔 {filename} 及其所有資料嗎?此操作無法撤銷。",
|
||||
"delete_success": "存檔已刪除",
|
||||
"delete_failed": "刪除失敗"
|
||||
},
|
||||
"llm": {
|
||||
"loading": "載入中...",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { ref, shallowRef } from 'vue';
|
||||
import { avatarApi } from '../api';
|
||||
import type { AvatarDetail, RegionDetail, SectDetail } from '../types/core';
|
||||
|
||||
@@ -16,7 +16,8 @@ export const useUiStore = defineStore('ui', () => {
|
||||
const selectedTarget = ref<Selection | null>(null);
|
||||
|
||||
// 详情数据 (可能为空,或正在加载)
|
||||
const detailData = ref<AvatarDetail | RegionDetail | SectDetail | null>(null);
|
||||
// 使用 shallowRef 避免深层响应式转换带来的性能开销 (对于大型嵌套对象,如 AvatarDetail)
|
||||
const detailData = shallowRef<AvatarDetail | RegionDetail | SectDetail | null>(null);
|
||||
const isLoadingDetail = ref(false);
|
||||
const detailError = ref<string | null>(null);
|
||||
|
||||
|
||||
@@ -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