第 1 卷第 1 章:1–9 年

This commit is contained in:
Chen Yi
2026-10-05 17:30:11 +08:00
parent c56394afdd
commit 3468433c9a
34 changed files with 2564 additions and 4 deletions
+159
View File
@@ -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
View File
@@ -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())
+10 -1
View File
@@ -12,13 +12,22 @@ from pathlib import Path
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" # 原始数据,不入库
EXPORT_DIR = DATA_DIR / "exports" # legends 导出原始文件
OUTPUT_DIR = PROJECT_DIR / "output" # 成品,入库
CHAPTER_DIR = OUTPUT_DIR / "chapters"
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_USER = "23518"
WIN_DF_ROOT = Path(f"/mnt/c/Users/{WINDOWS_USER}/DwarfFortress") # 下载/安装根目录
+141
View File
@@ -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
+347
View File
@@ -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)
+1 -1
View File
@@ -13,7 +13,7 @@ import time
from dataclasses import dataclass
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
@@ -9,7 +9,7 @@ from __future__ import annotations
import subprocess
from pathlib import Path
from .config import GITEA_REMOTE, PROJECT_DIR, SSH_KEY
from dfannals.config import GITEA_REMOTE, PROJECT_DIR, SSH_KEY
GITIGNORE = """\
# 原始数据与存档:不入库
+140
View File
@@ -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
+195
View File
@@ -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)
+108
View File
@@ -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]
+26
View File
@@ -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年三次和约与一次联盟建立。*
+126
View File
@@ -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 |
+475
View File
@@ -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 条次要事件未列
+2 -1
View File
@@ -1,7 +1,8 @@
# 要塞编年史官 · 写作规范
你是一本矮人要塞编年史的**执笔者**。你不是游戏外的解说员,而是这个世界里负责记录的人:
你亲眼见过这些事,或者在事后翻过档案,现在由你把它们写下来。
你守着档案库,把发生过的事一件件记下来;少数是你亲历的,多数是你事后翻档案、听幸存者口述追述的。
正因如此,你对这些陈年旧帐有自己的看法——而且你很乐意说出来。
## 口吻
+13
View File
@@ -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"]
+8
View File
@@ -0,0 +1,8 @@
{
"include": ["dfannals"],
"extraPaths": [".", "/home/chendy/df-annals"],
"pythonVersion": "3.12",
"typeCheckingMode": "basic",
"reportMissingImports": "warning",
"reportAttributeAccessIssue": "warning"
}
+26
View File
@@ -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"
+103
View File
@@ -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())
+27
View File
@@ -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, ' '))
+28
View File
@@ -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))
+57
View File
@@ -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()
+39
View File
@@ -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()
+56
View File
@@ -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()
+49
View File
@@ -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()
+61
View File
@@ -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"
+6
View File
@@ -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
+14
View File
@@ -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 @@
+1
View File
@@ -0,0 +1 @@
defusedxml>=0.7
+1
View File
@@ -0,0 +1 @@
dfannals
+23
View File
@@ -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
View File
@@ -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>
+131
View File
@@ -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())