Files
dwarf-fortress-annals/dfannals/episodes.py
T
Chen Yi e0073e90bd 第 1 卷(人物主线):第 1–3 集 + 人物表
主角线:Guspu Frillyknots / Ral Fastenhatchets / Alath Blottedmine
由线索挖掘器自动选定,按因果链切集,经 5 维度自评闸门(≥12 分)后入库。
2026-10-05 19:28:49 +08:00

193 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""因果链切集:把一条人物线索切成"一集一个完整回合"。
参数来自访谈决策:
- 相邻事件间隔 >2 年即断开(比推荐值更紧,节奏更快)
- 短链只合并,不设线索总量下限
- 链路过长按字数预算拆上下集
- 每集正文预算落在 800–1500 字
字数预算用「事件条数 × 每条展开字数」估算。系数 110 是按此前实测校准的:
12 条史料事件生成的中文正文约 1100–1400 字。
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from dfannals.legends import Event, World
from dfannals.threads import Thread
# 相邻事件间隔超过它即断开
GAP_YEARS = 2
# 每条史料在正文里大致展开的字数。实测区间约 85–115(同样 12 条事件,
# 两次生成分别得到 1026 字与 1388 字),取中值偏保守,避免把集拆得过碎。
CHARS_PER_EVENT = 95
MIN_CHARS = 800 # 每集预算下限
MAX_CHARS = 1500 # 每集预算上限
@dataclass
class Episode:
index: int
start_year: int
end_year: int
events: list[Event] = field(default_factory=list)
part: int = 1
parts: int = 1
@property
def key(self) -> str:
base = f"{self.index:03d}-{self.start_year}-{self.end_year}"
return base if self.parts == 1 else f"{base}-p{self.part}"
@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} 年"
@property
def estimated_chars(self) -> int:
return len(self.events) * CHARS_PER_EVENT
def in_budget(self) -> bool:
return MIN_CHARS <= self.estimated_chars <= MAX_CHARS or self.parts > 1
def _min_events() -> int:
return max(1, math.ceil(MIN_CHARS / CHARS_PER_EVENT))
def _max_events() -> int:
return max(1, MAX_CHARS // CHARS_PER_EVENT)
def _split_chains(events: list[Event], gap_years: int) -> list[list[Event]]:
"""按时间间隔把事件串切成因果链。"""
chains: list[list[Event]] = []
current: list[Event] = []
prev_year: int | None = None
for e in events:
if current and prev_year is not None and e.year - prev_year > gap_years:
chains.append(current)
current = []
current.append(e)
prev_year = e.year
if current:
chains.append(current)
return chains
def _merge_short(chains: list[list[Event]], min_events: int) -> list[list[Event]]:
"""短链只合并:攒够预算就收一集,尾部不足的并入上一集。"""
merged: list[list[Event]] = []
buffer: list[Event] = []
for chain in chains:
buffer.extend(chain)
if len(buffer) >= min_events:
merged.append(buffer)
buffer = []
if buffer:
if merged:
merged[-1].extend(buffer)
else:
merged = [buffer]
return merged
def _split_long(chain: list[Event]) -> list[list[Event]]:
"""过长链路按预算拆成若干段(对应正文的上/下集)。
注意不能均分:14 条事件均分成 7+7 会得到两个 770 字的碎片,
两端都跌破 800 字下限。所以先取最小段数,再确保每段不低于下限。
"""
n = len(chain)
max_events = _max_events()
min_events = _min_events()
if n <= max_events:
return [chain]
parts = math.ceil(n / max_events)
while parts > 1 and math.ceil(n / parts) < min_events:
parts -= 1
size = math.ceil(n / parts)
return [chain[i:i + size] for i in range(0, n, size)]
def plan_episodes(
thread: Thread,
gap_years: int = GAP_YEARS,
min_chars: int = MIN_CHARS,
max_chars: int = MAX_CHARS,
) -> list[Episode]:
"""给定主角组,切出剧集序列。"""
if not thread.events:
return []
chains = _split_chains(thread.events, gap_years)
merged = _merge_short(chains, _min_events())
episodes: list[Episode] = []
for chain in merged:
segments = _split_long(chain)
for i, segment in enumerate(segments, 1):
episodes.append(
Episode(
index=len(episodes) + 1,
start_year=segment[0].year,
end_year=segment[-1].year,
events=segment,
part=i,
parts=len(segments),
)
)
return episodes
def cast_of(world: World, thread: Thread, episode: Episode) -> tuple[list[int], list[int]]:
"""返回 (本集出场的主角, 本集出现的对手/外人)。"""
counter: dict[int, int] = {}
for e in episode.events:
for fid in e.figure_ids():
counter[fid] = counter.get(fid, 0) + 1
members = set(thread.members)
heros = sorted((f for f in counter if f in members), key=lambda f: -counter[f])
others = sorted((f for f in counter if f not in members), key=lambda f: -counter[f])
return heros, others
def render_plan(world: World, thread: Thread, episodes: list[Episode], top_others: int = 3) -> str:
"""剧集清单:集号、年份范围、事件条数、主角与对手。"""
lines = [
f"线索主角:{'、'.join(world.figure_name(m) for m in thread.members)}",
f"线索跨度:{thread.label} | 事件 {len(thread.events)} 条 | 规划 {len(episodes)} 集",
"",
]
for ep in episodes:
heros, others = cast_of(world, thread, ep)
hero_names = "、".join(world.figure_name(h) for h in heros[:4]) or "(本集无主角出场)"
other_names = "、".join(world.figure_name(o) for o in others[:top_others])
budget = f"{ep.estimated_chars} 字估算"
mark = "" if MIN_CHARS <= ep.estimated_chars <= MAX_CHARS else " ⚠ 超出预算"
lines.append(
f" 第 {ep.index:2d} 集({ep.span})| {len(ep.events):2d} 条事件 | {budget}{mark}"
+ (f" | 上/下:{ep.part}/{ep.parts}" if ep.parts > 1 else "")
)
lines.append(f" 主角:{hero_names}")
if other_names:
lines.append(f" 对手/外人:{other_names}")
return "\n".join(lines)
def budget_report(episodes: list[Episode]) -> str:
if not episodes:
return "无剧集"
inside = sum(1 for e in episodes if MIN_CHARS <= e.estimated_chars <= MAX_CHARS)
sizes = [len(e.events) for e in episodes]
return (
f"共 {len(episodes)} 集;事件条数 {min(sizes)}–{max(sizes)};"
f"估算字数 {min(e.estimated_chars for e in episodes)}–{max(e.estimated_chars for e in episodes)};"
f"落在 {MIN_CHARS}–{MAX_CHARS} 预算内的 {inside}/{len(episodes)} 集"
"(估算按每条约 95 字,真实字数在生成时会再校正)"
)