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
@@ -0,0 +1,15 @@
1
+ """galbook —— 让 agent 像人一样玩 galgame:读剧本、遇到选项自己选、存档/读档/回溯。
2
+
3
+ 组成:
4
+ ir.py 引擎无关的中间格式(story.json + scenes/*.json)与校验
5
+ expr.py 安全的小表达式求值(条件 / 赋值),不用 eval
6
+ engine.py 运行时:推进、选项、存档快照、已读跳过、回溯
7
+ store.py 多 agent 并发存储:每个 agent 一份档案 + 存档位,逐 agent 加锁、原子写
8
+ web.py HTTP 接口(独立服务 python -m galbook serve,或嵌进任何 http.server;纯文本接口给 agent 省 token)
9
+ importers/ 各引擎脚本 → 中间格式(qlie / kag(吉里吉里·Tyrano) / renpy / nscripter / galscript / textdump / legacy 书稿;可加插件)
10
+ server.py 独立 HTTP 服务(可选令牌、每个令牌绑定 agent 名)
11
+ mcp_server.py MCP(stdio)服务;tools.py 工具定义(MCP / OpenAI 共用)
12
+ i18n.py 文本接口 zh / en
13
+ 只用 Python 标准库。
14
+ """
15
+ VERSION = "galbook-1.2.4"
@@ -0,0 +1,138 @@
1
+ """python3 -m galbook <命令> …
2
+
3
+ build 把解包目录转成可玩剧本 python3 -m galbook build --src 解包目录 --out books/<书>/story
4
+ detect 只认引擎 python3 -m galbook detect 目录
5
+ serve 起 HTTP 服务(页面+接口) python3 -m galbook serve --books books --data play-data
6
+ mcp MCP 服务(stdio) python3 -m galbook mcp --books books --data play-data --agent alice
7
+ play 命令行里玩一步 python3 -m galbook play books play-data <书> <名字> next n=20
8
+ do 同上,参数走环境变量(给 shell 工具用)
9
+ GALBOOK_BOOKS=books GALBOOK_DATA=play-data GALBOOK_BOOK=fog-harbor GALBOOK_AGENT=alice
10
+ python3 -m galbook do next n=20 ;python3 -m galbook do books ;python3 -m galbook do help
11
+ tools 打印工具定义 python3 -m galbook tools --format openai|mcp
12
+ demo 装自带的演示故事到 books/ python3 -m galbook demo --books books
13
+ version
14
+
15
+ serve / mcp / play / do 都认 --no-demo(或环境变量 GALBOOK_NO_DEMO=1):books 为空时不自动装演示书"""
16
+ import sys
17
+
18
+
19
+ def _utf8_stdio():
20
+ """Windows 控制台/管道默认 GBK 之类,中文/日文会乱码或报错:统一 UTF-8。"""
21
+ for f in (sys.stdout, sys.stderr):
22
+ if hasattr(f, "reconfigure") and (f.encoding or "").lower().replace("-", "") != "utf8":
23
+ try:
24
+ f.reconfigure(encoding="utf-8", errors="replace")
25
+ except Exception: # noqa
26
+ pass
27
+
28
+
29
+ def do(argv):
30
+ """galbook do <动作> [k=v …] [--book B] [--agent A] [--books DIR] [--data DIR] [--lang en]"""
31
+ import os
32
+ from .play import Library
33
+ from . import tools
34
+ kw, rest, flags = {}, [], {}
35
+ it = iter(argv)
36
+ for x in it:
37
+ if x.startswith("--") and x[2:] in ("book", "agent", "books", "data", "lang"):
38
+ flags[x[2:]] = next(it, "")
39
+ elif "=" in x and not x.startswith("="):
40
+ k, _, v = x.partition("=")
41
+ kw[k] = v
42
+ else:
43
+ rest.append(x)
44
+ if not rest:
45
+ print(do.__doc__)
46
+ return 2
47
+ action = rest[0]
48
+ books = flags.get("books") or os.environ.get("GALBOOK_BOOKS", "books")
49
+ data = flags.get("data") or os.environ.get("GALBOOK_DATA", "play-data")
50
+ book = kw.pop("book", None) or flags.get("book") or os.environ.get("GALBOOK_BOOK")
51
+ agent = kw.pop("agent", None) or flags.get("agent") or os.environ.get("GALBOOK_AGENT")
52
+ lang = flags.get("lang") or os.environ.get("GALBOOK_LANG", "zh")
53
+ name = "galbook_" + {"skip": "next"}.get(action, action)
54
+ if action == "skip":
55
+ kw["skip_read"] = True
56
+ if "n" in kw:
57
+ kw["n"] = int(kw["n"])
58
+ if "steps" in kw:
59
+ kw["steps"] = int(kw["steps"])
60
+ if name == "galbook_choose" and kw.get("i", "random") != "random":
61
+ kw["i"] = int(kw["i"])
62
+ if "img" in kw:
63
+ kw["img"] = kw["img"] in ("1", "true")
64
+ try:
65
+ print(tools.call_tool(Library(books, data), name, kw, book, agent, lang, style="cli", lang_override=flags.get("lang")))
66
+ except Exception as e: # noqa
67
+ from .i18n import msg
68
+ print("错误 / error: %s" % msg(lang, str(e)))
69
+ return 1
70
+ return 0
71
+
72
+
73
+ def main(argv=None):
74
+ _utf8_stdio()
75
+ a = list(sys.argv[1:] if argv is None else argv)
76
+ if not a or a[0] in ("-h", "--help", "help"):
77
+ print(__doc__)
78
+ return 0
79
+ cmd, rest = a[0], a[1:]
80
+ if cmd in ("serve", "mcp", "play", "do"):
81
+ from .demo_boot import no_demo_flag
82
+ rest = no_demo_flag(rest)
83
+ if cmd == "build":
84
+ from .build import main as m
85
+ elif cmd == "serve":
86
+ from .server import main as m
87
+ elif cmd == "mcp":
88
+ from .mcp_server import main as m
89
+ elif cmd == "play":
90
+ from .cli import main as m
91
+ elif cmd == "tools":
92
+ from .tools import main as m
93
+ elif cmd == "detect":
94
+ from .importers import detect
95
+ for d in rest or ["."]:
96
+ print("%s: %s(%s)" % (d, *detect(d)))
97
+ return 0
98
+ elif cmd == "do":
99
+ return do(rest)
100
+ elif cmd == "demo":
101
+ return demo(rest)
102
+ elif cmd in ("version", "--version"):
103
+ from . import VERSION
104
+ print(VERSION)
105
+ return 0
106
+ else:
107
+ print("不认识的命令 %s\n%s" % (cmd, __doc__))
108
+ return 2
109
+ return m(rest)
110
+
111
+
112
+ def demo(argv):
113
+ """把 galbook/demo/ 下的演示故事编译进 books/(图一起拷过去)。"""
114
+ import argparse
115
+ import os
116
+ import shutil
117
+ from .build import main as build
118
+ ap = argparse.ArgumentParser(prog="galbook demo")
119
+ ap.add_argument("--books", default="books")
120
+ a = ap.parse_args(argv)
121
+ root = os.path.join(os.path.dirname(os.path.abspath(__file__)), "demo")
122
+ for slug in sorted(os.listdir(root)):
123
+ src = os.path.join(root, slug)
124
+ if not os.path.isdir(src):
125
+ continue
126
+ dst = os.path.join(a.books, slug)
127
+ if os.path.isdir(os.path.join(src, "assets")):
128
+ shutil.copytree(os.path.join(src, "assets"), os.path.join(dst, "assets"), dirs_exist_ok=True)
129
+ print("== %s" % slug)
130
+ rc = build(["--src", src, "--out", os.path.join(dst, "story"), "--assets-dir", src])
131
+ if rc:
132
+ return rc
133
+ print("好了:python3 -m galbook serve --books %s 然后打开 http://127.0.0.1:8765/play/" % a.books)
134
+ return 0
135
+
136
+
137
+ if __name__ == "__main__":
138
+ sys.exit(main())
@@ -0,0 +1,89 @@
1
+ """把一个解包目录转成可玩剧本。
2
+
3
+ python3 -m galbook build --src <解包目录> --out books/<书>/story [--engine auto] \\
4
+ [--assets-dir books/<书>] [--assets-list 文件] [--title 书名] [--entry 标签]
5
+
6
+ --engine auto 按文件特征认引擎;认不出会说明卡在哪一步(要先用什么工具解包/反编译)。
7
+ 写出前先校验:所有跳转/选项目标都必须存在,否则不写(加 --force 才写)。"""
8
+ import argparse
9
+ import json
10
+ import sys
11
+
12
+ from .importers import detect, get, load_plugin, names
13
+ from .importers.common import Assets
14
+ from .ir import validate, save_story, story_from_dict
15
+
16
+
17
+ def main(argv=None):
18
+ ap = argparse.ArgumentParser(prog="galbook.build")
19
+ ap.add_argument("--src", required=True)
20
+ ap.add_argument("--out", required=True)
21
+ ap.add_argument("--engine", default="auto", help="auto 或导入器名")
22
+ ap.add_argument("--plugin", action="append", default=[], help="额外的导入器插件 .py(可多次)")
23
+ ap.add_argument("--assets-dir", help="书目录(里面的 assets/ 会被索引,路径相对它)")
24
+ ap.add_argument("--assets-list", help="资源清单文件(每行一个相对 assets/ 的路径)")
25
+ ap.add_argument("--title")
26
+ ap.add_argument("--entry")
27
+ ap.add_argument("--force", action="store_true")
28
+ ap.add_argument("--strict", action="store_true",
29
+ help="有未识别指令或选择点对不上就失败(默认只警告)")
30
+ ap.add_argument("--dry-run", action="store_true")
31
+ a = ap.parse_args(argv)
32
+ for p in a.plugin:
33
+ print("加载插件:%s" % load_plugin(p))
34
+ eng = a.engine
35
+ if eng != "auto" and eng not in names():
36
+ print("没有导入器 %s(有:%s)" % (eng, " ".join(names())))
37
+ return 2
38
+ if eng == "auto":
39
+ eng, why = detect(a.src)
40
+ print("认引擎:%s(%s)" % (eng or "认不出", why))
41
+ if not eng:
42
+ return 2
43
+ assets = Assets.from_dir(a.assets_dir) if a.assets_dir else (Assets.from_list(a.assets_list) if a.assets_list else Assets())
44
+ kw = {"entry": a.entry} if a.entry else {}
45
+ meta, scenes = get(eng).build(a.src, assets=assets, **kw)
46
+ if a.title:
47
+ meta["title"] = a.title
48
+ probs = validate(meta, scenes)
49
+ st = meta.get("stats", {})
50
+ hist = {}
51
+ for sc in scenes.values():
52
+ for op in sc.get("ops", []):
53
+ hist[op.get("op")] = hist.get(op.get("op"), 0) + 1
54
+ _sel = st.get("choices")
55
+ if isinstance(_sel, int) and _sel != hist.get("choice", 0):
56
+ probs.append("选择点对不上:脚本里认出 %d 个 .select/choice,生成出来的 choice op 只有 %d 个"
57
+ "(中间可能被当成普通文本吃掉了)" % (_sel, hist.get("choice", 0)))
58
+ print("结构:" + json.dumps(hist, ensure_ascii=False))
59
+ print("分支:choice %d 个、jump %d 个、end %d 个%s" % (
60
+ hist.get("choice", 0), hist.get("jump", 0), hist.get("end", 0),
61
+ " ← 一个选择点都没有,检查转换器是不是吞了选项" if len(scenes) > 3 and not hist.get("choice") else ""))
62
+ st["ops"] = hist
63
+ clicks = [op["click"] for sc in scenes.values() for op in sc.get("ops", []) if op.get("op") == "say" and op.get("click")]
64
+ if clicks:
65
+ st["clicks"] = len(clicks)
66
+ print("特殊机制:可点的句子 %d 处(say.click;读的时候条件成立会在句尾挂 ◇,do=click 触发)" % len(clicks))
67
+ print("统计:" + json.dumps({k: v for k, v in st.items() if k not in ("assets", "unknown")}, ensure_ascii=False))
68
+ print("图片解析:" + json.dumps(st.get("assets"), ensure_ascii=False))
69
+ print("未识别指令(前 20):" + json.dumps(st.get("unknown"), ensure_ascii=False))
70
+ print("警告 %d 条%s" % (len(meta.get("warnings", [])), (":" + ";".join(meta["warnings"][:5])) if meta.get("warnings") else ""))
71
+ print("校验:%s" % ("通过" if not probs else "%d 个问题:%s" % (len(probs), ";".join(probs[:10]))))
72
+ _w = story_from_dict(meta, scenes).warnings
73
+ if _w:
74
+ print("可疑:" + ";".join(_w))
75
+ if a.strict and (probs or st.get("unknown")):
76
+ print("--strict:有未识别指令或校验问题,按约定失败")
77
+ return 1
78
+ if probs and not a.force:
79
+ print("有校验问题,没写文件(--force 强制写)")
80
+ return 1
81
+ if a.dry_run:
82
+ return 0
83
+ n = save_story(a.out, meta, scenes)
84
+ print("写出 %d 个文件 → %s" % (n, a.out))
85
+ return 0
86
+
87
+
88
+ if __name__ == "__main__":
89
+ sys.exit(main())
package/galbook/cli.py ADDED
@@ -0,0 +1,29 @@
1
+ """命令行里玩(不用起服务):
2
+ python3 -m galbook play <books目录> <存档目录> <书> <agent> <操作> [k=v ...]
3
+ 例:python3 -m galbook play books play-data lighthouse alice next n=30"""
4
+ import sys
5
+
6
+ from .play import Library
7
+ from .web import render_text
8
+
9
+
10
+ def main(argv=None):
11
+ a = list(argv if argv is not None else sys.argv[1:])
12
+ if len(a) < 5:
13
+ print(__doc__)
14
+ return 2
15
+ books, data, book, agent, do = a[:5]
16
+ kw = dict(x.split("=", 1) for x in a[5:])
17
+ if kw.pop("skip", "") in ("1", "true"):
18
+ kw["skip_read"] = True
19
+ img = kw.pop("img", "") in ("1", "true")
20
+ lang = kw.get("lang") if do == "new" else kw.pop("lang", None)
21
+ lib = Library(books, data)
22
+ r = lib.act(book, agent, do, **kw)
23
+ lang = lang or ((r.get("opts") or (lib.store.peek_run(book, agent) or {}).get("opts") or {}).get("lang")) or "zh"
24
+ sys.stdout.write(render_text(r, do, img, book, agent, lang))
25
+ return 0
26
+
27
+
28
+ if __name__ == "__main__":
29
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f2c27b"/><stop offset="1" stop-color="#f6e7c8"/></linearGradient></defs><rect width="1280" height="720" fill="url(#g)"/><text x="40" y="70" font-size="36" fill="#ffffff" fill-opacity=".55" font-family="sans-serif">CG 黎明</text></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#6b5133"/><stop offset="1" stop-color="#e8d6b0"/></linearGradient></defs><rect width="1280" height="720" fill="url(#g)"/><text x="40" y="70" font-size="36" fill="#ffffff" fill-opacity=".55" font-family="sans-serif">CG 旧信</text></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#24496b"/><stop offset="1" stop-color="#9fc4dd"/></linearGradient></defs><rect width="1280" height="720" fill="url(#g)"/><text x="40" y="70" font-size="36" fill="#ffffff" fill-opacity=".55" font-family="sans-serif">CG 归航</text></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#0b1630"/><stop offset="1" stop-color="#27406b"/></linearGradient></defs><rect width="1280" height="720" fill="url(#g)"/><text x="40" y="70" font-size="36" fill="#ffffff" fill-opacity=".55" font-family="sans-serif">港口·夜</text></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2a1d12"/><stop offset="1" stop-color="#7a5a2e"/></linearGradient></defs><rect width="1280" height="720" fill="url(#g)"/><text x="40" y="70" font-size="36" fill="#ffffff" fill-opacity=".55" font-family="sans-serif">灯室</text></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#3a2a40"/><stop offset="1" stop-color="#e9b872"/></linearGradient></defs><rect width="1280" height="720" fill="url(#g)"/><text x="40" y="70" font-size="36" fill="#ffffff" fill-opacity=".55" font-family="sans-serif">灯室·黎明</text></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#13263a"/><stop offset="1" stop-color="#3c6a80"/></linearGradient></defs><rect width="1280" height="720" fill="url(#g)"/><text x="40" y="70" font-size="36" fill="#ffffff" fill-opacity=".55" font-family="sans-serif">涨潮的小路</text></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="320" height="720" viewBox="0 0 320 720"><path d="M60 720 L80 300 Q160 250 240 300 L260 720 Z" fill="#2f4f6f"/><path d="M70 150 Q160 40 250 150 L260 330 L60 330 Z" fill="#3b2a24"/><circle cx="160" cy="180" r="70" fill="#f3dccb"/><circle cx="135" cy="170" r="7" fill="#333"/><circle cx="185" cy="170" r="7" fill="#333"/><path d="M 140 205 L 180 205" stroke="#a04040" stroke-width="5" fill="none"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="320" height="720" viewBox="0 0 320 720"><path d="M60 720 L80 300 Q160 250 240 300 L260 720 Z" fill="#2f4f6f"/><path d="M70 150 Q160 40 250 150 L260 330 L60 330 Z" fill="#3b2a24"/><circle cx="160" cy="180" r="70" fill="#f3dccb"/><circle cx="135" cy="170" r="7" fill="#333"/><circle cx="185" cy="170" r="7" fill="#333"/><path d="M 140 200 Q 160 222 180 200" stroke="#a04040" stroke-width="5" fill="none"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="320" height="720" viewBox="0 0 320 720"><path d="M60 720 L80 300 Q160 250 240 300 L260 720 Z" fill="#2f4f6f"/><path d="M70 150 Q160 40 250 150 L260 330 L60 330 Z" fill="#3b2a24"/><circle cx="160" cy="180" r="70" fill="#f3dccb"/><circle cx="135" cy="170" r="7" fill="#333"/><circle cx="185" cy="170" r="7" fill="#333"/><path d="M 152 208 a 8 10 0 1 0 16 0 a 8 10 0 1 0 -16 0" stroke="#a04040" stroke-width="5" fill="none"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1d1a17"/><stop offset="1" stop-color="#5a4b3a"/></linearGradient></defs><rect width="1280" height="720" fill="url(#g)"/><text x="40" y="70" font-size="36" fill="#ffffff" fill-opacity=".55" font-family="sans-serif">仓库</text></svg>
@@ -0,0 +1,97 @@
1
+ # 雾港灯塔 —— galbook 自带的演示故事(CC0,随便用)。3 个选项点、4 个结局、1 条通关后解锁的隐藏线。
2
+ title: 雾港灯塔
3
+ author: galbook demo
4
+ license: CC0-1.0
5
+
6
+ === prologue 序章
7
+ @bg harbor_night
8
+ @bgm calm_sea
9
+ 入夜后,港口被雾吞没。只有远处的灯塔还在一明一灭。
10
+ 你是来送最后一班信件的邮差。信封上写着:「致灯塔守夜人·汐」。
11
+ @show 汐 shio_normal center
12
+ 汐:「这么大的雾还送信?……你胆子不小。」
13
+ 汐:「灯里的油快没了。我得去仓库拿,可那条路被潮水淹了一半。」
14
+ ? 你会怎么做?
15
+ - 「我陪你去。」 -> help do f.trust += 1; f.brave += 1
16
+ - 「我在这儿帮你看着灯。」 -> stay do f.trust += 1
17
+ - 「信送到了,我该走了。」 -> leave
18
+ - 「……那封信,其实是寄给二十年前的你吧?」 -> secret if s.cleared_light
19
+
20
+ * help
21
+ @hide 汐
22
+ @bg path_tide
23
+ 冰冷的海水没过脚踝。汐走在前面,提灯在雾里晕成一团。
24
+ 汐:「你走路怎么这么吵……不过,谢谢。」
25
+ -> warehouse#
26
+
27
+ * stay
28
+ @bg lamp_room
29
+ 你守在灯旁,替她转动沉重的反光镜。雾里传来汽笛声——有船在找方向。
30
+ 汐(远处):「镜子别停!他们看得见!」
31
+ $ f.saved_ship = 1
32
+ -> warehouse#
33
+
34
+ * leave
35
+ @clear
36
+ @bg harbor_night
37
+ 你把信放在门口的石阶上,转身离开。回头时,灯光比来时暗了一些。
38
+ @end ending 雾里的背影
39
+
40
+ === warehouse 仓库
41
+ @bg warehouse
42
+ @show 汐 shio_smile center
43
+ 仓库里堆满了旧油桶,和一摞没有拆开的信。全是寄给她的。
44
+ 汐:「……我从来不敢拆。怕里面写的是『别等了』。」
45
+ ? 你要不要劝她拆开?
46
+ - 「拆吧。不管写什么,你都等得起。」 -> open do f.trust += 1
47
+ - 「不想拆就不拆。灯亮着就好。」 -> keep
48
+ - 偷偷把最上面一封塞进她的外套口袋 -> sneak if f.brave >= 1
49
+
50
+ * open
51
+ 汐撕开信封的手在发抖。读着读着,她笑出了声,又哭了。
52
+ 汐:「他说……他说他每天晚上都在海那边看这盏灯。」
53
+ -> finale#
54
+
55
+ * keep
56
+ 汐把信重新码好,像码一摞旧书。
57
+ 汐:「谢谢你没逼我。」
58
+ -> finale#
59
+
60
+ * sneak
61
+ 你把信塞进她口袋的时候,她其实看见了,只是没说。
62
+ $ f.trust += 2
63
+ -> finale#
64
+
65
+ === finale 黎明
66
+ @bg lamp_room_dawn
67
+ 雾散了。天边是一层很薄的金色。
68
+ if f.trust >= 2 -> good
69
+ if f.saved_ship == 1 -> ship
70
+ -> normal
71
+
72
+ * good
73
+ @cg cg_dawn_together
74
+ 汐:「明天……你还送信吗?」
75
+ 汐:「我是说,如果有寄给我的。」
76
+ $ s.cleared_light = 1
77
+ @end ending 灯火
78
+
79
+ * ship
80
+ @cg cg_ship
81
+ 港口里停着一艘被灯光救回来的船。船长托人送来一桶新油。
82
+ $ s.cleared_light = 1
83
+ @end ending 归航
84
+
85
+ * normal
86
+ 汐朝你挥了挥手,转身去擦那面镜子。
87
+ @end ending 寻常的早晨
88
+
89
+ === secret 二十年前的信
90
+ @route 隐藏线
91
+ @bg lamp_room
92
+ @show 汐 shio_surprised center
93
+ 汐:「……你怎么知道?」
94
+ 你把信封翻过来。邮戳上的年份,是她来到这座灯塔的那一年。
95
+ 汐:「原来,那时候就有人在等我回信了。」
96
+ @cg cg_old_letter
97
+ @end ending 回信
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f2c27b"/><stop offset="1" stop-color="#f6e7c8"/></linearGradient></defs><rect width="1280" height="720" fill="url(#g)"/><text x="40" y="70" font-size="36" fill="#ffffff" fill-opacity=".55" font-family="sans-serif">cg_dawn_together</text></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#6b5133"/><stop offset="1" stop-color="#e8d6b0"/></linearGradient></defs><rect width="1280" height="720" fill="url(#g)"/><text x="40" y="70" font-size="36" fill="#ffffff" fill-opacity=".55" font-family="sans-serif">cg_old_letter</text></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#24496b"/><stop offset="1" stop-color="#9fc4dd"/></linearGradient></defs><rect width="1280" height="720" fill="url(#g)"/><text x="40" y="70" font-size="36" fill="#ffffff" fill-opacity=".55" font-family="sans-serif">cg_ship</text></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#0b1630"/><stop offset="1" stop-color="#27406b"/></linearGradient></defs><rect width="1280" height="720" fill="url(#g)"/><text x="40" y="70" font-size="36" fill="#ffffff" fill-opacity=".55" font-family="sans-serif">harbor_night</text></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2a1d12"/><stop offset="1" stop-color="#7a5a2e"/></linearGradient></defs><rect width="1280" height="720" fill="url(#g)"/><text x="40" y="70" font-size="36" fill="#ffffff" fill-opacity=".55" font-family="sans-serif">lamp_room</text></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#3a2a40"/><stop offset="1" stop-color="#e9b872"/></linearGradient></defs><rect width="1280" height="720" fill="url(#g)"/><text x="40" y="70" font-size="36" fill="#ffffff" fill-opacity=".55" font-family="sans-serif">lamp_room_dawn</text></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#13263a"/><stop offset="1" stop-color="#3c6a80"/></linearGradient></defs><rect width="1280" height="720" fill="url(#g)"/><text x="40" y="70" font-size="36" fill="#ffffff" fill-opacity=".55" font-family="sans-serif">path_tide</text></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="320" height="720" viewBox="0 0 320 720"><path d="M60 720 L80 300 Q160 250 240 300 L260 720 Z" fill="#2f4f6f"/><path d="M70 150 Q160 40 250 150 L260 330 L60 330 Z" fill="#3b2a24"/><circle cx="160" cy="180" r="70" fill="#f3dccb"/><circle cx="135" cy="170" r="7" fill="#333"/><circle cx="185" cy="170" r="7" fill="#333"/><path d="M 140 205 L 180 205" stroke="#a04040" stroke-width="5" fill="none"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="320" height="720" viewBox="0 0 320 720"><path d="M60 720 L80 300 Q160 250 240 300 L260 720 Z" fill="#2f4f6f"/><path d="M70 150 Q160 40 250 150 L260 330 L60 330 Z" fill="#3b2a24"/><circle cx="160" cy="180" r="70" fill="#f3dccb"/><circle cx="135" cy="170" r="7" fill="#333"/><circle cx="185" cy="170" r="7" fill="#333"/><path d="M 140 200 Q 160 222 180 200" stroke="#a04040" stroke-width="5" fill="none"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="320" height="720" viewBox="0 0 320 720"><path d="M60 720 L80 300 Q160 250 240 300 L260 720 Z" fill="#2f4f6f"/><path d="M70 150 Q160 40 250 150 L260 330 L60 330 Z" fill="#3b2a24"/><circle cx="160" cy="180" r="70" fill="#f3dccb"/><circle cx="135" cy="170" r="7" fill="#333"/><circle cx="185" cy="170" r="7" fill="#333"/><path d="M 152 208 a 8 10 0 1 0 16 0 a 8 10 0 1 0 -16 0" stroke="#a04040" stroke-width="5" fill="none"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1d1a17"/><stop offset="1" stop-color="#5a4b3a"/></linearGradient></defs><rect width="1280" height="720" fill="url(#g)"/><text x="40" y="70" font-size="36" fill="#ffffff" fill-opacity=".55" font-family="sans-serif">warehouse</text></svg>
@@ -0,0 +1,97 @@
1
+ # Fog Harbor Lighthouse — galbook demo story (CC0). 3 choice points, 4 endings, 1 route unlocked after a clear.
2
+ title: Fog Harbor Lighthouse
3
+ author: galbook demo
4
+ license: CC0-1.0
5
+
6
+ === prologue Prologue
7
+ @bg harbor_night
8
+ @bgm calm_sea
9
+ After dark the harbor drowns in fog. Only the distant lighthouse still blinks.
10
+ You are the courier with the last letter of the day. The envelope reads: "To Shio, keeper of the light."
11
+ @show Shio shio_normal center
12
+ Shio: "Delivering mail in this fog? ...You've got nerve."
13
+ Shio: "The lamp is almost out of oil. I need to fetch some from the warehouse, but the tide has flooded half the path."
14
+ ? What do you do?
15
+ - "I'll come with you." -> help do f.trust += 1; f.brave += 1
16
+ - "I'll watch the lamp for you." -> stay do f.trust += 1
17
+ - "The letter's delivered. I should go." -> leave
18
+ - "...That letter was written to you twenty years ago, wasn't it?" -> secret if s.cleared_light
19
+
20
+ * help
21
+ @hide Shio
22
+ @bg path_tide
23
+ Cold seawater rises over your ankles. Shio walks ahead, her lantern a blur in the fog.
24
+ Shio: "You're so loud when you walk... but thanks."
25
+ -> warehouse#
26
+
27
+ * stay
28
+ @bg lamp_room
29
+ You stay by the lamp and turn the heavy mirror for her. A ship's horn sounds in the fog, searching for its way.
30
+ Shio: "Don't stop the mirror! They can see it!"
31
+ $ f.saved_ship = 1
32
+ -> warehouse#
33
+
34
+ * leave
35
+ @clear
36
+ @bg harbor_night
37
+ You leave the letter on the stone step and walk away. When you look back, the light seems dimmer than before.
38
+ @end ending A Back in the Fog
39
+
40
+ === warehouse Warehouse
41
+ @bg warehouse
42
+ @show Shio shio_smile center
43
+ The warehouse is full of old oil drums, and a stack of unopened letters. All addressed to her.
44
+ Shio: "...I never dared open them. In case they say 'stop waiting'."
45
+ ? Do you encourage her to open them?
46
+ - "Open them. Whatever they say, you can bear it." -> open do f.trust += 1
47
+ - "If you don't want to, don't. The light is enough." -> keep
48
+ - Quietly slip the top letter into her coat pocket -> sneak if f.brave >= 1
49
+
50
+ * open
51
+ Her hands shake as she tears the envelope. Reading, she laughs, then cries.
52
+ Shio: "He says... he watches this light every night from across the sea."
53
+ -> finale#
54
+
55
+ * keep
56
+ Shio stacks the letters back neatly, like old books.
57
+ Shio: "Thank you for not pushing me."
58
+ -> finale#
59
+
60
+ * sneak
61
+ She saw you slip the letter into her pocket. She just didn't say anything.
62
+ $ f.trust += 2
63
+ -> finale#
64
+
65
+ === finale Dawn
66
+ @bg lamp_room_dawn
67
+ The fog lifts. A thin line of gold appears on the horizon.
68
+ if f.trust >= 2 -> good
69
+ if f.saved_ship == 1 -> ship
70
+ -> normal
71
+
72
+ * good
73
+ @cg cg_dawn_together
74
+ Shio: "Will you... deliver mail again tomorrow?"
75
+ Shio: "I mean, if there's any for me."
76
+ $ s.cleared_light = 1
77
+ @end ending The Light
78
+
79
+ * ship
80
+ @cg cg_ship
81
+ In the harbor sits a ship the light brought home. The captain sends a barrel of fresh oil.
82
+ $ s.cleared_light = 1
83
+ @end ending Homecoming
84
+
85
+ * normal
86
+ Shio waves at you, then turns to polish the mirror.
87
+ @end ending An Ordinary Morning
88
+
89
+ === secret Twenty Years Ago
90
+ @route Hidden route
91
+ @bg lamp_room
92
+ @show Shio shio_surprised center
93
+ Shio: "...How did you know?"
94
+ You turn the envelope over. The postmark's year is the year she came to this lighthouse.
95
+ Shio: "So even back then, someone was waiting for my reply."
96
+ @cg cg_old_letter
97
+ @end ending The Reply
@@ -0,0 +1,63 @@
1
+ """首次运行引导:books 目录不存在或为空时,把自带演示书装进去。
2
+
3
+ 场景:`npm i galbook` 之后用户手里只有包,没有剧本目录;直接 `python -m galbook mcp`
4
+ 会因为找不到书而什么都玩不了。这里补一步,让“装完就能玩”成立。
5
+ """
6
+ import os
7
+ import shutil
8
+
9
+
10
+ def demo_enabled():
11
+ """GALBOOK_NO_DEMO=1/true/yes → 不自动装演示书。"""
12
+ return os.environ.get("GALBOOK_NO_DEMO", "").strip().lower() not in ("1", "true", "yes", "on")
13
+
14
+
15
+ def no_demo_flag(argv):
16
+ """从参数里摘掉 --no-demo;有的话设 GALBOOK_NO_DEMO=1(各入口共用)。返回剩下的参数。"""
17
+ argv = list(argv)
18
+ if "--no-demo" in argv:
19
+ argv = [x for x in argv if x != "--no-demo"]
20
+ os.environ["GALBOOK_NO_DEMO"] = "1"
21
+ return argv
22
+
23
+
24
+ def empty(books_dir):
25
+ if not os.path.isdir(books_dir):
26
+ return True
27
+ try:
28
+ with os.scandir(books_dir) as it:
29
+ return not any(it)
30
+ except OSError:
31
+ return True
32
+
33
+
34
+ def ensure_demo_books(books_dir):
35
+ """返回装了几本;目录里已经有书就不动。
36
+
37
+ 注意:build() 会往 stdout 打一堆日志。MCP 走 stdio 时 stdout 是协议通道,
38
+ 这里必须把它的输出吞掉,否则握手直接坏掉(实测过)。
39
+ """
40
+ if not empty(books_dir):
41
+ return 0
42
+ import contextlib
43
+ import io
44
+
45
+ from .build import main as build
46
+
47
+ root = os.path.join(os.path.dirname(os.path.abspath(__file__)), "demo")
48
+ if not os.path.isdir(root):
49
+ return 0
50
+ n = 0
51
+ for slug in sorted(os.listdir(root)):
52
+ src = os.path.join(root, slug)
53
+ if not os.path.isdir(src):
54
+ continue
55
+ dst = os.path.join(books_dir, slug)
56
+ if os.path.isdir(os.path.join(src, "assets")):
57
+ shutil.copytree(os.path.join(src, "assets"), os.path.join(dst, "assets"), dirs_exist_ok=True)
58
+ buf = io.StringIO()
59
+ with contextlib.redirect_stdout(buf):
60
+ rc = build(["--src", src, "--out", os.path.join(dst, "story"), "--assets-dir", src])
61
+ if rc == 0:
62
+ n += 1
63
+ return n