galbook 0.0.0-stage → 1.2.4

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.
Files changed (61) hide show
  1. package/.agents/skills/galbook/SKILL.md +44 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +114 -0
  4. package/README.md +133 -3
  5. package/bin/galbook.js +53 -0
  6. package/cordis.patch.yml +30 -0
  7. package/dsh-plugin/README.md +49 -0
  8. package/dsh-plugin/install.py +190 -0
  9. package/galbook/__init__.py +15 -0
  10. package/galbook/__main__.py +138 -0
  11. package/galbook/build.py +89 -0
  12. package/galbook/cli.py +29 -0
  13. package/galbook/demo/fog-harbor/assets/cg_dawn_together.svg +1 -0
  14. package/galbook/demo/fog-harbor/assets/cg_old_letter.svg +1 -0
  15. package/galbook/demo/fog-harbor/assets/cg_ship.svg +1 -0
  16. package/galbook/demo/fog-harbor/assets/harbor_night.svg +1 -0
  17. package/galbook/demo/fog-harbor/assets/lamp_room.svg +1 -0
  18. package/galbook/demo/fog-harbor/assets/lamp_room_dawn.svg +1 -0
  19. package/galbook/demo/fog-harbor/assets/path_tide.svg +1 -0
  20. package/galbook/demo/fog-harbor/assets/shio_normal.svg +1 -0
  21. package/galbook/demo/fog-harbor/assets/shio_smile.svg +1 -0
  22. package/galbook/demo/fog-harbor/assets/shio_surprised.svg +1 -0
  23. package/galbook/demo/fog-harbor/assets/warehouse.svg +1 -0
  24. package/galbook/demo/fog-harbor/story.gal +97 -0
  25. package/galbook/demo/fog-harbor-en/assets/cg_dawn_together.svg +1 -0
  26. package/galbook/demo/fog-harbor-en/assets/cg_old_letter.svg +1 -0
  27. package/galbook/demo/fog-harbor-en/assets/cg_ship.svg +1 -0
  28. package/galbook/demo/fog-harbor-en/assets/harbor_night.svg +1 -0
  29. package/galbook/demo/fog-harbor-en/assets/lamp_room.svg +1 -0
  30. package/galbook/demo/fog-harbor-en/assets/lamp_room_dawn.svg +1 -0
  31. package/galbook/demo/fog-harbor-en/assets/path_tide.svg +1 -0
  32. package/galbook/demo/fog-harbor-en/assets/shio_normal.svg +1 -0
  33. package/galbook/demo/fog-harbor-en/assets/shio_smile.svg +1 -0
  34. package/galbook/demo/fog-harbor-en/assets/shio_surprised.svg +1 -0
  35. package/galbook/demo/fog-harbor-en/assets/warehouse.svg +1 -0
  36. package/galbook/demo/fog-harbor-en/story.gal +97 -0
  37. package/galbook/demo_boot.py +63 -0
  38. package/galbook/engine.py +325 -0
  39. package/galbook/expr.py +107 -0
  40. package/galbook/i18n.py +85 -0
  41. package/galbook/importers/__init__.py +106 -0
  42. package/galbook/importers/common.py +94 -0
  43. package/galbook/importers/galscript.py +218 -0
  44. package/galbook/importers/kag.py +326 -0
  45. package/galbook/importers/legacy.py +122 -0
  46. package/galbook/importers/nscripter.py +314 -0
  47. package/galbook/importers/qlie.py +498 -0
  48. package/galbook/importers/renpy.py +333 -0
  49. package/galbook/importers/textdump.py +128 -0
  50. package/galbook/ir.py +266 -0
  51. package/galbook/mcp_server.py +102 -0
  52. package/galbook/play.py +374 -0
  53. package/galbook/server.py +199 -0
  54. package/galbook/static/play.html +151 -0
  55. package/galbook/store.py +160 -0
  56. package/galbook/tools.py +99 -0
  57. package/galbook/web.py +316 -0
  58. package/icon.svg +21 -0
  59. package/locale/en.json +6 -0
  60. package/locale/zh.json +6 -0
  61. package/package.json +58 -6
