dse-core 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dse_core/__init__.py +40 -0
- dse_core/cli.py +148 -0
- dse_core/example/content/starter.yml +79 -0
- dse_core/example/dse.yml +64 -0
- dse_core/graph.py +125 -0
- dse_core/loader.py +99 -0
- dse_core/manifest.py +76 -0
- dse_core/modes.py +72 -0
- dse_core/plugins/__init__.py +5 -0
- dse_core/plugins/combat.py +258 -0
- dse_core/plugins/console_io.py +60 -0
- dse_core/plugins/engine.py +204 -0
- dse_core/plugins/scenes.py +129 -0
- dse_core/plugins/state.py +151 -0
- dse_core/runtime.py +152 -0
- dse_core/safety.py +101 -0
- dse_core/schema/scene.schema.json +139 -0
- dse_core/schema.py +38 -0
- dse_core/sdk.py +146 -0
- dse_core-0.1.0.dist-info/METADATA +117 -0
- dse_core-0.1.0.dist-info/RECORD +25 -0
- dse_core-0.1.0.dist-info/WHEEL +5 -0
- dse_core-0.1.0.dist-info/entry_points.txt +2 -0
- dse_core-0.1.0.dist-info/licenses/LICENSE +21 -0
- dse_core-0.1.0.dist-info/top_level.txt +1 -0
dse_core/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""dse-core · 字典场景引擎(Python 版)—— 场景即字典,能力即插件。
|
|
2
|
+
|
|
3
|
+
快速上手::
|
|
4
|
+
|
|
5
|
+
from dse_core import create_engine, play, inspect_project
|
|
6
|
+
|
|
7
|
+
play(root="./my-game") # 装配并跑完整局
|
|
8
|
+
ctx = create_engine(root="./my-game") # 只装配,自行驱动主循环
|
|
9
|
+
inspect_project("./my-game") # 只读体检
|
|
10
|
+
|
|
11
|
+
Node/Cordis 版(姊妹仓库 dse-core)的移植;内容包为 YAML 数据文件,
|
|
12
|
+
条件表达式走 AST 白名单求值(dse_core.safety)。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .runtime import Context, Service
|
|
16
|
+
from .sdk import (
|
|
17
|
+
__version__,
|
|
18
|
+
analyze,
|
|
19
|
+
create_engine,
|
|
20
|
+
inspect_project,
|
|
21
|
+
load_dict,
|
|
22
|
+
load_packs,
|
|
23
|
+
load_project,
|
|
24
|
+
play,
|
|
25
|
+
scene_chars,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"__version__",
|
|
30
|
+
"Context",
|
|
31
|
+
"Service",
|
|
32
|
+
"analyze",
|
|
33
|
+
"create_engine",
|
|
34
|
+
"inspect_project",
|
|
35
|
+
"load_dict",
|
|
36
|
+
"load_packs",
|
|
37
|
+
"load_project",
|
|
38
|
+
"play",
|
|
39
|
+
"scene_chars",
|
|
40
|
+
]
|
dse_core/cli.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""命令行 dse:play / scenes / info / check。
|
|
2
|
+
|
|
3
|
+
移植自 dse-core(Node) cli.js 的 P1 子集(packs / new / doctor / saves
|
|
4
|
+
属 P2)。约定:stdout 只放结果,诊断走 stderr;check 发现问题退出码 1。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import __version__, graph
|
|
15
|
+
from .loader import load_pack_file, load_project
|
|
16
|
+
from .plugins.scenes import find_dangling, validate_scenes
|
|
17
|
+
from .sdk import inspect_project, load_dict, play
|
|
18
|
+
from .schema import validate_pack
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _print_banner() -> None:
|
|
22
|
+
title = f"字 典 场 景 引 擎 v{__version__}"
|
|
23
|
+
width = sum(2 if ord(ch) > 0x2E7F else 1 for ch in title)
|
|
24
|
+
border = "═" * 40
|
|
25
|
+
print(f"╔{border}╗")
|
|
26
|
+
print(f"║ {title}{' ' * max(0, 37 - width)}║")
|
|
27
|
+
print(f"║ Dictionary Scene Engine (Python){' ' * max(0, 37 - 32)}║")
|
|
28
|
+
print(f"╚{border}╝")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _resolve_root(root: str | None) -> str:
|
|
32
|
+
"""未指定 --root 时:当前目录有 dse.yml 就用它,否则用包内自带示例工程。"""
|
|
33
|
+
if root is not None:
|
|
34
|
+
return root
|
|
35
|
+
cwd = Path.cwd()
|
|
36
|
+
if (cwd / "dse.yml").is_file():
|
|
37
|
+
return str(cwd)
|
|
38
|
+
from .sdk import engine_root
|
|
39
|
+
|
|
40
|
+
return str(engine_root())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _cmd_play(args) -> int:
|
|
44
|
+
if args.input:
|
|
45
|
+
os.environ["DSE_INPUT"] = args.input
|
|
46
|
+
if args.banner:
|
|
47
|
+
_print_banner()
|
|
48
|
+
return play(root=args.root, entry=args.entry)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _cmd_scenes(args) -> int:
|
|
52
|
+
data = load_dict(args.root)
|
|
53
|
+
print(f"工程: {data['root']}(入口 {data['entry']},内容包 {len(data['packFiles'])} 个)")
|
|
54
|
+
for scene_id in data["ids"]:
|
|
55
|
+
scene = data["dict"][scene_id]
|
|
56
|
+
mode = scene.get("mode") or "normal"
|
|
57
|
+
title = scene.get("title") or scene_id
|
|
58
|
+
n_choices = len(scene.get("choices") or [])
|
|
59
|
+
print(f" {scene_id:<20} [{mode:<8}] {title}({n_choices} 选项)")
|
|
60
|
+
print(f"共 {len(data['ids'])} 个场景")
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _cmd_info(args) -> int:
|
|
65
|
+
report = inspect_project(args.root)
|
|
66
|
+
print(f"工程: {report['root']}")
|
|
67
|
+
print(f"入口: {report['entry']}")
|
|
68
|
+
print(f"场景: {report['scenes']} | 结局: {report['endings']}(可达 {report['endingsReachable']})")
|
|
69
|
+
print(f"内容包: {', '.join(report['packFiles']) or '(无)'}")
|
|
70
|
+
for m in report["packs"]:
|
|
71
|
+
print(f" - {m['id']} v{m['version']}《{m['name']}》 {m['scenes']} 场景 modes={m['modes']}")
|
|
72
|
+
if report["dangling"]:
|
|
73
|
+
print("悬空跳转:", file=sys.stderr)
|
|
74
|
+
for d in report["dangling"]:
|
|
75
|
+
print(f" - {d['from']} -> {d['to']} ({d['kind']})", file=sys.stderr)
|
|
76
|
+
if report["unreachable"]:
|
|
77
|
+
print(f"警告: 不可达场景 {', '.join(report['unreachable'])}", file=sys.stderr)
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _cmd_check(args) -> int:
|
|
82
|
+
root = Path(args.root)
|
|
83
|
+
problems: list[str] = []
|
|
84
|
+
try:
|
|
85
|
+
project = load_project(root)
|
|
86
|
+
except Exception as exc: # noqa: BLE001 — 加载失败本身就是 check 的结论
|
|
87
|
+
print(f"[check] 工程加载失败: {exc}", file=sys.stderr)
|
|
88
|
+
return 1
|
|
89
|
+
|
|
90
|
+
# 1. Schema + 结构校验(逐内容包)
|
|
91
|
+
for rel in project.pack_files:
|
|
92
|
+
path = (root / rel).resolve()
|
|
93
|
+
_, scenes = load_pack_file(path)
|
|
94
|
+
problems += validate_pack(scenes, source=rel)
|
|
95
|
+
problems += validate_scenes(scenes, rel)
|
|
96
|
+
|
|
97
|
+
# 2. 悬空跳转
|
|
98
|
+
problems += find_dangling(project.dict)
|
|
99
|
+
|
|
100
|
+
# 3. 图分析(不可达仅警告,不判失败)
|
|
101
|
+
report = graph.analyze(project.dict, project.entry)
|
|
102
|
+
|
|
103
|
+
if problems:
|
|
104
|
+
print(f"[check] 发现 {len(problems)} 个问题:", file=sys.stderr)
|
|
105
|
+
for p in problems:
|
|
106
|
+
print(p, file=sys.stderr)
|
|
107
|
+
return 1
|
|
108
|
+
print(f"[check] 通过:{report['scenes']} 场景 / {len(report['endings'])} 结局(可达 {len(report['endingsReachable'])})")
|
|
109
|
+
if report["unreachable"]:
|
|
110
|
+
print(f"警告: 不可达场景 {', '.join(report['unreachable'])}", file=sys.stderr)
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def main(argv: list[str] | None = None) -> int:
|
|
115
|
+
parser = argparse.ArgumentParser(
|
|
116
|
+
prog="dse",
|
|
117
|
+
description="字典场景引擎 CLI —— 场景即字典,能力即插件",
|
|
118
|
+
)
|
|
119
|
+
parser.add_argument("--version", action="version", version=f"dse-core {__version__}")
|
|
120
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
121
|
+
|
|
122
|
+
p_play = sub.add_parser("play", help="开始游戏")
|
|
123
|
+
p_play.add_argument("--root", default=None, help="工程根目录(含 dse.yml);缺省:当前目录有则用,否则用包内示例")
|
|
124
|
+
p_play.add_argument("--entry", help="覆盖入口场景 ID")
|
|
125
|
+
p_play.add_argument("--input", help='脚本输入序列,逗号分隔(如 "1,1,a,a"),用尽自动退出')
|
|
126
|
+
p_play.add_argument("--banner", action="store_true", help="打印启动横幅")
|
|
127
|
+
p_play.set_defaults(func=_cmd_play)
|
|
128
|
+
|
|
129
|
+
for name, help_text, func in (
|
|
130
|
+
("scenes", "列出全部场景", _cmd_scenes),
|
|
131
|
+
("info", "工程体检(结局/悬空/不可达)", _cmd_info),
|
|
132
|
+
("check", "校验内容(Schema/悬空),有问题退出码 1", _cmd_check),
|
|
133
|
+
):
|
|
134
|
+
p = sub.add_parser(name, help=help_text)
|
|
135
|
+
p.add_argument("--root", default=None, help="工程根目录(含 dse.yml);缺省:当前目录有则用,否则用包内示例")
|
|
136
|
+
p.set_defaults(func=func)
|
|
137
|
+
|
|
138
|
+
args = parser.parse_args(argv)
|
|
139
|
+
args.root = _resolve_root(args.root)
|
|
140
|
+
try:
|
|
141
|
+
return args.func(args)
|
|
142
|
+
except (FileNotFoundError, ValueError, KeyError) as exc:
|
|
143
|
+
print(f"[dse] {exc}", file=sys.stderr)
|
|
144
|
+
return 1
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
sys.exit(main())
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# 内容包:starter(P1 · 战斗线最小可玩版)
|
|
2
|
+
#
|
|
3
|
+
# 定位与 Node 版 starter.js 相同:骨架演示,不是游戏 —— 让新同学读完这
|
|
4
|
+
# 一个文件就知道内容包能写什么。P1 覆盖 normal / battle 两种模式与
|
|
5
|
+
# @restart;trade / dialogue / diary 线随 P3 能力插件补齐后迁入。
|
|
6
|
+
pack:
|
|
7
|
+
id: starter
|
|
8
|
+
name: 启程 · 山道
|
|
9
|
+
version: 0.1.0
|
|
10
|
+
entry: start
|
|
11
|
+
description: P1 示例:一条跑通 normal/battle/结局/@restart 的最小可玩线路
|
|
12
|
+
author: dse-core
|
|
13
|
+
modes: [battle]
|
|
14
|
+
requires:
|
|
15
|
+
plugins: [combat]
|
|
16
|
+
state: [gold, hp, atk]
|
|
17
|
+
|
|
18
|
+
scenes:
|
|
19
|
+
# ── 枢纽:进山,或回屋 ──────────────────────────────────────
|
|
20
|
+
start:
|
|
21
|
+
title: 村口 · 启程
|
|
22
|
+
text: 天没亮透,土路泛着潮气。你背上行囊,你娘把最后一包干粮塞进侧袋,手在包袱上按了按,没说话。往北是山,山里有过狼;身后是自家那扇还没上漆的木门。
|
|
23
|
+
choices:
|
|
24
|
+
- text: 往北进山
|
|
25
|
+
do: { 'flags.starter.mountain': true }
|
|
26
|
+
goto: forest
|
|
27
|
+
- text: 回屋,今天不出门
|
|
28
|
+
goto: inn-wait
|
|
29
|
+
|
|
30
|
+
# ── 战斗线:normal → battle ─────────────────────────────────
|
|
31
|
+
forest:
|
|
32
|
+
title: 山道 · 林深处
|
|
33
|
+
text: 松针压了一路,越往里越静。走到半坡,风忽然停了,林子里只剩你自己的脚步声。前面的灌木动了一下——一只独眼狼从阴影里站起来,耳朵贴着背,喉咙里滚着低音。它挡的是唯一的路。腰间那柄锈剑冰凉。
|
|
34
|
+
choices:
|
|
35
|
+
- text: 拔剑
|
|
36
|
+
goto: wolf-fight
|
|
37
|
+
- text: 握着剑柄,慢慢退回去
|
|
38
|
+
do: { 'flags.starter.fled': true }
|
|
39
|
+
goto: start
|
|
40
|
+
|
|
41
|
+
wolf-fight:
|
|
42
|
+
title: 山道 · 遭遇
|
|
43
|
+
text: 狼绕着你走了半圈,那只独眼始终盯着你的右手,没有眨过。你想起村里老猎户的话:它看的不是你的人,是你什么时候先动。于是你也不动,任由汗从额头滑到下巴,等它先犯错。
|
|
44
|
+
mode: battle
|
|
45
|
+
battle: { enemy: wolf, onWin: forest-win, onLose: forest-lose, onFlee: start }
|
|
46
|
+
|
|
47
|
+
forest-win:
|
|
48
|
+
title: 山道 · 狼倒下之后
|
|
49
|
+
text: 狼倒在松针上,血把针叶黏成一团,慢慢不再起伏。你喘了很久才直起腰。狼窝旁边压着一只旧木匣,里头是一枚生锈的渡口令牌。顺着狼来的方向往下走,能听见水声——但雾太浓,今天过不了河。
|
|
50
|
+
onEnter: { 'items.token': +1, 'flags.starter.wolfWin': true }
|
|
51
|
+
choices:
|
|
52
|
+
- text: 揣好令牌,回村
|
|
53
|
+
do: { 'flags.starter.backHome': true }
|
|
54
|
+
goto: start
|
|
55
|
+
- text: 力气还够,在林间再过一夜
|
|
56
|
+
if: 'hp >= 10'
|
|
57
|
+
goto: end-hero
|
|
58
|
+
|
|
59
|
+
forest-lose:
|
|
60
|
+
title: 结局 · 山道无归
|
|
61
|
+
text: 你倒下的时候,最后看见的是松针间漏下来的那点天光。狼没有立刻动口,它先闻了闻你的手——那手上什么也没有。这山上从此多了一条不该走的道,可每年还是有人走,因为每个人都觉得自己跟别人不一样。
|
|
62
|
+
choices:
|
|
63
|
+
- text: 再来一次
|
|
64
|
+
goto: '@restart'
|
|
65
|
+
|
|
66
|
+
# ── 结局 ────────────────────────────────────────────────────
|
|
67
|
+
end-hero:
|
|
68
|
+
title: 结局 · 过河的人
|
|
69
|
+
text: 你在林间生了一堆火。令牌在怀里,锈迹被体温焐得温热。河那头有什么你不知道,但你知道自己是从哪边过来的——这就够了。
|
|
70
|
+
choices:
|
|
71
|
+
- text: 再来一次
|
|
72
|
+
goto: '@restart'
|
|
73
|
+
|
|
74
|
+
inn-wait:
|
|
75
|
+
title: 结局 · 留在岸这边
|
|
76
|
+
text: 你回了屋,把门关上。路还摆在那儿,明天还在——你这样想,然后就睡着了。有些山,是不用过的。
|
|
77
|
+
choices:
|
|
78
|
+
- text: 再来一次
|
|
79
|
+
goto: '@restart'
|
dse_core/example/dse.yml
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# 字典场景引擎内核(dse-core)· 插件清单(Python 版)
|
|
2
|
+
#
|
|
3
|
+
# 与内容仓库的关系:本文件是**引擎默认开发配置**,只挂自带示例内容包
|
|
4
|
+
# (content/starter.yml)。真正的项目应在自己的仓库里放一份 dse.yml,
|
|
5
|
+
# 指向自己的内容包与数值表 —— 引擎代码一行都不用改。
|
|
6
|
+
#
|
|
7
|
+
# 依赖由运行时按 inject 声明拓扑编排:全部就绪后才会 setup。
|
|
8
|
+
#
|
|
9
|
+
# 插件分层:
|
|
10
|
+
# 内核层 scenes / state / io / engine 引擎不可拔的四个基础服务
|
|
11
|
+
# 能力层 combat 接管 scene.mode 的可选玩法
|
|
12
|
+
# (P3 补 trade / dialogue / diary)
|
|
13
|
+
|
|
14
|
+
# ── 内核层 ──────────────────────────────────────────────────────
|
|
15
|
+
- id: scenes
|
|
16
|
+
config:
|
|
17
|
+
packs: # 内容包列表(后者覆盖前者同 ID 场景)
|
|
18
|
+
- ./content/starter.yml # 引擎自带示例;换成自己的内容包即可
|
|
19
|
+
|
|
20
|
+
- id: state
|
|
21
|
+
config:
|
|
22
|
+
# 时间循环保留域:goto '@loop:<场景>' 时保留这些前缀下的知识,
|
|
23
|
+
# 其余状态回 initial("知识跨轮保留、消耗态每轮复原")
|
|
24
|
+
loopKeep:
|
|
25
|
+
- clues.loop
|
|
26
|
+
- flags.loop
|
|
27
|
+
# 玩家初始状态。内容包用到的顶层字段必须在这里预声明,
|
|
28
|
+
# 否则条件表达式求值时报 NameError(dse check 会拦下)
|
|
29
|
+
initial:
|
|
30
|
+
name: 旅人
|
|
31
|
+
hp: 20
|
|
32
|
+
maxHp: 20
|
|
33
|
+
atk: 3 # 基础攻击(+ 武器加成 = 实际 ATK)
|
|
34
|
+
def: 2 # 防御(每点抵消 defenseFactor 点伤害)
|
|
35
|
+
crit: 0.15 # 暴击率
|
|
36
|
+
gold: 20
|
|
37
|
+
items: {}
|
|
38
|
+
flags:
|
|
39
|
+
loop: {} # 时间循环中间层(预声明,条件链才不抛错)
|
|
40
|
+
clues:
|
|
41
|
+
loop: {} # 时间循环跨轮知识(loopKeep 保留域)
|
|
42
|
+
|
|
43
|
+
- id: io
|
|
44
|
+
|
|
45
|
+
- id: engine
|
|
46
|
+
config:
|
|
47
|
+
entry: start # 入场场景 ID(可用 DSE_ENTRY / --entry 覆盖)
|
|
48
|
+
prompt: '>> '
|
|
49
|
+
|
|
50
|
+
# ── 能力层(每种能力接管一种 scene.mode)────────────────────────
|
|
51
|
+
- id: combat
|
|
52
|
+
config:
|
|
53
|
+
formula: # 数值公式参数(可选,覆盖默认值)
|
|
54
|
+
variance: 0.2 # 伤害随机波动 ±20%
|
|
55
|
+
defenseFactor: 0.5 # 每点防御抵消的伤害
|
|
56
|
+
critMult: 1.8 # 暴击倍率
|
|
57
|
+
defendCut: 0.5 # 防御姿态减伤 50%
|
|
58
|
+
fleeChance: 0.45 # 逃跑成功率
|
|
59
|
+
weapons: # 武器加成(按背包数量累加到玩家 ATK)
|
|
60
|
+
rusty_sword: { name: 锈剑, atk: 4 }
|
|
61
|
+
silver_dagger: { name: 银匕首, atk: 3 }
|
|
62
|
+
enemies: # 敌人字典(数据驱动,与场景字典同哲学)
|
|
63
|
+
wolf: { name: 独眼狼, hp: 12, atk: 3, def: 0, crit: 0.10, drops: { gold: +5, 'items.wolf_pelt': +1 } }
|
|
64
|
+
bandit: { name: 沙盗, hp: 14, atk: 3, def: 1, crit: 0.12, drops: { gold: +12 } }
|
dse_core/graph.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""场景图分析(纯函数,不依赖运行时)。
|
|
2
|
+
|
|
3
|
+
移植自 dse-core(Node) lib/graph.mjs:从「场景字典 + 入口」出发做静态图
|
|
4
|
+
分析 —— 可达性、结局、悬空边、入度、深度、最短路径。引擎化之前这些逻辑
|
|
5
|
+
散落在 lint 与测试里各写一份(BFS 口径还不一致),现在收敛到一处共用。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .modes import is_special_goto, mode_edges, resolve_goto
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def is_ending(scene) -> bool:
|
|
14
|
+
"""结局判定:normal 模式且无选项,或所有选项都是特殊目标(@restart 等)。"""
|
|
15
|
+
if not scene:
|
|
16
|
+
return False
|
|
17
|
+
if scene.get("mode") and scene["mode"] != "normal":
|
|
18
|
+
return False
|
|
19
|
+
choices = scene.get("choices") or []
|
|
20
|
+
return len(choices) == 0 or all(str(c.get("goto") or "").startswith("@") for c in choices)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def edges_of(scene_id: str, scene) -> list[dict]:
|
|
24
|
+
"""单个场景的全部出边(选项 goto + 模式流转边)。"""
|
|
25
|
+
out: list[dict] = []
|
|
26
|
+
for choice in (scene or {}).get("choices") or []:
|
|
27
|
+
raw = str(choice.get("goto") or "")
|
|
28
|
+
if not raw:
|
|
29
|
+
continue
|
|
30
|
+
target = resolve_goto(raw)
|
|
31
|
+
if not target or target.startswith("@"):
|
|
32
|
+
continue # @restart 不参与可达
|
|
33
|
+
out.append({"from": scene_id, "to": target, "kind": "choice", "raw": raw})
|
|
34
|
+
mode = (scene or {}).get("mode") or "normal"
|
|
35
|
+
block = (scene or {}).get(mode) or {}
|
|
36
|
+
for key in mode_edges(mode):
|
|
37
|
+
target = block.get(key)
|
|
38
|
+
if target is not None:
|
|
39
|
+
out.append({"from": scene_id, "to": str(target), "kind": "mode", "raw": f"{mode}.{key}"})
|
|
40
|
+
return out
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def reachable_from(scenes: dict, entry: str) -> dict:
|
|
44
|
+
"""从 entry 出发 BFS,返回 {reachable, order, depth}。
|
|
45
|
+
|
|
46
|
+
注意:Python 的 set 不保序,order 单独用列表记录(Node 的 Set 保插入序)。
|
|
47
|
+
"""
|
|
48
|
+
reachable: set[str] = set()
|
|
49
|
+
order: list[str] = []
|
|
50
|
+
depth: dict[str, int] = {}
|
|
51
|
+
queue: list[tuple[str, int]] = [(entry, 0)]
|
|
52
|
+
while queue:
|
|
53
|
+
scene_id, d = queue.pop(0)
|
|
54
|
+
if scene_id in reachable or scene_id not in scenes:
|
|
55
|
+
continue
|
|
56
|
+
reachable.add(scene_id)
|
|
57
|
+
order.append(scene_id)
|
|
58
|
+
depth[scene_id] = d
|
|
59
|
+
for edge in edges_of(scene_id, scenes[scene_id]):
|
|
60
|
+
if edge["to"] not in reachable:
|
|
61
|
+
queue.append((edge["to"], d + 1))
|
|
62
|
+
return {"reachable": reachable, "order": order, "depth": depth}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def analyze(scenes: dict, entry: str) -> dict:
|
|
66
|
+
"""全图分析:可达性 + 悬空 + 结局 + 入度统计。"""
|
|
67
|
+
result = reachable_from(scenes, entry)
|
|
68
|
+
reachable, order, depth = result["reachable"], result["order"], result["depth"]
|
|
69
|
+
dangling: list[dict] = []
|
|
70
|
+
ending_ids: list[str] = []
|
|
71
|
+
in_degree: dict[str, int] = {}
|
|
72
|
+
|
|
73
|
+
for scene_id, scene in scenes.items():
|
|
74
|
+
if is_ending(scene):
|
|
75
|
+
ending_ids.append(scene_id)
|
|
76
|
+
for edge in edges_of(scene_id, scene):
|
|
77
|
+
in_degree[edge["to"]] = in_degree.get(edge["to"], 0) + 1
|
|
78
|
+
if edge["to"] not in scenes:
|
|
79
|
+
dangling.append(edge)
|
|
80
|
+
# 特殊目标自身的合法性(@loop: 目标必须存在)
|
|
81
|
+
for choice in (scene or {}).get("choices") or []:
|
|
82
|
+
raw = str(choice.get("goto") or "")
|
|
83
|
+
if raw.startswith("@") and not is_special_goto(raw):
|
|
84
|
+
dangling.append({"from": scene_id, "to": raw, "kind": "special", "raw": raw})
|
|
85
|
+
elif raw.startswith("@loop:"):
|
|
86
|
+
target = resolve_goto(raw)
|
|
87
|
+
if target not in scenes:
|
|
88
|
+
dangling.append({"from": scene_id, "to": target, "kind": "loop", "raw": raw})
|
|
89
|
+
|
|
90
|
+
unreachable = [sid for sid in scenes if sid not in reachable]
|
|
91
|
+
return {
|
|
92
|
+
"scenes": len(scenes),
|
|
93
|
+
"reachable": reachable,
|
|
94
|
+
"reachableCount": len(reachable),
|
|
95
|
+
"unreachable": unreachable,
|
|
96
|
+
"order": order,
|
|
97
|
+
"depth": depth,
|
|
98
|
+
"dangling": dangling,
|
|
99
|
+
"endings": ending_ids,
|
|
100
|
+
"endingsReachable": [i for i in ending_ids if i in reachable],
|
|
101
|
+
"deadEnds": [sid for sid in order if in_degree.get(sid, 0) == 0 and sid != entry],
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def shortest_path(scenes: dict, entry: str, target_id: str) -> list[str] | None:
|
|
106
|
+
"""从 entry 到指定场景的一条最短路径(供测试生成"直达路线")。"""
|
|
107
|
+
prev: dict[str, str] = {}
|
|
108
|
+
queue = [entry]
|
|
109
|
+
seen = {entry}
|
|
110
|
+
while queue:
|
|
111
|
+
scene_id = queue.pop(0)
|
|
112
|
+
if scene_id == target_id:
|
|
113
|
+
break
|
|
114
|
+
for edge in edges_of(scene_id, scenes.get(scene_id)):
|
|
115
|
+
if edge["to"] in seen:
|
|
116
|
+
continue
|
|
117
|
+
seen.add(edge["to"])
|
|
118
|
+
prev[edge["to"]] = scene_id
|
|
119
|
+
queue.append(edge["to"])
|
|
120
|
+
if target_id not in seen:
|
|
121
|
+
return None
|
|
122
|
+
path = [target_id]
|
|
123
|
+
while path[0] != entry:
|
|
124
|
+
path.insert(0, prev.get(path[0]))
|
|
125
|
+
return path
|
dse_core/loader.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""工程加载器:dse.yml + YAML 内容包 → 场景字典。
|
|
2
|
+
|
|
3
|
+
移植自 dse-core(Node) lib/dict.mjs。与 Node 版的差异:内容包从 ESM
|
|
4
|
+
模块改为 **YAML 数据文件**(pack 元数据 + scenes 字典同文件),因此不
|
|
5
|
+
需要 import 任何用户代码。
|
|
6
|
+
|
|
7
|
+
YAML 内容包结构::
|
|
8
|
+
|
|
9
|
+
pack: { id: starter, name: 启程, entry: start } # 元数据(可选)
|
|
10
|
+
scenes:
|
|
11
|
+
start: { title: ..., text: ..., choices: [...] }
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
from .manifest import normalize_manifest
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class LoadedProject:
|
|
26
|
+
"""一个已加载的引擎工程(dse.yml + 全部内容包合并结果)。"""
|
|
27
|
+
|
|
28
|
+
root: Path
|
|
29
|
+
dict: dict # 合并后的场景字典(同 ID 后者覆盖前者)
|
|
30
|
+
ids: list # 场景 ID 列表(合并序)
|
|
31
|
+
pack_files: list # 内容包相对路径列表(顺序即覆盖优先级)
|
|
32
|
+
entry: str # 入口场景 ID
|
|
33
|
+
manifests: list = field(default_factory=list) # 各内容包的规范化 manifest
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_yaml(path: str | Path) -> dict:
|
|
37
|
+
"""读取 YAML 文件;空文件返回 {}。"""
|
|
38
|
+
with open(path, encoding="utf-8") as f:
|
|
39
|
+
return yaml.safe_load(f) or {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def read_plugin_config(manifest, plugin_id: str) -> dict:
|
|
43
|
+
"""从 dse.yml 插件清单里取出指定 id 的 config(找不到返回 {})。"""
|
|
44
|
+
entries = manifest if isinstance(manifest, list) else []
|
|
45
|
+
for item in entries:
|
|
46
|
+
if isinstance(item, dict) and item.get("id") == plugin_id:
|
|
47
|
+
return item.get("config") or {}
|
|
48
|
+
return {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_pack_file(path: str | Path) -> tuple[dict, dict]:
|
|
52
|
+
"""读取一个 YAML 内容包 → (manifest_raw, scenes)。"""
|
|
53
|
+
data = load_yaml(path)
|
|
54
|
+
if not isinstance(data, dict):
|
|
55
|
+
raise ValueError(f"内容包 {path} 顶层必须是对象(pack / scenes 两个键)")
|
|
56
|
+
scenes = data.get("scenes") or {}
|
|
57
|
+
if not isinstance(scenes, dict):
|
|
58
|
+
raise ValueError(f"内容包 {path} 的 scenes 必须是「场景 ID → 场景定义」的对象")
|
|
59
|
+
return data.get("pack") or {}, scenes
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_project(root: str | Path) -> LoadedProject:
|
|
63
|
+
"""加载整个工程:dse.yml + 全部内容包。"""
|
|
64
|
+
root = Path(root)
|
|
65
|
+
manifest = load_yaml(root / "dse.yml")
|
|
66
|
+
if not isinstance(manifest, list):
|
|
67
|
+
raise ValueError(f"{root / 'dse.yml'} 必须是插件条目列表")
|
|
68
|
+
scenes_cfg = read_plugin_config(manifest, "scenes")
|
|
69
|
+
engine_cfg = read_plugin_config(manifest, "engine")
|
|
70
|
+
pack_files = list(scenes_cfg.get("packs") or [])
|
|
71
|
+
|
|
72
|
+
merged: dict = {}
|
|
73
|
+
manifests = []
|
|
74
|
+
for rel in pack_files:
|
|
75
|
+
path = (root / rel).resolve()
|
|
76
|
+
if not path.is_file():
|
|
77
|
+
raise FileNotFoundError(f"内容包不存在: {rel}(相对于 {root})")
|
|
78
|
+
raw, pack_scenes = load_pack_file(path)
|
|
79
|
+
manifests.append(normalize_manifest(raw, rel, pack_scenes))
|
|
80
|
+
merged.update(pack_scenes) # 同 ID 后者覆盖前者(与 scenes 插件叠加语义一致)
|
|
81
|
+
|
|
82
|
+
return LoadedProject(
|
|
83
|
+
root=root,
|
|
84
|
+
dict=merged,
|
|
85
|
+
ids=list(merged.keys()),
|
|
86
|
+
pack_files=pack_files,
|
|
87
|
+
entry=engine_cfg.get("entry") or "start",
|
|
88
|
+
manifests=manifests,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def scene_chars(scene: dict) -> int:
|
|
93
|
+
"""场景正文长度(title + text + 各选项文本),与校验器口径一致。"""
|
|
94
|
+
choices = (scene or {}).get("choices") or []
|
|
95
|
+
return (
|
|
96
|
+
len((scene or {}).get("title") or "")
|
|
97
|
+
+ len((scene or {}).get("text") or "")
|
|
98
|
+
+ sum(len(c.get("text") or "") for c in choices)
|
|
99
|
+
)
|
dse_core/manifest.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""内容包元数据(manifest):规范化 / 校验。
|
|
2
|
+
|
|
3
|
+
移植自 dse-core(Node) lib/registry.mjs 的纯逻辑部分(ESM 动态 import
|
|
4
|
+
由 loader 接管,这里只保留 manifest 语义)。
|
|
5
|
+
|
|
6
|
+
内容包约定(YAML,一个文件 = 一个包)::
|
|
7
|
+
|
|
8
|
+
pack: # 元数据(可选,缺省从文件推导)
|
|
9
|
+
id: starter
|
|
10
|
+
name: 启程
|
|
11
|
+
version: 0.1.0
|
|
12
|
+
entry: start # 本包推荐入口(缺省取第一个场景)
|
|
13
|
+
modes: [battle] # 用到了哪些 scene.mode
|
|
14
|
+
requires:
|
|
15
|
+
plugins: [combat] # 需要哪些能力插件接管
|
|
16
|
+
state: [gold] # 需要 state.initial 预声明哪些顶层字段
|
|
17
|
+
scenes: # 场景字典本体
|
|
18
|
+
start: { ... }
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from .modes import mode_names
|
|
27
|
+
|
|
28
|
+
_ID_RE = re.compile(r"^[a-z][a-z0-9_-]*$")
|
|
29
|
+
_SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def strip_ext(file: str) -> str:
|
|
33
|
+
"""取文件名去扩展名(manifest id 的缺省值)。"""
|
|
34
|
+
return Path(file).stem
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def normalize_manifest(raw: dict | None, file: str, scenes: dict | None = None) -> dict:
|
|
38
|
+
"""补齐 manifest 缺省字段(id 取文件名,entry 取首个场景,modes 自动探测)。"""
|
|
39
|
+
raw = raw or {}
|
|
40
|
+
scenes = scenes or {}
|
|
41
|
+
ids = list(scenes.keys())
|
|
42
|
+
fallback_id = str(raw.get("id") or strip_ext(file))
|
|
43
|
+
modes = set(raw.get("modes") or [])
|
|
44
|
+
for scene in scenes.values():
|
|
45
|
+
if isinstance(scene, dict) and scene.get("mode"):
|
|
46
|
+
modes.add(scene["mode"])
|
|
47
|
+
return {
|
|
48
|
+
"id": fallback_id,
|
|
49
|
+
"name": raw.get("name") or fallback_id,
|
|
50
|
+
"version": raw.get("version") or "0.0.0",
|
|
51
|
+
"entry": raw.get("entry") or (ids[0] if ids else None),
|
|
52
|
+
"description": raw.get("description") or "",
|
|
53
|
+
"author": raw.get("author") or "",
|
|
54
|
+
"modes": sorted(modes),
|
|
55
|
+
"requires": {
|
|
56
|
+
"plugins": list((raw.get("requires") or {}).get("plugins") or []),
|
|
57
|
+
"state": list((raw.get("requires") or {}).get("state") or []),
|
|
58
|
+
},
|
|
59
|
+
"scenes": len(ids),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def validate_manifest(manifest) -> list[str]:
|
|
64
|
+
"""校验 manifest,返回问题列表(空列表 = 合法)。"""
|
|
65
|
+
if not isinstance(manifest, dict):
|
|
66
|
+
return ["manifest 不是对象"]
|
|
67
|
+
problems: list[str] = []
|
|
68
|
+
if not _ID_RE.match(str(manifest.get("id") or "")):
|
|
69
|
+
problems.append(f'id "{manifest.get("id")}" 不符合命名规范(小写字母开头,仅 a-z 0-9 _ -)')
|
|
70
|
+
if manifest.get("version") is not None and not _SEMVER_RE.match(str(manifest["version"])):
|
|
71
|
+
problems.append(f'version "{manifest["version"]}" 不是语义化版本')
|
|
72
|
+
known = mode_names()
|
|
73
|
+
for mode in manifest.get("modes") or []:
|
|
74
|
+
if mode not in known:
|
|
75
|
+
problems.append(f'引用了未注册的模式 "{mode}"')
|
|
76
|
+
return problems
|