Merge remote-tracking branch 'yuan/main' into plus

This commit is contained in:
2026-02-06 23:57:20 +08:00
25 changed files with 2013 additions and 392 deletions

View File

@@ -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="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" />
</a>
</p>

View File

@@ -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="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" />
</a>
</p>

View 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.

View File

@@ -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无论是死是活并清理所有与其相关的双向关系。

View File

@@ -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:

View File

@@ -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
View 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"]

View File

@@ -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", {})

View File

@@ -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}"

View File

@@ -25,6 +25,7 @@
- 事件实时写入SQLiteJSON中的events字段仅用于旧存档迁移
"""
import json
import re
from pathlib import Path
from datetime import datetime
from typing import List, Optional, TYPE_CHECKING
@@ -38,22 +39,45 @@ from src.utils.config import CONFIG
from src.classes.language import language_manager
from src.sim.load.load_game import get_events_db_path
# 主角特质 ID: 穿越者=30, 气运之子=31.
PROTAGONIST_PERSONA_IDS = {30, 31}
def sanitize_save_name(name: str) -> str:
"""清理存档名称,只保留安全字符。"""
# 移除文件系统不允许的字符。
safe_name = re.sub(r'[\\/:*?"<>|]', '', name)
# 只保留中文、字母、数字和下划线。
safe_name = re.sub(r'[^\w\u4e00-\u9fff]', '_', safe_name)
return safe_name[:50] if safe_name else "save"
def find_protagonist_name(world: "World") -> Optional[str]:
"""查找主角名字(具有气运之子或穿越者特质的存活角色)。"""
for avatar in world.avatar_manager.avatars.values():
persona_ids = [p.id for p in avatar.personas] if avatar.personas else []
if any(pid in PROTAGONIST_PERSONA_IDS for pid in persona_ids):
return avatar.name
return None
def save_game(
world: "World",
simulator: "Simulator",
existed_sects: List["Sect"],
save_path: Optional[Path] = None
save_path: Optional[Path] = None,
custom_name: Optional[str] = None
) -> tuple[bool, Optional[str]]:
"""
保存游戏状态到文件
Args:
world: 世界对象
simulator: 模拟器对象
existed_sects: 本局启用的宗门列表
save_path: 保存路径默认为saves/时间戳_游戏时间.json
custom_name: 用户自定义的存档名称
Returns:
(保存是否成功, 保存的文件名)
"""
@@ -62,15 +86,21 @@ def save_game(
if save_path is None:
saves_dir = CONFIG.paths.saves
saves_dir.mkdir(parents=True, exist_ok=True)
# 生成友好的文件名20251111_193000_Y100M1.json
# 生成友好的文件名
now = datetime.now()
time_str = now.strftime("%Y%m%d_%H%M%S")
year = world.month_stamp.get_year()
month = world.month_stamp.get_month().value
game_time_str = f"Y{year}M{month}"
filename = f"{time_str}_{game_time_str}.json"
# 处理自定义名称。
if custom_name:
safe_name = sanitize_save_name(custom_name)
filename = f"{safe_name}_{time_str}.json"
else:
filename = f"{time_str}_{game_time_str}.json"
save_path = saves_dir / filename
else:
save_path = Path(save_path)
@@ -95,6 +125,12 @@ def save_game(
else:
print(f"警告: 当前事件数据库不存在: {current_db_path}")
# 计算角色统计。
alive_count = len(world.avatar_manager.avatars)
dead_count = len(world.avatar_manager.dead_avatars)
total_count = alive_count + dead_count
protagonist_name = find_protagonist_name(world)
# 构建元信息
meta = {
"version": CONFIG.meta.version,
@@ -104,6 +140,12 @@ def save_game(
# SQLite 事件数据库信息。
"events_db": str(events_db_path.name),
"event_count": world.event_manager.count(),
# 新增元数据。
"avatar_count": total_count,
"alive_count": alive_count,
"dead_count": dead_count,
"protagonist_name": protagonist_name,
"custom_name": custom_name,
}
# 构建世界数据

View File

@@ -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

View File

@@ -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)

View File

@@ -1,5 +1,5 @@
meta:
version: "1.4.1"
version: "1.4.2"
llm:
default_modes:

View 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

View 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

View 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

View 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)
})
})
})

View File

@@ -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) {

View File

@@ -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>&#9889;</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">&#9733; {{ 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>

View File

@@ -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() {

View File

@@ -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...",

View File

@@ -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": "加载中...",

View File

@@ -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": "載入中...",

View File

@@ -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);

View File

@@ -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 ---