package/galbook/ir.py ADDED
@@ -0,0 +1,266 @@
1
+ """中间格式 galbook/1(引擎无关)。完整说明见 SCHEMA.md。
2
+
3
+ books/<slug>/story/story.json
4
+ {"format": "galbook/1", "title", "engine", "importer", "entry": {"scene", "label"},
5
+ "scenes": {"<id>": {"title", "file", "tags": [...], "route"}},
6
+ "routes": [{"id", "title", "start": {"scene", "label"}}],
7
+ "system_vars": [...], "stats": {...}, "warnings": [...]}
8
+ books/<slug>/story/scenes/<file>.json
9
+ {"id", "title", "ops": [ {op...}, ... ]}
10
+
11
+ op 一览(任何 op 都可带 "if": "<条件>",条件不成立就跳过这一条)
12
+ say {"who": "灯", "text": "「…」", "voice": "ak_0001"} who="" 是旁白
13
+ bg {"img": "assets/画像/bg_001.bmp" | null} 背景(null=清掉)
14
+ cg {"img": ... | null} 事件图(盖整页)
15
+ sprite {"name": "灯", "img": ... | null, "pos": "center"} 立绘(img=null 下场)
16
+ sprites_clear {}
17
+ bgm {"name": "bgm006" | null}
18
+ label {"name": "MAIN"}
19
+ choice {"id": "sel1", "prompt": "", "options": [{"text", "goto": {"scene", "label"}, "set", "if"}],
20
+ "auto_single": false}
21
+ jump {"scene": "<id>"?, "label": "<name>"?} 省略 scene = 本场景
22
+ call {"scene"?, "label"?} ret {}
23
+ set {"expr": "f.x = 1; s.clear_a = 1"}
24
+ event {"name": "endroll" | "movie" | ..., "arg": ...} 只做标记,给 agent 看
25
+ end {"kind": "ending" | "title" | "stop", "name": "..."}
26
+ """
27
+ import json
28
+ import os
29
+ import threading
30
+
31
+ FORMAT = "galbook/1"
32
+ OPS = {"say", "bg", "cg", "sprite", "sprites_clear", "bgm", "label", "choice", "jump", "call", "ret",
33
+ "set", "event", "end"}
34
+ EXITS = ("choice", "jump", "call", "ret", "end")
35
+
36
+
37
+ class StoryError(ValueError):
38
+ pass
39
+
40
+
41
+ class Scene(object):
42
+ __slots__ = ("id", "title", "ops", "labels", "tags", "route", "says")
43
+
44
+ def __init__(self, sid, data, meta=None):
45
+ meta = meta or {}
46
+ self.id = sid
47
+ self.title = data.get("title") or meta.get("title") or sid
48
+ self.ops = data.get("ops") or []
49
+ self.tags = list(meta.get("tags") or data.get("tags") or [])
50
+ self.route = meta.get("route") or data.get("route") or ""
51
+ self.labels = {}
52
+ self.says = 0
53
+ for i, op in enumerate(self.ops):
54
+ if op.get("op") == "label":
55
+ self.labels.setdefault(op.get("name"), i)
56
+ elif op.get("op") == "say":
57
+ self.says += 1
58
+
59
+
60
+ class Story(object):
61
+ """已加载的一本可玩剧本(只读,多线程共享)。"""
62
+
63
+ def __init__(self, meta, scenes, base_dir=None):
64
+ self.meta = meta
65
+ self.title = meta.get("title", "")
66
+ self.entry = meta.get("entry") or {}
67
+ self.scenes = scenes # id -> Scene
68
+ self.base_dir = base_dir
69
+ self.routes = meta.get("routes") or []
70
+ self.mode = meta.get("mode", "branch")
71
+
72
+ def scene(self, sid):
73
+ sc = self.scenes.get(sid)
74
+ if sc is None:
75
+ raise StoryError("剧本里没有场景 %r" % sid)
76
+ return sc
77
+
78
+ def first_say(self, sid, pc, look=200):
79
+ """从 (sid, pc) 往后找第一句台词的位置(跟着无条件 jump 走),找不到返回 None。用于「新内容」标记。"""
80
+ for _ in range(8):
81
+ sc = self.scenes.get(sid)
82
+ if sc is None:
83
+ return None
84
+ for j in range(pc, min(len(sc.ops), pc + look)):
85
+ op = sc.ops[j]
86
+ k = op.get("op")
87
+ if k == "say":
88
+ return sid, j
89
+ if k in ("choice", "end", "call", "ret") or (k == "jump" and op.get("if")):
90
+ return None
91
+ if k == "jump":
92
+ try:
93
+ sid, pc = self.resolve(op.get("scene"), op.get("label"), sid)
94
+ except StoryError:
95
+ return None
96
+ break
97
+ else:
98
+ return None
99
+ return None
100
+
101
+ @property
102
+ def warnings(self):
103
+ """可疑但不致命:分支被压成直线这类。跟 problems(真的坏了)分开。"""
104
+ if not hasattr(self, "_warnings"):
105
+ w = []
106
+ ch = self.op_counts.get("choice", 0)
107
+ if len(self.routes) > 1 and ch == 0:
108
+ w.append("这本标了 %d 条路线,但全库 0 个选择点——转换时选项可能被当成普通台词吞掉了"
109
+ % len(self.routes))
110
+ elif len(self.scenes) > 20 and ch == 0:
111
+ w.append("全库 %d 场、0 个选择点;若原作有分支,检查转换器是否丢了 .select / 选项块"
112
+ % len(self.scenes))
113
+ self._warnings = w
114
+ return self._warnings
115
+
116
+ @property
117
+ def op_counts(self):
118
+ """全库 op 计数(say/choice/jump/end…)。choice 为 0 的分支书 = 转换时把选项丢了。"""
119
+ if not hasattr(self, "_op_counts"):
120
+ c = {}
121
+ for sc in self.scenes.values():
122
+ for op in sc.ops:
123
+ k = op.get("op")
124
+ c[k] = c.get(k, 0) + 1
125
+ self._op_counts = c
126
+ return self._op_counts
127
+
128
+ def _count(self):
129
+ if not hasattr(self, "_counts"):
130
+ cgs, ends = set(), set()
131
+ for sc in self.scenes.values():
132
+ for op in sc.ops:
133
+ if op.get("op") == "cg" and op.get("img"):
134
+ cgs.add(op["img"])
135
+ elif op.get("op") == "end" and (op.get("kind") or "ending") == "ending":
136
+ ends.add((sc.id, op.get("name")))
137
+ self._counts = (len(cgs), len(ends))
138
+ return self._counts
139
+
140
+ def cg_total(self):
141
+ return self._count()[0]
142
+
143
+ def endings_total(self):
144
+ return self._count()[1]
145
+
146
+ @property
147
+ def problems(self):
148
+ """加载后能查出来的结构问题(没出口的场景等);空列表 = 没发现。"""
149
+ if not hasattr(self, "_problems"):
150
+ probs = []
151
+ for sid, sc in self.scenes.items():
152
+ if not any(op.get("op") in EXITS for op in sc.ops):
153
+ probs.append("%s(%s): 没有出口(缺 choice / jump / end / ret)" % (sid, sc.title))
154
+ self._problems = probs
155
+ return self._problems
156
+
157
+ def resolve(self, scene, label, here=None):
158
+ sid = scene or here
159
+ sc = self.scene(sid)
160
+ if label in (None, ""):
161
+ return sid, 0
162
+ if label not in sc.labels:
163
+ raise StoryError("场景 %r 里没有标签 %r" % (sid, label))
164
+ return sid, sc.labels[label]
165
+
166
+
167
+ def validate(meta, scene_datas):
168
+ """返回问题清单(空 = 通过)。scene_datas: id -> scene dict"""
169
+ probs = []
170
+ if meta.get("format") != FORMAT:
171
+ probs.append("format 不是 %s" % FORMAT)
172
+ labels = {}
173
+ for sid, d in scene_datas.items():
174
+ labels[sid] = {op.get("name") for op in d.get("ops", []) if op.get("op") == "label"}
175
+ def chk(where, sc, lb):
176
+ if sc is not None and sc not in scene_datas:
177
+ probs.append("%s: 跳到不存在的场景 %r" % (where, sc))
178
+ elif lb not in (None, "") and lb not in labels.get(sc, set()):
179
+ probs.append("%s: 场景 %r 没有标签 %r" % (where, sc, lb))
180
+ e = meta.get("entry") or {}
181
+ chk("entry", e.get("scene"), e.get("label"))
182
+ for sid, d in scene_datas.items():
183
+ ops_ = d.get("ops") or []
184
+ if not any(op.get("op") in EXITS for op in ops_):
185
+ probs.append("%s(%s): 场景没有出口——没有 choice / jump / end / ret,"
186
+ "玩家会卡在最后一句(多半是转换时丢了控制流)" % (sid, d.get("title") or sid))
187
+ for sid, d in scene_datas.items():
188
+ for i, op in enumerate(d.get("ops", [])):
189
+ k = op.get("op")
190
+ w = "%s#%d" % (sid, i)
191
+ if k not in OPS:
192
+ probs.append("%s: 未知 op %r" % (w, k))
193
+ if op.get("click") is not None: # 只校验,不改任何 op 的语义(点击由 play.py 处理)
194
+ ck = op["click"]
195
+ if k != "say" or not isinstance(ck, dict) or not (ck.get("set") or ck.get("goto")):
196
+ probs.append("%s: click 只能挂在 say 上,且要有 set 或 goto" % w)
197
+ elif ck.get("goto"):
198
+ chk(w + ".click", ck["goto"].get("scene") or sid, ck["goto"].get("label"))
199
+ elif k in ("jump", "call"):
200
+ chk(w, op.get("scene") or sid, op.get("label"))
201
+ elif k == "choice":
202
+ if not op.get("options"):
203
+ probs.append("%s: 选项为空" % w)
204
+ for j, o in enumerate(op.get("options") or []):
205
+ g = o.get("goto") or {}
206
+ chk("%s.opt%d" % (w, j), g.get("scene") or sid, g.get("label"))
207
+ return probs
208
+
209
+
210
+ def save_story(out_dir, meta, scene_datas):
211
+ """写出 story.json + scenes/*.json(原子写)。返回写出的文件数。"""
212
+ os.makedirs(os.path.join(out_dir, "scenes"), exist_ok=True)
213
+ meta = dict(meta)
214
+ meta["format"] = FORMAT
215
+ scenes_meta = meta.setdefault("scenes", {})
216
+ n = 0
217
+ for i, (sid, d) in enumerate(scene_datas.items(), 1):
218
+ fn = "scenes/s%04d.json" % i
219
+ m = scenes_meta.setdefault(sid, {})
220
+ m.setdefault("title", d.get("title") or sid)
221
+ m["file"] = fn
222
+ m["says"] = sum(1 for op in d.get("ops", []) if op.get("op") == "say")
223
+ _atomic_json(os.path.join(out_dir, fn), dict(d, id=sid))
224
+ n += 1
225
+ _atomic_json(os.path.join(out_dir, "story.json"), meta)
226
+ return n + 1
227
+
228
+
229
+ def _atomic_json(path, obj):
230
+ tmp = path + ".tmp%d" % os.getpid()
231
+ with open(tmp, "w", encoding="utf-8") as f:
232
+ json.dump(obj, f, ensure_ascii=False, separators=(",", ":"))
233
+ os.replace(tmp, path)
234
+
235
+
236
+ _cache = {}
237
+ _cache_lock = threading.Lock()
238
+
239
+
240
+ def load_story(story_dir):
241
+ """按 story.json 的 mtime 缓存;改了剧本不用重启服务。"""
242
+ sj = os.path.join(story_dir, "story.json")
243
+ st = os.stat(sj)
244
+ key = (story_dir, st.st_mtime_ns, st.st_size)
245
+ with _cache_lock:
246
+ hit = _cache.get(story_dir)
247
+ if hit and hit[0] == key:
248
+ return hit[1]
249
+ with open(sj, encoding="utf-8") as f:
250
+ meta = json.load(f)
251
+ if meta.get("format") != FORMAT:
252
+ raise StoryError("%s 不是 %s 格式" % (sj, FORMAT))
253
+ scenes = {}
254
+ for sid, m in (meta.get("scenes") or {}).items():
255
+ with open(os.path.join(story_dir, m["file"]), encoding="utf-8") as f:
256
+ scenes[sid] = Scene(sid, json.load(f), m)
257
+ story = Story(meta, scenes, story_dir)
258
+ with _cache_lock:
259
+ _cache[story_dir] = (key, story)
260
+ return story
261
+
262
+
263
+ def story_from_dict(meta, scene_datas):
264
+ """内存里直接构造(legacy 书稿适配、测试用)。"""
265
+ scenes = {sid: Scene(sid, d, (meta.get("scenes") or {}).get(sid)) for sid, d in scene_datas.items()}
266
+ return Story(meta, scenes)
@@ -0,0 +1,102 @@
1
+ """MCP 服务(stdio,JSON-RPC 2.0,一行一条):让任何支持 MCP 的 agent 直接玩。
2
+
3
+ python3 -m galbook mcp --books ./books --data ./play-data [--book 默认书] [--agent 默认名] [--lang zh|en]
4
+
5
+ 在 MCP 客户端里配置(示例):
6
+ {"mcpServers": {"galbook": {"command": "python3",
7
+ "args": ["-m", "galbook", "mcp", "--books", "/path/books", "--data", "/path/play-data", "--agent", "alice"]}}}
8
+ 多个 agent 可以各起一个 MCP 进程(不同 --agent),共用同一个 --data 目录;存储层有文件锁,互不干扰。
9
+ 也可以不起进程:python3 -m galbook serve 自带 Streamable HTTP 的 MCP 端点 POST /mcp?book=…&agent=…(见 server.py)。"""
10
+ import argparse
11
+ import os
12
+ import json
13
+ import sys
14
+ import traceback
15
+
16
+ from . import VERSION
17
+ from .play import Library
18
+ from .tools import mcp_tools, call_tool
19
+ from .i18n import msg
20
+
21
+ PROTO = "2024-11-05"
22
+
23
+
24
+ def handle(lib, req, book=None, agent=None, lang="zh", force_agent=None):
25
+ """处理一条 JSON-RPC 请求,返回响应 dict;通知返回 None。stdio 和 HTTP(/mcp)共用。
26
+ force_agent:HTTP 令牌绑定了名字时,强制用这个名字(防冒名)。"""
27
+ if not isinstance(req, dict):
28
+ return {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "invalid request"}}
29
+ mid, method, params = req.get("id"), req.get("method"), req.get("params") or {}
30
+ if mid is None: # 通知(notifications/initialized 等)不回
31
+ return None
32
+ try:
33
+ if method == "initialize":
34
+ res = {"protocolVersion": params.get("protocolVersion") or PROTO, "capabilities": {"tools": {}},
35
+ "serverInfo": {"name": "galbook", "version": VERSION},
36
+ "instructions": "Play visual novels like a human: galbook_help first, set galbook_persona, "
37
+ "then galbook_new / galbook_next / galbook_choose (always give a reason)."}
38
+ elif method == "ping":
39
+ res = {}
40
+ elif method == "tools/list":
41
+ res = {"tools": mcp_tools()}
42
+ elif method == "tools/call":
43
+ args = dict(params.get("arguments") or {})
44
+ try:
45
+ if force_agent:
46
+ if args.get("agent") not in (None, "", force_agent):
47
+ raise ValueError("这个令牌只能用名字 %s / this token can only play as %s" % (force_agent, force_agent))
48
+ args["agent"] = force_agent
49
+ text = call_tool(lib, params.get("name"), args, book, agent, lang)
50
+ res = {"content": [{"type": "text", "text": text}], "isError": False}
51
+ except (ValueError, KeyError) as e:
52
+ res = {"content": [{"type": "text", "text": msg(lang, str(e))}], "isError": True}
53
+ else:
54
+ return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32601, "message": "method not found: %s" % method}}
55
+ return {"jsonrpc": "2.0", "id": mid, "result": res}
56
+ except Exception as e: # noqa
57
+ traceback.print_exc(file=sys.stderr)
58
+ return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32603, "message": "%s: %s" % (type(e).__name__, e)}}
59
+
60
+
61
+ def serve(lib, book=None, agent=None, lang="zh", fin=None, fout=None):
62
+ fin = fin or sys.stdin
63
+ fout = fout or sys.stdout
64
+
65
+ def send(obj):
66
+ fout.write(json.dumps(obj, ensure_ascii=False) + "\n")
67
+ fout.flush()
68
+
69
+ for line in fin:
70
+ line = line.strip()
71
+ if not line:
72
+ continue
73
+ try:
74
+ req = json.loads(line)
75
+ except ValueError:
76
+ send({"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}})
77
+ continue
78
+ r = handle(lib, req, book, agent, lang)
79
+ if r is not None:
80
+ send(r)
81
+
82
+
83
+ def main(argv=None):
84
+ ap = argparse.ArgumentParser(prog="galbook mcp")
85
+ ap.add_argument("--books", default="books")
86
+ ap.add_argument("--data", default="play-data")
87
+ ap.add_argument("--book")
88
+ ap.add_argument("--agent")
89
+ ap.add_argument("--lang", default="zh", choices=("zh", "en"))
90
+ ap.add_argument("--no-demo", action="store_true", help="books 为空时不自动装演示书(同 GALBOOK_NO_DEMO=1)")
91
+ a = ap.parse_args(argv)
92
+ if a.no_demo:
93
+ os.environ["GALBOOK_NO_DEMO"] = "1"
94
+ for f in (sys.stdin, sys.stdout): # Windows 管道默认不是 UTF-8,MCP 要求 UTF-8
95
+ if hasattr(f, "reconfigure"):
96
+ f.reconfigure(encoding="utf-8")
97
+ serve(Library(a.books, a.data), a.book, a.agent, a.lang)
98
+ return 0
99
+
100
+
101
+ if __name__ == "__main__":
102
+ sys.exit(main())