| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """
- 500章写作沙盘模拟 - 数据链稳定性压力测试
- 测试目标:
- 1. state.json 增长曲线(文件大小随章节变化)
- 2. entities_v3 实体数量增长
- 3. alias_index 别名索引膨胀
- 4. 伏笔追踪(埋设/回收比例)
- 5. 原子写入性能
- 6. index.db 查询性能
- 模拟参数(基于典型网文):
- - 500章,每章约3500字
- - 平均每章新增 0.8 个角色(前100章密集,后期稀疏)
- - 平均每章新增 0.3 个地点
- - 平均每章埋设 0.5 个伏笔,回收 0.3 个
- - 主角每 10 章升级一次境界
- - 每 5 章更新一次关系
- """
- import json
- import os
- import sys
- import time
- import random
- import shutil
- import tempfile
- from pathlib import Path
- from datetime import datetime
- from typing import Dict, Any, List
- # 添加脚本目录到路径
- script_dir = Path(__file__).resolve().parent
- sys.path.insert(0, str(script_dir))
- from security_utils import atomic_write_json, read_json_safe
- # Windows 编码修复
- if sys.platform == 'win32':
- import io
- sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
- sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
- # ============================================================================
- # 模拟配置
- # ============================================================================
- CONFIG = {
- "total_chapters": 500,
- "words_per_chapter": 3500,
- # 实体生成概率(随章节递减)
- "new_character_base_rate": 0.8, # 前50章
- "new_character_decay": 0.95, # 每50章衰减
- "new_location_rate": 0.3,
- "new_item_rate": 0.2,
- "new_faction_rate": 0.1,
- "new_technique_rate": 0.15,
- # 伏笔
- "foreshadow_plant_rate": 0.5,
- "foreshadow_resolve_rate": 0.3,
- "foreshadow_tiers": ["核心", "支线", "装饰"],
- "foreshadow_tier_weights": [0.1, 0.3, 0.6],
- # 主角升级
- "protagonist_upgrade_interval": 10,
- "realms": ["练气", "筑基", "金丹", "元婴", "化神", "炼虚", "合体", "大乘", "渡劫"],
- "layers_per_realm": 9,
- # 关系更新
- "relationship_update_interval": 5,
- "relationship_types": ["ally", "enemy", "romance", "mentor", "rival", "family"],
- # 别名生成
- "alias_per_character": 2.5, # 平均每个角色的别名数
- }
- # 随机名字池
- SURNAME_POOL = ["林", "陈", "王", "李", "张", "刘", "赵", "黄", "周", "吴", "徐", "孙", "马", "朱", "胡", "郭", "何", "高", "罗", "郑"]
- NAME_POOL = ["天", "云", "风", "雷", "火", "水", "月", "星", "龙", "凤", "虎", "鹤", "剑", "刀", "枪", "棍", "拳", "掌", "指", "心"]
- LOCATION_PREFIX = ["天", "云", "龙", "凤", "青", "白", "黑", "红", "金", "玉"]
- LOCATION_SUFFIX = ["山", "谷", "城", "峰", "洞", "海", "林", "湖", "殿", "宗"]
- class SimulationMetrics:
- """模拟指标收集器"""
- def __init__(self):
- self.checkpoints: List[Dict] = []
- self.write_times: List[float] = []
- self.errors: List[str] = []
- def record_checkpoint(self, chapter: int, state: Dict, state_file: Path):
- """记录检查点"""
- file_size = state_file.stat().st_size if state_file.exists() else 0
- entities_v3 = state.get("entities_v3", {})
- entity_counts = {
- etype: len(entities)
- for etype, entities in entities_v3.items()
- }
- total_entities = sum(entity_counts.values())
- alias_count = len(state.get("alias_index", {}))
- foreshadowing = state.get("foreshadowing", [])
- active_foreshadow = len([f for f in foreshadowing if f.get("status") == "未回收"])
- resolved_foreshadow = len([f for f in foreshadowing if f.get("status") == "已回收"])
- relationships = state.get("relationships", [])
- if isinstance(relationships, dict):
- relationships = list(relationships.values())
- self.checkpoints.append({
- "chapter": chapter,
- "file_size_kb": file_size / 1024,
- "total_entities": total_entities,
- "entity_counts": entity_counts,
- "alias_count": alias_count,
- "active_foreshadow": active_foreshadow,
- "resolved_foreshadow": resolved_foreshadow,
- "relationship_count": len(relationships) if isinstance(relationships, list) else 0,
- "avg_write_time_ms": sum(self.write_times[-10:]) / max(len(self.write_times[-10:]), 1) * 1000,
- })
- def record_write_time(self, duration: float):
- self.write_times.append(duration)
- def record_error(self, error: str):
- self.errors.append(error)
- def generate_report(self) -> str:
- """生成测试报告"""
- if not self.checkpoints:
- return "No data collected"
- final = self.checkpoints[-1]
- first = self.checkpoints[0]
- lines = [
- "=" * 60,
- "📊 500章沙盘模拟测试报告",
- "=" * 60,
- "",
- "## 基础指标",
- f"- 总章节数: {final['chapter']}",
- f"- 总字数: {final['chapter'] * CONFIG['words_per_chapter']:,}",
- "",
- "## state.json 增长",
- f"- 初始大小: {first['file_size_kb']:.2f} KB",
- f"- 最终大小: {final['file_size_kb']:.2f} KB",
- f"- 增长倍数: {final['file_size_kb'] / max(first['file_size_kb'], 0.1):.1f}x",
- "",
- "## 实体统计",
- f"- 总实体数: {final['total_entities']}",
- ]
- for etype, count in final['entity_counts'].items():
- lines.append(f" - {etype}: {count}")
- lines.extend([
- f"- 别名索引条目: {final['alias_count']}",
- "",
- "## 伏笔统计",
- f"- 活跃伏笔: {final['active_foreshadow']}",
- f"- 已回收伏笔: {final['resolved_foreshadow']}",
- f"- 回收率: {final['resolved_foreshadow'] / max(final['active_foreshadow'] + final['resolved_foreshadow'], 1) * 100:.1f}%",
- "",
- "## 性能指标",
- f"- 平均写入时间: {sum(self.write_times) / max(len(self.write_times), 1) * 1000:.2f} ms",
- f"- 最大写入时间: {max(self.write_times) * 1000:.2f} ms" if self.write_times else "N/A",
- f"- 最小写入时间: {min(self.write_times) * 1000:.2f} ms" if self.write_times else "N/A",
- "",
- "## 错误统计",
- f"- 错误数: {len(self.errors)}",
- ])
- if self.errors:
- lines.append("- 错误详情:")
- for err in self.errors[:5]:
- lines.append(f" - {err}")
- # 增长曲线(每100章采样)
- lines.extend([
- "",
- "## 增长曲线(每100章)",
- "| 章节 | 文件大小(KB) | 实体数 | 别名数 | 活跃伏笔 | 写入时间(ms) |",
- "|------|-------------|-------|-------|---------|-------------|",
- ])
- for cp in self.checkpoints:
- if cp['chapter'] % 100 == 0 or cp['chapter'] == final['chapter']:
- lines.append(
- f"| {cp['chapter']} | {cp['file_size_kb']:.1f} | "
- f"{cp['total_entities']} | {cp['alias_count']} | "
- f"{cp['active_foreshadow']} | {cp['avg_write_time_ms']:.1f} |"
- )
- # 稳定性评估
- lines.extend([
- "",
- "## 稳定性评估",
- ])
- # 检查文件大小是否在合理范围
- if final['file_size_kb'] < 500:
- lines.append("✅ 文件大小合理 (< 500KB)")
- elif final['file_size_kb'] < 1024:
- lines.append("⚠️ 文件大小偏大 (500KB-1MB),建议启用归档")
- else:
- lines.append("❌ 文件过大 (> 1MB),需要优化")
- # 检查写入性能
- avg_write = sum(self.write_times) / max(len(self.write_times), 1) * 1000
- if avg_write < 50:
- lines.append("✅ 写入性能良好 (< 50ms)")
- elif avg_write < 200:
- lines.append("⚠️ 写入性能一般 (50-200ms)")
- else:
- lines.append("❌ 写入性能差 (> 200ms)")
- # 检查错误率
- if not self.errors:
- lines.append("✅ 无错误")
- else:
- lines.append(f"❌ 有 {len(self.errors)} 个错误")
- lines.append("")
- lines.append("=" * 60)
- return "\n".join(lines)
- class ChapterSimulator:
- """章节模拟器"""
- def __init__(self, project_root: Path):
- self.project_root = project_root
- self.state_file = project_root / ".webnovel" / "state.json"
- self.metrics = SimulationMetrics()
- self.generated_names = set()
- self.entity_id_counter = 0
- def _generate_id(self, prefix: str) -> str:
- self.entity_id_counter += 1
- return f"{prefix}_{self.entity_id_counter:05d}"
- def _generate_character_name(self) -> str:
- for _ in range(100):
- name = random.choice(SURNAME_POOL) + random.choice(NAME_POOL) + random.choice(NAME_POOL)
- if name not in self.generated_names:
- self.generated_names.add(name)
- return name
- return f"角色_{len(self.generated_names)}"
- def _generate_location_name(self) -> str:
- return random.choice(LOCATION_PREFIX) + random.choice(LOCATION_SUFFIX)
- def _get_character_rate(self, chapter: int) -> float:
- """根据章节获取角色生成概率(递减)"""
- decay_periods = chapter // 50
- rate = CONFIG["new_character_base_rate"] * (CONFIG["new_character_decay"] ** decay_periods)
- return max(rate, 0.1) # 最低 10%
- def init_project(self):
- """初始化模拟项目"""
- self.project_root.mkdir(parents=True, exist_ok=True)
- (self.project_root / ".webnovel").mkdir(exist_ok=True)
- (self.project_root / "正文").mkdir(exist_ok=True)
- # 初始 state.json
- initial_state = {
- "project_info": {
- "title": "模拟测试小说",
- "genre": "玄幻",
- "created_at": datetime.now().strftime("%Y-%m-%d"),
- "target_chapters": 500,
- },
- "progress": {
- "current_chapter": 0,
- "total_words": 0,
- },
- "protagonist_state": {
- "name": "林天",
- "realm": "练气",
- "layer": 1,
- "golden_finger": {"name": "混沌珠", "level": 1},
- },
- "entities_v3": {
- "角色": {},
- "地点": {},
- "物品": {},
- "势力": {},
- "招式": {},
- },
- "alias_index": {},
- "foreshadowing": [],
- "relationships": [],
- }
- # 添加主角到实体
- protagonist_id = "protagonist_lintian"
- initial_state["entities_v3"]["角色"][protagonist_id] = {
- "canonical_name": "林天",
- "desc": "主角,拥有混沌珠",
- "tier": "核心",
- "aliases": ["林天", "天哥", "林少侠"],
- "current": {"realm": "练气", "layer": 1},
- "history": [],
- }
- initial_state["alias_index"]["林天"] = [{"type": "角色", "id": protagonist_id}]
- initial_state["alias_index"]["天哥"] = [{"type": "角色", "id": protagonist_id}]
- atomic_write_json(self.state_file, initial_state, backup=False)
- return initial_state
- def simulate_chapter(self, chapter: int, state: Dict) -> Dict:
- """模拟一章的数据变化"""
- # 1. 更新进度
- state["progress"]["current_chapter"] = chapter
- state["progress"]["total_words"] += CONFIG["words_per_chapter"]
- entities_v3 = state["entities_v3"]
- alias_index = state["alias_index"]
- # 2. 新增角色(概率递减)
- if random.random() < self._get_character_rate(chapter):
- char_name = self._generate_character_name()
- char_id = self._generate_id("char")
- tier = random.choices(
- ["核心", "支线", "装饰"],
- weights=[0.1, 0.3, 0.6]
- )[0]
- entities_v3["角色"][char_id] = {
- "canonical_name": char_name,
- "desc": f"第{chapter}章出场的{tier}角色",
- "tier": tier,
- "aliases": [char_name],
- "current": {"first_appearance": chapter},
- "history": [],
- }
- alias_index[char_name] = [{"type": "角色", "id": char_id}]
- # 生成额外别名
- if random.random() < 0.5:
- alias = char_name[0] + "兄" if random.random() < 0.5 else char_name + "前辈"
- entities_v3["角色"][char_id]["aliases"].append(alias)
- if alias not in alias_index:
- alias_index[alias] = []
- alias_index[alias].append({"type": "角色", "id": char_id})
- # 3. 新增地点
- if random.random() < CONFIG["new_location_rate"]:
- loc_name = self._generate_location_name()
- loc_id = self._generate_id("loc")
- entities_v3["地点"][loc_id] = {
- "canonical_name": loc_name,
- "desc": f"第{chapter}章出现的地点",
- "tier": "装饰",
- "aliases": [loc_name],
- "current": {},
- "history": [],
- }
- alias_index[loc_name] = [{"type": "地点", "id": loc_id}]
- # 4. 新增物品
- if random.random() < CONFIG["new_item_rate"]:
- item_name = random.choice(["灵", "仙", "神", "圣"]) + random.choice(["剑", "丹", "符", "器"])
- item_id = self._generate_id("item")
- entities_v3["物品"][item_id] = {
- "canonical_name": item_name,
- "desc": f"第{chapter}章获得的物品",
- "tier": "装饰",
- "aliases": [item_name],
- "current": {},
- "history": [],
- }
- if item_name not in alias_index:
- alias_index[item_name] = []
- alias_index[item_name].append({"type": "物品", "id": item_id})
- # 5. 埋设伏笔
- if random.random() < CONFIG["foreshadow_plant_rate"]:
- tier = random.choices(
- CONFIG["foreshadow_tiers"],
- weights=CONFIG["foreshadow_tier_weights"]
- )[0]
- target = chapter + random.randint(10, 100)
- state["foreshadowing"].append({
- "id": f"fs_{chapter}_{random.randint(1000, 9999)}",
- "content": f"第{chapter}章埋设的{tier}伏笔",
- "tier": tier,
- "status": "未回收",
- "planted_chapter": chapter,
- "target_chapter": target,
- })
- # 6. 回收伏笔
- active_foreshadows = [
- f for f in state["foreshadowing"]
- if f.get("status") == "未回收" and f.get("target_chapter", 999) <= chapter
- ]
- for fs in active_foreshadows:
- if random.random() < CONFIG["foreshadow_resolve_rate"]:
- fs["status"] = "已回收"
- fs["resolved_chapter"] = chapter
- # 7. 主角升级
- if chapter % CONFIG["protagonist_upgrade_interval"] == 0:
- ps = state["protagonist_state"]
- current_layer = ps.get("layer", 1)
- current_realm_idx = CONFIG["realms"].index(ps.get("realm", "练气"))
- if current_layer < CONFIG["layers_per_realm"]:
- ps["layer"] = current_layer + 1
- elif current_realm_idx < len(CONFIG["realms"]) - 1:
- ps["realm"] = CONFIG["realms"][current_realm_idx + 1]
- ps["layer"] = 1
- # 8. 更新关系
- if chapter % CONFIG["relationship_update_interval"] == 0:
- char_ids = list(entities_v3["角色"].keys())
- if len(char_ids) >= 2:
- char1, char2 = random.sample(char_ids, 2)
- rel_type = random.choice(CONFIG["relationship_types"])
- state["relationships"].append({
- "char1_id": char1,
- "char2_id": char2,
- "type": rel_type,
- "intensity": random.randint(30, 100),
- "established_chapter": chapter,
- })
- return state
- def run_simulation(self, checkpoint_interval: int = 10):
- """运行完整模拟"""
- print("🚀 开始500章沙盘模拟...")
- print(f"📁 测试目录: {self.project_root}")
- print()
- state = self.init_project()
- self.metrics.record_checkpoint(0, state, self.state_file)
- start_time = time.time()
- for chapter in range(1, CONFIG["total_chapters"] + 1):
- try:
- # 模拟章节
- state = self.simulate_chapter(chapter, state)
- # 原子写入
- write_start = time.time()
- atomic_write_json(self.state_file, state, use_lock=True, backup=False)
- write_duration = time.time() - write_start
- self.metrics.record_write_time(write_duration)
- # 记录检查点
- if chapter % checkpoint_interval == 0:
- self.metrics.record_checkpoint(chapter, state, self.state_file)
- elapsed = time.time() - start_time
- eta = elapsed / chapter * (CONFIG["total_chapters"] - chapter)
- print(f" 第 {chapter:3d} 章完成 | "
- f"文件 {self.state_file.stat().st_size / 1024:.1f}KB | "
- f"实体 {sum(len(e) for e in state['entities_v3'].values())} | "
- f"写入 {write_duration*1000:.1f}ms | "
- f"ETA {eta:.0f}s")
- except Exception as e:
- self.metrics.record_error(f"Chapter {chapter}: {str(e)}")
- print(f" ❌ 第 {chapter} 章错误: {e}")
- # 最终检查点
- self.metrics.record_checkpoint(CONFIG["total_chapters"], state, self.state_file)
- total_time = time.time() - start_time
- print()
- print(f"✅ 模拟完成!总耗时: {total_time:.1f}s")
- print()
- return self.metrics.generate_report()
- def main():
- """主函数"""
- # 创建临时测试目录
- test_dir = Path(tempfile.mkdtemp(prefix="webnovel_stress_test_"))
- try:
- simulator = ChapterSimulator(test_dir)
- report = simulator.run_simulation(checkpoint_interval=10)
- print(report)
- # 保存报告
- report_file = test_dir / "stress_test_report.md"
- report_file.write_text(report, encoding="utf-8")
- print(f"\n📄 报告已保存: {report_file}")
- # 询问是否保留测试数据
- print(f"\n测试数据目录: {test_dir}")
- print("(测试完成后可手动删除)")
- except KeyboardInterrupt:
- print("\n⚠️ 测试被中断")
- except Exception as e:
- print(f"\n❌ 测试失败: {e}")
- raise
- if __name__ == "__main__":
- main()
|