675 lines
30 KiB
Python
675 lines
30 KiB
Python
"""将生成数据合并到已有职业实现。
|
|
|
|
优先按职业 ref、其次 UUID 匹配;缺少稳定身份时才兼容旧中文名称。
|
|
保留运行时类编号、名称、关联规则和手写方法;旧技能缺少 ref 时补齐绑定及导入。
|
|
生成器独有技能保持注释,运行时独有技能原样保留;职业变体按 prefix 查找来源。
|
|
|
|
先执行 backend/openapi/scripts/generate.py 同步 refs 和生成数据,再运行本脚本。
|
|
--check 只检查合并及语法,不写职业文件;--generated-root/--character-root 可指定验证目录。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ast
|
|
from dataclasses import dataclass
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import time
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
GENERATED_ROOT = ROOT / "backend" / "openapi" / "generate"
|
|
CHARACTER_ROOT = ROOT / "backend" / "core" / "character" / "GF"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SkillBlock:
|
|
class_name: str
|
|
skill_name: str
|
|
start: int
|
|
end: int
|
|
position: str | None
|
|
cell_pos: str | None
|
|
ref: str | None
|
|
ref_key: tuple[str, str, str] | None
|
|
uuid: str | None
|
|
|
|
|
|
def _offsets(source: str) -> list[int]:
|
|
result = [0]
|
|
for line in source.splitlines(keepends=True):
|
|
result.append(result[-1] + len(line))
|
|
return result
|
|
|
|
|
|
def _skills(source: str, path: Path) -> list[SkillBlock]:
|
|
tree = ast.parse(source, filename=str(path))
|
|
offsets = _offsets(source)
|
|
imports = {
|
|
item.asname or item.name: (node.module, item.name)
|
|
for node in tree.body if isinstance(node, ast.ImportFrom)
|
|
and (node.module or '').startswith('core.character.refs.')
|
|
for item in node.names
|
|
}
|
|
result: list[SkillBlock] = []
|
|
for node in tree.body:
|
|
if not isinstance(node, ast.ClassDef) or not re.fullmatch(r"Skill\d+", node.name):
|
|
continue
|
|
name = None
|
|
position = None
|
|
cell_pos = None
|
|
ref = ref_key = uuid = None
|
|
for statement in node.body:
|
|
if not isinstance(statement, (ast.Assign, ast.AnnAssign)):
|
|
continue
|
|
targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target]
|
|
if len(targets) != 1 or not isinstance(targets[0], ast.Name):
|
|
continue
|
|
if targets[0].id == 'uuid' and isinstance(statement.value, ast.Constant):
|
|
uuid = statement.value.value
|
|
if targets[0].id == 'ref':
|
|
value = statement.value
|
|
if not isinstance(value, ast.Attribute) or not isinstance(value.value, ast.Name) or value.value.id not in imports:
|
|
raise ValueError(f'{path}: 技能 ref 必须引用已导入的职业 Ref 成员')
|
|
ref = ast.unparse(value)
|
|
ref_key = (*imports[value.value.id], value.attr)
|
|
if targets[0].id == "name" and isinstance(statement.value, ast.Constant):
|
|
name = statement.value.value
|
|
elif targets[0].id == "position":
|
|
position = ast.get_source_segment(source, statement.value)
|
|
elif targets[0].id == "cellPos":
|
|
cell_pos = ast.get_source_segment(source, statement.value)
|
|
if not isinstance(name, str) or node.end_lineno is None:
|
|
raise ValueError(f"{path}:{node.lineno}: invalid {node.name}")
|
|
result.append(
|
|
SkillBlock(
|
|
node.name,
|
|
name,
|
|
offsets[node.lineno - 1],
|
|
offsets[node.end_lineno],
|
|
position,
|
|
cell_pos,
|
|
ref, ref_key, uuid,
|
|
)
|
|
)
|
|
return result
|
|
|
|
|
|
def _module_skill_bounds(source: str, path: Path) -> tuple[int, int]:
|
|
"""Return the code-only header end and profession-footer start offsets."""
|
|
tree = ast.parse(source, filename=str(path))
|
|
offsets = _offsets(source)
|
|
indexes = [
|
|
index
|
|
for index, node in enumerate(tree.body)
|
|
if isinstance(node, ast.ClassDef) and re.fullmatch(r"Skill\d+", node.name)
|
|
]
|
|
if not indexes:
|
|
raise ValueError(f"missing skills: {path}")
|
|
first_index, last_index = indexes[0], indexes[-1]
|
|
header_end = offsets[tree.body[first_index - 1].end_lineno] if first_index else 0
|
|
footer_start = (
|
|
offsets[tree.body[last_index + 1].lineno - 1]
|
|
if last_index + 1 < len(tree.body)
|
|
else len(source)
|
|
)
|
|
return header_end, footer_start
|
|
|
|
|
|
def _set_generated_field(block: str, field: str, value: str | None) -> str:
|
|
if value is None:
|
|
return block
|
|
assignment = re.compile(rf"(?m)^(\s*{re.escape(field)}\s*(?::[^=\n]+)?=\s*).*$")
|
|
if assignment.search(block):
|
|
return assignment.sub(rf"\g<1>{value}", block, count=1)
|
|
return re.sub(r"(?m)^(\s*name\s*=.*)$", rf"\g<1>\n {field} = {value}", block, count=1)
|
|
|
|
|
|
def _statement_key(statement: ast.stmt) -> str | None:
|
|
if isinstance(statement, (ast.Assign, ast.AnnAssign)):
|
|
targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target]
|
|
if len(targets) == 1 and isinstance(targets[0], ast.Name):
|
|
return targets[0].id
|
|
return None
|
|
|
|
|
|
def _is_data_key(key: str | None) -> bool:
|
|
return bool(key and re.fullmatch(r"data\d+", key))
|
|
|
|
|
|
def _sync_generated_assignment(
|
|
runtime_block: str, generated_block: str, field: str
|
|
) -> str:
|
|
"""Replace all runtime definitions of one field with the generated one."""
|
|
runtime_class = ast.parse(runtime_block).body[0]
|
|
generated_class = ast.parse(generated_block).body[0]
|
|
if not isinstance(runtime_class, ast.ClassDef) or not isinstance(generated_class, ast.ClassDef):
|
|
return runtime_block
|
|
generated_statement = next(
|
|
(statement for statement in generated_class.body if _statement_key(statement) == field),
|
|
None,
|
|
)
|
|
if generated_statement is None:
|
|
return runtime_block
|
|
|
|
runtime_statements = [
|
|
statement for statement in runtime_class.body if _statement_key(statement) == field
|
|
]
|
|
runtime_lines = runtime_block.splitlines(keepends=True)
|
|
generated_lines = generated_block.splitlines(keepends=True)
|
|
generated_source = generated_lines[
|
|
generated_statement.lineno - 1:generated_statement.end_lineno
|
|
]
|
|
if runtime_statements:
|
|
insertion_line = runtime_statements[0].lineno - 1
|
|
for statement in sorted(runtime_statements, key=lambda item: item.lineno, reverse=True):
|
|
runtime_lines[statement.lineno - 1:statement.end_lineno] = []
|
|
else:
|
|
anchor = next(
|
|
(
|
|
statement
|
|
for key in ("custom", "hasUP", "hasVP", "uuid")
|
|
for statement in reversed(runtime_class.body)
|
|
if _statement_key(statement) == key
|
|
),
|
|
None,
|
|
)
|
|
insertion_line = anchor.end_lineno if anchor is not None else 1
|
|
generated_source = ["\n", *generated_source]
|
|
runtime_lines[insertion_line:insertion_line] = generated_source
|
|
return "".join(runtime_lines)
|
|
|
|
|
|
def _sync_generated_data(runtime_block: str, generated_block: str) -> str:
|
|
"""Rebuild dataN/hitN in numeric order using generated data definitions."""
|
|
runtime_class = ast.parse(runtime_block).body[0]
|
|
generated_class = ast.parse(generated_block).body[0]
|
|
if not isinstance(runtime_class, ast.ClassDef) or not isinstance(generated_class, ast.ClassDef):
|
|
return runtime_block
|
|
runtime_lines = runtime_block.splitlines(keepends=True)
|
|
generated_lines = generated_block.splitlines(keepends=True)
|
|
runtime_items = {
|
|
_statement_key(statement): (index, statement)
|
|
for index, statement in enumerate(runtime_class.body)
|
|
if _statement_key(statement)
|
|
}
|
|
generated_items = {
|
|
_statement_key(statement): (index, statement)
|
|
for index, statement in enumerate(generated_class.body)
|
|
if _statement_key(statement)
|
|
}
|
|
selected_statements = [
|
|
(index, statement, _statement_key(statement))
|
|
for index, statement in enumerate(runtime_class.body)
|
|
if _is_data_key(_statement_key(statement))
|
|
or re.fullmatch(r"hit\d+", _statement_key(statement) or "")
|
|
]
|
|
generated_data = {key: value for key, value in generated_items.items() if _is_data_key(key)}
|
|
if not selected_statements and not generated_data:
|
|
return runtime_block
|
|
|
|
def numeric_key(key: str) -> int:
|
|
return int(re.search(r"\d+$", key).group())
|
|
|
|
def segment(
|
|
lines: list[str], class_node: ast.ClassDef, index: int, statement: ast.stmt
|
|
) -> list[str]:
|
|
previous_end = class_node.body[index - 1].end_lineno if index else class_node.lineno
|
|
return lines[previous_end:statement.end_lineno]
|
|
|
|
# Remove every old dataN/hitN statement together with its leading comment
|
|
# trivia. They are re-emitted below in deterministic numeric order.
|
|
removals: list[tuple[int, int]] = []
|
|
for index, statement, _ in selected_statements:
|
|
previous_end = runtime_class.body[index - 1].end_lineno if index else runtime_class.lineno
|
|
removals.append((previous_end, statement.end_lineno))
|
|
if removals:
|
|
insertion_line = min(start for start, _ in removals)
|
|
else:
|
|
insertion_anchor = next(
|
|
(
|
|
statement
|
|
for statement in runtime_class.body
|
|
if _statement_key(statement) == "associate"
|
|
or isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
),
|
|
None,
|
|
)
|
|
insertion_line = (
|
|
insertion_anchor.lineno - 1
|
|
if insertion_anchor is not None
|
|
else runtime_class.end_lineno
|
|
)
|
|
for start, end in sorted(removals, reverse=True):
|
|
runtime_lines[start:end] = []
|
|
|
|
data_keys = sorted(
|
|
set(generated_data) | {key for key in runtime_items if _is_data_key(key)},
|
|
key=numeric_key,
|
|
)
|
|
rebuilt: list[str] = []
|
|
emitted_hits: set[str] = set()
|
|
for data_key in data_keys:
|
|
source_items = generated_items if data_key in generated_data else runtime_items
|
|
source_lines = generated_lines if data_key in generated_data else runtime_block.splitlines(keepends=True)
|
|
source_class = generated_class if data_key in generated_data else runtime_class
|
|
index, statement = source_items[data_key]
|
|
rebuilt.extend(segment(source_lines, source_class, index, statement))
|
|
|
|
hit_key = f"hit{numeric_key(data_key)}"
|
|
if hit_key in runtime_items:
|
|
_, hit_statement = runtime_items[hit_key]
|
|
rebuilt.extend(runtime_block.splitlines(keepends=True)[hit_statement.lineno - 1:hit_statement.end_lineno])
|
|
emitted_hits.add(hit_key)
|
|
|
|
# Retain unusual hitN fields even when no matching dataN exists.
|
|
for hit_key in sorted(
|
|
(
|
|
key
|
|
for key in runtime_items
|
|
if re.fullmatch(r"hit\d+", key) and key not in emitted_hits
|
|
),
|
|
key=numeric_key,
|
|
):
|
|
_, hit_statement = runtime_items[hit_key]
|
|
rebuilt.extend(runtime_block.splitlines(keepends=True)[hit_statement.lineno - 1:hit_statement.end_lineno])
|
|
|
|
runtime_lines[insertion_line:insertion_line] = rebuilt
|
|
return "".join(runtime_lines)
|
|
|
|
|
|
def _sync_generated_comments(runtime_block: str, generated_block: str) -> str:
|
|
"""Sync generated docstrings and leading field comments into runtime code."""
|
|
runtime_class = ast.parse(runtime_block).body[0]
|
|
generated_class = ast.parse(generated_block).body[0]
|
|
if not isinstance(runtime_class, ast.ClassDef) or not isinstance(generated_class, ast.ClassDef):
|
|
return runtime_block
|
|
|
|
runtime_lines = runtime_block.splitlines(keepends=True)
|
|
generated_lines = generated_block.splitlines(keepends=True)
|
|
replacements: list[tuple[int, int, list[str]]] = []
|
|
|
|
# The generated API description is authoritative, but executable methods
|
|
# and assignments continue to come from the runtime implementation.
|
|
runtime_doc = runtime_class.body[0] if runtime_class.body and isinstance(runtime_class.body[0], ast.Expr) and isinstance(runtime_class.body[0].value, ast.Constant) and isinstance(runtime_class.body[0].value.value, str) else None
|
|
generated_doc = generated_class.body[0] if generated_class.body and isinstance(generated_class.body[0], ast.Expr) and isinstance(generated_class.body[0].value, ast.Constant) and isinstance(generated_class.body[0].value.value, str) else None
|
|
if runtime_doc is not None and generated_doc is not None:
|
|
replacements.append(
|
|
(runtime_doc.lineno - 1, runtime_doc.end_lineno, generated_lines[generated_doc.lineno - 1:generated_doc.end_lineno])
|
|
)
|
|
elif runtime_doc is None and generated_doc is not None:
|
|
# Insert the generated skill description immediately below the class
|
|
# declaration when the old runtime class had no docstring.
|
|
replacements.append(
|
|
(
|
|
runtime_class.lineno,
|
|
runtime_class.lineno,
|
|
generated_lines[generated_doc.lineno - 1:generated_doc.end_lineno],
|
|
)
|
|
)
|
|
|
|
generated_by_key = {_statement_key(statement): (index, statement) for index, statement in enumerate(generated_class.body) if _statement_key(statement)}
|
|
for runtime_index, runtime_statement in enumerate(runtime_class.body):
|
|
key = _statement_key(runtime_statement)
|
|
if key not in generated_by_key:
|
|
continue
|
|
generated_index, generated_statement = generated_by_key[key]
|
|
runtime_previous_end = runtime_class.body[runtime_index - 1].end_lineno if runtime_index else runtime_class.lineno
|
|
generated_previous_end = generated_class.body[generated_index - 1].end_lineno if generated_index else generated_class.lineno
|
|
generated_trivia = generated_lines[generated_previous_end:generated_statement.lineno - 1]
|
|
# Only replace trivia when it carries actual generated comments. Blank
|
|
# spacing alone should not disturb hand-formatted runtime code.
|
|
if any(line.lstrip().startswith("#") for line in generated_trivia):
|
|
replacements.append(
|
|
(runtime_previous_end, runtime_statement.lineno - 1, generated_trivia)
|
|
)
|
|
|
|
for start, end, replacement in sorted(replacements, reverse=True):
|
|
runtime_lines[start:end] = replacement
|
|
return "".join(runtime_lines)
|
|
|
|
|
|
def _append_missing_methods(
|
|
runtime_block: str, generated_block: str, method_names: set[str]
|
|
) -> str:
|
|
"""Append generated class methods that the runtime skill does not define."""
|
|
runtime_class = ast.parse(runtime_block).body[0]
|
|
generated_class = ast.parse(generated_block).body[0]
|
|
if not isinstance(runtime_class, ast.ClassDef) or not isinstance(generated_class, ast.ClassDef):
|
|
return runtime_block
|
|
existing = {
|
|
statement.name
|
|
for statement in runtime_class.body
|
|
if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
}
|
|
generated_lines = generated_block.splitlines(keepends=True)
|
|
additions: list[str] = []
|
|
for statement in generated_class.body:
|
|
if not isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
continue
|
|
if statement.name not in method_names or statement.name in existing:
|
|
continue
|
|
additions.extend(generated_lines[statement.lineno - 1:statement.end_lineno])
|
|
existing.add(statement.name)
|
|
if not additions:
|
|
return runtime_block
|
|
return runtime_block.rstrip() + "\n\n" + "".join(additions).rstrip() + "\n"
|
|
|
|
|
|
def _sync_generated_method_docstrings(
|
|
runtime_block: str, generated_block: str, method_names: set[str]
|
|
) -> str:
|
|
"""Use generated method docstrings while preserving runtime method bodies."""
|
|
runtime_class = ast.parse(runtime_block).body[0]
|
|
generated_class = ast.parse(generated_block).body[0]
|
|
if not isinstance(runtime_class, ast.ClassDef) or not isinstance(generated_class, ast.ClassDef):
|
|
return runtime_block
|
|
|
|
runtime_methods = {
|
|
statement.name: statement
|
|
for statement in runtime_class.body
|
|
if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
}
|
|
generated_methods = {
|
|
statement.name: statement
|
|
for statement in generated_class.body
|
|
if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
}
|
|
runtime_lines = runtime_block.splitlines(keepends=True)
|
|
generated_lines = generated_block.splitlines(keepends=True)
|
|
replacements: list[tuple[int, int, list[str]]] = []
|
|
|
|
def docstring_statement(method: ast.FunctionDef | ast.AsyncFunctionDef) -> ast.Expr | None:
|
|
if not method.body:
|
|
return None
|
|
statement = method.body[0]
|
|
if (
|
|
isinstance(statement, ast.Expr)
|
|
and isinstance(statement.value, ast.Constant)
|
|
and isinstance(statement.value.value, str)
|
|
):
|
|
return statement
|
|
return None
|
|
|
|
for name in method_names:
|
|
runtime_method = runtime_methods.get(name)
|
|
generated_method = generated_methods.get(name)
|
|
if runtime_method is None or generated_method is None:
|
|
continue
|
|
generated_doc = docstring_statement(generated_method)
|
|
if generated_doc is None:
|
|
continue
|
|
generated_source = generated_lines[
|
|
generated_doc.lineno - 1:generated_doc.end_lineno
|
|
]
|
|
runtime_doc = docstring_statement(runtime_method)
|
|
if runtime_doc is not None:
|
|
replacements.append(
|
|
(runtime_doc.lineno - 1, runtime_doc.end_lineno, generated_source)
|
|
)
|
|
else:
|
|
# A method without a docstring starts directly with executable
|
|
# code. Insert the generated VP description before that code.
|
|
replacements.append(
|
|
(runtime_method.body[0].lineno - 1, runtime_method.body[0].lineno - 1, generated_source)
|
|
)
|
|
|
|
for start, end, replacement in sorted(replacements, reverse=True):
|
|
runtime_lines[start:end] = replacement
|
|
return "".join(runtime_lines)
|
|
|
|
|
|
def _ensure_get_data_import(source: str) -> str:
|
|
if not re.search(r"(?m)^\s*data\d+\s*=\s*get_data\(", source):
|
|
return source
|
|
pattern = re.compile(r"(?m)^(from\s+core\.basic\.skill\s+import\s+)([^\n]+)$")
|
|
match = pattern.search(source)
|
|
if match is None:
|
|
raise ValueError("generated data requires a core.basic.skill import")
|
|
imports = [item.strip() for item in match.group(2).split(",")]
|
|
if "get_data" in imports:
|
|
return source
|
|
imports.append("get_data")
|
|
return source[:match.start()] + match.group(1) + ", ".join(imports) + source[match.end():]
|
|
|
|
|
|
def _sync_module_assignment(
|
|
runtime_source: str, generated_source: str, field: str
|
|
) -> str:
|
|
"""Make a module-level assignment match the generated module."""
|
|
runtime_tree = ast.parse(runtime_source)
|
|
generated_tree = ast.parse(generated_source)
|
|
generated_statement = next(
|
|
(
|
|
statement
|
|
for statement in generated_tree.body
|
|
if _statement_key(statement) == field
|
|
),
|
|
None,
|
|
)
|
|
if generated_statement is None:
|
|
return runtime_source
|
|
runtime_statements = [
|
|
statement
|
|
for statement in runtime_tree.body
|
|
if _statement_key(statement) == field
|
|
]
|
|
runtime_lines = runtime_source.splitlines(keepends=True)
|
|
generated_lines = generated_source.splitlines(keepends=True)
|
|
assignment = generated_lines[
|
|
generated_statement.lineno - 1:generated_statement.end_lineno
|
|
]
|
|
if runtime_statements:
|
|
insertion_line = runtime_statements[0].lineno - 1
|
|
for statement in sorted(runtime_statements, key=lambda item: item.lineno, reverse=True):
|
|
runtime_lines[statement.lineno - 1:statement.end_lineno] = []
|
|
replacement = assignment
|
|
else:
|
|
imports = [
|
|
statement
|
|
for statement in runtime_tree.body
|
|
if isinstance(statement, (ast.Import, ast.ImportFrom))
|
|
]
|
|
insertion_line = imports[-1].end_lineno if imports else 0
|
|
replacement = [*assignment, "\n"]
|
|
runtime_lines[insertion_line:insertion_line] = replacement
|
|
return "".join(runtime_lines)
|
|
|
|
|
|
def _comment(block: str, name: str) -> str:
|
|
lines = [f"# MIGRATION: character 中没有匹配身份的技能“{name}”,暂不启用\n"]
|
|
lines.extend("# " + line.rstrip() + "\n" if line.strip() else "#\n" for line in block.splitlines())
|
|
return "".join(lines)
|
|
|
|
|
|
def _identity_indexes(skills, path):
|
|
"""校验稳定身份唯一性,禁止重复引用或 UUID 被字典静默覆盖。"""
|
|
indexes = {}
|
|
for field in ('ref_key', 'uuid', 'skill_name'):
|
|
index = {}
|
|
for skill in skills:
|
|
value = getattr(skill, field)
|
|
if value is None:
|
|
continue
|
|
if value in index:
|
|
raise ValueError(f'{path}: duplicate {field}: {value}')
|
|
index[value] = skill
|
|
indexes[field] = index
|
|
return indexes
|
|
|
|
|
|
def _match_skill(skill, indexes):
|
|
"""优先 ref、其次 UUID;仅在缺少可比较身份时兼容旧中文名称。"""
|
|
by_ref = indexes['ref_key'].get(skill.ref_key)
|
|
by_uuid = indexes['uuid'].get(skill.uuid)
|
|
if by_ref is not None and by_uuid is not None and by_ref is not by_uuid:
|
|
raise ValueError(f'技能 ref 与 UUID 指向不同目标: {skill.skill_name}')
|
|
candidate = by_ref or by_uuid or indexes['skill_name'].get(skill.skill_name)
|
|
if candidate is None:
|
|
return None
|
|
if skill.ref_key and candidate.ref_key and skill.ref_key != candidate.ref_key:
|
|
raise ValueError(f'技能引用冲突: {skill.skill_name}: {candidate.ref} -> {skill.ref}')
|
|
if by_ref is None and skill.uuid and candidate.uuid and skill.uuid != candidate.uuid:
|
|
raise ValueError(f'同名技能 UUID 冲突: {skill.skill_name}')
|
|
return candidate
|
|
|
|
|
|
def _ensure_ref_imports(source, generated):
|
|
"""补齐生成代码的职业 Ref 导入,保留原有别名,拒绝别名指向不同对象。"""
|
|
tree = ast.parse(source)
|
|
imports = {
|
|
item.asname or item.name: (node.module, item.name)
|
|
for node in tree.body if isinstance(node, ast.ImportFrom) for item in node.names
|
|
}
|
|
additions = []
|
|
for node in ast.parse(generated).body:
|
|
if not isinstance(node, ast.ImportFrom) or not (node.module or '').startswith('core.character.refs.'):
|
|
continue
|
|
for item in node.names:
|
|
alias = item.asname or item.name
|
|
if alias in imports:
|
|
if imports[alias] != (node.module, item.name):
|
|
raise ValueError(f'职业引用导入别名冲突: {alias}')
|
|
else:
|
|
additions.append(f'from {node.module} import {item.name}' + (f' as {item.asname}' if item.asname else '') + '\n')
|
|
imports[alias] = (node.module, item.name)
|
|
if not additions:
|
|
return source
|
|
position = max((node.end_lineno for node in tree.body if isinstance(node, (ast.Import, ast.ImportFrom))), default=0)
|
|
if position == 0:
|
|
return ''.join(additions) + '\n' + source.lstrip('\r\n')
|
|
lines = source.splitlines(keepends=True)
|
|
lines[position:position] = additions
|
|
return ''.join(lines)
|
|
|
|
|
|
def merge(generated_path: Path, character_path: Path) -> tuple[str, dict[str, int]]:
|
|
generated = generated_path.read_text(encoding="utf-8")
|
|
character = character_path.read_text(encoding="utf-8")
|
|
generated_skills = _skills(generated, generated_path)
|
|
character_skills = _skills(character, character_path)
|
|
if not generated_skills or not character_skills:
|
|
raise ValueError(f"missing skills: {generated_path} or {character_path}")
|
|
runtime = _identity_indexes(character_skills, character_path)
|
|
_identity_indexes(generated_skills, generated_path)
|
|
used = set()
|
|
|
|
character_header_end, character_footer_start = _module_skill_bounds(character, character_path)
|
|
generated_header_end, _ = _module_skill_bounds(generated, generated_path)
|
|
chunks = [character[:character_header_end].rstrip() + "\n\n"]
|
|
chunks.append(generated[generated_header_end:generated_skills[0].start])
|
|
cursor = generated_skills[0].start
|
|
matched = commented = 0
|
|
for index, generated_skill in enumerate(generated_skills):
|
|
if index:
|
|
chunks.append(generated[cursor:generated_skill.start])
|
|
runtime_skill = _match_skill(generated_skill, runtime)
|
|
if runtime_skill is None:
|
|
chunks.append(_comment(generated[generated_skill.start:generated_skill.end], generated_skill.skill_name))
|
|
commented += 1
|
|
else:
|
|
if runtime_skill.class_name in used:
|
|
raise ValueError(f'多个生成技能匹配同一运行时技能: {runtime_skill.class_name}')
|
|
used.add(runtime_skill.class_name)
|
|
block = character[runtime_skill.start:runtime_skill.end]
|
|
generated_block = generated[generated_skill.start:generated_skill.end]
|
|
if generated_skill.ref is not None and runtime_skill.ref is None:
|
|
block = _set_generated_field(block, 'ref', generated_skill.ref)
|
|
# 名称保留运行时值,防止破坏未迁移的名称关联;稳定 ref 匹配不要求名称相同。
|
|
if generated_skill.uuid is not None and generated_skill.ref_key == runtime_skill.ref_key and generated_skill.ref_key is not None:
|
|
block = _set_generated_field(block, 'uuid', repr(generated_skill.uuid))
|
|
block = _sync_generated_assignment(block, generated_block, "vps")
|
|
block = _sync_generated_data(block, generated_block)
|
|
block = _sync_generated_comments(block, generated_block)
|
|
block = _set_generated_field(block, "position", generated_skill.position)
|
|
block = _set_generated_field(block, "cellPos", generated_skill.cell_pos)
|
|
block = _append_missing_methods(block, generated_block, {"vp_1", "vp_2"})
|
|
block = _sync_generated_method_docstrings(
|
|
block, generated_block, {"vp_1", "vp_2"}
|
|
)
|
|
chunks.append(block.rstrip() + "\n")
|
|
matched += 1
|
|
cursor = generated_skill.end
|
|
# 未出现在新生成数据中的手写技能必须保留,不能随布局重建被删除。
|
|
for skill in character_skills:
|
|
if skill.class_name not in used:
|
|
chunks.append('\n' + character[skill.start:skill.end].rstrip() + '\n')
|
|
chunks.append("\n" + character[character_footer_start:].lstrip("\r\n"))
|
|
merged = "".join(chunks)
|
|
merged = "\n".join(line.rstrip() for line in merged.splitlines()) + "\n"
|
|
merged = _sync_module_assignment(merged, generated, "prefix")
|
|
merged = _ensure_get_data_import(merged)
|
|
merged = _ensure_ref_imports(merged, generated)
|
|
return merged, {
|
|
"generated": len(generated_skills), "runtime": len(character_skills),
|
|
"matched": matched, "commented": commented,
|
|
"runtime_only": len(character_skills) - matched,
|
|
}
|
|
|
|
|
|
def _atomic_write(path: Path, content: str) -> None:
|
|
"""Write beside the target and atomically replace it on Windows.
|
|
|
|
Writing an already-open Python source file directly can intermittently
|
|
produce ``OSError: [Errno 22]`` on Windows. A sibling temporary file also
|
|
prevents a failed write from leaving a partially truncated module.
|
|
"""
|
|
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
try:
|
|
temporary.write_bytes(content.encode("utf-8"))
|
|
for attempt in range(3):
|
|
try:
|
|
os.replace(temporary, path)
|
|
return
|
|
except OSError:
|
|
if attempt == 2:
|
|
raise
|
|
time.sleep(0.1 * (attempt + 1))
|
|
except OSError as error:
|
|
raise OSError(
|
|
error.errno,
|
|
f"failed to replace {path} using temporary file {temporary}: {error.strerror}",
|
|
str(path),
|
|
) from error
|
|
finally:
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--check", action="store_true", help="validate without writing")
|
|
parser.add_argument("--generated-root", type=Path, default=GENERATED_ROOT)
|
|
parser.add_argument("--character-root", type=Path, default=CHARACTER_ROOT)
|
|
args = parser.parse_args()
|
|
totals = {key: 0 for key in ("files", "generated", "runtime", "matched", "commented", "runtime_only")}
|
|
for character_path in sorted(args.character_root.rglob("*.py")):
|
|
if character_path.name == '__init__.py':
|
|
continue
|
|
# 使用数据 prefix 找生成来源,输出圣骑士等变体也能复用来源职业。
|
|
module = ast.parse(character_path.read_text(encoding='utf-8'))
|
|
prefix = next((node.value.value for node in module.body
|
|
if _statement_key(node) == 'prefix' and isinstance(node.value, ast.Constant)), None)
|
|
relative = character_path.relative_to(args.character_root)
|
|
generated_relative = Path(*prefix.split('/')[:2]).with_suffix('.py') if isinstance(prefix, str) else relative
|
|
generated_path = args.generated_root / generated_relative
|
|
if not generated_path.exists():
|
|
print(f"SKIP {relative}: no generated profession")
|
|
continue
|
|
output, stats = merge(generated_path, character_path)
|
|
ast.parse(output, filename=str(character_path))
|
|
if not args.check:
|
|
_atomic_write(character_path, output)
|
|
totals["files"] += 1
|
|
for key, value in stats.items():
|
|
totals[key] += value
|
|
print(f"{'CHECK' if args.check else 'WRITE'} {relative}: "
|
|
f"matched={stats['matched']} commented={stats['commented']} runtime_only={stats['runtime_only']}")
|
|
print("TOTAL " + " ".join(f"{key}={value}" for key, value in totals.items()))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|