第 1 卷第 1 章:1–9 年
This commit is contained in:
@@ -0,0 +1,159 @@
|
|||||||
|
# pyright: reportMissingImports=false, reportAttributeAccessIssue=false
|
||||||
|
# 说明:LSP 的 Python 环境看不到本项目包(未安装进它的 site-packages),
|
||||||
|
# 会把「包内互相 import」误报为缺失模块。运行时导入已由执行验证通过。
|
||||||
|
|
||||||
|
"""组织 prompt、调用模型、控制篇幅、跑事实校验,产出一章成品。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dfannals import factcheck
|
||||||
|
from dfannals import slice as chapter_slice
|
||||||
|
from dfannals.config import (
|
||||||
|
CHAPTER_MAX_CHARS,
|
||||||
|
CHAPTER_MIN_CHARS,
|
||||||
|
DATA_DIR,
|
||||||
|
PROMPT_DIR,
|
||||||
|
Gateway,
|
||||||
|
)
|
||||||
|
from dfannals.legends import World
|
||||||
|
from dfannals.llm import Reply, chat
|
||||||
|
|
||||||
|
STATE_FILE = DATA_DIR / "state.json"
|
||||||
|
SPEC_FILE = PROMPT_DIR / "chronicler.md"
|
||||||
|
|
||||||
|
TAIL_CHARS = 600 # 交给下一章衔接的上一章结尾长度
|
||||||
|
REPAIR_ATTEMPTS = 2
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class State:
|
||||||
|
world: str = ""
|
||||||
|
volume: int = 1
|
||||||
|
done: list[str] = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.done is None:
|
||||||
|
self.done = []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path: Path = STATE_FILE) -> State:
|
||||||
|
if not path.is_file():
|
||||||
|
return cls()
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return cls()
|
||||||
|
return cls(world=data.get("world", ""), volume=data.get("volume", 1), done=data.get("done", []))
|
||||||
|
|
||||||
|
def save(self, path: Path = STATE_FILE) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps({"world": self.world, "volume": self.volume, "done": self.done},
|
||||||
|
ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
def mark_done(self, key: str) -> None:
|
||||||
|
if key not in self.done:
|
||||||
|
self.done.append(key)
|
||||||
|
|
||||||
|
|
||||||
|
def cjk_length(text: str) -> int:
|
||||||
|
"""按中文习惯计字数:CJK 字符与拉丁单词都算 1。"""
|
||||||
|
without_code = re.sub(r"```.*?```", "", text, flags=re.S)
|
||||||
|
without_meta = re.sub(r"^\s*[#>|\-*].*$", "", without_code, flags=re.M)
|
||||||
|
n = 0
|
||||||
|
for ch in without_meta:
|
||||||
|
if (
|
||||||
|
unicodedata.east_asian_width(ch) in ("W", "F")
|
||||||
|
or ch.isalnum()
|
||||||
|
or ch in ",。!?;:、"
|
||||||
|
):
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def previous_tail(chapter_dir: Path, chapters: list[chapter_slice.Chapter], key: str) -> str:
|
||||||
|
"""取上一章结尾,作为本章的衔接线索。"""
|
||||||
|
idx = next((i for i, c in enumerate(chapters) if c.key == key), None)
|
||||||
|
if idx is None or idx == 0:
|
||||||
|
return ""
|
||||||
|
prev = chapters[idx - 1]
|
||||||
|
path = chapter_dir / f"{prev.key}.md"
|
||||||
|
if not path.is_file():
|
||||||
|
return ""
|
||||||
|
text = path.read_text(encoding="utf-8").strip()
|
||||||
|
return text[-TAIL_CHARS:]
|
||||||
|
|
||||||
|
|
||||||
|
def build_messages(world: World, chapter: chapter_slice.Chapter, tail: str) -> list[dict]:
|
||||||
|
spec = SPEC_FILE.read_text(encoding="utf-8")
|
||||||
|
payload = chapter_slice.material(world, chapter)
|
||||||
|
user = [
|
||||||
|
f"世界:{world.name}" + (f"({world.altname})" if world.altname else ""),
|
||||||
|
f"本书体例:第 {chapter.index} 章,覆盖 {chapter.span}",
|
||||||
|
"",
|
||||||
|
"以下是本章的史料素材(来自 legends 导出,事件名称保留游戏原文):",
|
||||||
|
"",
|
||||||
|
payload,
|
||||||
|
]
|
||||||
|
if tail:
|
||||||
|
user += [
|
||||||
|
"",
|
||||||
|
"### 上一章结尾(用于衔接,不要复述,只承接状态)",
|
||||||
|
"",
|
||||||
|
tail,
|
||||||
|
]
|
||||||
|
user += [
|
||||||
|
"",
|
||||||
|
f"现在请写出本章正文,{CHAPTER_MIN_CHARS}–{CHAPTER_MAX_CHARS} 字。",
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
{"role": "system", "content": spec},
|
||||||
|
{"role": "user", "content": "\n".join(user)},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _repair(messages: list[dict], draft: str, length: int, gateway: Gateway | None) -> str:
|
||||||
|
target = (
|
||||||
|
f"内容太短({length} 字),请扩写到 {CHAPTER_MIN_CHARS}–{CHAPTER_MAX_CHARS} 字,补充细节与对白,不要注水。"
|
||||||
|
if length < CHAPTER_MIN_CHARS
|
||||||
|
else f"内容太长({length} 字),请压缩到 {CHAPTER_MAX_CHARS} 字以内,删掉次要枝节,保留主干与最有趣的段落。"
|
||||||
|
)
|
||||||
|
reply: Reply = chat(
|
||||||
|
messages + [{"role": "assistant", "content": draft}, {"role": "user", "content": target}],
|
||||||
|
gateway=gateway,
|
||||||
|
)
|
||||||
|
return reply.content
|
||||||
|
|
||||||
|
|
||||||
|
def generate(
|
||||||
|
world: World,
|
||||||
|
chapter: chapter_slice.Chapter,
|
||||||
|
*,
|
||||||
|
tail: str = "",
|
||||||
|
gateway: Gateway | None = None,
|
||||||
|
max_repairs: int = REPAIR_ATTEMPTS,
|
||||||
|
) -> tuple[str, list[factcheck.Suspicion], Reply | None]:
|
||||||
|
"""生成一章:调用模型 → 篇幅修正 → 事实校验 → 标注。"""
|
||||||
|
messages = build_messages(world, chapter, tail)
|
||||||
|
reply = chat(messages, gateway=gateway)
|
||||||
|
body = reply.content
|
||||||
|
|
||||||
|
for _ in range(max_repairs):
|
||||||
|
length = cjk_length(body)
|
||||||
|
if CHAPTER_MIN_CHARS <= length <= CHAPTER_MAX_CHARS:
|
||||||
|
break
|
||||||
|
body = _repair(messages, body, length, gateway)
|
||||||
|
|
||||||
|
suspicions = factcheck.check(body, world)
|
||||||
|
return factcheck.annotate(body, suspicions), suspicions, reply
|
||||||
|
|
||||||
|
|
||||||
|
def write_chapter(chapter_dir: Path, chapter: chapter_slice.Chapter, body: str) -> Path:
|
||||||
|
chapter_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = chapter_dir / f"{chapter.key}.md"
|
||||||
|
path.write_text(body.strip() + "\n", encoding="utf-8")
|
||||||
|
return path
|
||||||
+181
@@ -0,0 +1,181 @@
|
|||||||
|
# pyright: reportMissingImports=false, reportAttributeAccessIssue=false
|
||||||
|
# 说明:LSP 的 Python 环境看不到本项目包(未安装进它的 site-packages),
|
||||||
|
# 会把「包内互相 import」误报为缺失模块。运行时导入已由执行验证通过。
|
||||||
|
|
||||||
|
"""命令行入口。
|
||||||
|
|
||||||
|
python3 -m dfannals.cli status 查看当前进度
|
||||||
|
python3 -m dfannals.cli index 解析 exports → 年表 + 人物索引
|
||||||
|
python3 -m dfannals.cli plan 显示切章方案(不调用模型)
|
||||||
|
python3 -m dfannals.cli next 生成下一章 → 校验 → 推送
|
||||||
|
python3 -m dfannals.cli run 一条命令跑完:索引 + 下一章 + 推送
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dfannals import chronicle, factcheck, legends, publish, timeline
|
||||||
|
from dfannals import slice as chapter_slice
|
||||||
|
from dfannals.config import EXPORT_DIR, GITEA_WEB, chapter_dir, ensure_dirs, volume_dir
|
||||||
|
|
||||||
|
|
||||||
|
def find_exports(export_dir: Path) -> list[Path]:
|
||||||
|
"""找出 legends 导出文件。原生 legends.xml 与 DFHack 的 legends_plus.xml 都要。"""
|
||||||
|
if not export_dir.is_dir():
|
||||||
|
return []
|
||||||
|
files = sorted(export_dir.glob("*.xml"))
|
||||||
|
base = [f for f in files if not f.name.endswith("-legends_plus.xml") and "legends_plus" not in f.name]
|
||||||
|
plus = [f for f in files if "legends_plus" in f.name]
|
||||||
|
return base + plus
|
||||||
|
|
||||||
|
|
||||||
|
def load_world(export_dir: Path) -> legends.World:
|
||||||
|
paths = find_exports(export_dir)
|
||||||
|
if not paths:
|
||||||
|
raise SystemExit(
|
||||||
|
f"在 {export_dir} 里没找到 legends XML。\n"
|
||||||
|
"请先在游戏里导出(DF 的 Export XML + DFHack 的 exportlegends),"
|
||||||
|
"再把文件复制进这个目录。"
|
||||||
|
)
|
||||||
|
return legends.load(paths)
|
||||||
|
|
||||||
|
|
||||||
|
def _current_volume(state: chronicle.State, world: legends.World) -> int:
|
||||||
|
"""世界换了就开新卷。"""
|
||||||
|
if state.world != world.name:
|
||||||
|
if state.world:
|
||||||
|
state.volume += 1
|
||||||
|
state.done = []
|
||||||
|
state.world = world.name
|
||||||
|
return state.volume
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_status(args: argparse.Namespace) -> int:
|
||||||
|
state = chronicle.State.load()
|
||||||
|
if not state.world:
|
||||||
|
print("尚未开始。先运行 index。")
|
||||||
|
return 0
|
||||||
|
vdir = volume_dir(state.volume)
|
||||||
|
print(f"世界:{state.world}")
|
||||||
|
print(f"卷号:第 {state.volume} 卷")
|
||||||
|
print(f"已完成章节:{len(state.done)}")
|
||||||
|
for key in state.done:
|
||||||
|
print(f" · {key}")
|
||||||
|
print(f"成品目录:{vdir}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_index(args: argparse.Namespace) -> int:
|
||||||
|
ensure_dirs()
|
||||||
|
world = load_world(EXPORT_DIR)
|
||||||
|
state = chronicle.State.load()
|
||||||
|
volume = _current_volume(state, world)
|
||||||
|
chapters = chapter_slice.plan(world)
|
||||||
|
|
||||||
|
vdir = volume_dir(volume)
|
||||||
|
timeline.write_outputs(world, vdir)
|
||||||
|
state.save()
|
||||||
|
|
||||||
|
print(legends.describe(world))
|
||||||
|
print()
|
||||||
|
print(chapter_slice.overview(world, chapters))
|
||||||
|
print()
|
||||||
|
print(f"年表与人物索引已写入 {vdir}")
|
||||||
|
|
||||||
|
if not args.no_push:
|
||||||
|
publish.ensure_repo()
|
||||||
|
head = publish.commit_and_push(
|
||||||
|
f"第 {volume} 卷索引:{world.name}({len(chapters)} 章规划)",
|
||||||
|
[vdir / "timeline.md", vdir / "figures.md"],
|
||||||
|
)
|
||||||
|
print(f"已推送索引:{head or '(无改动)'} → {GITEA_WEB}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_plan(args: argparse.Namespace) -> int:
|
||||||
|
world = load_world(EXPORT_DIR)
|
||||||
|
chapters = chapter_slice.plan(world)
|
||||||
|
print(chapter_slice.overview(world, chapters))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_next(args: argparse.Namespace) -> int:
|
||||||
|
ensure_dirs()
|
||||||
|
world = load_world(EXPORT_DIR)
|
||||||
|
state = chronicle.State.load()
|
||||||
|
volume = _current_volume(state, world)
|
||||||
|
chapters = chapter_slice.plan(world)
|
||||||
|
cdir = chapter_dir(volume)
|
||||||
|
|
||||||
|
todo = [c for c in chapters if c.key not in state.done]
|
||||||
|
if not todo:
|
||||||
|
print("本卷史料已全部写完。需要新世界才能开新卷。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
chapter = todo[0]
|
||||||
|
print(f"正在写第 {chapter.index} 章:{chapter.span}({len(chapter.events)} 条事件)")
|
||||||
|
tail = chronicle.previous_tail(cdir, chapters, chapter.key)
|
||||||
|
body, suspicions, reply = chronicle.generate(world, chapter, tail=tail)
|
||||||
|
length = chronicle.cjk_length(body)
|
||||||
|
print(f"生成完成:{length} 字" + (f" | 总 token {reply.total_tokens}" if reply else ""))
|
||||||
|
|
||||||
|
print(factcheck.report(suspicions))
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("\n--- 试运行,不写文件 ---\n")
|
||||||
|
print(body[:1200])
|
||||||
|
return 0
|
||||||
|
|
||||||
|
path = chronicle.write_chapter(cdir, chapter, body)
|
||||||
|
state.mark_done(chapter.key)
|
||||||
|
state.save()
|
||||||
|
print(f"已写入 {path}")
|
||||||
|
|
||||||
|
publish.ensure_repo()
|
||||||
|
head = publish.commit_and_push(
|
||||||
|
f"第 {volume} 卷第 {chapter.index} 章:{chapter.span}"
|
||||||
|
+ (f"(专名校验可疑 {len(suspicions)} 处)" if suspicions else "")
|
||||||
|
)
|
||||||
|
print(f"已推送:{head or '(无改动)'} → {GITEA_WEB}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_run(args: argparse.Namespace) -> int:
|
||||||
|
rc = cmd_index(args)
|
||||||
|
if rc:
|
||||||
|
return rc
|
||||||
|
return cmd_next(args)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog="dfannals", description="矮人要塞编年史生成管道")
|
||||||
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
p = sub.add_parser("status", help="查看当前进度")
|
||||||
|
p.set_defaults(func=cmd_status)
|
||||||
|
|
||||||
|
p = sub.add_parser("index", help="解析 exports → 年表 + 人物索引")
|
||||||
|
p.add_argument("--no-push", action="store_true", help="只写本地,不推送")
|
||||||
|
p.set_defaults(func=cmd_index)
|
||||||
|
|
||||||
|
p = sub.add_parser("plan", help="显示切章方案")
|
||||||
|
p.set_defaults(func=cmd_plan)
|
||||||
|
|
||||||
|
p = sub.add_parser("next", help="生成下一章")
|
||||||
|
p.add_argument("--dry-run", action="store_true", help="只生成不落盘、不推送")
|
||||||
|
p.set_defaults(func=cmd_next)
|
||||||
|
|
||||||
|
p = sub.add_parser("run", help="一条命令:索引 + 下一章 + 推送")
|
||||||
|
p.add_argument("--no-push", action="store_true")
|
||||||
|
p.add_argument("--dry-run", action="store_true")
|
||||||
|
p.set_defaults(func=cmd_run)
|
||||||
|
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
ensure_dirs()
|
||||||
|
return args.func(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -12,13 +12,22 @@ from pathlib import Path
|
|||||||
HOME = Path.home()
|
HOME = Path.home()
|
||||||
|
|
||||||
# ---------------------------------------------------------------- 目录
|
# ---------------------------------------------------------------- 目录
|
||||||
PROJECT_DIR = Path(__file__).resolve().parents[2] # /home/chendy/df-annals
|
PROJECT_DIR = Path(__file__).resolve().parents[1] # /home/chendy/df-annals
|
||||||
DATA_DIR = PROJECT_DIR / "data" # 原始数据,不入库
|
DATA_DIR = PROJECT_DIR / "data" # 原始数据,不入库
|
||||||
EXPORT_DIR = DATA_DIR / "exports" # legends 导出原始文件
|
EXPORT_DIR = DATA_DIR / "exports" # legends 导出原始文件
|
||||||
OUTPUT_DIR = PROJECT_DIR / "output" # 成品,入库
|
OUTPUT_DIR = PROJECT_DIR / "output" # 成品,入库
|
||||||
CHAPTER_DIR = OUTPUT_DIR / "chapters"
|
CHAPTER_DIR = OUTPUT_DIR / "chapters"
|
||||||
PROMPT_DIR = PROJECT_DIR / "prompts"
|
PROMPT_DIR = PROJECT_DIR / "prompts"
|
||||||
|
|
||||||
|
|
||||||
|
def volume_dir(volume: int) -> Path:
|
||||||
|
"""每一卷(一个世界)独立的成品目录。"""
|
||||||
|
return OUTPUT_DIR / "volumes" / f"v{volume}"
|
||||||
|
|
||||||
|
|
||||||
|
def chapter_dir(volume: int) -> Path:
|
||||||
|
return volume_dir(volume) / "chapters"
|
||||||
|
|
||||||
# ---------------------------------------------------------------- 游戏(Windows 侧)
|
# ---------------------------------------------------------------- 游戏(Windows 侧)
|
||||||
WINDOWS_USER = "23518"
|
WINDOWS_USER = "23518"
|
||||||
WIN_DF_ROOT = Path(f"/mnt/c/Users/{WINDOWS_USER}/DwarfFortress") # 下载/安装根目录
|
WIN_DF_ROOT = Path(f"/mnt/c/Users/{WINDOWS_USER}/DwarfFortress") # 下载/安装根目录
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# pyright: reportMissingImports=false, reportAttributeAccessIssue=false
|
||||||
|
# 说明:LSP 的 Python 环境看不到本项目包(未安装进它的 site-packages),
|
||||||
|
# 会把「包内互相 import」误报为缺失模块。运行时导入已由执行验证通过。
|
||||||
|
|
||||||
|
"""专名事实校验:正文里出现的专有名词必须能在史料中找到。
|
||||||
|
|
||||||
|
策略(按访谈决策:自动直推、不阻断,但标注可疑处):
|
||||||
|
- 抽出正文中的拉丁字母专名序列(本项目的约定是专名保留英文原文)
|
||||||
|
- 与史料已知专名集合比对
|
||||||
|
- 未知专名既不阻断推送,也不静默放过,而是连同上下文一起标注出来
|
||||||
|
|
||||||
|
注意:这一步只能拦住"凭空出现的人名地名",拦不住编造的对白与心理描写——
|
||||||
|
后者按用户选择属于允许的自由演绎范围。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from dfannals.legends import World, pretty
|
||||||
|
|
||||||
|
# 正文里允许出现、但不算专名的常见英文词(游戏术语/通用词)
|
||||||
|
COMMON_WORDS = {
|
||||||
|
"Dwarf", "Dwarves", "Dwarven", "Fortress", "Fort", "Mountain", "Mountains", "Hill",
|
||||||
|
"River", "Lake", "Forest", "Desert", "Ocean", "Cave", "Caves", "Town", "City",
|
||||||
|
"King", "Queen", "Lord", "Lady", "Captain", "General", "Soldier", "Miner", "Smith",
|
||||||
|
"Hammer", "Axe", "Sword", "Shield", "Gold", "Silver", "Iron", "Steel", "Copper",
|
||||||
|
"Bronze", "Wood", "Stone", "Water", "Beer", "Ale", "Wine", "Goblin", "Goblins",
|
||||||
|
"Elf", "Elves", "Human", "Humans", "Kobold", "Kobolds", "Troll", "Trolls",
|
||||||
|
"Beast", "Beasts", "Titan", "Titans", "Dragon", "Dragons", "Demon", "Demons",
|
||||||
|
"God", "Gods", "Temple", "Shrine", "Tomb", "Market", "Tavern", "Library",
|
||||||
|
"Legend", "Legends", "Annals", "Chronicle", "Chapter", "Year", "Years",
|
||||||
|
"North", "South", "East", "West", "Spring", "Summer", "Autumn", "Winter",
|
||||||
|
"The", "A", "An", "And", "Or", "But", "In", "On", "At", "By", "Of", "To", "For",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 拉丁专名序列:首字母大写的词,可含 ' - 与数字
|
||||||
|
NAME_RE = re.compile(r"\b[A-Z][A-Za-z'\-]*(?:\s+[A-Z][A-Za-z'\-]*)*\b")
|
||||||
|
_CJK = re.compile(r"[\u4e00-\u9fff]")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Suspicion:
|
||||||
|
name: str
|
||||||
|
count: int
|
||||||
|
context: str
|
||||||
|
|
||||||
|
|
||||||
|
def known_names(world: World) -> set[str]:
|
||||||
|
"""史料中出现过的全部专名,含单词与整名两种粒度。"""
|
||||||
|
names: set[str] = set()
|
||||||
|
|
||||||
|
def add(value: str) -> None:
|
||||||
|
value = (value or "").strip()
|
||||||
|
if not value:
|
||||||
|
return
|
||||||
|
names.add(value)
|
||||||
|
# 同时收录展示形式:正文里专名会做首字母大写,两边必须一致
|
||||||
|
shown = pretty(value)
|
||||||
|
names.add(shown)
|
||||||
|
for token in re.split(r"[\s,]+", shown):
|
||||||
|
token = token.strip("'\"()[]")
|
||||||
|
if token and token[0].isupper():
|
||||||
|
names.add(token)
|
||||||
|
# 原样小写形式也保留,避免漏报
|
||||||
|
names.add(value.lower())
|
||||||
|
|
||||||
|
# 世界名必须拆词:否则 "The World of Prophecy" 里的 Prophecy 会被当成凭空编造
|
||||||
|
add(world.name)
|
||||||
|
add(world.altname)
|
||||||
|
|
||||||
|
for fig in world.figures.values():
|
||||||
|
add(fig.name)
|
||||||
|
add(fig.race)
|
||||||
|
add(fig.profession)
|
||||||
|
for ent in world.entities.values():
|
||||||
|
add(ent.name)
|
||||||
|
add(ent.type)
|
||||||
|
add(ent.race)
|
||||||
|
for site in world.sites.values():
|
||||||
|
add(site.name)
|
||||||
|
add(site.type)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _is_candidate(name: str, known: set[str]) -> bool:
|
||||||
|
if name in known:
|
||||||
|
return False
|
||||||
|
tokens = name.split()
|
||||||
|
# 全部由常见词组成的串不是专名
|
||||||
|
if all(t in COMMON_WORDS for t in tokens):
|
||||||
|
return False
|
||||||
|
# 全大写的是史料里的枚举值(GOBLIN、MINER、DARK_FORTRESS),不是人名地名
|
||||||
|
return not all(len(t) >= 2 and t.isupper() for t in tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def check(text: str, world: World) -> list[Suspicion]:
|
||||||
|
"""返回可疑专名列表(按出现次数降序)。"""
|
||||||
|
known = known_names(world)
|
||||||
|
found: dict[str, list[str]] = {}
|
||||||
|
|
||||||
|
for match in NAME_RE.finditer(text):
|
||||||
|
raw = match.group(0)
|
||||||
|
# 逐级回退:整串不认,就试着拆成更短的已知名字,减少误报
|
||||||
|
if not _is_candidate(raw, known):
|
||||||
|
continue
|
||||||
|
if any(part in known for part in raw.split()):
|
||||||
|
# 名字里有一部分是史料已知的(例如 "Urist the Bold"),不当作凭空编造
|
||||||
|
continue
|
||||||
|
start = max(0, match.start() - 20)
|
||||||
|
end = min(len(text), match.end() + 20)
|
||||||
|
found.setdefault(raw, []).append(text[start:end].replace("\n", " "))
|
||||||
|
|
||||||
|
return [
|
||||||
|
Suspicion(name=n, count=len(ctxs), context=ctxs[0])
|
||||||
|
for n, ctxs in sorted(found.items(), key=lambda kv: (-len(kv[1]), kv[0]))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def report(suspicions: list[Suspicion], top: int = 12) -> str:
|
||||||
|
if not suspicions:
|
||||||
|
return "专名校验:未发现史料之外的专有名词。"
|
||||||
|
lines = [f"专名校验:发现 {len(suspicions)} 个可疑专名(不阻断推送,仅标注)"]
|
||||||
|
for s in suspicions[:top]:
|
||||||
|
lines.append(f" · {s.name} ×{s.count} —— 上下文:…{s.context}…")
|
||||||
|
if len(suspicions) > top:
|
||||||
|
lines.append(f" (其余 {len(suspicions) - top} 个见校验报告文件)")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def annotate(chapter_md: str, suspicions: list[Suspicion]) -> str:
|
||||||
|
"""在章节末尾追加可见的校验提示。"""
|
||||||
|
if not suspicions:
|
||||||
|
return chapter_md
|
||||||
|
names = "、".join(f"{s.name}×{s.count}" for s in suspicions[:10])
|
||||||
|
block = (
|
||||||
|
"\n\n---\n\n"
|
||||||
|
f"> ⚠️ **史料核验提示**:以下专名未在 legends 史料中找到,可能是演绎或错误:{names}。\n"
|
||||||
|
"> 其余对白与心理描写属于允许的文学演绎,不在校验范围内。\n"
|
||||||
|
)
|
||||||
|
return chapter_md.rstrip() + block
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
"""解析矮人要塞的 legends 导出。
|
||||||
|
|
||||||
|
数据来源有两份,按 id 合并:
|
||||||
|
1. ``legends.xml`` —— DF 原生 "Export XML" 产物:名字、年份、事件主体
|
||||||
|
2. ``legends_plus.xml`` —— DFHack ``exportlegends`` 产物:补充字段(sex/race 等)
|
||||||
|
|
||||||
|
因为大世界的导出可达数十 MB,这里用 iterparse 流式解析。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from defusedxml.ElementTree import iterparse
|
||||||
|
|
||||||
|
# 事件里指向其他实体的字段 → 指向哪类对象
|
||||||
|
ID_FIELDS: dict[str, str] = {
|
||||||
|
"hfid": "figure",
|
||||||
|
"hist_figure_id": "figure",
|
||||||
|
"slayer_hfid": "figure",
|
||||||
|
"slayer_item_id": "artifact",
|
||||||
|
"target_hfid": "figure",
|
||||||
|
"source_hfid": "figure",
|
||||||
|
"site_id": "site",
|
||||||
|
"site_civ_id": "entity",
|
||||||
|
"entity_id": "entity",
|
||||||
|
"attacker_civ_id": "entity",
|
||||||
|
"defender_civ_id": "entity",
|
||||||
|
"artifact_id": "artifact",
|
||||||
|
"region_id": "region",
|
||||||
|
"feature_layer_id": "region",
|
||||||
|
"deity": "figure",
|
||||||
|
"worshipper_hfid": "figure",
|
||||||
|
"creature_id": "creature",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Figure:
|
||||||
|
id: int
|
||||||
|
name: str = ""
|
||||||
|
race: str = ""
|
||||||
|
sex: int | None = None
|
||||||
|
birth_year: int | None = None
|
||||||
|
death_year: int | None = None
|
||||||
|
profession: str = ""
|
||||||
|
entities: list[int] = field(default_factory=list)
|
||||||
|
sites: list[int] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def alive_span(self) -> str:
|
||||||
|
# DF 用负数占位表示“无此项”(未记录/未死),不要当成真实年份显示
|
||||||
|
birth = self.birth_year if (self.birth_year or 0) >= 0 else None
|
||||||
|
death = self.death_year if (self.death_year or 0) >= 0 else None
|
||||||
|
if birth is None and death is None:
|
||||||
|
return "生卒不详"
|
||||||
|
b = "?" if birth is None else str(birth)
|
||||||
|
d = "在世/不详" if death is None else str(death)
|
||||||
|
return f"{b}–{d}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Event:
|
||||||
|
id: int
|
||||||
|
year: int
|
||||||
|
seconds72: int = 0
|
||||||
|
type: str = ""
|
||||||
|
fields: dict[str, list[str]] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def ids(self, tag: str) -> list[int]:
|
||||||
|
out: list[int] = []
|
||||||
|
for raw in self.fields.get(tag, []):
|
||||||
|
try:
|
||||||
|
out.append(int(raw))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
def text(self, tag: str) -> str:
|
||||||
|
vals = self.fields.get(tag)
|
||||||
|
return vals[0] if vals else ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Entity:
|
||||||
|
id: int
|
||||||
|
name: str = ""
|
||||||
|
type: str = ""
|
||||||
|
race: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Site:
|
||||||
|
id: int
|
||||||
|
name: str = ""
|
||||||
|
type: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 展示用工具
|
||||||
|
def pretty(name: str) -> str:
|
||||||
|
"""展示用:DF 的程序化名字全小写,正文里按首字母大写会更像专名。
|
||||||
|
|
||||||
|
只影响渲染,不改变史料本身。
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return name
|
||||||
|
return " ".join(part[:1].upper() + part[1:] for part in name.split())
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class World:
|
||||||
|
name: str = ""
|
||||||
|
altname: str = ""
|
||||||
|
figures: dict[int, Figure] = field(default_factory=dict)
|
||||||
|
events: list[Event] = field(default_factory=list)
|
||||||
|
entities: dict[int, Entity] = field(default_factory=dict)
|
||||||
|
sites: dict[int, Site] = field(default_factory=dict)
|
||||||
|
sources: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- 查询
|
||||||
|
# 约定:id 为负表示“无此项”(DF 用 -1 占位),返回空串让调用方跳过。
|
||||||
|
def figure_name(self, fid: int) -> str:
|
||||||
|
if fid < 0:
|
||||||
|
return ""
|
||||||
|
fig = self.figures.get(fid)
|
||||||
|
return pretty(fig.name) if fig and fig.name else f"HF#{fid}"
|
||||||
|
|
||||||
|
def entity_name(self, eid: int) -> str:
|
||||||
|
if eid < 0:
|
||||||
|
return ""
|
||||||
|
ent = self.entities.get(eid)
|
||||||
|
return pretty(ent.name) if ent and ent.name else f"Entity#{eid}"
|
||||||
|
|
||||||
|
def site_name(self, sid: int) -> str:
|
||||||
|
if sid < 0:
|
||||||
|
return ""
|
||||||
|
st = self.sites.get(sid)
|
||||||
|
return pretty(st.name) if st and st.name else f"Site#{sid}"
|
||||||
|
|
||||||
|
def event_years(self) -> tuple[int, int]:
|
||||||
|
years = [e.year for e in self.events if e.year]
|
||||||
|
return (min(years), max(years)) if years else (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 解析工具
|
||||||
|
def _int_or_none(text: str | None) -> int | None:
|
||||||
|
if text is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _children(elem: Any) -> dict[str, list[str]]:
|
||||||
|
"""把元素的子标签收成 {tag: [文本, ...]}。"""
|
||||||
|
out: dict[str, list[str]] = {}
|
||||||
|
for child in elem:
|
||||||
|
val = (child.text or "").strip()
|
||||||
|
out.setdefault(child.tag, []).append(val)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _first(d: dict[str, list[str]], *keys: str) -> str:
|
||||||
|
for k in keys:
|
||||||
|
if d.get(k):
|
||||||
|
return d[k][0]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# XML 1.0 不允许的控制字符(保留 \t \n \r)
|
||||||
|
ILLEGAL_BYTES = bytes(range(0x09)) + b"\x0b\x0c" + bytes(range(0x0e, 0x20))
|
||||||
|
CLEAN_CACHE = Path("data/exports/clean")
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize(src: Path, dst: Path, chunk_size: int = 8 << 20) -> Path:
|
||||||
|
"""把 DF 导出的 legends XML 转成合规的 UTF-8 文件。
|
||||||
|
|
||||||
|
DF 会在名字里嵌入 \\x10/\\x11 等 CP437 控制字节做专名标记,
|
||||||
|
XML 1.0 禁止这些字节,严格解析器会直接报错。
|
||||||
|
这里做两件事:剔除非法控制字节;把 CP437 声明与实际字节统一成 UTF-8。
|
||||||
|
cp437 是单字节编码,因此可以按块转换而不会切断字符。
|
||||||
|
"""
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with src.open("rb") as fin, dst.open("wb") as fout:
|
||||||
|
first = True
|
||||||
|
while True:
|
||||||
|
buf = fin.read(chunk_size)
|
||||||
|
if not buf:
|
||||||
|
break
|
||||||
|
buf = buf.translate(None, ILLEGAL_BYTES)
|
||||||
|
if first:
|
||||||
|
buf = buf.replace(b"encoding='CP437'", b"encoding='UTF-8'", 1)
|
||||||
|
buf = buf.replace(b'encoding="CP437"', b'encoding="UTF-8"', 1)
|
||||||
|
first = False
|
||||||
|
fout.write(buf.decode("cp437").encode("utf-8"))
|
||||||
|
return dst
|
||||||
|
|
||||||
|
|
||||||
|
def clean_copy(src: Path) -> Path:
|
||||||
|
"""返回可解析的干净副本,带缓存(源文件更新则重建)。
|
||||||
|
|
||||||
|
缓存写到 data/exports/clean/ 子目录,避开 find_exports 的 *.xml 扫描,
|
||||||
|
否则干净副本会被当作第二份史料,导致事件数翻倍。
|
||||||
|
"""
|
||||||
|
dst = CLEAN_CACHE / src.name
|
||||||
|
if dst.is_file() and dst.stat().st_mtime >= src.stat().st_mtime and dst.stat().st_size > 0:
|
||||||
|
return dst
|
||||||
|
return sanitize(src, dst)
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_unsafe_xml(path: Path) -> None:
|
||||||
|
"""拒绝带 DTD / 实体声明的文件。
|
||||||
|
|
||||||
|
DOCTYPE 必须出现在根元素之前,因此只检查文件头部即可拦住
|
||||||
|
XXE 与 billion-laughs 这两类实体展开攻击。
|
||||||
|
"""
|
||||||
|
with path.open("rb") as fh:
|
||||||
|
head = fh.read(65536)
|
||||||
|
for marker in (b"<!DOCTYPE", b"<!ENTITY"):
|
||||||
|
if marker in head:
|
||||||
|
raise ValueError(f"拒绝解析含 DTD/实体声明的文件({marker.decode()}): {path}")
|
||||||
|
|
||||||
|
|
||||||
|
WRAPPERS = ("historical_figures", "historical_events", "entities", "sites")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_file(path: Path, world: World) -> None:
|
||||||
|
"""流式解析一个 legends XML,合并进 world。
|
||||||
|
|
||||||
|
用元素深度而不是包裹标签的进出状态来控制语义:
|
||||||
|
只有 depth == 2(即 <df_world> 的直接子元素)才会被当作世界级字段,
|
||||||
|
这样历史人物、地区里的 <name> 永远不会污染世界名。
|
||||||
|
"""
|
||||||
|
_reject_unsafe_xml(path)
|
||||||
|
world.sources.append(str(path))
|
||||||
|
depth = 0
|
||||||
|
parser = iterparse(str(path), events=("start", "end"))
|
||||||
|
for event, elem in parser:
|
||||||
|
if event == "start":
|
||||||
|
depth += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
tag = elem.tag
|
||||||
|
if depth == 2:
|
||||||
|
if tag == "name":
|
||||||
|
world.name = world.name or (elem.text or "").strip()
|
||||||
|
elif tag == "altname":
|
||||||
|
world.altname = world.altname or (elem.text or "").strip()
|
||||||
|
elif tag == "historical_figure":
|
||||||
|
_add_figure(world, elem)
|
||||||
|
elem.clear()
|
||||||
|
elif tag == "historical_event":
|
||||||
|
_add_event(world, elem)
|
||||||
|
elem.clear()
|
||||||
|
elif tag == "entity":
|
||||||
|
_add_entity(world, elem)
|
||||||
|
elem.clear()
|
||||||
|
elif tag == "site":
|
||||||
|
_add_site(world, elem)
|
||||||
|
elem.clear()
|
||||||
|
|
||||||
|
depth -= 1
|
||||||
|
|
||||||
|
|
||||||
|
def _add_figure(world: World, elem) -> None:
|
||||||
|
d = _children(elem)
|
||||||
|
fid = _int_or_none(_first(d, "id"))
|
||||||
|
if fid is None:
|
||||||
|
return
|
||||||
|
fig = world.figures.setdefault(fid, Figure(id=fid))
|
||||||
|
fig.name = fig.name or _first(d, "name")
|
||||||
|
fig.race = fig.race or _first(d, "race")
|
||||||
|
fig.profession = fig.profession or _first(d, "profession")
|
||||||
|
if fig.sex is None:
|
||||||
|
fig.sex = _int_or_none(_first(d, "sex"))
|
||||||
|
if fig.birth_year is None:
|
||||||
|
fig.birth_year = _int_or_none(_first(d, "birth_year"))
|
||||||
|
if fig.death_year is None:
|
||||||
|
fig.death_year = _int_or_none(_first(d, "death_year"))
|
||||||
|
|
||||||
|
|
||||||
|
def _add_event(world: World, elem) -> None:
|
||||||
|
d = _children(elem)
|
||||||
|
eid = _int_or_none(_first(d, "id"))
|
||||||
|
year = _int_or_none(_first(d, "year"))
|
||||||
|
if eid is None or year is None:
|
||||||
|
return
|
||||||
|
world.events.append(
|
||||||
|
Event(
|
||||||
|
id=eid,
|
||||||
|
year=year,
|
||||||
|
seconds72=_int_or_none(_first(d, "seconds72")) or 0,
|
||||||
|
type=_first(d, "type"),
|
||||||
|
fields=d,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_entity(world: World, elem) -> None:
|
||||||
|
d = _children(elem)
|
||||||
|
eid = _int_or_none(_first(d, "id"))
|
||||||
|
if eid is None:
|
||||||
|
return
|
||||||
|
ent = world.entities.setdefault(eid, Entity(id=eid))
|
||||||
|
ent.name = ent.name or _first(d, "name")
|
||||||
|
ent.type = ent.type or _first(d, "type")
|
||||||
|
ent.race = ent.race or _first(d, "race")
|
||||||
|
|
||||||
|
|
||||||
|
def _add_site(world: World, elem) -> None:
|
||||||
|
d = _children(elem)
|
||||||
|
sid = _int_or_none(_first(d, "id"))
|
||||||
|
if sid is None:
|
||||||
|
return
|
||||||
|
st = world.sites.setdefault(sid, Site(id=sid))
|
||||||
|
st.name = st.name or _first(d, "name")
|
||||||
|
st.type = st.type or _first(d, "type")
|
||||||
|
|
||||||
|
|
||||||
|
def load(paths: list[Path]) -> World:
|
||||||
|
world = World()
|
||||||
|
for p in paths:
|
||||||
|
if not p.is_file():
|
||||||
|
raise FileNotFoundError(f"缺少 legends 导出文件: {p}")
|
||||||
|
if p.stat().st_size == 0:
|
||||||
|
raise ValueError(f"legends 导出文件为空: {p}")
|
||||||
|
parse_file(clean_copy(p), world)
|
||||||
|
world.events.sort(key=lambda e: (e.year, e.seconds72, e.id))
|
||||||
|
return world
|
||||||
|
|
||||||
|
|
||||||
|
def describe(world: World, top: int = 8) -> str:
|
||||||
|
lo, hi = world.event_years()
|
||||||
|
by_type: dict[str, int] = {}
|
||||||
|
for e in world.events:
|
||||||
|
by_type[e.type] = by_type.get(e.type, 0) + 1
|
||||||
|
hot = sorted(by_type.items(), key=lambda kv: -kv[1])[:top]
|
||||||
|
lines = [
|
||||||
|
f"世界: {world.name}" + (f"({world.altname})" if world.altname else ""),
|
||||||
|
f"历史跨度: {lo}–{hi}({hi - lo} 年)| 事件 {len(world.events)} 条",
|
||||||
|
f"历史人物 {len(world.figures)} | 文明/组织 {len(world.entities)} | 地点 {len(world.sites)}",
|
||||||
|
"高频事件类型: " + ", ".join(f"{t}×{n}" for t, n in hot),
|
||||||
|
f"来源: {', '.join(Path(s).name for s in world.sources)}",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -13,7 +13,7 @@ import time
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from .config import MAX_TOKENS_CHAPTER, MODEL, Gateway, load_gateway
|
from dfannals.config import MAX_TOKENS_CHAPTER, MODEL, Gateway, load_gateway
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .config import GITEA_REMOTE, PROJECT_DIR, SSH_KEY
|
from dfannals.config import GITEA_REMOTE, PROJECT_DIR, SSH_KEY
|
||||||
|
|
||||||
GITIGNORE = """\
|
GITIGNORE = """\
|
||||||
# 原始数据与存档:不入库
|
# 原始数据与存档:不入库
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""事件显著度评分与小人物过滤。
|
||||||
|
|
||||||
|
真实数据给的教训:40 万条事件里绝大多数是流水账
|
||||||
|
(change hf state 7.3 万、change hf job 5.8 万、written content composed 3.9 万),
|
||||||
|
直接按类型切章会得到 5910 章,毫无叙事价值。
|
||||||
|
|
||||||
|
因此策略是:
|
||||||
|
1. 只保留天然有叙事张力的事件类型(攻城、灭国、神器、死亡、结盟……)
|
||||||
|
2. 再按"参与者有多重要"做二次排序,让编年史跟着大人物走
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from dfannals.legends import Event, World
|
||||||
|
|
||||||
|
# 事件类型 → 基础分。0 分表示不单独叙事(流水账)
|
||||||
|
NARRATIVE: dict[str, int] = {
|
||||||
|
# ---- 天崩地裂级
|
||||||
|
"destroyed site": 100,
|
||||||
|
"razed structure": 100,
|
||||||
|
"entity overthrown": 100,
|
||||||
|
"hf destroyed site": 95,
|
||||||
|
"plundered site": 95,
|
||||||
|
"field battle": 95,
|
||||||
|
"entity dissolved": 90,
|
||||||
|
"holy city declaration": 90,
|
||||||
|
"site taken over": 90,
|
||||||
|
"entity breach feature layer": 90,
|
||||||
|
"entity alliance formed": 85,
|
||||||
|
"peace accepted": 85,
|
||||||
|
"hf enslaved": 85,
|
||||||
|
"hf revived": 80,
|
||||||
|
"entity relocate": 80,
|
||||||
|
"hf ransomed": 80,
|
||||||
|
"peace rejected": 78,
|
||||||
|
# ---- 重大
|
||||||
|
"created site": 75,
|
||||||
|
"reclaim site": 72,
|
||||||
|
"attacked site": 70,
|
||||||
|
"hf attacked site": 70,
|
||||||
|
"entity created": 70,
|
||||||
|
"artifact destroyed": 70,
|
||||||
|
"new site leader": 65,
|
||||||
|
"created world construction": 60,
|
||||||
|
"entity persecuted": 60,
|
||||||
|
"hf convicted": 60,
|
||||||
|
"site dispute": 55,
|
||||||
|
"failed intrigue corruption": 55,
|
||||||
|
"artifact recovered": 60,
|
||||||
|
"artifact possessed": 60,
|
||||||
|
# ---- 值得一记
|
||||||
|
"artifact created": 45,
|
||||||
|
"artifact lost": 45,
|
||||||
|
"creature devoured": 42,
|
||||||
|
"hf abducted": 40,
|
||||||
|
"assume identity": 40,
|
||||||
|
"knowledge discovered": 38,
|
||||||
|
"artifact given": 36,
|
||||||
|
"agreement formed": 36,
|
||||||
|
"hf gains secret goal": 36,
|
||||||
|
"item stolen": 34,
|
||||||
|
"hf performed horrible experiments": 45,
|
||||||
|
"failed frame attempt": 40,
|
||||||
|
"hf profaned structure": 38,
|
||||||
|
"changed creature type": 36,
|
||||||
|
"hf interrogated": 34,
|
||||||
|
"created structure": 30,
|
||||||
|
"hf wounded": 30,
|
||||||
|
"hf learns secret": 34,
|
||||||
|
"changed hf body state": 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 死亡单独处理:分数取决于死者本身有多重要
|
||||||
|
DEATH_TYPES = ("hf died",)
|
||||||
|
|
||||||
|
CATEGORIES: list[tuple[tuple[str, ...], str]] = [
|
||||||
|
(("war", "battle", "siege", "raid", "attack", "plunder", "destroy", "sack", "taken over"), "战事"),
|
||||||
|
(("died", "death", "slain", "murder", "assassin", "enslaved", "ransomed", "revived"), "生死"),
|
||||||
|
(("created site", "reclaim", "relocate", "dissolved", "overthrown", "alliance", "entity created"), "兴亡"),
|
||||||
|
(("artifact", "masterpiece", "construction", "structure", "razed"), "器物"),
|
||||||
|
(("peace", "agreement", "tribute", "persecuted", "convicted", "dispute", "intrigue"), "政争"),
|
||||||
|
(("knowledge", "secret", "abducted", "identity", "experiment", "profaned", "devoured"), "异闻"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def category(event: Event) -> str:
|
||||||
|
t = event.type.lower()
|
||||||
|
for keys, label in CATEGORIES:
|
||||||
|
if any(k in t for k in keys):
|
||||||
|
return label
|
||||||
|
return "杂记"
|
||||||
|
|
||||||
|
|
||||||
|
def prominence(world: World) -> Counter[int]:
|
||||||
|
"""每个人物被卷入的事件次数——用来衡量他在史料里有多重要。"""
|
||||||
|
counts: Counter[int] = Counter()
|
||||||
|
for e in world.events:
|
||||||
|
seen: set[int] = set()
|
||||||
|
for tag in ("hfid", "slayer_hfid", "target_hfid", "source_hfid", "deity", "worshipper_hfid"):
|
||||||
|
for fid in e.ids(tag):
|
||||||
|
seen.add(fid)
|
||||||
|
counts.update(seen)
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def _participants(event: Event) -> list[int]:
|
||||||
|
out: list[int] = []
|
||||||
|
for tag in ("hfid", "slayer_hfid", "target_hfid", "source_hfid", "deity"):
|
||||||
|
out.extend(event.ids(tag))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# 人物重要度阈值:参与事件数达到这个量级才算"要角"
|
||||||
|
PROMINENT = 60
|
||||||
|
|
||||||
|
|
||||||
|
def significance(event: Event, prom: Counter[int] | None = None, prominent: int = PROMINENT) -> int:
|
||||||
|
"""事件的故事价值。0 表示不值得单独写。"""
|
||||||
|
t = event.type.lower()
|
||||||
|
base = NARRATIVE.get(t, 0)
|
||||||
|
people = _participants(event)
|
||||||
|
top = max((prom.get(p, 0) for p in people), default=0) if prom else 0
|
||||||
|
|
||||||
|
if t in DEATH_TYPES:
|
||||||
|
# 无名死者不写;要角之死按重要度加分
|
||||||
|
if top < prominent:
|
||||||
|
return 0
|
||||||
|
return 55 + min(45, top // 10)
|
||||||
|
|
||||||
|
if base == 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# 参与者越重要,越值得写;但不要盖过类型本身的分量
|
||||||
|
bonus = min(25, top // 25) if prom else 0
|
||||||
|
return base + bonus
|
||||||
|
|
||||||
|
|
||||||
|
def is_narrative(event: Event, prom: Counter[int] | None = None) -> bool:
|
||||||
|
return significance(event, prom) > 0
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""把史料切成章节素材。
|
||||||
|
|
||||||
|
真实数据(403853 条事件 / 250 年)证明:按"重要事件数量"切章会得到几千章。
|
||||||
|
所以改为先按时间窗分桶(每窗 10 年),再在窗内按显著度取前 N 条——
|
||||||
|
这样一章天然覆盖一段年月,既有大事件链,也不会漏掉时代的推进。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from dfannals import legends as lg
|
||||||
|
from dfannals import score
|
||||||
|
from dfannals.legends import Event, World
|
||||||
|
|
||||||
|
YEARS_PER_CHAPTER = 10 # 一个时间窗覆盖的年数
|
||||||
|
EVENTS_PER_CHAPTER = 12 # 每章最多收录的史料条目
|
||||||
|
MIN_EVENTS_PER_CHAPTER = 3 # 少于这个数量就并入上一章(太平年月)
|
||||||
|
MAX_PER_CATEGORY = 4 # 同一类事件每章上限,避免整章都是讣告
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Chapter:
|
||||||
|
index: int
|
||||||
|
start_year: int
|
||||||
|
end_year: int
|
||||||
|
events: list[Event] = field(default_factory=list)
|
||||||
|
quiet_years: list[tuple[int, int]] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key(self) -> str:
|
||||||
|
return f"{self.index:03d}-{self.start_year}-{self.end_year}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def span(self) -> str:
|
||||||
|
if self.start_year == self.end_year:
|
||||||
|
return f"{self.start_year} 年"
|
||||||
|
return f"{self.start_year}–{self.end_year} 年"
|
||||||
|
|
||||||
|
def categories(self) -> list[str]:
|
||||||
|
seen: list[str] = []
|
||||||
|
for e in self.events:
|
||||||
|
c = score.category(e)
|
||||||
|
if c not in seen:
|
||||||
|
seen.append(c)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def plan(
|
||||||
|
world: World,
|
||||||
|
years_per_chapter: int = YEARS_PER_CHAPTER,
|
||||||
|
per_chapter: int = EVENTS_PER_CHAPTER,
|
||||||
|
min_events: int = MIN_EVENTS_PER_CHAPTER,
|
||||||
|
max_per_category: int = MAX_PER_CATEGORY,
|
||||||
|
) -> list[Chapter]:
|
||||||
|
prom = score.prominence(world)
|
||||||
|
scored = [(score.significance(e, prom), e) for e in world.events]
|
||||||
|
scored = [pair for pair in scored if pair[0] > 0]
|
||||||
|
if not scored:
|
||||||
|
lo, hi = world.event_years()
|
||||||
|
return [Chapter(index=1, start_year=lo, end_year=hi)]
|
||||||
|
|
||||||
|
lo = min(e.year for _, e in scored)
|
||||||
|
hi = max(e.year for _, e in scored)
|
||||||
|
|
||||||
|
buckets: dict[int, list[tuple[int, Event]]] = defaultdict(list)
|
||||||
|
for sig, e in scored:
|
||||||
|
buckets[e.year // years_per_chapter].append((sig, e))
|
||||||
|
|
||||||
|
chapters: list[Chapter] = []
|
||||||
|
for b in range(lo // years_per_chapter, hi // years_per_chapter + 1):
|
||||||
|
window_start = max(b * years_per_chapter, lo)
|
||||||
|
window_end = min(b * years_per_chapter + years_per_chapter - 1, hi)
|
||||||
|
ranked = sorted(buckets.get(b, []), key=lambda p: (-p[0], p[1].year, p[1].id))
|
||||||
|
# 按类别限额挑选:让一章里有战事、兴亡、器物,而不是 12 条死亡讣告
|
||||||
|
picks: list[tuple[int, Event]] = []
|
||||||
|
used: dict[str, int] = {}
|
||||||
|
for sig, ev in ranked:
|
||||||
|
cat = score.category(ev)
|
||||||
|
if used.get(cat, 0) >= max_per_category:
|
||||||
|
continue
|
||||||
|
used[cat] = used.get(cat, 0) + 1
|
||||||
|
picks.append((sig, ev))
|
||||||
|
if len(picks) >= per_chapter:
|
||||||
|
break
|
||||||
|
events = sorted((e for _, e in picks), key=lambda e: (e.year, e.seconds72, e.id))
|
||||||
|
|
||||||
|
if not events:
|
||||||
|
if chapters:
|
||||||
|
chapters[-1].quiet_years.append((window_start, window_end))
|
||||||
|
chapters[-1].end_year = window_end
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(events) < min_events and chapters:
|
||||||
|
chapters[-1].events.extend(events)
|
||||||
|
chapters[-1].end_year = window_end
|
||||||
|
continue
|
||||||
|
|
||||||
|
chapters.append(
|
||||||
|
Chapter(index=len(chapters) + 1, start_year=window_start, end_year=window_end, events=events)
|
||||||
|
)
|
||||||
|
|
||||||
|
for i, c in enumerate(chapters, 1):
|
||||||
|
c.index = i
|
||||||
|
return chapters
|
||||||
|
|
||||||
|
|
||||||
|
def _figure_line(world: World, fid: int, prom) -> str:
|
||||||
|
fig = world.figures.get(fid)
|
||||||
|
if not fig:
|
||||||
|
return f"- HF#{fid}(史料无记载)"
|
||||||
|
bits = [lg.pretty(fig.name) or f"HF#{fid}", fig.race or "未知种族", fig.alive_span]
|
||||||
|
if fig.profession:
|
||||||
|
bits.append(fig.profession)
|
||||||
|
bits.append(f"史料出现 {prom.get(fid, 0)} 次")
|
||||||
|
return "- " + " | ".join(bits)
|
||||||
|
|
||||||
|
|
||||||
|
def material(world: World, chapter: Chapter, max_figures: int = 40) -> str:
|
||||||
|
"""把一章的史料渲染成给模型看的素材文本。"""
|
||||||
|
prom = score.prominence(world)
|
||||||
|
lines: list[str] = [
|
||||||
|
f"## 本章范围:{chapter.span}",
|
||||||
|
"",
|
||||||
|
f"共 {len(chapter.events)} 条史料,类型:{('、'.join(chapter.categories())) or '无'}。",
|
||||||
|
"",
|
||||||
|
"### 事件流水(按时间排序,专名保留游戏原文)",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
participation: dict[int, int] = {}
|
||||||
|
for e in chapter.events:
|
||||||
|
chunks = [f"{e.year}年", e.type]
|
||||||
|
for tag, kind in lg.ID_FIELDS.items():
|
||||||
|
for i in e.ids(tag):
|
||||||
|
# id 为负是“无此项”,不要渲染成 HF#-1 这类噪音
|
||||||
|
if kind == "figure":
|
||||||
|
name = world.figure_name(i)
|
||||||
|
elif kind == "site":
|
||||||
|
name = world.site_name(i)
|
||||||
|
elif kind == "entity":
|
||||||
|
name = world.entity_name(i)
|
||||||
|
else:
|
||||||
|
name = ""
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
chunks.append(f"{tag}={name}")
|
||||||
|
if kind == "figure":
|
||||||
|
participation[i] = participation.get(i, 0) + 1
|
||||||
|
extra = []
|
||||||
|
for field_name in ("state", "reason", "circumstance"):
|
||||||
|
v = e.text(field_name)
|
||||||
|
if v and v not in ("-1", ""):
|
||||||
|
extra.append(f"{field_name}={v}")
|
||||||
|
if extra:
|
||||||
|
chunks.append("·".join(extra))
|
||||||
|
lines.append("- " + " · ".join(chunks))
|
||||||
|
|
||||||
|
if chapter.quiet_years:
|
||||||
|
spans = "、".join(f"{a}–{b} 年" for a, b in chapter.quiet_years)
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
"### 太平年月(史料无值得立传的事件)",
|
||||||
|
"",
|
||||||
|
f"- {spans}:这段时期只留下琐碎记录,可一笔带过。",
|
||||||
|
]
|
||||||
|
|
||||||
|
lines += ["", "### 本章登场人物(史料中的身份信息)", ""]
|
||||||
|
for fid, _ in sorted(participation.items(), key=lambda kv: -prom.get(kv[0], 0))[:max_figures]:
|
||||||
|
lines.append(_figure_line(world, fid, prom))
|
||||||
|
|
||||||
|
sites = sorted({n for e in chapter.events for i in e.ids("site_id") if (n := world.site_name(i))})
|
||||||
|
if sites:
|
||||||
|
lines += ["", "### 相关地点", "", "- " + "、".join(sites)]
|
||||||
|
|
||||||
|
ents = sorted({n for e in chapter.events for i in e.ids("entity_id") if (n := world.entity_name(i))})
|
||||||
|
if ents:
|
||||||
|
lines += ["", "### 相关势力", "", "- " + "、".join(ents)]
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def overview(world: World, chapters: list[Chapter], limit: int = 10) -> str:
|
||||||
|
lo, hi = world.event_years()
|
||||||
|
lines = [
|
||||||
|
f"世界:{world.name}" + (f"({world.altname})" if world.altname else ""),
|
||||||
|
f"历史:{lo}–{hi} 年 | 史料事件 {len(world.events)} 条 | "
|
||||||
|
f"人物 {len(world.figures)} | 势力 {len(world.entities)} | 地点 {len(world.sites)}",
|
||||||
|
f"规划章节:{len(chapters)} 章(每章约 {YEARS_PER_CHAPTER} 年 / 最多 {EVENTS_PER_CHAPTER} 条史料)",
|
||||||
|
]
|
||||||
|
for c in chapters[:limit]:
|
||||||
|
lines.append(f" 第 {c.index} 章 {c.span}:{len(c.events)} 条({('、'.join(c.categories())) or '无'})")
|
||||||
|
if len(chapters) > limit:
|
||||||
|
lines.append(f" …… 其余 {len(chapters) - limit} 章略")
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# pyright: reportMissingImports=false, reportAttributeAccessIssue=false
|
||||||
|
# 说明:LSP 的 Python 环境看不到本项目包(未安装进它的 site-packages),
|
||||||
|
# 会把「包内互相 import」误报为缺失模块。运行时导入已由执行验证通过。
|
||||||
|
|
||||||
|
"""生成迷你年表与人物索引(随成品入库的两种辅助文件)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dfannals import legends as lg
|
||||||
|
from dfannals import score
|
||||||
|
from dfannals.legends import Event, World
|
||||||
|
|
||||||
|
# 评分标准统一在 dfannals/score.py,年表与切章共用同一套。
|
||||||
|
significance = score.significance
|
||||||
|
category = score.category
|
||||||
|
|
||||||
|
|
||||||
|
def event_line(world: World, e: Event) -> str:
|
||||||
|
"""把一条事件压成一行可读文字。"""
|
||||||
|
chunks: list[str] = [e.type]
|
||||||
|
for tag, kind in lg.ID_FIELDS.items():
|
||||||
|
for i in e.ids(tag):
|
||||||
|
if kind == "figure":
|
||||||
|
name = world.figure_name(i)
|
||||||
|
elif kind == "site":
|
||||||
|
name = world.site_name(i)
|
||||||
|
elif kind == "entity":
|
||||||
|
name = world.entity_name(i)
|
||||||
|
else:
|
||||||
|
name = ""
|
||||||
|
if name:
|
||||||
|
chunks.append(f"{tag}={name}")
|
||||||
|
return f"- **{e.year}** [{category(e)}] " + " · ".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def build_timeline(world: World, min_score: int = 70, per_decade: int = 14) -> str:
|
||||||
|
"""按十年分组的重要事件年表。"""
|
||||||
|
prom = score.prominence(world)
|
||||||
|
lo, hi = world.event_years()
|
||||||
|
important = [e for e in world.events if significance(e, prom) >= min_score]
|
||||||
|
buckets: dict[int, list[Event]] = {}
|
||||||
|
for e in important:
|
||||||
|
buckets.setdefault((e.year // 10) * 10, []).append(e)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"# {world.name} · 年表",
|
||||||
|
"",
|
||||||
|
f"- 历史跨度:{lo}–{hi} 年" if hi else "- 历史跨度:未知",
|
||||||
|
f"- 事件总数:{len(world.events)},其中重要事件 {len(important)}",
|
||||||
|
f"- 历史人物 {len(world.figures)} 位 · 文明与组织 {len(world.entities)} 个 · 地点 {len(world.sites)} 处",
|
||||||
|
"",
|
||||||
|
"> 本表由 legends 导出数据自动生成,只收录叙事权重较高的事件;"
|
||||||
|
"完整数据留在本地,不入库。",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for decade in sorted(buckets):
|
||||||
|
evs = sorted(buckets[decade], key=lambda e: (e.year, e.seconds72, e.id))
|
||||||
|
shown = sorted(evs, key=lambda e: -significance(e, prom))[:per_decade]
|
||||||
|
shown.sort(key=lambda e: (e.year, e.seconds72, e.id))
|
||||||
|
lines.append(f"## {decade}s")
|
||||||
|
lines.append("")
|
||||||
|
lines.extend(event_line(world, e) for e in shown)
|
||||||
|
if len(evs) > len(shown):
|
||||||
|
lines.append(f"- …另有 {len(evs) - len(shown)} 条次要事件未列")
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def build_figures(world: World, top: int = 120) -> str:
|
||||||
|
"""人物索引:按参与事件数排序,给出身份与生卒。"""
|
||||||
|
involvement: Counter[int] = Counter()
|
||||||
|
for e in world.events:
|
||||||
|
seen: set[int] = set()
|
||||||
|
for tag, kind in lg.ID_FIELDS.items():
|
||||||
|
if kind != "figure":
|
||||||
|
continue
|
||||||
|
for i in e.ids(tag):
|
||||||
|
seen.add(i)
|
||||||
|
involvement.update(seen)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"# {world.name} · 人物索引",
|
||||||
|
"",
|
||||||
|
f"共 {len(world.figures)} 位历史人物,按在史料中出现的次数排序(前 {top} 位)。",
|
||||||
|
"",
|
||||||
|
"| 人物 | 种族 | 生卒 | 职业 | 事件数 |",
|
||||||
|
"|---|---|---|---|---|",
|
||||||
|
]
|
||||||
|
ranked = sorted(world.figures.values(), key=lambda f: -involvement.get(f.id, 0))
|
||||||
|
for fig in ranked[:top]:
|
||||||
|
name = lg.pretty(fig.name) or f"HF#{fig.id}"
|
||||||
|
prof = fig.profession or "—"
|
||||||
|
lines.append(
|
||||||
|
f"| {name} | {fig.race or '—'} | {fig.alive_span} | {prof} | {involvement.get(fig.id, 0)} |"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def write_outputs(world: World, out_dir: Path, min_score: int = 40) -> list[Path]:
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
timeline = out_dir / "timeline.md"
|
||||||
|
figures = out_dir / "figures.md"
|
||||||
|
timeline.write_text(build_timeline(world, min_score=min_score), encoding="utf-8")
|
||||||
|
figures.write_text(build_figures(world), encoding="utf-8")
|
||||||
|
return [timeline, figures]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 第一章:黎明之地的第一场火
|
||||||
|
|
||||||
|
我翻开 Mon Sagus 的档案第一卷,纸边还卷着霉味。The Plane of Dawn——他们管它叫黎明之地。可黎明前最黑的那一会儿,先来的是刀。
|
||||||
|
|
||||||
|
元年,Nutssound 这个名字第一次出现,就带了三件事:Yemi Deermoths 死了,Thelire Echomothers 死了,The Tired Growls 的聚落被 The Washed Terrors 洗劫。顺序是死亡在前,劫掠在后?还是劫掠在前,死亡在后?档案只给了年份,没给月日。我只知道这两个精灵死在 Nutssound,别的什么都没留下。两个名字被涂在死亡册上,旁边没有生平。
|
||||||
|
|
||||||
|
The Washed Terrors 是那场火的主凶。他们冲进 Nutssound,把 The Tired Growls 的仓库翻了个底朝天,又顺手在城外跟守军打了一场野战。那场野战被单独记了一笔:attacker 是 The Washed Terrors,defender 是 The Tired Growls。听上去像是劫掠之后,守军追出来,又被打了一顿。我不在场,但想想也不会太体面。
|
||||||
|
|
||||||
|
同一年,The Faithful Scourge 和 The Lovely Avalanche 也打了一场。这场仗跟 Nutssound 没什么关系,只是同一卷里夹着,像隔着河看对岸烧房子。两边都没占到便宜,否则后来也不会急吼吼地签和约。
|
||||||
|
|
||||||
|
真正让我背脊发凉的是 Robustbolted。这条记录只有一句:某个历史人物摧毁了 Robustbolted。没留下名字,只挂着 The Sunken Corridor 和 The Stake Of Cities 两个名号,一个像是地契上的所属,一个像是城头上的守军。行凶者是谁?档案里是一片空白。史官只记结果,不记凶手。我翻遍了卷宗,Robustbolted 的毁灭者像水渗进沙里,一点痕迹都没有。
|
||||||
|
|
||||||
|
第二年,Nutssound 又添了两个名字:Vema Seedanguished 和 Masami Nightbeak。两位精灵,同样死在 Nutssound。我猜他们不是第一年那场劫掠的幸存者,就是后来被派去善后的人。The Washed Terrors 或许没走远,或许是别的势力趁火打劫。档案没写他们怎么死的,只写他们死在那。Nutssound 这四个精灵——Yemi、Thelire、Vema、Masami——像钉在柱子上的四根钉子,把这座聚落的两年钉死在流血里。
|
||||||
|
|
||||||
|
然后,突然就安静了。
|
||||||
|
|
||||||
|
第三年,和约。第三年,一个实体联盟正式缔结。第三年,又一份和约。第四年,再一份和约。三年之内签了三次和约,还结成联盟。我怀疑那些使节是不是在赶场子,从这张谈判桌跑到那张谈判桌,墨迹还没干就互相握手。战争打到第三年,各方都觉得该歇了,于是歇了。
|
||||||
|
|
||||||
|
只是 The Tired Growls 那边未必咽得下这口气:Nutssound 被血洗,四个精灵死在自家地盘,转头跟 The Washed Terrors 坐进同一间屋子签和约。这和平来得太便宜了。档案里没有 The Tired Growls 的抗议记录,也没有复仇的记载。但他们没死光,没死光就有账可算。
|
||||||
|
|
||||||
|
Robustbolted 的废墟还摊在那儿,凶手无名。The Stake Of Cities 没等到说法,The Sunken Corridor 也没再露头。两个名字挂在同一页上,像隔着废墟互相瞪眼。这事儿像个没盖的井口,谁路过都得往里看一眼。
|
||||||
|
|
||||||
|
我把这卷合上,封皮写着“1–9年”。九年的档案,前两年血流成河,后七年一纸和平。黎明之地,哼。
|
||||||
|
|
||||||
|
---
|
||||||
|
*史料来源:legends 导出,1–2年 Nutssound 四位历史人物死亡记录,1年 Nutssound 被劫掠及两场野战,1年 Robustbolted 被毁,3–4年三次和约与一次联盟建立。*
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Mon Sagus · 人物索引
|
||||||
|
|
||||||
|
共 41924 位历史人物,按在史料中出现的次数排序(前 120 位)。
|
||||||
|
|
||||||
|
| 人物 | 种族 | 生卒 | 职业 | 事件数 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Stobsa Boilpukes The Most | HYDRA | 生卒不详 | — | 191 |
|
||||||
|
| Raz Scorchedlanced The Hardy | COLOSSUS_BRONZE | 生卒不详 | — | 171 |
|
||||||
|
| Langgud Dawncats The Washed Seed | TITAN_3 | ?–204 | — | 168 |
|
||||||
|
| Zogast Tufttop The Decisive Key | HYDRA | 生卒不详 | — | 161 |
|
||||||
|
| Inefa Dawnorange The Snarling Puke | TITAN_7 | 生卒不详 | — | 145 |
|
||||||
|
| Gaspa Begunhog The Clean | HYDRA | 88–在世/不详 | — | 144 |
|
||||||
|
| Oka Tinglistened The Howl Of Prestige | CYCLOPS | 生卒不详 | — | 127 |
|
||||||
|
| Od Mucusrock The Gleams Of Grease | HYDRA | 生卒不详 | — | 122 |
|
||||||
|
| Ngumost Apesstrap The Mountainous | BIRD_ROC | 生卒不详 | — | 121 |
|
||||||
|
| Yare Sistersubmerge | ELF | 4–在世/不详 | — | 115 |
|
||||||
|
| Otust Wondereddriven The Beloved | ETTIN | 生卒不详 | — | 113 |
|
||||||
|
| Ostzu Granitewanders The Flicker Of Intensity | CYCLOPS | 生卒不详 | — | 106 |
|
||||||
|
| Are Flaxrose The Bearded | TITAN_2 | 生卒不详 | — | 103 |
|
||||||
|
| Quathari Holyglen | ELF | 0–在世/不详 | — | 98 |
|
||||||
|
| Urer Sunsshore The Hairy | TITAN_9 | 生卒不详 | — | 97 |
|
||||||
|
| Irene Ramlarge The Strong | BIRD_ROC | 生卒不详 | — | 95 |
|
||||||
|
| Thaci Authoredferns | ELF | 77–在世/不详 | — | 87 |
|
||||||
|
| Lapama Bittentwig | ELF | 生卒不详 | — | 87 |
|
||||||
|
| Asada Darkbalances The Ordered Sewer | YETI | ?–224 | — | 86 |
|
||||||
|
| Kifino Latewings | ELF | 79–在世/不详 | — | 84 |
|
||||||
|
| Shigos Saffronjumps The Teal Berries | TITAN_6 | 生卒不详 | — | 83 |
|
||||||
|
| Avafi Rootskull | ELF | 10–在世/不详 | — | 83 |
|
||||||
|
| Ova Packvessels | ELF | 114–在世/不详 | — | 83 |
|
||||||
|
| Iru Sensedclam | ELF | 99–在世/不详 | — | 81 |
|
||||||
|
| Genam Searingdwell | HUMAN | 129–193 | — | 81 |
|
||||||
|
| Rine Clenchdrinkers The Legend Of Wilts | YETI | ?–232 | — | 80 |
|
||||||
|
| Thalu Shellbud | ELF | 43–在世/不详 | — | 80 |
|
||||||
|
| Nefola Wheeleagle | ELF | 生卒不详 | — | 78 |
|
||||||
|
| Domas Meetingtongs | DWARF | 103–在世/不详 | — | 78 |
|
||||||
|
| Langgud Tradebears The Eviscerated Chill Of Pregnancies | YETI | 生卒不详 | — | 77 |
|
||||||
|
| Erith Orbrelieve | DWARF | 71–222 | — | 77 |
|
||||||
|
| Evala Dalemoisten | ELF | 生卒不详 | — | 76 |
|
||||||
|
| Thone Mawpunch | ELF | 58–在世/不详 | — | 76 |
|
||||||
|
| Ilime Lobsterfancied | ELF | 110–在世/不详 | — | 76 |
|
||||||
|
| Kiros Cloudstones The Divine Delights | YETI | ?–219 | — | 74 |
|
||||||
|
| Quathari Skunkdive | ELF | 65–在世/不详 | — | 74 |
|
||||||
|
| Kivish Boltpartner | DWARF | 74–233 | — | 74 |
|
||||||
|
| Nemile Worldglisten | ELF | 79–在世/不详 | — | 74 |
|
||||||
|
| Lomifo Oakfill The Severe | YETI | ?–242 | — | 73 |
|
||||||
|
| Enkos Apepulp The Plain | HYDRA | ?–206 | — | 72 |
|
||||||
|
| Caraca Owlstrap | ELF | 73–在世/不详 | — | 72 |
|
||||||
|
| Lali Woodenpraise | ELF | 79–在世/不详 | — | 72 |
|
||||||
|
| Irene Biteglade | ELF | 生卒不详 | — | 72 |
|
||||||
|
| Ral Fastenhatchets | DWARF | 164–在世/不详 | — | 71 |
|
||||||
|
| Imimi Shorelines | ELF | 75–在世/不详 | — | 70 |
|
||||||
|
| Amala Leaptested | ELF | 72–在世/不详 | — | 69 |
|
||||||
|
| Salo Metblaze | ELF | 84–在世/不详 | — | 69 |
|
||||||
|
| Efami Churchcoasts | ELF | 121–在世/不详 | — | 69 |
|
||||||
|
| Themiyi Pinessplattered | ELF | 72–在世/不详 | — | 68 |
|
||||||
|
| Lolama Silkankle | ELF | 26–在世/不详 | — | 67 |
|
||||||
|
| Tobul Robustzenith The Mint Yell | GIANT | 生卒不详 | — | 66 |
|
||||||
|
| Yemi Goodclouds | ELF | 7–在世/不详 | — | 66 |
|
||||||
|
| Durad Meadowjumped The Paddle Of Fords | TITAN_1 | 生卒不详 | — | 65 |
|
||||||
|
| Lek Willspecial The Meteor Of Fame | GIANT | 生卒不详 | — | 64 |
|
||||||
|
| Darala Sportstrengths The Prestigious | GIANT | 生卒不详 | — | 64 |
|
||||||
|
| On Rocksfades The Mellow Mother | TROLL | ?–174 | — | 64 |
|
||||||
|
| Us Systemsanctum The Delightful Fragment Of Celebrations | YETI | 24–207 | — | 64 |
|
||||||
|
| Stinthad Glazedallies | DWARF | 74–225 | — | 64 |
|
||||||
|
| Thiliri Listenblaze | ELF | 85–在世/不详 | — | 64 |
|
||||||
|
| Cacame Birdcombats | ELF | 15–在世/不详 | — | 64 |
|
||||||
|
| Reg Runrings | DWARF | 50–220 | — | 63 |
|
||||||
|
| Imimi Skinmirrored | ELF | 95–在世/不详 | — | 63 |
|
||||||
|
| Cata Stylescarlet | ELF | 109–在世/不详 | — | 63 |
|
||||||
|
| Tirist Helmedtowns | DWARF | 110–在世/不详 | — | 63 |
|
||||||
|
| Eletha Orangeseason | ELF | 生卒不详 | — | 63 |
|
||||||
|
| Atera Earthenwiped | ELF | 152–在世/不详 | — | 63 |
|
||||||
|
| Zur Pucewanders The Blue Canyon | CYCLOPS | 生卒不详 | — | 62 |
|
||||||
|
| Itur Bristleknight The Sparkle Of Smiles | ETTIN | 生卒不详 | — | 62 |
|
||||||
|
| Sol Shellrisen The First | TROLL | ?–180 | — | 62 |
|
||||||
|
| Urdim Joinedlashed | DWARF | 98–在世/不详 | — | 62 |
|
||||||
|
| Lida Birdtraded | ELF | 124–在世/不详 | — | 62 |
|
||||||
|
| Cequova Lacycolored | ELF | 130–在世/不详 | — | 62 |
|
||||||
|
| Arane Coastalglens | ELF | 142–在世/不详 | — | 62 |
|
||||||
|
| Emene Torchtops The Adventurous | CYCLOPS | 生卒不详 | — | 61 |
|
||||||
|
| Alisa Embracedlovers | ELF | 28–在世/不详 | — | 61 |
|
||||||
|
| Mifava Fuchsiaown | ELF | 124–在世/不详 | — | 61 |
|
||||||
|
| Xuspgas Kissedhates | GOBLIN | 59–在世/不详 | — | 60 |
|
||||||
|
| Asob Machinestill | DWARF | 78–238 | — | 60 |
|
||||||
|
| Amayi Clearmusics | ELF | 80–在世/不详 | — | 60 |
|
||||||
|
| Themiyi Dreamsadored | ELF | 85–在世/不详 | — | 60 |
|
||||||
|
| Famime Sweetelders | ELF | 124–在世/不详 | — | 60 |
|
||||||
|
| Nemile Crazyglitter | ELF | 生卒不详 | — | 60 |
|
||||||
|
| Ana Waxedjade | ELF | 41–在世/不详 | — | 59 |
|
||||||
|
| Nemo Leopardanguished | ELF | 生卒不详 | — | 59 |
|
||||||
|
| Vema Wasprooters | ELF | 5–在世/不详 | — | 58 |
|
||||||
|
| Ova Guardedsuitor | ELF | 63–在世/不详 | — | 58 |
|
||||||
|
| Ngokang Singedoomed | GOBLIN | 74–在世/不详 | — | 58 |
|
||||||
|
| Kubuk Mountainpaddle | DWARF | 92–在世/不详 | — | 58 |
|
||||||
|
| Fikod Humidpaddled | DWARF | 92–在世/不详 | — | 58 |
|
||||||
|
| Zutthan Anguishink | DWARF | 98–在世/不详 | — | 58 |
|
||||||
|
| Rofa Sensecherished | ELF | 105–在世/不详 | — | 58 |
|
||||||
|
| Mimale Incenseflower | ELF | 107–在世/不详 | — | 58 |
|
||||||
|
| Caraca Hairdeer | ELF | 116–在世/不详 | — | 58 |
|
||||||
|
| Covema Questedwings | ELF | 生卒不详 | — | 58 |
|
||||||
|
| Rifa Bandedsatins | ELF | 121–在世/不详 | — | 58 |
|
||||||
|
| Tira Divedmerges | ELF | 153–在世/不详 | — | 58 |
|
||||||
|
| Ngebzo Pagesteals | GOBLIN | 64–在世/不详 | — | 57 |
|
||||||
|
| Apasi Howlriddled | ELF | 75–在世/不详 | — | 57 |
|
||||||
|
| Mimale Seaclinch | ELF | 111–在世/不详 | — | 57 |
|
||||||
|
| Ile Smiledpetal | ELF | 120–在世/不详 | — | 57 |
|
||||||
|
| Ave Naturaldances | ELF | 122–在世/不详 | — | 57 |
|
||||||
|
| Thiliri Hoofwinds | ELF | 生卒不详 | — | 57 |
|
||||||
|
| Snang Yawninghates | GOBLIN | 141–193 | — | 57 |
|
||||||
|
| Olzul Masterbrains The Remarkable | FORGOTTEN_BEAST_76 | 生卒不详 | — | 56 |
|
||||||
|
| Asne Ecrutaken The Emerald Perfection | CYCLOPS | 生卒不详 | — | 56 |
|
||||||
|
| Amxu Hateflashes | GOBLIN | 75–在世/不详 | — | 56 |
|
||||||
|
| Lapama Plungemoths | ELF | 118–在世/不详 | — | 56 |
|
||||||
|
| Rathe Grottovises | ELF | 45–在世/不详 | — | 56 |
|
||||||
|
| Ula Thunderedmurk | ELF | 133–在世/不详 | — | 56 |
|
||||||
|
| Kogan Herooar | DWARF | 140–在世/不详 | — | 56 |
|
||||||
|
| Guspu Frillyknots | EAGLE_MAN | 151–246 | — | 56 |
|
||||||
|
| Dadu Twinklingflashed The Contested | CYCLOPS | 生卒不详 | — | 55 |
|
||||||
|
| Geshud Roughentered | DWARF | 35–197 | — | 55 |
|
||||||
|
| Arile Pristinefuture | ELF | 63–在世/不详 | — | 55 |
|
||||||
|
| Ilathe Frenzydeer | ELF | 70–在世/不详 | — | 55 |
|
||||||
|
| Nine Sweetnessholds | ELF | 70–在世/不详 | — | 55 |
|
||||||
|
| Urvad Basissalve | DWARF | 110–在世/不详 | — | 55 |
|
||||||
|
| Yare Snarledbirth | ELF | 95–在世/不详 | — | 55 |
|
||||||
|
| Itha Sportsongs | ELF | 生卒不详 | — | 55 |
|
||||||
|
| Ducim Pickices | DWARF | 136–在世/不详 | — | 55 |
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
# Mon Sagus · 年表
|
||||||
|
|
||||||
|
- 历史跨度:1–250 年
|
||||||
|
- 事件总数:403853,其中重要事件 46431
|
||||||
|
- 历史人物 41924 位 · 文明与组织 3186 个 · 地点 1453 处
|
||||||
|
|
||||||
|
> 本表由 legends 导出数据自动生成,只收录叙事权重较高的事件;完整数据留在本地,不入库。
|
||||||
|
|
||||||
|
## 0s
|
||||||
|
|
||||||
|
- **1** [生死] hf died · hfid=Yemi Deermoths · site_id=Nutssound
|
||||||
|
- **1** [生死] hf died · hfid=Thelire Echomothers · site_id=Nutssound
|
||||||
|
- **2** [生死] hf died · hfid=Vema Seedanguished · site_id=Nutssound
|
||||||
|
- **2** [生死] hf died · hfid=Masami Nightbeak · site_id=Nutssound
|
||||||
|
- **2** [生死] hf died · hfid=Athe Saffronboot · site_id=Nutssound
|
||||||
|
- **2** [生死] hf died · hfid=Amxu Flydreamy · site_id=Scalysatins
|
||||||
|
- **2** [生死] hf died · hfid=Sazir Polishedlances · site_id=Amazedmirrored
|
||||||
|
- **2** [生死] hf died · hfid=Bembul Silverygroove · site_id=Amazedmirrored
|
||||||
|
- **2** [生死] hf died · hfid=Urvad Rayspaints · site_id=Amazedmirrored
|
||||||
|
- **2** [生死] hf died · hfid=Goden Yawnedcrafts · site_id=Amazedmirrored
|
||||||
|
- **2** [生死] hf died · hfid=Rovod Abbeybolt · site_id=Amazedmirrored
|
||||||
|
- **2** [生死] hf died · hfid=Zuglar Crowdedmine · site_id=Amazedmirrored
|
||||||
|
- **2** [生死] hf died · hfid=Urvad Bowelguild · site_id=Amazedmirrored
|
||||||
|
- **2** [生死] hf died · hfid=Domas Brownlashed · site_id=Amazedmirrored
|
||||||
|
- …另有 324 条次要事件未列
|
||||||
|
|
||||||
|
## 10s
|
||||||
|
|
||||||
|
- **10** [生死] hf died · hfid=Uthra Hardyrake · site_id=Pathlance
|
||||||
|
- **11** [生死] hf died · hfid=Zolak Werecoil · site_id=Stretchedvice
|
||||||
|
- **11** [生死] hf died · hfid=Utes Lieshut · site_id=Stretchedvice
|
||||||
|
- **11** [生死] hf died · hfid=Hiner Plaitedangels · site_id=Praisepot
|
||||||
|
- **12** [生死] hf died · hfid=Ubbul Crewsold
|
||||||
|
- **12** [生死] hf died · hfid=Coce Willglimmered
|
||||||
|
- **12** [生死] hf died · hfid=Busbel Brushedhomely · site_id=Praisepot
|
||||||
|
- **13** [生死] hf died · hfid=Umer Umbrawilted
|
||||||
|
- **13** [生死] hf died · hfid=Afedi Crackworks
|
||||||
|
- **14** [生死] hf died · hfid=Aril Glowingsecrets
|
||||||
|
- **15** [生死] hf died · hfid=Kamca Pantedunion
|
||||||
|
- **16** [生死] hf died · hfid=Sinaru Rareclapped
|
||||||
|
- **16** [生死] hf died · hfid=Lulo Yawningburial
|
||||||
|
- **16** [生死] hf died · hfid=Rinmol Scornlocks
|
||||||
|
- …另有 319 条次要事件未列
|
||||||
|
|
||||||
|
## 20s
|
||||||
|
|
||||||
|
- **20** [生死] hf died · hfid=Fikod Frightfulcrabs
|
||||||
|
- **20** [生死] hf died · hfid=Sapi Agedanvils
|
||||||
|
- **20** [生死] hf died · hfid=Osnon Gazelulled
|
||||||
|
- **21** [生死] hf died · hfid=Stom Tattoothrones
|
||||||
|
- **21** [生死] hf died · hfid=Dirgepractices
|
||||||
|
- **21** [生死] hf died · hfid=Sanera Sullencombats
|
||||||
|
- **21** [生死] hf died · hfid=Shedim Glowspread
|
||||||
|
- **21** [生死] hf died · hfid=Fira Twinkledgullies · site_id=Naturalpoets
|
||||||
|
- **21** [生死] hf died · hfid=Themiyi Ivyransacks · site_id=Naturalpoets
|
||||||
|
- **21** [生死] hf died · hfid=Lema Raptoropened · site_id=Naturalpoets
|
||||||
|
- **21** [生死] hf died · hfid=Imimi Bornmirror · site_id=Naturalpoets
|
||||||
|
- **21** [生死] hf died · hfid=Fewetha Hazeembraced · site_id=Naturalpoets
|
||||||
|
- **22** [生死] hf died · hfid=Ipana Cleanedtreaties
|
||||||
|
- **22** [生死] hf died · hfid=Heju Soarburied
|
||||||
|
- …另有 421 条次要事件未列
|
||||||
|
|
||||||
|
## 30s
|
||||||
|
|
||||||
|
- **30** [生死] hf died · hfid=Nganiz Flagroars
|
||||||
|
- **30** [生死] hf died · hfid=Mubun Slowsprayed
|
||||||
|
- **30** [生死] hf died · hfid=Mosus Parchedsyrup · site_id=Wallwhipped
|
||||||
|
- **30** [生死] hf died · hfid=Olon Cobaltkin · site_id=Wallwhipped
|
||||||
|
- **30** [生死] hf died · hfid=Sigun Elderposts · site_id=Wallwhipped
|
||||||
|
- **30** [生死] hf died · hfid=Lor Crestedrock · site_id=Wallwhipped
|
||||||
|
- **30** [生死] hf died · hfid=Zuglar Shovemine · site_id=Wallwhipped
|
||||||
|
- **30** [生死] hf died · hfid=Logem Soakedcave · site_id=Wallwhipped
|
||||||
|
- **30** [生死] hf died · hfid=Amost Minddoor · site_id=Wallwhipped
|
||||||
|
- **30** [生死] hf died · hfid=Bomrek Furnacedagger · site_id=Wallwhipped
|
||||||
|
- **30** [生死] hf died · hfid=Sodel Woundfriends · site_id=Wallwhipped
|
||||||
|
- **30** [生死] hf died · hfid=Nako Dungeonrasp · site_id=Wallwhipped
|
||||||
|
- **30** [生死] hf died · hfid=Atis Allyblockade · site_id=Wallwhipped
|
||||||
|
- **30** [生死] hf died · hfid=Urist Craftkissed · site_id=Wallwhipped
|
||||||
|
- …另有 488 条次要事件未列
|
||||||
|
|
||||||
|
## 40s
|
||||||
|
|
||||||
|
- **40** [生死] hf died · hfid=Belom Wardmasters
|
||||||
|
- **40** [生死] hf died · hfid=Ane Godlystable
|
||||||
|
- **40** [生死] hf died · hfid=Obraz Urgespattered
|
||||||
|
- **40** [生死] hf died · hfid=Justras Oceancinders
|
||||||
|
- **40** [生死] hf died · hfid=Evhol Scooprelieves
|
||||||
|
- **40** [生死] hf died · hfid=Fath Reasondyes
|
||||||
|
- **40** [生死] hf died · hfid=Logem Zealoil
|
||||||
|
- **41** [生死] hf died · hfid=Sasm Dissolveprinces
|
||||||
|
- **41** [生死] hf died · hfid=Ostusm Oakransacked
|
||||||
|
- **41** [生死] hf died · hfid=Buzong Bindnet
|
||||||
|
- **41** [生死] hf died · hfid=Thifi Troubleclearing
|
||||||
|
- **41** [生死] hf died · hfid=Espe Lancemusty
|
||||||
|
- **41** [生死] hf died · hfid=Uzong Copperromanced
|
||||||
|
- **41** [生死] hf died · hfid=Ngustrek Searchedstrangles
|
||||||
|
- …另有 575 条次要事件未列
|
||||||
|
|
||||||
|
## 50s
|
||||||
|
|
||||||
|
- **50** [生死] hf died · hfid=Ronira Tirecalm
|
||||||
|
- **50** [生死] hf died · hfid=Stroxzak Budtomes
|
||||||
|
- **50** [生死] hf died · hfid=Sustgo Languishgifted
|
||||||
|
- **51** [生死] hf died · hfid=Ilum Eastdoused
|
||||||
|
- **51** [生死] hf died · hfid=Losta Backcleansed
|
||||||
|
- **51** [生死] hf died · hfid=Sanera Fangpaints · site_id=Talkedraptor
|
||||||
|
- **51** [生死] hf died · hfid=Bosa Silentevil · site_id=Talkedraptor
|
||||||
|
- **51** [生死] hf died · hfid=Zolak Leakydungeon · site_id=Talkedraptor
|
||||||
|
- **51** [生死] hf died · hfid=Olngo Eviltrenches · site_id=Talkedraptor
|
||||||
|
- **51** [生死] hf died · hfid=Oda Divewealths · site_id=Talkedraptor
|
||||||
|
- **51** [生死] hf died · hfid=Ale Rosybride · site_id=Talkedraptor
|
||||||
|
- **51** [生死] hf died · hfid=Stozu Wardseduces · site_id=Talkedraptor
|
||||||
|
- **51** [生死] hf died · hfid=Tode Crushedwicked · site_id=Talkedraptor
|
||||||
|
- **51** [生死] hf died · hfid=Yare Bowmother · site_id=Talkedraptor
|
||||||
|
- …另有 781 条次要事件未列
|
||||||
|
|
||||||
|
## 60s
|
||||||
|
|
||||||
|
- **60** [生死] hf died · hfid=Mete Wetplay
|
||||||
|
- **60** [生死] hf died · hfid=Laba Hoistedbeasts
|
||||||
|
- **60** [生死] hf died · hfid=Nicana Canyonripper
|
||||||
|
- **60** [生死] hf died · hfid=Negatedbent
|
||||||
|
- **60** [生死] hf died · hfid=Imiwa Berrysavage
|
||||||
|
- **61** [生死] hf died · hfid=Zes Faithfulscalded
|
||||||
|
- **62** [生死] hf died · hfid=Dasar Rackpacked
|
||||||
|
- **62** [生死] hf died · hfid=Ayara Rumoredjumps
|
||||||
|
- **62** [生死] hf died · hfid=Lor Rawworks
|
||||||
|
- **62** [生死] hf died · hfid=Onul Entrancestandards
|
||||||
|
- **63** [生死] hf died · hfid=Ongslun Headrespect
|
||||||
|
- **63** [生死] hf died · hfid=Limili Conjurerulers
|
||||||
|
- **64** [生死] hf died · hfid=Jepum Murderedsiege
|
||||||
|
- **64** [生死] hf died · hfid=Thebil Moralfails
|
||||||
|
- …另有 938 条次要事件未列
|
||||||
|
|
||||||
|
## 70s
|
||||||
|
|
||||||
|
- **70** [生死] hf died · hfid=Ora Dwindlingbowed
|
||||||
|
- **70** [生死] hf died · hfid=Xoslo Tundrathunder
|
||||||
|
- **70** [生死] hf died · hfid=Nod Gloweddrip
|
||||||
|
- **70** [生死] hf died · hfid=Pesor Castlecharred
|
||||||
|
- **70** [生死] hf died · hfid=Stozu Witchyawned · site_id=Hauntblack
|
||||||
|
- **70** [生死] hf died · hfid=Azstrog Scorpiongilt · site_id=Releaselabored
|
||||||
|
- **70** [生死] hf died · hfid=Ngokang Terrorbears · site_id=Releaselabored
|
||||||
|
- **70** [生死] hf died · hfid=Melbil Cloistercovers · site_id=Releaselabored
|
||||||
|
- **70** [生死] hf died · hfid=Ngoso Lostscorpions · site_id=Releaselabored
|
||||||
|
- **70** [生死] hf died · hfid=Ngokang Phantommonsters · site_id=Releaselabored
|
||||||
|
- **70** [生死] hf died · hfid=Asmel Ringmeshes · site_id=Releaselabored
|
||||||
|
- **70** [生死] hf died · hfid=Ago Entrymonster · site_id=Releaselabored
|
||||||
|
- **70** [生死] hf died · hfid=Mosus Glovedbraved · site_id=Releaselabored
|
||||||
|
- **70** [生死] hf died · hfid=Snodub Savantsteals · site_id=Releaselabored
|
||||||
|
- …另有 1209 条次要事件未列
|
||||||
|
|
||||||
|
## 80s
|
||||||
|
|
||||||
|
- **80** [生死] hf died · hfid=Siniti Magicfinds
|
||||||
|
- **80** [生死] hf died · hfid=Amal Dawnsplit
|
||||||
|
- **80** [生死] hf died · hfid=Lun Smileparches
|
||||||
|
- **80** [生死] hf died · hfid=Cenopi Bandshowered
|
||||||
|
- **80** [生死] hf died · hfid=Ubstusp Dabblerscreamed
|
||||||
|
- **80** [生死] hf died · hfid=Anu Knighttrough
|
||||||
|
- **80** [生死] hf died · hfid=Irol Tattoowhispered
|
||||||
|
- **80** [生死] hf died · hfid=Thruni Wanderswamp
|
||||||
|
- **81** [生死] hf died · hfid=Luslem Presentace
|
||||||
|
- **81** [生死] hf died · hfid=Turot Distanceskinny
|
||||||
|
- **81** [生死] hf died · hfid=Buslon Tiredholy · site_id=Weakenedbeaches
|
||||||
|
- **81** [生死] hf died · hfid=Shem Cobalttowns · site_id=Weakenedbeaches
|
||||||
|
- **81** [生死] hf died · hfid=Themiyi Lakescalds · site_id=Weakenedbeaches
|
||||||
|
- **81** [生死] hf died · hfid=Futace Packhowls · site_id=Weakenedbeaches
|
||||||
|
- …另有 1281 条次要事件未列
|
||||||
|
|
||||||
|
## 90s
|
||||||
|
|
||||||
|
- **90** [生死] hf died · hfid=Rikkir Fedfold
|
||||||
|
- **90** [生死] hf died · hfid=Angos Witheredentrance
|
||||||
|
- **90** [生死] hf died · hfid=Rodem Matchgrieved
|
||||||
|
- **90** [生死] hf died · hfid=Doce Finddives
|
||||||
|
- **90** [生死] hf died · hfid=Ongu Sensecage · site_id=Galeclods
|
||||||
|
- **90** [生死] hf died · hfid=Erush Boardedsabres · site_id=Galeclods
|
||||||
|
- **90** [生死] hf died · hfid=Mones Royalnestles · site_id=Galeclods
|
||||||
|
- **90** [生死] hf died · hfid=Bathru Cookplots · site_id=Galeclods
|
||||||
|
- **90** [生死] hf died · hfid=Rith Boltedquested · site_id=Galeclods
|
||||||
|
- **90** [生死] hf died · hfid=Zin Empiresrevered · site_id=Galeclods
|
||||||
|
- **90** [生死] hf died · hfid=Age Springspaint · site_id=Galeclods
|
||||||
|
- **90** [生死] hf died · hfid=Oxut Braveflax · site_id=Galeclods
|
||||||
|
- **90** [生死] hf died · hfid=Emmun Roastsmile · site_id=Galeclods
|
||||||
|
- **90** [生死] hf died · hfid=Muthe Lakesdabbles · site_id=Galeclods
|
||||||
|
- …另有 1275 条次要事件未列
|
||||||
|
|
||||||
|
## 100s
|
||||||
|
|
||||||
|
- **100** [生死] hf died · hfid=Omshit Vanishedmarket
|
||||||
|
- **100** [生死] hf died · hfid=Tobul Submergestandard
|
||||||
|
- **100** [生死] hf died · hfid=Athri Clashedzeals · site_id=Strapsong
|
||||||
|
- **100** [生死] hf died · hfid=Engdul Satinyunited · site_id=Brushpleat
|
||||||
|
- **100** [生死] hf died · hfid=Bugi Trimshower · site_id=Brushpleat
|
||||||
|
- **100** [生死] hf died · hfid=Quazo Blushcontrols · site_id=Brushpleat
|
||||||
|
- **100** [生死] hf died · hfid=Shebi Ramshocks · site_id=Brushpleat
|
||||||
|
- **100** [生死] hf died · hfid=Citu Mothgladness · site_id=Brushpleat
|
||||||
|
- **100** [生死] hf died · hfid=Amec Trumpetland · site_id=Brushpleat
|
||||||
|
- **100** [生死] hf died · hfid=Bora Shortfound · site_id=Brushpleat
|
||||||
|
- **100** [生死] hf died · hfid=Orda Certainsells · site_id=Brushpleat
|
||||||
|
- **100** [生死] hf died · hfid=Nako Growllies · site_id=Brushpleat
|
||||||
|
- **100** [生死] hf died · hfid=Irka Garishmortals · site_id=Brushpleat
|
||||||
|
- **100** [生死] hf died · hfid=Kad Skullcook · site_id=Brushpleat
|
||||||
|
- …另有 1456 条次要事件未列
|
||||||
|
|
||||||
|
## 110s
|
||||||
|
|
||||||
|
- **110** [生死] hf died · hfid=Alnos Yellsshin
|
||||||
|
- **110** [生死] hf died · hfid=Wulema Neutralwhips
|
||||||
|
- **110** [生死] hf died · hfid=Molcet Thrownchanted
|
||||||
|
- **110** [生死] hf died · hfid=Nekol Lordfear
|
||||||
|
- **110** [生死] hf died · hfid=Lida Hameveiled
|
||||||
|
- **110** [生死] hf died · hfid=Dur Fountainlast
|
||||||
|
- **110** [生死] hf died · hfid=Lelgo Sheensaved · site_id=Dangledthundered
|
||||||
|
- **110** [生死] hf died · hfid=Tosace Toothtufts · site_id=Dangledthundered
|
||||||
|
- **110** [生死] hf died · hfid=Kesp Hoodsound · site_id=Dangledthundered
|
||||||
|
- **110** [生死] hf died · hfid=Lershi Fatechamber · site_id=Dangledthundered
|
||||||
|
- **110** [生死] hf died · hfid=Zega Perfectplate · site_id=Weakenedbeaches
|
||||||
|
- **110** [生死] hf died · hfid=Conu Clutchedworker · site_id=Weakenedbeaches
|
||||||
|
- **110** [生死] hf died · hfid=Kamca Moltenhonors · site_id=Weakenedbeaches
|
||||||
|
- **110** [生死] hf died · hfid=Getak Perplexedarmor · site_id=Weakenedbeaches
|
||||||
|
- …另有 2102 条次要事件未列
|
||||||
|
|
||||||
|
## 120s
|
||||||
|
|
||||||
|
- **120** [生死] hf died · hfid=Dana Knittedbridle
|
||||||
|
- **120** [生死] hf died · hfid=Anoth Dreamarch
|
||||||
|
- **120** [生死] hf died · hfid=Dothan Treatycolor
|
||||||
|
- **120** [生死] hf died · hfid=Sesle Fondledstilled
|
||||||
|
- **120** [生死] hf died · hfid=Sulthu Sellclams
|
||||||
|
- **120** [生死] hf died · hfid=Agen Jeststrangers
|
||||||
|
- **120** [生死] hf died · hfid=Rabin Gleeden
|
||||||
|
- **120** [生死] hf died · hfid=Frudulutrolbus
|
||||||
|
- **120** [生死] hf died · hfid=Perom Polishbreeches · site_id=Strapsong
|
||||||
|
- **120** [生死] hf died · hfid=Kadol Mirrorpost · site_id=Spinestheater
|
||||||
|
- **120** [生死] hf died · hfid=Kubuk Covenroom · site_id=Spinestheater
|
||||||
|
- **120** [生死] hf died · hfid=Edzul Palacedwelled · site_id=Spinestheater
|
||||||
|
- **120** [生死] hf died · hfid=Cog Skinnedhammer · site_id=Spinestheater
|
||||||
|
- **120** [生死] hf died · hfid=Ezum Catchtrade · site_id=Spinestheater
|
||||||
|
- …另有 1973 条次要事件未列
|
||||||
|
|
||||||
|
## 130s
|
||||||
|
|
||||||
|
- **130** [生死] hf died · hfid=Vigorvenoms
|
||||||
|
- **130** [生死] hf died · hfid=Par Streambreached
|
||||||
|
- **130** [生死] hf died · hfid=Ofo Oattrough
|
||||||
|
- **130** [生死] hf died · hfid=Thon Urgeways
|
||||||
|
- **130** [生死] hf died · hfid=Kutsmob Plaitedwicked · site_id=Hatredgrasp
|
||||||
|
- **130** [生死] hf died · hfid=Atu Monsterchilled · site_id=Dreadfondled
|
||||||
|
- **130** [生死] hf died · hfid=Snodub Wickeddashed · site_id=Dreadfondled
|
||||||
|
- **130** [器物] razed structure · site_id=Wallwhipped
|
||||||
|
- **130** [器物] razed structure · site_id=Wallwhipped
|
||||||
|
- **131** [生死] hf died · hfid=Umtha Searedvines
|
||||||
|
- **131** [生死] hf died · hfid=Kammat Stirredspooned
|
||||||
|
- **131** [生死] hf died · hfid=Ilpi Scarredplain
|
||||||
|
- **131** [生死] hf died · hfid=Frofojlaylin
|
||||||
|
- **131** [生死] hf died · hfid=Sareve Playtangles · site_id=Hellshakes
|
||||||
|
- …另有 1978 条次要事件未列
|
||||||
|
|
||||||
|
## 140s
|
||||||
|
|
||||||
|
- **140** [生死] hf died · hfid=Whimswindy
|
||||||
|
- **140** [生死] hf died · hfid=Stral Lovenourished
|
||||||
|
- **140** [生死] hf died · hfid=Dumur Dearthentry
|
||||||
|
- **140** [生死] hf died · hfid=Jepum Moppedgrain
|
||||||
|
- **140** [生死] hf died · hfid=Mastrod Puzzlingskunk
|
||||||
|
- **140** [生死] hf died · hfid=Xugot Hugsphantom
|
||||||
|
- **140** [生死] hf died · hfid=Rithod Paddledshows
|
||||||
|
- **140** [生死] hf died · hfid=Bale Lureplated
|
||||||
|
- **140** [生死] hf died · hfid=Nifih Wingkissed
|
||||||
|
- **140** [生死] hf died · hfid=Anir Dreamdimples
|
||||||
|
- **140** [生死] hf died · hfid=Ngom Staffspider · site_id=Nestleaders
|
||||||
|
- **140** [生死] hf died · hfid=Tise Furylake · site_id=Nestleaders
|
||||||
|
- **141** [生死] hf died · hfid=Manorpears
|
||||||
|
- **141** [生死] hf died · hfid=Sosma Puzzlecurious
|
||||||
|
- …另有 2906 条次要事件未列
|
||||||
|
|
||||||
|
## 150s
|
||||||
|
|
||||||
|
- **150** [生死] hf died · hfid=Styleddim
|
||||||
|
- **150** [生死] hf died · hfid=Rithod Ashinks
|
||||||
|
- **150** [生死] hf died · hfid=Pobe Girdercrests
|
||||||
|
- **150** [生死] hf died · hfid=Rafeb Esteemfigure
|
||||||
|
- **150** [生死] hf died · hfid=Kost Torriddecent
|
||||||
|
- **150** [生死] hf died · hfid=Pak Skirtshanks
|
||||||
|
- **150** [生死] hf died · hfid=Accok Brightnessmobs
|
||||||
|
- **151** [生死] hf died · hfid=Peakbald
|
||||||
|
- **151** [生死] hf died · hfid=Shriveledchained
|
||||||
|
- **151** [生死] hf died · hfid=Stost Trapwild
|
||||||
|
- **151** [生死] hf died · hfid=Tikbo Bearauburn
|
||||||
|
- **151** [生死] hf died · hfid=Esmmi Polishedended
|
||||||
|
- **151** [生死] hf died · hfid=Dastot Searwheels
|
||||||
|
- **152** [生死] hf died · hfid=Frightfulgleamed
|
||||||
|
- …另有 2412 条次要事件未列
|
||||||
|
|
||||||
|
## 160s
|
||||||
|
|
||||||
|
- **160** [生死] hf died · hfid=Dabblertire
|
||||||
|
- **160** [生死] hf died · hfid=Enshal Watchfulspeeches
|
||||||
|
- **160** [生死] hf died · hfid=Pullpaged
|
||||||
|
- **160** [生死] hf died · hfid=Palacecovers
|
||||||
|
- **160** [生死] hf died · hfid=Hushedtrenches
|
||||||
|
- **160** [生死] hf died · hfid=Gifthonors
|
||||||
|
- **160** [生死] hf died · hfid=Smearedmuted
|
||||||
|
- **160** [生死] hf died · hfid=Sula Toeequaled
|
||||||
|
- **160** [生死] hf died · hfid=Imi Obeyedspread
|
||||||
|
- **160** [生死] hf died · hfid=Rel Touchedprice
|
||||||
|
- **160** [生死] hf died · hfid=Shene Noblesocket
|
||||||
|
- **160** [生死] hf died · hfid=Zinga Scholarblaze
|
||||||
|
- **160** [生死] hf died · hfid=Orda Bridledunions
|
||||||
|
- **160** [生死] hf died · hfid=Takayrsnus
|
||||||
|
- …另有 1923 条次要事件未列
|
||||||
|
|
||||||
|
## 170s
|
||||||
|
|
||||||
|
- **170** [生死] hf died · hfid=Trustknows
|
||||||
|
- **170** [生死] hf died · hfid=Clashedtoes
|
||||||
|
- **170** [生死] hf died · hfid=Odi Equalhairs
|
||||||
|
- **170** [生死] hf died · hfid=Rushmurder
|
||||||
|
- **170** [生死] hf died · hfid=Xetan Riverbottled
|
||||||
|
- **170** [生死] hf died · hfid=Em Padscatches
|
||||||
|
- **170** [生死] hf died · hfid=Ngekil Jademeadows
|
||||||
|
- **170** [生死] hf died · hfid=Upnal Smithentrance
|
||||||
|
- **170** [生死] hf died · hfid=Diso Orangesteam
|
||||||
|
- **170** [生死] hf died · hfid=Dosheb Riverbalances
|
||||||
|
- **170** [生死] hf died · hfid=Ejel Crestknowledge
|
||||||
|
- **170** [生死] hf died · hfid=Someg Lightnourished
|
||||||
|
- **170** [生死] hf died · hfid=Ismir Fruityprice
|
||||||
|
- **170** [生死] hf died · hfid=Rabin Counselledsalve
|
||||||
|
- …另有 2129 条次要事件未列
|
||||||
|
|
||||||
|
## 180s
|
||||||
|
|
||||||
|
- **180** [生死] hf died · hfid=Sogdol Cindernettles
|
||||||
|
- **180** [生死] hf died · hfid=Struslot Swelteredmaster
|
||||||
|
- **180** [生死] hf died · hfid=Epo Taperedclouts The Fame Of Wads
|
||||||
|
- **180** [生死] hf died · hfid=Thothil Dutyrasps
|
||||||
|
- **180** [生死] hf died · hfid=Breachurges The Creative
|
||||||
|
- **180** [生死] hf died · hfid=Ustra Matchedcatches
|
||||||
|
- **180** [生死] hf died · hfid=Illdulls
|
||||||
|
- **180** [生死] hf died · hfid=Adil Axebuttoned
|
||||||
|
- **180** [生死] hf died · hfid=Beru Rootcoal
|
||||||
|
- **180** [生死] hf died · hfid=Ur Divinemyth
|
||||||
|
- **180** [生死] hf died · hfid=Conu Trenchclusters
|
||||||
|
- **180** [生死] hf died · hfid=Jonic Twinkledblankets
|
||||||
|
- **180** [生死] hf died · hfid=Kunod Bentponder
|
||||||
|
- **180** [生死] hf died · hfid=Tekud Scalejudged
|
||||||
|
- …另有 3024 条次要事件未列
|
||||||
|
|
||||||
|
## 190s
|
||||||
|
|
||||||
|
- **190** [生死] hf died · hfid=Suttu Waywardrampage
|
||||||
|
- **190** [生死] hf died · hfid=Maroonteaches
|
||||||
|
- **190** [生死] hf died · hfid=Issok Holdwipe
|
||||||
|
- **190** [生死] hf died · hfid=Pestrat Portentbone
|
||||||
|
- **190** [生死] hf died · hfid=Workerached
|
||||||
|
- **190** [生死] hf died · hfid=Giki Valleywhite
|
||||||
|
- **190** [生死] hf died · hfid=Mong Jestedshacks
|
||||||
|
- **190** [生死] hf died · hfid=Eniwa Velvetyecho
|
||||||
|
- **190** [生死] hf died · hfid=Zolak Wavehatred
|
||||||
|
- **190** [生死] hf died · hfid=Mel Handlebeard
|
||||||
|
- **190** [生死] hf died · hfid=Ustra Flukedrilled
|
||||||
|
- **190** [生死] hf died · hfid=Rosmic Stirredflier
|
||||||
|
- **190** [生死] hf died · hfid=Ekul Raptorbutton
|
||||||
|
- **190** [生死] hf died · hfid=Irsi Beardedbasis
|
||||||
|
- …另有 2946 条次要事件未列
|
||||||
|
|
||||||
|
## 200s
|
||||||
|
|
||||||
|
- **200** [生死] hf died · hfid=Crewtrade
|
||||||
|
- **200** [生死] hf died · hfid=Boldhandles
|
||||||
|
- **200** [生死] hf died · hfid=Bridgedlake
|
||||||
|
- **200** [生死] hf died · hfid=Drownedgladness The Speech Of Taming
|
||||||
|
- **200** [生死] hf died · hfid=Obeyflood
|
||||||
|
- **200** [生死] hf died · hfid=Ale Dabblingripe
|
||||||
|
- **200** [生死] hf died · hfid=Ebe Drillclap
|
||||||
|
- **200** [生死] hf died · hfid=Lipi Strapcleared
|
||||||
|
- **200** [生死] hf died · hfid=Cango Mawflash
|
||||||
|
- **200** [生死] hf died · hfid=Gurmus Spyaction
|
||||||
|
- **200** [生死] hf died · hfid=Gisep Wavebusy
|
||||||
|
- **200** [生死] hf died · hfid=Mas Butterrelieves
|
||||||
|
- **200** [生死] hf died · hfid=Anthath Touchedmerchants
|
||||||
|
- **200** [生死] hf died · hfid=Xidel Flickerjudged
|
||||||
|
- …另有 3170 条次要事件未列
|
||||||
|
|
||||||
|
## 210s
|
||||||
|
|
||||||
|
- **210** [生死] hf died · hfid=Loverwet
|
||||||
|
- **210** [生死] hf died · hfid=En Snakesnature
|
||||||
|
- **210** [生死] hf died · hfid=Admiredmired The Permanent Periwinkle
|
||||||
|
- **210** [生死] hf died · hfid=Ago Silkbeach
|
||||||
|
- **210** [生死] hf died · hfid=Cuthroz Sensesrecluse
|
||||||
|
- **210** [生死] hf died · hfid=Siti Lungplates
|
||||||
|
- **210** [生死] hf died · hfid=Mimap Cloudtreaties
|
||||||
|
- **210** [生死] hf died · hfid=Slapassault
|
||||||
|
- **210** [生死] hf died · hfid=Espir Wispclench
|
||||||
|
- **210** [生死] hf died · hfid=Rane Clustertour
|
||||||
|
- **210** [生死] hf died · hfid=Shadu Gemcounselled
|
||||||
|
- **210** [生死] hf died · hfid=Rogon Coastalcandles
|
||||||
|
- **210** [生死] hf died · hfid=Lami Cruxcaverns
|
||||||
|
- **210** [生死] hf died · hfid=Nomar Dawnchained
|
||||||
|
- …另有 3194 条次要事件未列
|
||||||
|
|
||||||
|
## 220s
|
||||||
|
|
||||||
|
- **220** [生死] hf died · hfid=Blockedcircles
|
||||||
|
- **220** [生死] hf died · hfid=Swamvoices
|
||||||
|
- **220** [生死] hf died · hfid=Umsest Faithclench
|
||||||
|
- **220** [生死] hf died · hfid=Passcolored
|
||||||
|
- **220** [生死] hf died · hfid=Yoresplashes
|
||||||
|
- **220** [生死] hf died · hfid=Olo Massivematches
|
||||||
|
- **220** [生死] hf died · hfid=Kutsmob Hatedtheaters
|
||||||
|
- **220** [生死] hf died · hfid=Jasmuk Whisperstarget
|
||||||
|
- **220** [生死] hf died · hfid=Seizefated
|
||||||
|
- **220** [生死] hf died · hfid=Defensestops
|
||||||
|
- **220** [生死] hf died · hfid=Ramul Gloryloaves
|
||||||
|
- **220** [生死] hf died · hfid=Rakfil Greatestdove
|
||||||
|
- **220** [生死] hf died · hfid=Quietgift
|
||||||
|
- **220** [生死] hf died · hfid=Tallquake
|
||||||
|
- …另有 3098 条次要事件未列
|
||||||
|
|
||||||
|
## 230s
|
||||||
|
|
||||||
|
- **230** [生死] hf died · hfid=Amak Frostyblood
|
||||||
|
- **230** [生死] hf died · hfid=Crowdtattoo
|
||||||
|
- **230** [生死] hf died · hfid=Negatedgroves
|
||||||
|
- **230** [生死] hf died · hfid=Abbeysilenced
|
||||||
|
- **230** [生死] hf died · hfid=Mysterythirsts
|
||||||
|
- **230** [生死] hf died · hfid=Fathri Spiraltangle
|
||||||
|
- **230** [生死] hf died · hfid=Flankdulls The Passage Of Glistening
|
||||||
|
- **230** [生死] hf died · hfid=Ronu Clappedstances
|
||||||
|
- **230** [生死] hf died · hfid=Bindtrouble The Boot Of Bursting
|
||||||
|
- **230** [生死] hf died · hfid=Leafyone The Slim Boats
|
||||||
|
- **230** [生死] hf died · hfid=Gica Gloryqueens
|
||||||
|
- **230** [生死] hf died · hfid=Kazis Ringboys
|
||||||
|
- **230** [生死] hf died · hfid=Dixil Buffflickers
|
||||||
|
- **230** [生死] hf died · hfid=Scalespy
|
||||||
|
- …另有 3071 条次要事件未列
|
||||||
|
|
||||||
|
## 240s
|
||||||
|
|
||||||
|
- **240** [生死] hf died · hfid=Cloisteredblizzard
|
||||||
|
- **240** [生死] hf died · hfid=Stokercheerful
|
||||||
|
- **240** [生死] hf died · hfid=Squeezingtarnish
|
||||||
|
- **240** [生死] hf died · hfid=Mec Beachreigned
|
||||||
|
- **240** [生死] hf died · hfid=Papos Blossomedwhim
|
||||||
|
- **240** [生死] hf died · hfid=Shimmeredimages
|
||||||
|
- **240** [生死] hf died · hfid=Hushflesh
|
||||||
|
- **240** [生死] hf died · hfid=Omud Taxpokers
|
||||||
|
- **240** [生死] hf died · hfid=Wimad Riddledcities
|
||||||
|
- **240** [生死] hf died · hfid=Flowerown The Climactic Song Of Summer
|
||||||
|
- **240** [生死] hf died · hfid=Quemer Blanketbasin
|
||||||
|
- **240** [生死] hf died · hfid=Rozmo Conjuredclenched
|
||||||
|
- **240** [生死] hf died · hfid=Ugrad Lashedhail
|
||||||
|
- **240** [生死] hf died · hfid=Om Brushpale
|
||||||
|
- …另有 3022 条次要事件未列
|
||||||
|
|
||||||
|
## 250s
|
||||||
|
|
||||||
|
- **250** [生死] hf died · hfid=Warbrews
|
||||||
|
- **250** [生死] hf died · hfid=Forkeddrench
|
||||||
|
- **250** [生死] hf died · hfid=Lar Riftadore
|
||||||
|
- **250** [生死] hf died · hfid=Itdun Fadeblossom
|
||||||
|
- **250** [生死] hf died · hfid=Hatchetfield The Friendly
|
||||||
|
- **250** [生死] hf died · hfid=Praisesquare
|
||||||
|
- **250** [生死] hf died · hfid=Stren Tightnesswhisked
|
||||||
|
- **250** [生死] hf died · hfid=Cavegild
|
||||||
|
- **250** [生死] hf died · hfid=Setduk Scrapeddwell
|
||||||
|
- **250** [生死] hf died · hfid=Er Keeperbasins
|
||||||
|
- **250** [生死] hf died · hfid=Capi Forkwaves
|
||||||
|
- **250** [生死] hf died · hfid=Buqui Bronzecook
|
||||||
|
- **250** [生死] hf died · hfid=Anthil Sculptureprank
|
||||||
|
- **250** [生死] hf died · hfid=Hob Gatehush
|
||||||
|
- …另有 52 条次要事件未列
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
# 要塞编年史官 · 写作规范
|
# 要塞编年史官 · 写作规范
|
||||||
|
|
||||||
你是一本矮人要塞编年史的**执笔者**。你不是游戏外的解说员,而是这个世界里负责记录的人:
|
你是一本矮人要塞编年史的**执笔者**。你不是游戏外的解说员,而是这个世界里负责记录的人:
|
||||||
你亲眼见过这些事,或者在事后翻过档案,现在由你把它们写下来。
|
你守着档案库,把发生过的事一件件记下来;少数是你亲历的,多数是你事后翻档案、听幸存者口述追述的。
|
||||||
|
正因如此,你对这些陈年旧帐有自己的看法——而且你很乐意说出来。
|
||||||
|
|
||||||
## 口吻
|
## 口吻
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[project]
|
||||||
|
name = "dfannals"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "把矮人要塞的 legends 历史数据写成中文战报体连载"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = ["defusedxml>=0.7"]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
packages = ["dfannals"]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"include": ["dfannals"],
|
||||||
|
"extraPaths": [".", "/home/chendy/df-annals"],
|
||||||
|
"pythonVersion": "3.12",
|
||||||
|
"typeCheckingMode": "basic",
|
||||||
|
"reportMissingImports": "warning",
|
||||||
|
"reportAttributeAccessIssue": "warning"
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 把本项目的 DFHack 脚本部署进游戏目录,并写入开机自动运行的 init 片段。
|
||||||
|
# scripts/deploy-dfhack.sh /mnt/c/Users/23518/DwarfFortress/df_53_16_win
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DF_DIR="${1:?用法: deploy-dfhack.sh <DF 目录>}"
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
SRC="$HERE/scripts/dfhack/dfannals"
|
||||||
|
|
||||||
|
[[ -d "$DF_DIR" ]] || { echo "游戏目录不存在: $DF_DIR" >&2; exit 1; }
|
||||||
|
[[ -d "$DF_DIR/hack" ]] || { echo "该目录里没有 hack/,DFHack 未安装?" >&2; exit 1; }
|
||||||
|
|
||||||
|
DEST="$DF_DIR/hack/scripts/dfannals"
|
||||||
|
mkdir -p "$DEST"
|
||||||
|
cp -v "$SRC"/*.lua "$DEST/"
|
||||||
|
|
||||||
|
INIT_DIR="$DF_DIR/dfhack-config/init"
|
||||||
|
mkdir -p "$INIT_DIR"
|
||||||
|
cat > "$INIT_DIR/dfannals.init" <<'EOF'
|
||||||
|
# 由 df-annals 项目部署。默认只跑观测脚本;需要全自动时改成 dfannals/drive。
|
||||||
|
dfannals/observe
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "已部署到:$DEST"
|
||||||
|
echo "init 片段:$INIT_DIR/dfannals.init"
|
||||||
|
ls -1 "$DEST"
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""从 DFHack 文本截图中按文字定位控件,并生成点击坐标。
|
||||||
|
|
||||||
|
配合 dfannals/screentext 与 dfannals/click 使用:
|
||||||
|
1) dfhack-run dfannals/screentext
|
||||||
|
2) scripts/df-screen.py click --label "History length" --text "250 years"
|
||||||
|
|
||||||
|
屏幕文本文件里第 1 行是表头,因此 屏幕 y = 文件行号 - 2。
|
||||||
|
坐标为 0 起始的字符网格坐标,与 dfannals/click 一致。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCREEN = Path("/mnt/c/Users/23518/DwarfFortress/dfannals-screen.txt")
|
||||||
|
|
||||||
|
|
||||||
|
def read_screen(path: Path) -> list[str]:
|
||||||
|
if not path.is_file():
|
||||||
|
sys.exit(f"找不到截图文件:{path}")
|
||||||
|
raw = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||||
|
return raw[1:] # 去掉 "# focus=..." 表头,使下标 = 屏幕 y
|
||||||
|
|
||||||
|
|
||||||
|
def char_index(line: str, needle: str) -> int:
|
||||||
|
return line.find(needle)
|
||||||
|
|
||||||
|
|
||||||
|
def find_row(lines: list[str], needle: str, start: int = 0) -> int:
|
||||||
|
for i in range(start, len(lines)):
|
||||||
|
if needle in lines[i]:
|
||||||
|
return i
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list(args: argparse.Namespace) -> int:
|
||||||
|
lines = read_screen(args.file)
|
||||||
|
for y, line in enumerate(lines):
|
||||||
|
if line.strip():
|
||||||
|
print(f"{y:3} | {line.rstrip()}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_click(args: argparse.Namespace) -> int:
|
||||||
|
lines = read_screen(args.file)
|
||||||
|
start = 0
|
||||||
|
if args.label:
|
||||||
|
label_row = find_row(lines, args.label)
|
||||||
|
if label_row < 0:
|
||||||
|
sys.exit(f"界面上找不到标签:{args.label}")
|
||||||
|
start = label_row
|
||||||
|
row = find_row(lines, args.text, start)
|
||||||
|
if row < 0:
|
||||||
|
sys.exit(f"找不到文字:{args.text}" + (f"(在 {args.label} 之下)" if args.label else ""))
|
||||||
|
|
||||||
|
line = lines[row]
|
||||||
|
idx = char_index(line, args.text)
|
||||||
|
x = idx + len(args.text) // 2
|
||||||
|
y = row
|
||||||
|
print(f"{x} {y}")
|
||||||
|
if args.verbose:
|
||||||
|
print(f"# 第 {row} 行,列 {idx},点击中心 ({x}, {y})", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_where(args: argparse.Namespace) -> int:
|
||||||
|
lines = read_screen(args.file)
|
||||||
|
for pattern in args.patterns:
|
||||||
|
row = find_row(lines, pattern)
|
||||||
|
if row < 0:
|
||||||
|
print(f"{pattern}: 未找到")
|
||||||
|
continue
|
||||||
|
idx = char_index(lines[row], pattern)
|
||||||
|
print(f"{pattern}: x={idx + len(pattern) // 2} y={row}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="DF 文本截图定位工具")
|
||||||
|
parser.add_argument("--file", type=Path, default=SCREEN)
|
||||||
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
p = sub.add_parser("list", help="列出非空行(带屏幕 y 坐标)")
|
||||||
|
p.set_defaults(func=cmd_list)
|
||||||
|
|
||||||
|
p = sub.add_parser("click", help="输出某段文字的中心坐标")
|
||||||
|
p.add_argument("--label", help="先定位这个标签,再在它下面找目标文字")
|
||||||
|
p.add_argument("--text", required=True, help="要点击的文字")
|
||||||
|
p.add_argument("-v", "--verbose", action="store_true")
|
||||||
|
p.set_defaults(func=cmd_click)
|
||||||
|
|
||||||
|
p = sub.add_parser("where", help="批量定位若干文字")
|
||||||
|
p.add_argument("patterns", nargs="+")
|
||||||
|
p.set_defaults(func=cmd_where)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
return args.func(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
--@ module = true
|
||||||
|
-- 向当前界面发送按键。用法:dfhack-run "dfannals/act SELECT"
|
||||||
|
-- 多个按键按顺序发送:dfhack-run "dfannals/act STANDARDSCROLL_DOWN SELECT"
|
||||||
|
local gui = require('gui')
|
||||||
|
|
||||||
|
local args = {...}
|
||||||
|
local scr = dfhack.gui.getCurViewscreen()
|
||||||
|
local sent = {}
|
||||||
|
|
||||||
|
for _, name in ipairs(args) do
|
||||||
|
local key = df.interface_key[name]
|
||||||
|
if key then
|
||||||
|
scr:feed_key(key)
|
||||||
|
sent[#sent + 1] = name
|
||||||
|
else
|
||||||
|
sent[#sent + 1] = '?未知按键:' .. name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local f = io.open('dfannals-act.log', 'a')
|
||||||
|
if f then
|
||||||
|
f:write(string.format('%s\t%s\tfocus=%s\n', os.date('%H:%M:%S'),
|
||||||
|
table.concat(sent, ' '), table.concat(dfhack.gui.getCurFocus(), '/')))
|
||||||
|
f:close()
|
||||||
|
end
|
||||||
|
|
||||||
|
print('sent: ' .. table.concat(sent, ' '))
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
--@ module = true
|
||||||
|
-- 在窗口坐标 (x, y) 模拟一次左键点击。用法:dfhack-run "dfannals/click 160 40"
|
||||||
|
-- 方式与 DFHack 自带测试工具一致:设置鼠标位置后 simulateInput('_MOUSE_L')。
|
||||||
|
local gui = require('gui')
|
||||||
|
|
||||||
|
local args = {...}
|
||||||
|
local x, y = tonumber(args[1]), tonumber(args[2])
|
||||||
|
if not x or not y then
|
||||||
|
print('用法: dfannals/click <x> <y>')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
df.global.gps.mouse_x = math.floor(x)
|
||||||
|
df.global.gps.precise_mouse_x = math.floor(x) * df.global.gps.tile_pixel_x
|
||||||
|
df.global.gps.mouse_y = math.floor(y)
|
||||||
|
df.global.gps.precise_mouse_y = math.floor(y) * df.global.gps.tile_pixel_y
|
||||||
|
|
||||||
|
local scr = dfhack.gui.getCurViewscreen()
|
||||||
|
gui.simulateInput(scr, '_MOUSE_L')
|
||||||
|
|
||||||
|
local f = io.open('dfannals-act.log', 'a')
|
||||||
|
if f then
|
||||||
|
f:write(string.format('%s\tclick %d,%d\tfocus=%s\n', os.date('%H:%M:%S'), x, y,
|
||||||
|
table.concat(dfhack.gui.getCurFocus(), '/')))
|
||||||
|
f:close()
|
||||||
|
end
|
||||||
|
|
||||||
|
print(string.format('clicked %d,%d', x, y))
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
--@ module = true
|
||||||
|
-- 把当前界面的字段结构导出到 dfannals-dump.txt,用于摸清 UI 元素。
|
||||||
|
-- 用法:dfhack-run "dfannals/dump"
|
||||||
|
local MAX_ITEMS = 400
|
||||||
|
|
||||||
|
local function is_scalar(v)
|
||||||
|
local t = type(v)
|
||||||
|
return t == 'number' or t == 'string' or t == 'boolean'
|
||||||
|
end
|
||||||
|
|
||||||
|
local function describe(obj, max_depth)
|
||||||
|
local out = {}
|
||||||
|
local function rec(o, prefix, depth)
|
||||||
|
if depth > max_depth or #out > MAX_ITEMS then return end
|
||||||
|
local ok, iter, state, ctrl = pcall(pairs, o)
|
||||||
|
if not ok then return end
|
||||||
|
for k, v in iter, state, ctrl do
|
||||||
|
if #out > MAX_ITEMS then return end
|
||||||
|
local key = tostring(k)
|
||||||
|
if is_scalar(v) then
|
||||||
|
out[#out + 1] = string.format('%s%s = %s', prefix, key, tostring(v))
|
||||||
|
else
|
||||||
|
local t = type(v)
|
||||||
|
out[#out + 1] = string.format('%s%s <%s>', prefix, key, t)
|
||||||
|
if t == 'table' or t == 'userdata' then
|
||||||
|
rec(v, prefix .. ' ', depth + 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
rec(obj, '', 1)
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local f = io.open('dfannals-dump.txt', 'w')
|
||||||
|
if not f then return end
|
||||||
|
|
||||||
|
local function write(line) f:write(line .. '\n') end
|
||||||
|
|
||||||
|
write('# focus=' .. table.concat(dfhack.gui.getCurFocus(), '/'))
|
||||||
|
write('# date=' .. os.date('%Y-%m-%d %H:%M:%S'))
|
||||||
|
|
||||||
|
local scr = dfhack.gui.getCurViewscreen()
|
||||||
|
local depth = 0
|
||||||
|
while scr and depth < 3 do
|
||||||
|
write('==== 屏幕层 ' .. depth .. ' ====')
|
||||||
|
local ok, lines = pcall(describe, scr, 2)
|
||||||
|
if ok then
|
||||||
|
for _, l in ipairs(lines) do write(l) end
|
||||||
|
else
|
||||||
|
write('(失败: ' .. tostring(lines) .. ')')
|
||||||
|
end
|
||||||
|
scr = scr.parent
|
||||||
|
depth = depth + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
f:close()
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
--@ module = true
|
||||||
|
-- 观测模式:把界面焦点变化持续追加到 dfannals-observe.log。
|
||||||
|
-- 用途:发现"新建世界 → 进入 legends → 导出"之间的真实界面序列。
|
||||||
|
|
||||||
|
local repeatUtil = require('repeat-util')
|
||||||
|
|
||||||
|
local LOG = 'dfannals-observe.log'
|
||||||
|
local last = nil
|
||||||
|
local ticks = 0
|
||||||
|
|
||||||
|
local function focus_string()
|
||||||
|
local ok, focus = pcall(dfhack.gui.getCurFocus)
|
||||||
|
if not ok or not focus then return '?' end
|
||||||
|
return table.concat(focus, '/')
|
||||||
|
end
|
||||||
|
|
||||||
|
function tick()
|
||||||
|
ticks = ticks + 1
|
||||||
|
local fs = focus_string()
|
||||||
|
if fs ~= last then
|
||||||
|
last = fs
|
||||||
|
local f = io.open(LOG, 'a')
|
||||||
|
if f then
|
||||||
|
f:write(string.format('%s\t%s\n', os.date('%H:%M:%S'), fs))
|
||||||
|
f:close()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function start()
|
||||||
|
repeatUtil.scheduleEvery('dfannals-observe', 10, tick)
|
||||||
|
local f = io.open(LOG, 'a')
|
||||||
|
if f then
|
||||||
|
f:write('--- observe start ' .. os.date('%Y-%m-%d %H:%M:%S') .. '\n')
|
||||||
|
f:close()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
start()
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
--@ module = true
|
||||||
|
-- 把当前界面状态写入 dfannals-probe.txt,供 WSL 侧读取。
|
||||||
|
-- 用法(在 WSL 里):"Dwarf Fortress.exe" 所在目录的 dfhack-run.exe dfannals/probe
|
||||||
|
|
||||||
|
local function safe_type(obj)
|
||||||
|
if not obj then return '?' end
|
||||||
|
local ok, t = pcall(function() return obj._type end)
|
||||||
|
if ok and t then return tostring(t) end
|
||||||
|
return '?'
|
||||||
|
end
|
||||||
|
|
||||||
|
local function viewstack()
|
||||||
|
local out = {}
|
||||||
|
local scr = dfhack.gui.getCurViewscreen()
|
||||||
|
local depth = 0
|
||||||
|
while scr and depth < 15 do
|
||||||
|
out[#out + 1] = string.format('[%d] %s', depth, safe_type(scr))
|
||||||
|
local ok, parent = pcall(function() return scr.parent end)
|
||||||
|
if not ok then break end
|
||||||
|
scr = parent
|
||||||
|
depth = depth + 1
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local function world_info()
|
||||||
|
local lines = {}
|
||||||
|
local ok, folder = pcall(dfhack.world.ReadWorldFolder)
|
||||||
|
lines[#lines + 1] = 'world_folder=' .. (ok and tostring(folder) or '?')
|
||||||
|
local ok2, year = pcall(dfhack.world.ReadCurrentYear)
|
||||||
|
lines[#lines + 1] = 'current_year=' .. (ok2 and tostring(year) or '?')
|
||||||
|
local ok3, legends = pcall(dfhack.world.isLegends)
|
||||||
|
lines[#lines + 1] = 'is_legends=' .. (ok3 and tostring(legends) or '?')
|
||||||
|
local ok4, fort = pcall(dfhack.world.isFortressMode)
|
||||||
|
lines[#lines + 1] = 'is_fortress=' .. (ok4 and tostring(fort) or '?')
|
||||||
|
local ok5, name = pcall(function() return dfhack.translation.translateName(df.global.world.world_data.name) end)
|
||||||
|
lines[#lines + 1] = 'world_name=' .. (ok5 and tostring(name) or '?')
|
||||||
|
return lines
|
||||||
|
end
|
||||||
|
|
||||||
|
function dump()
|
||||||
|
local lines = {}
|
||||||
|
local ok, focus = pcall(dfhack.gui.getCurFocus)
|
||||||
|
lines[#lines + 1] = 'focus=' .. ((ok and focus) and table.concat(focus, '/') or '?')
|
||||||
|
for _, l in ipairs(world_info()) do lines[#lines + 1] = l end
|
||||||
|
lines[#lines + 1] = 'viewstack:'
|
||||||
|
for _, l in ipairs(viewstack()) do lines[#lines + 1] = ' ' .. l end
|
||||||
|
|
||||||
|
local f = io.open('dfannals-probe.txt', 'w')
|
||||||
|
if f then
|
||||||
|
f:write(table.concat(lines, '\n') .. '\n')
|
||||||
|
f:close()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
dump()
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
--@ module = true
|
||||||
|
-- 遍历当前界面的控件树,导出控件名与矩形坐标,用于定位可点击目标。
|
||||||
|
-- 用法:dfhack-run "dfannals/widgets"
|
||||||
|
local MAX_DEPTH = 8
|
||||||
|
local MAX_LINES = 600
|
||||||
|
|
||||||
|
local function rect_str(w)
|
||||||
|
local ok, r = pcall(function() return w.rect end)
|
||||||
|
if not ok or not r then return '' end
|
||||||
|
local function g(k)
|
||||||
|
local ok2, v = pcall(function() return r[k] end)
|
||||||
|
return ok2 and tostring(v) or '?'
|
||||||
|
end
|
||||||
|
return string.format('rect=(%s,%s)-(%s,%s)', g('x1'), g('y1'), g('x2'), g('y2'))
|
||||||
|
end
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function walk(w, depth)
|
||||||
|
if not w or depth > MAX_DEPTH or #out > MAX_LINES then return end
|
||||||
|
local ok, name = pcall(function() return w.name end)
|
||||||
|
local nm = ok and tostring(name) or ''
|
||||||
|
local txt = ''
|
||||||
|
local ok3, t = pcall(function() return w.text end)
|
||||||
|
if ok3 and t then txt = tostring(t) end
|
||||||
|
out[#out + 1] = string.format('%s%s %s %s', string.rep(' ', depth), nm, rect_str(w),
|
||||||
|
txt ~= '' and ('text=' .. txt) or '')
|
||||||
|
local ok2, children = pcall(function() return w.children end)
|
||||||
|
if ok2 and children then
|
||||||
|
for _, c in ipairs(children) do walk(c, depth + 1) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local f = io.open('dfannals-widgets.txt', 'w')
|
||||||
|
if not f then return end
|
||||||
|
f:write('# focus=' .. table.concat(dfhack.gui.getCurFocus(), '/') .. '\n')
|
||||||
|
|
||||||
|
local sw, sh = dfhack.screen.getWindowSize()
|
||||||
|
f:write(string.format('# window=%dx%d\n', sw, sh))
|
||||||
|
|
||||||
|
local scr = dfhack.gui.getCurViewscreen()
|
||||||
|
local ok, widgets = pcall(function() return scr.widgets end)
|
||||||
|
if ok and widgets then
|
||||||
|
walk(widgets, 0)
|
||||||
|
else
|
||||||
|
out[#out + 1] = '(该界面没有 widgets 字段)'
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, l in ipairs(out) do f:write(l .. '\n') end
|
||||||
|
f:close()
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 在 Windows 侧安装 矮人要塞 免费版 + DFHack(经 WSL 的 /mnt/c 操作,免管理员权限)
|
||||||
|
#
|
||||||
|
# scripts/install-df.sh 安装/校验
|
||||||
|
# scripts/install-df.sh --download 重新下载缺的文件
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
WIN_USER="${WIN_USER:-23518}"
|
||||||
|
ROOT="/mnt/c/Users/${WIN_USER}/DwarfFortress"
|
||||||
|
DF_ZIP="df_53_16_win.zip"
|
||||||
|
DFHACK_ZIP="dfhack-53.16-r2-Windows-64bit.zip"
|
||||||
|
DF_URL="https://www.bay12games.com/dwarves/${DF_ZIP}"
|
||||||
|
DFHACK_URL="https://github.com/DFHack/dfhack/releases/download/53.16-r2/${DFHACK_ZIP}"
|
||||||
|
|
||||||
|
mkdir -p "$ROOT"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
download() {
|
||||||
|
local url="$1" out="$2"
|
||||||
|
echo "== 下载 $out"
|
||||||
|
for _ in $(seq 1 300); do
|
||||||
|
wget -c -q --timeout=30 --tries=2 -O "$out" "$url" && return 0
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
echo "下载失败:$url" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--download" ]]; then
|
||||||
|
[[ -s "$DF_ZIP" ]] || download "$DF_URL" "$DF_ZIP"
|
||||||
|
[[ -s "$DFHACK_ZIP" ]] || download "$DFHACK_URL" "$DFHACK_ZIP"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for f in "$DF_ZIP" "$DFHACK_ZIP"; do
|
||||||
|
[[ -s "$f" ]] || { echo "缺少 $f(先跑 --download)" >&2; exit 1; }
|
||||||
|
unzip -t "$f" >/dev/null || { echo "$f 已损坏" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "== 解压游戏本体"
|
||||||
|
DF_DIR="$(unzip -Z1 "$DF_ZIP" | head -1 | cut -d/ -f1)"
|
||||||
|
if [[ ! -d "$DF_DIR" ]]; then
|
||||||
|
unzip -q -o "$DF_ZIP"
|
||||||
|
fi
|
||||||
|
echo " 游戏目录:$ROOT/$DF_DIR"
|
||||||
|
|
||||||
|
echo "== 解压 DFHack(覆盖进游戏目录)"
|
||||||
|
unzip -q -o "$DFHACK_ZIP" -d "$DF_DIR"
|
||||||
|
|
||||||
|
echo "== 校验"
|
||||||
|
ls -1 "$DF_DIR" | head -20
|
||||||
|
if [[ -f "$DF_DIR/Dwarf Fortress.exe" ]]; then
|
||||||
|
echo " ✓ 启动器存在:Dwarf Fortress.exe"
|
||||||
|
else
|
||||||
|
echo " ✗ 未找到 Dwarf Fortress.exe" >&2
|
||||||
|
fi
|
||||||
|
if [[ -d "$DF_DIR/dfhack-config" && -d "$DF_DIR/hack" ]]; then
|
||||||
|
echo " ✓ DFHack 已就位(hack/ + dfhack-config/)"
|
||||||
|
else
|
||||||
|
echo " ✗ DFHack 目录缺失" >&2
|
||||||
|
fi
|
||||||
|
echo "$ROOT/$DF_DIR"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
Metadata-Version: 2.4
|
||||||
|
Name: dfannals
|
||||||
|
Version: 0.1.0
|
||||||
|
Summary: 把矮人要塞的 legends 历史数据写成中文战报体连载
|
||||||
|
Requires-Python: >=3.12
|
||||||
|
Requires-Dist: defusedxml>=0.7
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
README.md
|
||||||
|
pyproject.toml
|
||||||
|
src/dfannals/__init__.py
|
||||||
|
src/dfannals/config.py
|
||||||
|
src/dfannals/factcheck.py
|
||||||
|
src/dfannals/legends.py
|
||||||
|
src/dfannals/llm.py
|
||||||
|
src/dfannals/publish.py
|
||||||
|
src/dfannals/timeline.py
|
||||||
|
src/dfannals.egg-info/PKG-INFO
|
||||||
|
src/dfannals.egg-info/SOURCES.txt
|
||||||
|
src/dfannals.egg-info/dependency_links.txt
|
||||||
|
src/dfannals.egg-info/requires.txt
|
||||||
|
src/dfannals.egg-info/top_level.txt
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
defusedxml>=0.7
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dfannals
|
||||||
Vendored
+23
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding='UTF-8'?>
|
||||||
|
<df_world>
|
||||||
|
<name>The World of Prophecy</name>
|
||||||
|
<altname>Usthar Adil</altname>
|
||||||
|
<regions><region><id>0</id><name>The Hill of Fogs</name><type>FOREST</type></region></regions>
|
||||||
|
<sites>
|
||||||
|
<site><id>1</id><name>Boatmurdered</name><type>DARK_FORTRESS</type></site>
|
||||||
|
<site><id>2</id><name>Glazedbolts</name><type>CAVE</type></site>
|
||||||
|
</sites>
|
||||||
|
<historical_figures>
|
||||||
|
<historical_figure><id>10</id><name>Urist McMiner</name><race>DWARF</race><appeared>12</appeared><birth_year>12</birth_year><death_year>88</death_year><profession>MINER</profession></historical_figure>
|
||||||
|
<historical_figure><id>11</id><name>Kogan Deathspear</name><race>GOBLIN</race><appeared>4</appeared><birth_year>4</birth_year><profession>AXEMAN</profession></historical_figure>
|
||||||
|
</historical_figures>
|
||||||
|
<entities>
|
||||||
|
<entity><id>100</id><name>The Steel Confederacy</name><type>Civilization</type><race>DWARF</race></entity>
|
||||||
|
<entity><id>101</id><name>Nightcloaks</name><type>Civilization</type><race>GOBLIN</race></entity>
|
||||||
|
</entities>
|
||||||
|
<historical_events>
|
||||||
|
<historical_event><id>1</id><year>30</year><seconds72>5</seconds72><type>hf simple battle event</type><hfid>10</hfid><slayer_hfid>11</slayer_hfid><site_id>1</site_id></historical_event>
|
||||||
|
<historical_event><id>2</id><year>31</year><seconds72>0</seconds72><type>created site</type><site_id>2</site_id><entity_id>100</entity_id></historical_event>
|
||||||
|
<historical_event><id>3</id><year>88</year><seconds72>9</seconds72><type>hf died</type><hfid>10</hfid><slayer_hfid>11</slayer_hfid></historical_event>
|
||||||
|
</historical_events>
|
||||||
|
</df_world>
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding='UTF-8'?>
|
||||||
|
<df_world>
|
||||||
|
<name>The World of Prophecy</name>
|
||||||
|
<altname>Usthar Adil</altname>
|
||||||
|
<historical_figures>
|
||||||
|
<historical_figure><id>10</id><sex>0</sex><race>DWARF</race></historical_figure>
|
||||||
|
</historical_figures>
|
||||||
|
</df_world>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# pyright: reportMissingImports=false, reportAttributeAccessIssue=false
|
||||||
|
# 说明:LSP 的 Python 环境看不到本项目包,会把包内 import 误报为缺失模块。
|
||||||
|
"""事实校验的回归测试。
|
||||||
|
|
||||||
|
重点是按 t6 验收契约做**反例测试**:故意植入史料里不存在的人名/地名,
|
||||||
|
校验器必须把它揪出来;同时史料里真实存在(或属于游戏枚举值)的词不能被误报。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from dfannals import factcheck, legends # noqa: E402
|
||||||
|
|
||||||
|
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
||||||
|
|
||||||
|
|
||||||
|
def load_world() -> legends.World:
|
||||||
|
return legends.load(
|
||||||
|
[FIXTURES / "sample-legends.xml", FIXTURES / "sample-legends_plus.xml"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_world_parsed() -> None:
|
||||||
|
world = load_world()
|
||||||
|
assert world.name == "The World of Prophecy"
|
||||||
|
assert world.altname == "Usthar Adil"
|
||||||
|
assert world.figure_name(10) == "Urist McMiner"
|
||||||
|
assert world.site_name(1) == "Boatmurdered"
|
||||||
|
assert world.entity_name(100) == "The Steel Confederacy"
|
||||||
|
assert len(world.events) == 3
|
||||||
|
assert [e.year for e in world.events] == [30, 31, 88]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fake_names_are_caught() -> None:
|
||||||
|
"""反例:凭空编造的人名地名必须被抓出来。"""
|
||||||
|
world = load_world()
|
||||||
|
text = (
|
||||||
|
"Zoltan the Unmaker 从 Mount Doom 出发,袭击了 Urist McMiner 驻守的 Boatmurdered。"
|
||||||
|
"Kogan Deathspear 事后表示,The World of Prophecy 从未见过如此荒唐的进军。"
|
||||||
|
)
|
||||||
|
found = {s.name for s in factcheck.check(text, world)}
|
||||||
|
assert "Zoltan" in found or "Zoltan the Unmaker" in found, f"漏掉了假人名:{found}"
|
||||||
|
assert "Mount Doom" in found or "Doom" in found, f"漏掉了假地名:{found}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_names_not_flagged() -> None:
|
||||||
|
"""真名与枚举值不能被误报(这是上一轮修掉的假阳性)。"""
|
||||||
|
world = load_world()
|
||||||
|
text = (
|
||||||
|
"The World of Prophecy 的开局并不壮丽。他是个 GOBLIN 斧手,而对方是个 MINER。"
|
||||||
|
"Urist McMiner 死在 Boatmurdered,The Steel Confederacy 随后在 Glazedbolts 立城。"
|
||||||
|
"Usthar Adil 这个别名也见于档案。"
|
||||||
|
)
|
||||||
|
found = {s.name for s in factcheck.check(text, world)}
|
||||||
|
assert not found, f"真名被误报:{found}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lowercase_source_names_with_pretty_display() -> None:
|
||||||
|
"""回归:DF 程序名全小写,正文做首字母大写后不能被误报。
|
||||||
|
|
||||||
|
这个 bug 是在真实世界数据上踩出来的:史料里是 'yemi deermoths',
|
||||||
|
正文渲染成 'Yemi Deermoths',校验器当时把 6 个真名全报成了可疑。
|
||||||
|
"""
|
||||||
|
from dfannals.legends import Entity, Figure, Site, World
|
||||||
|
|
||||||
|
w = World(name="mon sagus", altname="the plane of dawn")
|
||||||
|
w.figures[1] = Figure(id=1, name="yemi deermoths", race="ELF")
|
||||||
|
w.sites[1] = Site(id=1, name="nutssound")
|
||||||
|
w.entities[1] = Entity(id=1, name="the washed terrors")
|
||||||
|
|
||||||
|
text = "Yemi Deermoths 死在 Nutssound,The Washed Terrors 动的手;Mon Sagus 记住了这件事。"
|
||||||
|
found = {s.name for s in factcheck.check(text, w)}
|
||||||
|
assert not found, f"展示形式的真名被误报:{found}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_negative_id_placeholders_not_in_known_names() -> None:
|
||||||
|
"""回归:id 为负表示“无此项”,不能被渲染成专名。"""
|
||||||
|
from dfannals.legends import Figure, World
|
||||||
|
|
||||||
|
w = World(name="w")
|
||||||
|
w.figures[1] = Figure(id=1, name="someone")
|
||||||
|
assert w.figure_name(-1) == ""
|
||||||
|
assert w.site_name(-1) == ""
|
||||||
|
assert w.entity_name(-1) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_annotation_only_when_suspicious() -> None:
|
||||||
|
world = load_world()
|
||||||
|
clean = "# 第一章\n\nUrist McMiner 站在 Boatmurdered 的门口。\n"
|
||||||
|
assert factcheck.annotate(clean, []) == clean
|
||||||
|
|
||||||
|
suspicions = factcheck.check("Zoltan 来了。", world)
|
||||||
|
assert suspicions, "应判定 Zoltan 可疑"
|
||||||
|
marked = factcheck.annotate(clean, suspicions)
|
||||||
|
assert "史料核验提示" in marked and "Zoltan" in marked
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsafe_xml_rejected() -> None:
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
evil = Path(tempfile.mkdtemp()) / "evil.xml"
|
||||||
|
evil.write_text(
|
||||||
|
'<?xml version="1.0"?><!DOCTYPE foo [<!ENTITY x "y">]><df_world><name>&x;</name></df_world>'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
legends.load([evil])
|
||||||
|
except ValueError as exc:
|
||||||
|
assert "DTD" in str(exc) or "实体" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("含 DTD 的文件未被拦截")
|
||||||
|
|
||||||
|
|
||||||
|
def _main() -> int:
|
||||||
|
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
||||||
|
failed = 0
|
||||||
|
for fn in tests:
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
print(f" ✓ {fn.__name__}")
|
||||||
|
except AssertionError as exc:
|
||||||
|
failed += 1
|
||||||
|
print(f" ✗ {fn.__name__}: {exc}")
|
||||||
|
print(f"\n{len(tests) - failed}/{len(tests)} 通过")
|
||||||
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(_main())
|
||||||
Reference in New Issue
Block a user