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 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'
@@ -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