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.
- package/.agents/skills/galbook/SKILL.md +44 -0
- package/LICENSE +21 -0
- package/README.en.md +114 -0
- package/README.md +133 -3
- package/bin/galbook.js +53 -0
- package/cordis.patch.yml +30 -0
- package/dsh-plugin/README.md +49 -0
- package/dsh-plugin/install.py +190 -0
- package/galbook/__init__.py +15 -0
- package/galbook/__main__.py +138 -0
- package/galbook/build.py +89 -0
- package/galbook/cli.py +29 -0
- package/galbook/demo/fog-harbor/assets/cg_dawn_together.svg +1 -0
- package/galbook/demo/fog-harbor/assets/cg_old_letter.svg +1 -0
- package/galbook/demo/fog-harbor/assets/cg_ship.svg +1 -0
- package/galbook/demo/fog-harbor/assets/harbor_night.svg +1 -0
- package/galbook/demo/fog-harbor/assets/lamp_room.svg +1 -0
- package/galbook/demo/fog-harbor/assets/lamp_room_dawn.svg +1 -0
- package/galbook/demo/fog-harbor/assets/path_tide.svg +1 -0
- package/galbook/demo/fog-harbor/assets/shio_normal.svg +1 -0
- package/galbook/demo/fog-harbor/assets/shio_smile.svg +1 -0
- package/galbook/demo/fog-harbor/assets/shio_surprised.svg +1 -0
- package/galbook/demo/fog-harbor/assets/warehouse.svg +1 -0
- package/galbook/demo/fog-harbor/story.gal +97 -0
- package/galbook/demo/fog-harbor-en/assets/cg_dawn_together.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/cg_old_letter.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/cg_ship.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/harbor_night.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/lamp_room.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/lamp_room_dawn.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/path_tide.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/shio_normal.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/shio_smile.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/shio_surprised.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/warehouse.svg +1 -0
- package/galbook/demo/fog-harbor-en/story.gal +97 -0
- package/galbook/demo_boot.py +63 -0
- package/galbook/engine.py +325 -0
- package/galbook/expr.py +107 -0
- package/galbook/i18n.py +85 -0
- package/galbook/importers/__init__.py +106 -0
- package/galbook/importers/common.py +94 -0
- package/galbook/importers/galscript.py +218 -0
- package/galbook/importers/kag.py +326 -0
- package/galbook/importers/legacy.py +122 -0
- package/galbook/importers/nscripter.py +314 -0
- package/galbook/importers/qlie.py +498 -0
- package/galbook/importers/renpy.py +333 -0
- package/galbook/importers/textdump.py +128 -0
- package/galbook/ir.py +266 -0
- package/galbook/mcp_server.py +102 -0
- package/galbook/play.py +374 -0
- package/galbook/server.py +199 -0
- package/galbook/static/play.html +151 -0
- package/galbook/store.py +160 -0
- package/galbook/tools.py +99 -0
- package/galbook/web.py +316 -0
- package/icon.svg +21 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +58 -6
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""运行时:把剧本往前推,遇到选项停下等 agent 选。
|
|
2
|
+
|
|
3
|
+
状态(state,可 JSON 序列化,存档就是它的拷贝):
|
|
4
|
+
scene, pc 当前位置
|
|
5
|
+
stack call 的返回栈 [[scene, pc], ...]
|
|
6
|
+
f 本局变量(随存档走)
|
|
7
|
+
vis 画面 {"bg", "cg", "sprites": {名字: {"img", "pos"}}, "bgm"}
|
|
8
|
+
pending 正在等的选项 {"scene", "pc", "id", "options": [{"i", "text", "k"}], "shown_at"}
|
|
9
|
+
ended 结束信息 {"kind", "name"} 或 None
|
|
10
|
+
turn 已经读过的台词句数(本局)
|
|
11
|
+
choices 本局做过的选择 [{"scene", "title", "id", "text", "i", "reason", "turn", "think_s"}]
|
|
12
|
+
log 最近 300 句回想 [{"who", "text", "scene"}]
|
|
13
|
+
rewind 最近 30 个选项点的快照(回溯用)
|
|
14
|
+
opts {"h": "fold"|"full"|"skip", "mode": "normal"|"ironman", "hints": bool, "lang": "zh"|"en"}
|
|
15
|
+
系统变量 s(通关标记等,跨周目)和已读记录 seen 不在 state 里,按 agent 存在档案中。
|
|
16
|
+
"""
|
|
17
|
+
import copy
|
|
18
|
+
import re
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
from .expr import evaluate, assign, ExprError
|
|
22
|
+
from .ir import StoryError
|
|
23
|
+
|
|
24
|
+
MAX_OPS_PER_CALL = 500000
|
|
25
|
+
LOG_KEEP = 300
|
|
26
|
+
REWIND_KEEP = 30
|
|
27
|
+
H_FOLD_TEXT = "〔此处略(H 场景,new 时传 h=full 可展开)〕"
|
|
28
|
+
H_FOLD_TEXT_EN = "〔Adult scene folded — start with h=full to show it〕"
|
|
29
|
+
|
|
30
|
+
_RB = re.compile(r"\[rb,([^,\]]+),([^\]]*)\]")
|
|
31
|
+
_TAG = re.compile(r"\[[A-Za-z]+(?:,[^\]]*)?\]")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def plain(text):
|
|
35
|
+
"""[rb,汉字,假名] → 汉字;其余 [xx,..] 标签去掉。"""
|
|
36
|
+
t = _RB.sub(lambda m: m.group(1), text or "")
|
|
37
|
+
return _TAG.sub("", t)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PlayError(ValueError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
H_MODES = ("fold", "full", "skip")
|
|
45
|
+
MODES = ("normal", "ironman")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def make_opts(h="fold", mode="normal", hints=False, lang="zh", **_):
|
|
49
|
+
"""一局的玩法选项(new 时定,存进存档)。
|
|
50
|
+
h H 场景:fold 折叠成一行(默认)/ full 照常 / skip 整段跳过不提示
|
|
51
|
+
mode normal 可存读档回溯 / ironman 铁人:不能读档、不能回溯,选了就认
|
|
52
|
+
hints 选项旁标「选过」「新内容」(像很多游戏的已选标记)
|
|
53
|
+
lang 文本接口的语言 zh / en"""
|
|
54
|
+
return {"h": h if h in H_MODES else "fold", "mode": mode if mode in MODES else "normal",
|
|
55
|
+
"hints": str(hints).lower() in ("1", "true", "yes", "on"), "lang": lang if lang in ("zh", "en") else "zh"}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def new_state(story, h="fold", **opts):
|
|
59
|
+
e = story.entry or {}
|
|
60
|
+
sid, pc = story.resolve(e.get("scene"), e.get("label"))
|
|
61
|
+
return {"scene": sid, "pc": pc, "stack": [], "f": {}, "vis": {"bg": None, "cg": None, "sprites": {}, "bgm": None},
|
|
62
|
+
"pending": None, "ended": None, "turn": 0, "choices": [], "log": [], "rewind": [],
|
|
63
|
+
"opts": make_opts(h=h, **opts), "started": time.time(), "h_shown": None}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------- 已读记录:每个场景一组区间 [[a,b],...](闭区间,pc 编号) ----------
|
|
67
|
+
def seen_has(seen, sid, pc):
|
|
68
|
+
for a, b in seen.get(sid, ()):
|
|
69
|
+
if a <= pc <= b:
|
|
70
|
+
return True
|
|
71
|
+
if a > pc:
|
|
72
|
+
return False
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def seen_add(seen, sid, pc):
|
|
77
|
+
rs = seen.setdefault(sid, [])
|
|
78
|
+
for i, (a, b) in enumerate(rs):
|
|
79
|
+
if a <= pc <= b:
|
|
80
|
+
return False
|
|
81
|
+
if pc == b + 1:
|
|
82
|
+
rs[i][1] = pc
|
|
83
|
+
if i + 1 < len(rs) and rs[i + 1][0] == pc + 1:
|
|
84
|
+
rs[i][1] = rs[i + 1][1]
|
|
85
|
+
del rs[i + 1]
|
|
86
|
+
return True
|
|
87
|
+
if pc == a - 1:
|
|
88
|
+
rs[i][0] = pc
|
|
89
|
+
return True
|
|
90
|
+
if pc < a:
|
|
91
|
+
rs.insert(i, [pc, pc])
|
|
92
|
+
return True
|
|
93
|
+
rs.append([pc, pc])
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _snapshot(state):
|
|
98
|
+
snap = {k: copy.deepcopy(v) for k, v in state.items() if k not in ("rewind", "log")}
|
|
99
|
+
snap["log_len"] = len(state.get("log", []))
|
|
100
|
+
return snap
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def advance(story, state, sysv, seen, n=20, skip_read=False):
|
|
104
|
+
"""往前推进,最多收 n 句「新」台词;遇到选项 / 结束就停。
|
|
105
|
+
skip_read=True:已读过的句子快进不计数(像游戏里的「跳过已读」),停在第一句没读过的或选项上。
|
|
106
|
+
返回 {"lines": [...], "skipped": 已读快进句数, "choice": ..., "ended": ..., "events": [...]}"""
|
|
107
|
+
out, events = [], []
|
|
108
|
+
skipped = 0
|
|
109
|
+
if state.get("pending"):
|
|
110
|
+
return _result(story, state, out, skipped, events)
|
|
111
|
+
if state.get("ended"):
|
|
112
|
+
return _result(story, state, out, skipped, events)
|
|
113
|
+
budget = MAX_OPS_PER_CALL
|
|
114
|
+
while budget > 0:
|
|
115
|
+
budget -= 1
|
|
116
|
+
sc = story.scene(state["scene"])
|
|
117
|
+
pc = state["pc"]
|
|
118
|
+
if pc >= len(sc.ops):
|
|
119
|
+
if state["stack"]:
|
|
120
|
+
state["scene"], state["pc"] = state["stack"].pop()
|
|
121
|
+
continue
|
|
122
|
+
state["ended"] = {"kind": "stop", "dead_end": True, "scene": sc.id,
|
|
123
|
+
"name": "场景 %s「%s」走到底且没有跳转(这个场景缺出口)" % (sc.id, sc.title)}
|
|
124
|
+
break
|
|
125
|
+
op = sc.ops[pc]
|
|
126
|
+
state["pc"] = pc + 1
|
|
127
|
+
cond = op.get("if")
|
|
128
|
+
k = op.get("op")
|
|
129
|
+
if cond and k != "choice":
|
|
130
|
+
try:
|
|
131
|
+
if not evaluate(cond, state["f"], sysv):
|
|
132
|
+
continue
|
|
133
|
+
except ExprError as e:
|
|
134
|
+
events.append({"warn": str(e)})
|
|
135
|
+
continue
|
|
136
|
+
if k == "say":
|
|
137
|
+
hm = state["opts"].get("h")
|
|
138
|
+
if "h" in sc.tags and hm != "full":
|
|
139
|
+
if hm != "skip" and state.get("h_shown") != sc.id:
|
|
140
|
+
state["h_shown"] = sc.id
|
|
141
|
+
line = {"who": "", "text": H_FOLD_TEXT_EN if state["opts"].get("lang") == "en" else H_FOLD_TEXT,
|
|
142
|
+
"scene": sc.id, "pc": pc, "folded": True}
|
|
143
|
+
out.append(line)
|
|
144
|
+
_log(state, line)
|
|
145
|
+
seen_add(seen, sc.id, pc)
|
|
146
|
+
continue
|
|
147
|
+
was = seen_has(seen, sc.id, pc)
|
|
148
|
+
seen_add(seen, sc.id, pc)
|
|
149
|
+
state["turn"] += 1
|
|
150
|
+
line = {"who": op.get("who") or "", "text": op.get("text") or "", "scene": sc.id, "pc": pc}
|
|
151
|
+
if op.get("voice"):
|
|
152
|
+
line["voice"] = op["voice"]
|
|
153
|
+
_log(state, line)
|
|
154
|
+
if skip_read and was:
|
|
155
|
+
skipped += 1
|
|
156
|
+
continue
|
|
157
|
+
line["vis"] = _vis_brief(state["vis"])
|
|
158
|
+
out.append(line)
|
|
159
|
+
if len(out) >= n:
|
|
160
|
+
break
|
|
161
|
+
elif k == "bg":
|
|
162
|
+
state["vis"]["bg"] = op.get("img")
|
|
163
|
+
state["vis"]["cg"] = None
|
|
164
|
+
elif k == "cg":
|
|
165
|
+
state["vis"]["cg"] = op.get("img")
|
|
166
|
+
if op.get("img"):
|
|
167
|
+
events.append({"cg": op["img"]})
|
|
168
|
+
elif k == "sprite":
|
|
169
|
+
if op.get("img"):
|
|
170
|
+
state["vis"]["sprites"][op.get("name", "?")] = {"img": op["img"], "pos": op.get("pos") or "center"}
|
|
171
|
+
else:
|
|
172
|
+
state["vis"]["sprites"].pop(op.get("name", "?"), None)
|
|
173
|
+
elif k == "sprites_clear":
|
|
174
|
+
state["vis"]["sprites"] = {}
|
|
175
|
+
elif k == "bgm":
|
|
176
|
+
state["vis"]["bgm"] = op.get("name")
|
|
177
|
+
elif k == "label":
|
|
178
|
+
nm = op.get("name") or ""
|
|
179
|
+
if nm and not nm.startswith("__"):
|
|
180
|
+
state["label"] = nm
|
|
181
|
+
elif k == "set":
|
|
182
|
+
try:
|
|
183
|
+
for name in assign(op.get("expr", ""), state["f"], sysv):
|
|
184
|
+
events.append({"set": name})
|
|
185
|
+
except ExprError as e:
|
|
186
|
+
events.append({"warn": str(e)})
|
|
187
|
+
elif k == "jump":
|
|
188
|
+
state["scene"], state["pc"] = story.resolve(op.get("scene"), op.get("label"), sc.id)
|
|
189
|
+
elif k == "call":
|
|
190
|
+
state["stack"].append([sc.id, state["pc"]])
|
|
191
|
+
state["scene"], state["pc"] = story.resolve(op.get("scene"), op.get("label"), sc.id)
|
|
192
|
+
elif k == "ret":
|
|
193
|
+
if state["stack"]:
|
|
194
|
+
state["scene"], state["pc"] = state["stack"].pop()
|
|
195
|
+
elif k == "event":
|
|
196
|
+
events.append({"event": op.get("name"), "arg": op.get("arg")})
|
|
197
|
+
elif k == "end":
|
|
198
|
+
state["ended"] = {"kind": op.get("kind") or "ending", "scene": sc.id,
|
|
199
|
+
"name": op.get("name") or ("%s·%s" % (sc.title, state["label"]) if state.get("label") and state["label"] != sc.title else sc.title)}
|
|
200
|
+
if op.get("how"):
|
|
201
|
+
state["ended"]["how"] = op["how"]
|
|
202
|
+
break
|
|
203
|
+
elif k == "choice":
|
|
204
|
+
opts = []
|
|
205
|
+
for j, o in enumerate(op.get("options") or []):
|
|
206
|
+
try:
|
|
207
|
+
ok = evaluate(o.get("if"), state["f"], sysv)
|
|
208
|
+
except ExprError as e:
|
|
209
|
+
events.append({"warn": str(e)})
|
|
210
|
+
ok = False
|
|
211
|
+
if ok:
|
|
212
|
+
opts.append({"i": len(opts) + 1, "text": plain(o.get("text", "")), "k": j})
|
|
213
|
+
if not opts:
|
|
214
|
+
continue
|
|
215
|
+
if len(opts) == 1 and op.get("auto_single"):
|
|
216
|
+
state["pc"] = pc
|
|
217
|
+
_take(story, state, sysv, sc, op, opts[0], reason="(只有一个可选,自动)", auto=True)
|
|
218
|
+
continue
|
|
219
|
+
state["pc"] = pc
|
|
220
|
+
state["pending"] = {"scene": sc.id, "pc": pc, "id": op.get("id") or ("c%d" % pc),
|
|
221
|
+
"prompt": op.get("prompt") or "", "options": opts, "shown_at": time.time()}
|
|
222
|
+
state["rewind"].append(_snapshot(state))
|
|
223
|
+
del state["rewind"][:-REWIND_KEEP]
|
|
224
|
+
break
|
|
225
|
+
else:
|
|
226
|
+
raise PlayError("一次推进执行了 %d 条指令还没停,剧本可能有死循环(%s#%d)"
|
|
227
|
+
% (MAX_OPS_PER_CALL, state["scene"], state["pc"]))
|
|
228
|
+
return _result(story, state, out, skipped, events)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _take(story, state, sysv, sc, op, opt, reason="", auto=False):
|
|
232
|
+
o = op["options"][opt["k"]]
|
|
233
|
+
if o.get("set"):
|
|
234
|
+
assign(o["set"], state["f"], sysv)
|
|
235
|
+
g = o.get("goto") or {}
|
|
236
|
+
shown = (state.get("pending") or {}).get("shown_at")
|
|
237
|
+
state["choices"].append({"scene": sc.id, "title": sc.title, "id": op.get("id") or "", "text": opt["text"],
|
|
238
|
+
"i": opt["i"], "of": None, "reason": (reason or "")[:500], "turn": state["turn"],
|
|
239
|
+
"think_s": round(time.time() - shown, 1) if shown else None, "auto": auto})
|
|
240
|
+
state["pending"] = None
|
|
241
|
+
if g:
|
|
242
|
+
state["scene"], state["pc"] = story.resolve(g.get("scene"), g.get("label"), sc.id)
|
|
243
|
+
else:
|
|
244
|
+
state["pc"] = state["pc"] + 1
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def choose(story, state, sysv, i, reason=""):
|
|
248
|
+
p = state.get("pending")
|
|
249
|
+
if not p:
|
|
250
|
+
raise PlayError("现在没有选项可选")
|
|
251
|
+
try:
|
|
252
|
+
i = int(i)
|
|
253
|
+
except (TypeError, ValueError):
|
|
254
|
+
raise PlayError("i 要填选项编号(1 开始)")
|
|
255
|
+
opt = next((o for o in p["options"] if o["i"] == i), None)
|
|
256
|
+
if opt is None:
|
|
257
|
+
raise PlayError("没有第 %d 个选项(共 %d 个)" % (i, len(p["options"])))
|
|
258
|
+
sc = story.scene(p["scene"])
|
|
259
|
+
op = sc.ops[p["pc"]]
|
|
260
|
+
state["pc"] = p["pc"]
|
|
261
|
+
_take(story, state, sysv, sc, op, opt, reason)
|
|
262
|
+
state["choices"][-1]["of"] = len(p["options"])
|
|
263
|
+
return state["choices"][-1]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def rewind(state, steps=1):
|
|
267
|
+
"""回到第 steps 个之前的选项点(1 = 最近一次的选项,重新选)。"""
|
|
268
|
+
rw = state.get("rewind") or []
|
|
269
|
+
if not rw:
|
|
270
|
+
raise PlayError("还没有可回溯的选项点")
|
|
271
|
+
steps = max(1, min(int(steps), len(rw)))
|
|
272
|
+
snap = rw[-steps]
|
|
273
|
+
keep = rw[:-steps] + [snap]
|
|
274
|
+
log = state.get("log", [])[:snap.get("log_len", 0)]
|
|
275
|
+
new = copy.deepcopy({k: v for k, v in snap.items() if k != "log_len"})
|
|
276
|
+
new["rewind"] = keep
|
|
277
|
+
new["log"] = log
|
|
278
|
+
if new.get("pending"):
|
|
279
|
+
new["pending"]["shown_at"] = time.time()
|
|
280
|
+
return new
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _log(state, line):
|
|
284
|
+
lg = state.setdefault("log", [])
|
|
285
|
+
lg.append({"who": line["who"], "text": line["text"], "scene": line["scene"]})
|
|
286
|
+
if len(lg) > LOG_KEEP + 50:
|
|
287
|
+
del lg[:-LOG_KEEP]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _vis_brief(vis):
|
|
291
|
+
return {"bg": vis.get("bg"), "cg": vis.get("cg"),
|
|
292
|
+
"sprites": [dict(name=k, **v) for k, v in (vis.get("sprites") or {}).items()]}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def where(story, state):
|
|
296
|
+
sc = story.scenes.get(state.get("scene"))
|
|
297
|
+
return {"scene": state.get("scene"), "title": sc.title if sc else "", "route": sc.route if sc else "",
|
|
298
|
+
"turn": state.get("turn", 0)}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def peek(story, state, recent=5):
|
|
302
|
+
"""不推进,只看当前在哪、有没有待选的选项(旁观 / status 用)。"""
|
|
303
|
+
r = _result(story, state, [], 0, [])
|
|
304
|
+
r["recent"] = [{"who": x.get("who", ""), "text": x.get("text", "")} for x in (state.get("log") or [])[-recent:]]
|
|
305
|
+
return r
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _result(story, state, lines, skipped, events):
|
|
309
|
+
r = {"lines": lines, "skipped": skipped, "where": where(story, state), "vis": _vis_brief(state["vis"])}
|
|
310
|
+
if state.get("pending"):
|
|
311
|
+
p = state["pending"]
|
|
312
|
+
r["choice"] = {"id": p["id"], "prompt": p.get("prompt", ""),
|
|
313
|
+
"options": [{"i": o["i"], "text": o["text"]} for o in p["options"]]}
|
|
314
|
+
if state.get("ended"):
|
|
315
|
+
r["ended"] = state["ended"]
|
|
316
|
+
ev = [e for e in events if "warn" not in e and "cg" not in e]
|
|
317
|
+
cgs = [e["cg"] for e in events if "cg" in e]
|
|
318
|
+
if cgs:
|
|
319
|
+
r["cg_seen"] = cgs
|
|
320
|
+
warns = [e["warn"] for e in events if "warn" in e]
|
|
321
|
+
if ev:
|
|
322
|
+
r["events"] = ev[-20:]
|
|
323
|
+
if warns:
|
|
324
|
+
r["warnings"] = warns[:10]
|
|
325
|
+
return r
|
package/galbook/expr.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""安全表达式:条件与赋值。
|
|
2
|
+
|
|
3
|
+
语法(各引擎的条件写法都先翻成这一种):
|
|
4
|
+
字面量 123 -4 "abc" 'abc' true false
|
|
5
|
+
变量 f.name(本局变量,随存档走) s.name(系统变量,跨周目,按 agent 存)
|
|
6
|
+
运算 + - * / % == != < <= > >= && || ! and or not 括号
|
|
7
|
+
赋值语句: f.x = 1 ; s.clear_a = 1 ; f.n += 2 (多条用 ; 分隔)
|
|
8
|
+
未定义变量按 0 处理(和多数 ADV 引擎一致)。
|
|
9
|
+
"""
|
|
10
|
+
import ast
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
_VAR = re.compile(r"\b([fs])\.([A-Za-z_\u0080-\uffff][\w\u0080-\uffff]*)")
|
|
14
|
+
_ALLOWED = (ast.Expression, ast.BoolOp, ast.BinOp, ast.UnaryOp, ast.Compare, ast.Constant,
|
|
15
|
+
ast.Name, ast.Load, ast.And, ast.Or, ast.Not, ast.USub, ast.UAdd, ast.Add, ast.Sub,
|
|
16
|
+
ast.Mult, ast.Div, ast.FloorDiv, ast.Mod, ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE,
|
|
17
|
+
ast.Subscript, ast.Index if hasattr(ast, "Index") else ast.Constant)
|
|
18
|
+
_CACHE = {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ExprError(ValueError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_STR = re.compile(r"(\"(?:[^\"\\]|\\.)*\"|'(?:[^'\\]|\\.)*')")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _translate_code(s):
|
|
29
|
+
s = s.replace("&&", " and ").replace("||", " or ")
|
|
30
|
+
s = re.sub(r"!(?!=)", " not ", s)
|
|
31
|
+
s = re.sub(r"\btrue\b", "True", s)
|
|
32
|
+
s = re.sub(r"\bfalse\b", "False", s)
|
|
33
|
+
return _VAR.sub(lambda m: 'V["%s.%s"]' % (m.group(1), m.group(2)), s)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _translate(src):
|
|
37
|
+
parts = _STR.split(str(src))
|
|
38
|
+
# 偶数段是代码,奇数段是字符串字面量(原样保留)
|
|
39
|
+
return "".join(p if i % 2 else _translate_code(p) for i, p in enumerate(parts)).strip()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def compile_expr(src):
|
|
43
|
+
if src in _CACHE:
|
|
44
|
+
return _CACHE[src]
|
|
45
|
+
py = _translate(src)
|
|
46
|
+
try:
|
|
47
|
+
tree = ast.parse(py, mode="eval")
|
|
48
|
+
except SyntaxError as e:
|
|
49
|
+
raise ExprError("表达式语法错误: %r (%s)" % (src, e))
|
|
50
|
+
for node in ast.walk(tree):
|
|
51
|
+
if not isinstance(node, _ALLOWED):
|
|
52
|
+
raise ExprError("表达式含不允许的成分 %s: %r" % (type(node).__name__, src))
|
|
53
|
+
if isinstance(node, ast.Name) and node.id not in ("V", "True", "False"):
|
|
54
|
+
raise ExprError("未知名字 %r(变量要写成 f.x 或 s.x): %r" % (node.id, src))
|
|
55
|
+
code = compile(tree, "<expr>", "eval")
|
|
56
|
+
_CACHE[src] = code
|
|
57
|
+
return code
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _Vars(dict):
|
|
61
|
+
def __missing__(self, k):
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def evaluate(src, f, s):
|
|
66
|
+
if src is None or src == "":
|
|
67
|
+
return True
|
|
68
|
+
v = _Vars()
|
|
69
|
+
for k, x in (f or {}).items():
|
|
70
|
+
v["f." + k] = x
|
|
71
|
+
for k, x in (s or {}).items():
|
|
72
|
+
v["s." + k] = x
|
|
73
|
+
try:
|
|
74
|
+
return eval(compile_expr(src), {"__builtins__": {}}, {"V": v})
|
|
75
|
+
except ExprError:
|
|
76
|
+
raise
|
|
77
|
+
except ZeroDivisionError:
|
|
78
|
+
return 0
|
|
79
|
+
except Exception as e:
|
|
80
|
+
raise ExprError("表达式求值失败 %r: %s" % (src, e))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_ASSIGN = re.compile(r"^\s*([fs])\.([\w\u0080-\uffff]+)\s*(\+=|-=|\*=|=)\s*(.+?)\s*$")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def assign(stmts, f, s):
|
|
87
|
+
"""执行 'f.x = 1; s.y += 2'。返回改动过的变量名列表。"""
|
|
88
|
+
changed = []
|
|
89
|
+
for st in re.split(r";(?=(?:[^\"']|\"[^\"]*\"|'[^']*')*$)", str(stmts)):
|
|
90
|
+
if not st.strip():
|
|
91
|
+
continue
|
|
92
|
+
m = _ASSIGN.match(st)
|
|
93
|
+
if not m:
|
|
94
|
+
raise ExprError("赋值语句看不懂: %r" % st)
|
|
95
|
+
scope, name, op, rhs = m.groups()
|
|
96
|
+
val = evaluate(rhs, f, s)
|
|
97
|
+
tgt = f if scope == "f" else s
|
|
98
|
+
if op == "=":
|
|
99
|
+
tgt[name] = val
|
|
100
|
+
elif op == "+=":
|
|
101
|
+
tgt[name] = tgt.get(name, 0) + val
|
|
102
|
+
elif op == "-=":
|
|
103
|
+
tgt[name] = tgt.get(name, 0) - val
|
|
104
|
+
elif op == "*=":
|
|
105
|
+
tgt[name] = tgt.get(name, 0) * val
|
|
106
|
+
changed.append(scope + "." + name)
|
|
107
|
+
return changed
|
package/galbook/i18n.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""文本接口的多语言(zh / en)。只管给 agent 看的框架文字;剧本台词保持原文。"""
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
T = {
|
|
5
|
+
"zh": {
|
|
6
|
+
"line_hdr": "【{title}】第 {turn} 句", "skipped": "(跳过已读 {n} 句)", "choice_hdr": "—— 选项{prompt} ——",
|
|
7
|
+
"choose_hint": "选:{base}choose&i=编号&reason=一句理由{auto}", "auto_saved": " (已自动存档 auto)",
|
|
8
|
+
"persona": "(你的人设:{p})", "mark_picked": "选过", "mark_new": "新内容",
|
|
9
|
+
"ended": "—— 结束:{name}({kind})——", "kind_ending": "结局", "kind_title": "回到标题", "kind_stop": "停住",
|
|
10
|
+
"after_end": "再来一局:{base}new 回到上个选项:{base}rewind 看收集:{base}gallery",
|
|
11
|
+
"after_end_iron": "再来一局:{base}new 看收集:{base}gallery",
|
|
12
|
+
"cont": "继续:{base}next", "you_chose": "你选了:{text}({i}/{of})", "no_saves": "还没有存档。",
|
|
13
|
+
"save_row": "[{slot}] {title} 第{turn}句 {preview}{note}", "note": " 备注:", "log_choices": "—— 做过的选择 ——",
|
|
14
|
+
"reason": "(理由:{r})", "read": "已读 {pct:.1f}%,看过 {n} 个场景",
|
|
15
|
+
"stats": "周目 {g},读了 {l} 句,选择 {c} 次,存档 {s} / 读档 {ld} / 回溯 {rw}",
|
|
16
|
+
"endings_seen": "到过的结局:", "sysvars": "系统记录:", "humanlike": "像不像人:",
|
|
17
|
+
"not_started": "《{title}》还没开始。新游戏:{base}new(可加 &mode=ironman &hints=1 &h=skip &lang=en)",
|
|
18
|
+
"has_saves": "有存档:{s}(读档:{base}load&slot=…)", "recent": "(最近几句)", "recent_load": "(读档前最后几句)",
|
|
19
|
+
"vis_cg": "CG ", "vis_bg": "背景 ", "vis_on": "在场 ", "sep": "、",
|
|
20
|
+
"ev_eyecatch": "〔转场:{a}〕", "ev_endroll": "〔片尾〕", "ev_clear": "〔通关记录:{a}〕",
|
|
21
|
+
"persona_now": "当前人设:{p}", "persona_none": "还没设人设。设:{base}persona&text=一段描述你性格/口味的话",
|
|
22
|
+
"gallery": "结局 {e}/{et}:{names}\n路线 {rs}/{rt}:{routes}\nCG {c}/{ct}\n已读 {pct:.1f}%,周目 {g}",
|
|
23
|
+
"opts": "本局:H={h} 模式={mode} 提示={hints} 语言={lg}", "error": "错误:{e}\n(玩法:/play/t)",
|
|
24
|
+
},
|
|
25
|
+
"en": {
|
|
26
|
+
"line_hdr": "[{title}] line {turn}", "skipped": "(skipped {n} already-read lines)", "choice_hdr": "—— CHOICE{prompt} ——",
|
|
27
|
+
"choose_hint": "Pick: {base}choose&i=N&reason=one+sentence+why{auto}", "auto_saved": " (auto-saved to slot auto)",
|
|
28
|
+
"persona": "(your persona: {p})", "mark_picked": "picked before", "mark_new": "new",
|
|
29
|
+
"ended": "—— END: {name} ({kind}) ——", "kind_ending": "ending", "kind_title": "back to title", "kind_stop": "stopped",
|
|
30
|
+
"after_end": "New game: {base}new Back to last choice: {base}rewind Collection: {base}gallery",
|
|
31
|
+
"after_end_iron": "New game: {base}new Collection: {base}gallery",
|
|
32
|
+
"cont": "Continue: {base}next", "you_chose": "You chose: {text} ({i}/{of})", "no_saves": "No saves yet.",
|
|
33
|
+
"save_row": "[{slot}] {title} line {turn} {preview}{note}", "note": " note: ", "log_choices": "—— Choices made ——",
|
|
34
|
+
"reason": " (why: {r})", "read": "Read {pct:.1f}%, {n} scenes visited",
|
|
35
|
+
"stats": "Playthroughs {g}, lines {l}, choices {c}, saves {s} / loads {ld} / rewinds {rw}",
|
|
36
|
+
"endings_seen": "Endings reached: ", "sysvars": "System flags: ", "humanlike": "Human-likeness: ",
|
|
37
|
+
"not_started": "\"{title}\" not started. New game: {base}new (options: &mode=ironman &hints=1 &h=skip &lang=en)",
|
|
38
|
+
"has_saves": "Saves: {s} (load: {base}load&slot=…)", "recent": "(last lines)", "recent_load": "(last lines before this save)",
|
|
39
|
+
"vis_cg": "CG ", "vis_bg": "BG ", "vis_on": "on stage: ", "sep": ", ",
|
|
40
|
+
"ev_eyecatch": "〔scene change: {a}〕", "ev_endroll": "〔credits〕", "ev_clear": "〔clear flag: {a}〕",
|
|
41
|
+
"persona_now": "Persona: {p}", "persona_none": "No persona yet. Set one: {base}persona&text=a+few+lines+about+your+character",
|
|
42
|
+
"gallery": "Endings {e}/{et}: {names}\nRoutes {rs}/{rt}: {routes}\nCG {c}/{ct}\nRead {pct:.1f}%, playthroughs {g}",
|
|
43
|
+
"opts": "This run: H={h} mode={mode} hints={hints} lang={lg}", "error": "Error: {e}\n(how to play: /play/t?lang=en)",
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# 常见报错 / 提示的英文(引擎内部报错是中文,按前缀翻)
|
|
48
|
+
_ERR = [
|
|
49
|
+
(r"^还没开始。.*", "Not started yet. Use new (new game) or load."),
|
|
50
|
+
(r"^现在没有选项可选", "There is no choice to make right now."),
|
|
51
|
+
(r"^没有第 (\d+) 个选项(共 (\d+) 个)", r"No option \1 (there are \2)."),
|
|
52
|
+
(r"^i 要填选项编号.*", "i must be the option number (from 1) or random."),
|
|
53
|
+
(r"^还没有可回溯的选项点", "No choice point to rewind to yet."),
|
|
54
|
+
(r"^存档位 (\S+) 是空的", r"Save slot \1 is empty."),
|
|
55
|
+
(r"^存档位只能是.*", "Slot must be auto / quick / 1-99."),
|
|
56
|
+
(r"^agent 名不合法.*", "Invalid agent name (1-40 letters/digits/CJK/_-.)."),
|
|
57
|
+
(r"^没有这本书: (.*)", r"No such book: \1"),
|
|
58
|
+
(r"^铁人模式这一局不能(.*?)(.*", r"Ironman run: \1 is disabled (choices are final; start a new game to retry)."),
|
|
59
|
+
(r"^不认识的操作 (.*?)(可用:(.*))", r"Unknown action \1 (available: \2)"),
|
|
60
|
+
(r"^do 只能是:(.*)", r"do must be one of: \1"),
|
|
61
|
+
(r"^新游戏开始(第 (\d+) 周目)", r"New game started (playthrough #\1)"),
|
|
62
|
+
(r"^已存到 (.*)", r"Saved to \1"), (r"^读档 (.*)", r"Loaded \1"), (r"^回到选项点", "Back at the choice point"),
|
|
63
|
+
(r"^点了一下,什么也没发生。", "You click. Nothing happens."), (r"^点了一下。$", "Click."),
|
|
64
|
+
(r"^点击效果执行失败:(.*)", r"Click effect failed: \1"),
|
|
65
|
+
(r"^人设已记下", "Persona saved"), (r"^人设已清空", "Persona cleared"),
|
|
66
|
+
(r"^路径多了一段.*", "Too many path segments: /play/t/<book>/<name>?do=…"),
|
|
67
|
+
]
|
|
68
|
+
_KIND = {"读档": "loading", "手动存档": "manual saving", "回溯": "rewinding"}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def t(lang, key, **kw):
|
|
72
|
+
d = T.get(lang) or T["zh"]
|
|
73
|
+
return (d.get(key) or T["zh"][key]).format(**kw)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def msg(lang, s):
|
|
77
|
+
if lang != "en" or not s:
|
|
78
|
+
return s
|
|
79
|
+
for pat, rep in _ERR:
|
|
80
|
+
if re.match(pat, s):
|
|
81
|
+
out = re.sub(pat, rep, s)
|
|
82
|
+
for zh, en in _KIND.items():
|
|
83
|
+
out = out.replace(zh, en)
|
|
84
|
+
return out
|
|
85
|
+
return s
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""各引擎脚本 → galbook/1 中间格式。
|
|
2
|
+
|
|
3
|
+
内置:qlie / kag(吉里吉里·Tyrano)/ renpy / nscripter / galscript(.gal 手写格式)/ textdump(VNTextPatch 等文本导出,线性)
|
|
4
|
+
插件:环境变量 GALBOOK_PLUGINS(多个用 os.pathsep 分隔)指向 .py 文件或目录;或 build 时 --plugin x.py。
|
|
5
|
+
插件模块要有 NAME、build(src, assets=None, **opts) -> (meta, scenes),可选 detect(src) -> (bool, 说明)。
|
|
6
|
+
详见 docs/IMPORTERS.md。
|
|
7
|
+
"""
|
|
8
|
+
import importlib
|
|
9
|
+
import importlib.util
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
NAMES = ("qlie", "kag", "renpy", "nscripter", "galscript", "textdump")
|
|
13
|
+
_plugins = {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_plugin(path):
|
|
17
|
+
"""加载一个插件文件,返回它的 NAME。"""
|
|
18
|
+
spec = importlib.util.spec_from_file_location("galbook_plugin_%d" % len(_plugins), path)
|
|
19
|
+
mod = importlib.util.module_from_spec(spec)
|
|
20
|
+
spec.loader.exec_module(mod)
|
|
21
|
+
if not getattr(mod, "NAME", None) or not callable(getattr(mod, "build", None)):
|
|
22
|
+
raise ValueError("插件 %s 缺 NAME 或 build()" % path)
|
|
23
|
+
_plugins[mod.NAME] = mod
|
|
24
|
+
return mod.NAME
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _env_plugins():
|
|
28
|
+
for p in filter(None, os.environ.get("GALBOOK_PLUGINS", "").split(os.pathsep)):
|
|
29
|
+
files = [os.path.join(p, f) for f in sorted(os.listdir(p)) if f.endswith(".py")] if os.path.isdir(p) else [p]
|
|
30
|
+
for f in files:
|
|
31
|
+
if not any(getattr(m, "__file__", None) == f for m in _plugins.values()):
|
|
32
|
+
load_plugin(f)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def names():
|
|
36
|
+
_env_plugins()
|
|
37
|
+
return list(NAMES) + [n for n in _plugins if n not in NAMES]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get(name):
|
|
41
|
+
_env_plugins()
|
|
42
|
+
if name in _plugins:
|
|
43
|
+
return _plugins[name]
|
|
44
|
+
if name not in NAMES:
|
|
45
|
+
raise ValueError("没有这个导入器: %r(有:%s)" % (name, " ".join(names())))
|
|
46
|
+
return importlib.import_module("." + name, __name__)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def detect(src):
|
|
50
|
+
"""按文件特征认引擎。返回 (engine, 依据);认不出返回 (None, 说明)。插件先认。"""
|
|
51
|
+
if not os.path.isdir(src):
|
|
52
|
+
return None, "目录不存在或不是目录: %s" % src
|
|
53
|
+
_env_plugins()
|
|
54
|
+
for n, m in _plugins.items():
|
|
55
|
+
if callable(getattr(m, "detect", None)):
|
|
56
|
+
ok, why = m.detect(src)
|
|
57
|
+
if ok:
|
|
58
|
+
return n, why
|
|
59
|
+
names_ = []
|
|
60
|
+
for dp, dn, fn in os.walk(src):
|
|
61
|
+
for f in fn:
|
|
62
|
+
names_.append(os.path.join(dp, f))
|
|
63
|
+
if len(names_) > 20000:
|
|
64
|
+
break
|
|
65
|
+
low = [n.lower() for n in names_]
|
|
66
|
+
|
|
67
|
+
def has(ext):
|
|
68
|
+
return [n for n in low if n.endswith(ext)]
|
|
69
|
+
if has(".gal"):
|
|
70
|
+
return "galscript", "有 galscript 剧本(.gal)"
|
|
71
|
+
if any(os.path.basename(n) in ("route.s", "select.s") for n in low) or (has(".s") and any("シナリオ" in n for n in names_)):
|
|
72
|
+
return "qlie", "有 QLIE 的 .s 剧本(route.s/select.s 或 シナリオ目录)"
|
|
73
|
+
if has(".pack") and not has(".s"):
|
|
74
|
+
return None, "QLIE .pack 封包:先用 GARbro 解包出 .s 再导入"
|
|
75
|
+
if has(".rpy"):
|
|
76
|
+
return "renpy", "有 Ren'Py 的 .rpy 源码"
|
|
77
|
+
if has(".rpyc") or has(".rpa"):
|
|
78
|
+
return None, "Ren'Py 编译过的 .rpyc/.rpa:先用 unrpa 解包、unrpyc 反编译成 .rpy 再导入"
|
|
79
|
+
if has(".ks"):
|
|
80
|
+
return "kag", "有吉里吉里 KAG / TyranoScript 的 .ks 剧本"
|
|
81
|
+
if has(".xp3"):
|
|
82
|
+
return None, "吉里吉里 .xp3 封包:先用 GARbro / KrkrExtract 解出 .ks 再导入"
|
|
83
|
+
if any(os.path.basename(n) in ("0.txt", "00.txt", "nscript.dat", "nscript.___") for n in low):
|
|
84
|
+
return "nscripter", "有 NScripter 的 0.txt / nscript.dat"
|
|
85
|
+
from . import textdump
|
|
86
|
+
ok, why = textdump.detect(src)
|
|
87
|
+
if ok:
|
|
88
|
+
return "textdump", why
|
|
89
|
+
hint = "(可以先用 VNTextPatch extractlocal 抽成 .json 再用 textdump 导入,能读但没有分支)"
|
|
90
|
+
if any(os.path.basename(n) in ("scene.pck", "gameexe.dat") for n in low):
|
|
91
|
+
return None, "SiglusEngine(Scene.pck):字节码+按作品加密,见 docs/ENGINES.md"
|
|
92
|
+
if any(os.path.basename(n).startswith("seen") and n.endswith(".txt") for n in low):
|
|
93
|
+
return None, "RealLive(SEEN.TXT):用 RLdev kprl 反编译,或 VNTextPatch --format=reallive 抽文本" + hint
|
|
94
|
+
if has(".pfs") or has(".ast") or has(".asb"):
|
|
95
|
+
return None, "Artemis(.pfs/.ast):解包后可用 VNTextPatch 抽文本" + hint
|
|
96
|
+
if has(".mjo"):
|
|
97
|
+
return None, "Majiro(.mjo 字节码)" + hint
|
|
98
|
+
if has(".ws2"):
|
|
99
|
+
return None, "AdvHD(.ws2)" + hint
|
|
100
|
+
if has(".cst"):
|
|
101
|
+
return None, "CatSystem2(.cst)" + hint
|
|
102
|
+
if has(".ybn"):
|
|
103
|
+
return None, "YU-RIS(.ybn)" + hint
|
|
104
|
+
if has(".arc") and any("sysgrp" in n or "data0" in n for n in low):
|
|
105
|
+
return None, "像 BGI/Ethornell(.arc):解包后 VNTextPatch --format=ethornell" + hint
|
|
106
|
+
return None, "认不出引擎(看了 %d 个文件)" % len(names_)
|