galbook 1.2.4 → 1.2.5

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/galbook/cli.py CHANGED
@@ -1,29 +1,31 @@
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())
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, want_vis
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
+ vis = kw.pop("vis", None)
22
+ lib = Library(books, data)
23
+ vis = want_vis(lib, book, vis)
24
+ r = lib.act(book, agent, do, **kw)
25
+ lang = lang or ((r.get("opts") or (lib.store.peek_run(book, agent) or {}).get("opts") or {}).get("lang")) or "zh"
26
+ sys.stdout.write(render_text(r, do, img, book, agent, lang, vis=vis))
27
+ return 0
28
+
29
+
30
+ if __name__ == "__main__":
31
+ sys.exit(main())
@@ -0,0 +1,121 @@
1
+ """把书里的图变成能直接塞给多模态模型的格式(只用标准库)。
2
+
3
+ load_for_model(path, max_side=1280) -> (mime, bytes) 或 (None, 原因)
4
+ PNG / JPEG / GIF / WebP 原样给;BMP(QLIE 等老引擎常见)转成 PNG,太大时按整数倍缩小;
5
+ SVG 和其他格式不转(模型多半不认),返回原因,调用方改给路径。"""
6
+ import struct
7
+ import zlib
8
+
9
+ PASS = ((b"\x89PNG\r\n\x1a\n", "image/png"), (b"\xff\xd8\xff", "image/jpeg"), (b"GIF87a", "image/gif"),
10
+ (b"GIF89a", "image/gif"))
11
+ MAX_BYTES = 8 * 1024 * 1024
12
+
13
+
14
+ def sniff(head):
15
+ for magic, mime in PASS:
16
+ if head.startswith(magic):
17
+ return mime
18
+ if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
19
+ return "image/webp"
20
+ if head[:2] == b"BM":
21
+ return "image/bmp"
22
+ if b"<svg" in head[:512].lower():
23
+ return "image/svg+xml"
24
+ return None
25
+
26
+
27
+ def load_for_model(path, max_side=1280):
28
+ try:
29
+ with open(path, "rb") as f:
30
+ data = f.read(MAX_BYTES + 1)
31
+ except OSError as e:
32
+ return None, "读不到图 / cannot read: %s" % e.__class__.__name__
33
+ if len(data) > MAX_BYTES:
34
+ return None, "图太大(>8MB)/ too large"
35
+ mime = sniff(data[:600])
36
+ if mime in ("image/png", "image/jpeg", "image/gif", "image/webp"):
37
+ return mime, data
38
+ if mime == "image/bmp":
39
+ try:
40
+ return "image/png", bmp_to_png(data, max_side)
41
+ except ValueError as e:
42
+ return None, "BMP 转不了 / unsupported BMP: %s" % e
43
+ if mime == "image/svg+xml":
44
+ return None, "SVG 不能直接当图片发 / SVG is not sent as an image"
45
+ return None, "不认识的图片格式 / unknown image format"
46
+
47
+
48
+ def bmp_to_png(data, max_side=1280):
49
+ """未压缩 BMP(1/4/8 位调色板、16/24/32 位)→ PNG(RGB)。"""
50
+ if data[:2] != b"BM" or len(data) < 54:
51
+ raise ValueError("not a BMP")
52
+ off = struct.unpack_from("<I", data, 10)[0]
53
+ hsize = struct.unpack_from("<I", data, 14)[0]
54
+ if hsize == 12:
55
+ w, h, _, bpp = struct.unpack_from("<HHHH", data, 18)
56
+ comp, ncol, palsz = 0, 0, 3
57
+ else:
58
+ w, h, _, bpp, comp = struct.unpack_from("<iiHHI", data, 18)
59
+ ncol = struct.unpack_from("<I", data, 46)[0] if hsize >= 40 else 0
60
+ palsz = 4
61
+ if comp not in (0, 3) or (comp == 3 and bpp not in (16, 32)):
62
+ raise ValueError("compressed (%d)" % comp)
63
+ top_down = h < 0
64
+ h = abs(h)
65
+ if not (0 < w <= 10000 and 0 < h <= 10000):
66
+ raise ValueError("bad size")
67
+ stride = ((w * bpp + 31) // 32) * 4
68
+ if off + stride * h > len(data):
69
+ raise ValueError("truncated")
70
+ pal = None
71
+ if bpp <= 8:
72
+ n = ncol or (1 << bpp)
73
+ p0 = 14 + hsize
74
+ pal = [data[p0 + i * palsz:p0 + i * palsz + 3][::-1] for i in range(n)] # BGR → RGB
75
+ pal += [b"\0\0\0"] * (256 - len(pal))
76
+ step = 1
77
+ while max(w, h) // step > max_side:
78
+ step += 1
79
+ ow, oh = (w + step - 1) // step, (h + step - 1) // step
80
+ rows = []
81
+ for oy in range(oh):
82
+ y = oy * step
83
+ src = y if top_down else h - 1 - y
84
+ row = data[off + src * stride: off + src * stride + stride]
85
+ if bpp == 24:
86
+ px = bytearray(w * 3)
87
+ px[0::3], px[1::3], px[2::3] = row[2:w * 3:3], row[1:w * 3:3], row[0:w * 3:3]
88
+ elif bpp == 32:
89
+ px = bytearray(w * 3)
90
+ px[0::3], px[1::3], px[2::3] = row[2:w * 4:4], row[1:w * 4:4], row[0:w * 4:4]
91
+ elif bpp == 16:
92
+ px = bytearray()
93
+ for x in range(w):
94
+ v = row[2 * x] | (row[2 * x + 1] << 8)
95
+ px += bytes(((v >> 10 & 31) * 255 // 31, (v >> 5 & 31) * 255 // 31, (v & 31) * 255 // 31))
96
+ elif bpp == 8:
97
+ px = b"".join(pal[b] for b in row[:w])
98
+ elif bpp in (4, 1):
99
+ per = 8 // bpp
100
+ mask = (1 << bpp) - 1
101
+ idx = [(row[x // per] >> (8 - bpp * (x % per + 1))) & mask for x in range(w)]
102
+ px = b"".join(pal[i] for i in idx)
103
+ else:
104
+ raise ValueError("%d bpp" % bpp)
105
+ if step > 1:
106
+ px = b"".join(bytes(px[x * 3:x * 3 + 3]) for x in range(0, w, step))
107
+ rows.append(b"\0" + bytes(px))
108
+ return _png(ow, oh, b"".join(rows))
109
+
110
+
111
+ def _png(w, h, raw):
112
+ def chunk(t, d):
113
+ return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xffffffff)
114
+ return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
115
+ + chunk(b"IDAT", zlib.compress(raw, 6)) + chunk(b"IEND", b""))
116
+
117
+
118
+ def png_size(data):
119
+ if data[:8] == b"\x89PNG\r\n\x1a\n":
120
+ return struct.unpack(">II", data[16:24])
121
+ return None
@@ -8,6 +8,7 @@
8
8
  多个 agent 可以各起一个 MCP 进程(不同 --agent),共用同一个 --data 目录;存储层有文件锁,互不干扰。
9
9
  也可以不起进程:python3 -m galbook serve 自带 Streamable HTTP 的 MCP 端点 POST /mcp?book=…&agent=…(见 server.py)。"""
10
10
  import argparse
11
+ import base64
11
12
  import os
12
13
  import json
13
14
  import sys
@@ -15,7 +16,7 @@ import traceback
15
16
 
16
17
  from . import VERSION
17
18
  from .play import Library
18
- from .tools import mcp_tools, call_tool
19
+ from .tools import mcp_tools, call_tool, call_tool_rich
19
20
  from .i18n import msg
20
21
 
21
22
  PROTO = "2024-11-05"
@@ -46,8 +47,12 @@ def handle(lib, req, book=None, agent=None, lang="zh", force_agent=None):
46
47
  if args.get("agent") not in (None, "", force_agent):
47
48
  raise ValueError("这个令牌只能用名字 %s / this token can only play as %s" % (force_agent, force_agent))
48
49
  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}
50
+ text, images = call_tool_rich(lib, params.get("name"), args, book, agent, lang)
51
+ content = [{"type": "text", "text": text}]
52
+ for lb, mime, data in images: # 图和文在同一条消息里(MCP image content)
53
+ content.append({"type": "text", "text": "〔图〕" + lb})
54
+ content.append({"type": "image", "data": base64.b64encode(data).decode("ascii"), "mimeType": mime})
55
+ res = {"content": content, "isError": False}
51
56
  except (ValueError, KeyError) as e:
52
57
  res = {"content": [{"type": "text", "text": msg(lang, str(e))}], "isError": True}
53
58
  else:
package/galbook/play.py CHANGED
@@ -11,8 +11,8 @@ from .ir import load_story, StoryError
11
11
  from .importers import legacy
12
12
  from .store import Store, StoreError
13
13
 
14
- READONLY = ("status", "saves", "log", "profile", "gallery")
15
- ACTIONS = ("new", "next", "choose", "save", "load", "saves", "rewind", "log", "status", "profile", "persona", "gallery", "click")
14
+ READONLY = ("status", "saves", "log", "profile", "gallery", "look")
15
+ ACTIONS = ("new", "next", "choose", "save", "load", "saves", "rewind", "log", "status", "profile", "persona", "gallery", "click", "look")
16
16
  SLUG_OK = lambda s: bool(s) and "/" not in s and "\\" not in s and not s.startswith(".")
17
17
 
18
18
 
@@ -90,7 +90,7 @@ class Library(object):
90
90
  st = engine.new_state(story, h=h, mode=mode, hints=hints, lang=lang or S.profile.get("lang") or "zh")
91
91
  S.set_run(st)
92
92
  S.profile["stats"]["games"] = S.profile["stats"].get("games", 0) + 1
93
- r = engine.advance(story, st, S.profile["s"], S.profile["seen"], n=0)
93
+ r = self._advance_clicks(story, S, st, 0, False)
94
94
  S.profile["stats"]["lines"] = S.profile["stats"].get("lines", 0) + len(r["lines"])
95
95
  self._decorate(story, S, st, r)
96
96
  return {"ok": True, "msg": "新游戏开始(第 %d 周目)" % S.profile["stats"]["games"], "state": r,
@@ -105,7 +105,9 @@ class Library(object):
105
105
  n = max(1, min(int(n or 20), 200))
106
106
  before_end = st.get("ended")
107
107
  st.pop("_click", None) # 上一批的「可点」只在那一批有效(原作的点击也只在那一句上)
108
+ vis_before = engine._vis_brief(st["vis"]) # 读这一批之前画面是什么样(文本接口据此只在变化时报〔画面〕)
108
109
  r = self._advance_clicks(story, S, st, n, bool(skip_read))
110
+ r["vis_before"] = vis_before
109
111
  S.touch_run()
110
112
  S.profile["stats"]["lines"] = S.profile["stats"].get("lines", 0) + len(r["lines"])
111
113
  if r.get("choice") and not (st.get("_autosaved_at") == [st["scene"], st["pc"], st["turn"]]):
@@ -113,7 +115,10 @@ class Library(object):
113
115
  S.save_slot("auto", self._meta(story, st, "到选项自动存"))
114
116
  if r.get("cg_seen"):
115
117
  got = S.profile.setdefault("cg", [])
116
- got.extend(c for c in dict.fromkeys(r["cg_seen"]) if c not in got)
118
+ new_cg = [c for c in dict.fromkeys(r["cg_seen"]) if c not in got]
119
+ if new_cg:
120
+ r["cg_new"] = new_cg # 这个 agent 第一次看到的 CG(img=1 时只给这些附图)
121
+ got.extend(new_cg)
117
122
  self._decorate(story, S, st, r)
118
123
  if st.get("ended") and not before_end:
119
124
  e = st["ended"]
@@ -200,6 +205,33 @@ class Library(object):
200
205
  S.profile["stats"]["clicks"] = S.profile["stats"].get("clicks", 0) + 1
201
206
  return {"ok": True, "msg": "点了一下。", "clicked": True, "where": engine.where(story, new)}
202
207
 
208
+ def _a_look(self, story, S, bg=None, **_):
209
+ """看一眼现在的画面(只读):有 CG 就是 CG;否则是在场的立绘(每人一张,带位置和表情码);
210
+ 没人在场或 bg=1 时加上背景。返回要看的图的列表,文本接口给地址,MCP 直接给图。"""
211
+ st = self._need_run(S)
212
+ v = engine._vis_brief(st["vis"])
213
+ return {"ok": True, "vis": v, "look": screen_picks(v, with_bg=str(bg).lower() in ("1", "true", "yes")),
214
+ "where": engine.where(story, st)}
215
+
216
+ def image_path(self, slug, rel):
217
+ """书里的图的绝对路径;只认 assets/ 下面、真实存在、不越界的文件,否则 None。"""
218
+ if not rel or not SLUG_OK(slug) or not str(rel).startswith("assets/"):
219
+ return None
220
+ base = os.path.realpath(os.path.join(self.books_dir, slug, "assets"))
221
+ p = os.path.realpath(os.path.join(self.books_dir, slug, rel))
222
+ if not p.startswith(base + os.sep) or not os.path.isfile(p):
223
+ return None
224
+ return p
225
+
226
+ def has_images(self, slug):
227
+ """书里有没有图(决定〔画面〕行默认开不开)。"""
228
+ d = os.path.join(self.books_dir, slug, "assets")
229
+ try:
230
+ with os.scandir(d) as it:
231
+ return any(True for _ in it)
232
+ except OSError:
233
+ return False
234
+
203
235
  def _a_choose(self, story, S, i=None, reason="", **_):
204
236
  st = self._need_run(S)
205
237
  st.pop("_click", None)
@@ -244,7 +276,7 @@ class Library(object):
244
276
  st["pending"]["shown_at"] = time.time()
245
277
  S.set_run(st)
246
278
  S.profile["stats"]["loads"] = S.profile["stats"].get("loads", 0) + 1
247
- r = engine.advance(story, st, S.profile["s"], S.profile["seen"], n=0)
279
+ r = self._advance_clicks(story, S, st, 0, False)
248
280
  self._decorate(story, S, st, r)
249
281
  return {"ok": True, "msg": "读档 %s" % slot, "meta": d.get("meta"), "state": r, "opts": st.get("opts"),
250
282
  "recent": [dict(who=x["who"], text=x["text"]) for x in st.get("log", [])[-5:]]}
@@ -344,6 +376,28 @@ class Library(object):
344
376
  "pending": bool(st.get("pending"))}
345
377
 
346
378
 
379
+ def face_code(sp):
380
+ """立绘的表情码:文件名去掉角色名和扩展名(例 雫_中_02_01_01.png → 中_02_01_01)。
381
+ 不是表情的名字,但同一个码就是同一张脸;配 look 看一眼就知道是什么表情。"""
382
+ b = os.path.splitext(os.path.basename(str(sp.get("img") or "")))[0]
383
+ nm = sp.get("name") or ""
384
+ if nm and b.startswith(nm):
385
+ b = b[len(nm):].lstrip("_- .")
386
+ return b
387
+
388
+
389
+ def screen_picks(v, with_bg=False):
390
+ """一屏「值得看」的图:[(说明, 路径)]。CG 盖住立绘,所以有 CG 只给 CG。"""
391
+ v = v or {}
392
+ if v.get("cg"):
393
+ return [("CG", v["cg"])]
394
+ out = [("%s:%s(%s)" % (s.get("name", "?"), s.get("pos") or "center", face_code(s)), s["img"])
395
+ for s in v.get("sprites") or [] if s.get("img")]
396
+ if v.get("bg") and (with_bg or not out):
397
+ out.insert(0, ("bg", v["bg"]))
398
+ return out
399
+
400
+
347
401
  def has_clicks(story):
348
402
  """剧本里有没有可点的句子(say 上带 click 字段);结果缓存在 story 上。"""
349
403
  v = getattr(story, "_gb_has_clicks", None)
package/galbook/tools.py CHANGED
@@ -6,7 +6,7 @@
6
6
  import json
7
7
 
8
8
  from .play import Library # noqa: F401 (类型提示用)
9
- from .web import render_text, restyle, HELP
9
+ from .web import render_text, restyle, HELP, want_vis, image_picks
10
10
  from .i18n import msg
11
11
  from . import VERSION
12
12
 
@@ -25,7 +25,8 @@ TOOLS = [
25
25
  ("galbook_next", "next", "Read the next n lines; stops at a choice or ending. / 往下读", {
26
26
  "n": {"type": "integer", "minimum": 1, "maximum": 200, "description": "lines to read (default 20)"},
27
27
  "skip_read": {"type": "boolean", "description": "fast-forward lines you have already read"},
28
- "img": {"type": "boolean", "description": "include background/sprite/CG names"}}),
28
+ "img": {"type": "boolean", "description": "attach images, but only for a CG you see for the first time and the ending screen / 只在第一次见到的 CG 和结局那屏附图"},
29
+ "vis": {"type": "boolean", "description": "one [screen] line when the picture changes (default: on if the book has images)"}}),
29
30
  ("galbook_choose", "choose", "Pick an option at a choice and say why. / 选选项并写理由", {
30
31
  "i": {"type": ["integer", "string"], "description": "option number from 1, or \"random\""},
31
32
  "reason": {"type": "string", "description": "one sentence: why your persona picks this"}}),
@@ -43,6 +44,8 @@ TOOLS = [
43
44
  ("galbook_gallery", "gallery", "Collection progress: endings, routes, CG, read ratio. / 收集进度", {}),
44
45
  ("galbook_profile", "profile", "Stats and human-likeness numbers. / 记录", {}),
45
46
  ("galbook_click", "click", "Click the screen. / 点一下画面", {}),
47
+ ("galbook_look", "look", "Look at the current screen: the CG, or each character on stage (position + face code), as images. / 看一眼现在的画面", {
48
+ "bg": {"type": "boolean", "description": "also include the background"}}),
46
49
  ]
47
50
 
48
51
 
@@ -63,19 +66,29 @@ def openai_tools():
63
66
 
64
67
  def call_tool(lib, name, args, default_book=None, default_agent=None, lang="zh", style="tool", lang_override=None):
65
68
  """执行一个工具,返回纯文本。style:提示里的下一步写成 tool(默认)/ cli / http 形式。"""
69
+ return call_tool_rich(lib, name, args, default_book, default_agent, lang, style, lang_override, with_images=False)[0]
70
+
71
+
72
+ def call_tool_rich(lib, name, args, default_book=None, default_agent=None, lang="zh", style="tool", lang_override=None,
73
+ with_images=True, max_side=1280):
74
+ """同 call_tool,另外返回要附的图:[(说明, mime, bytes)](MCP 用 image content 直接发)。
75
+ 只在 look、或 img=1 时「第一次见到的 CG / 结局那屏」才有图;转不了的图(SVG 等)在文本里给路径。"""
66
76
  spec = next((x for x in TOOLS if x[0] == name), None)
67
77
  if spec is None:
68
78
  raise ValueError("unknown tool %s" % name)
69
79
  args = dict(args or {})
70
80
  if name == "galbook_books":
71
- return json.dumps(lib.books(), ensure_ascii=False, indent=1)
81
+ return json.dumps(lib.books(), ensure_ascii=False, indent=1), []
72
82
  if name == "galbook_help":
73
- return HELP.get(lang, HELP["zh"]) % VERSION
83
+ return HELP.get(lang, HELP["zh"]) % VERSION, []
74
84
  book = args.pop("book", None) or default_book
75
85
  agent = args.pop("agent", None) or default_agent
76
86
  if not book or not agent:
77
87
  raise ValueError("需要 book 和 agent / book and agent are required")
78
- img = bool(args.pop("img", False))
88
+ img = args.pop("img", False) in (True, 1, "1", "true")
89
+ vis = want_vis(lib, book, args.pop("vis", None))
90
+ if "bg" in args:
91
+ args["bg"] = "1" if args["bg"] in (True, "1", "true", 1) else "0"
79
92
  if "hints" in args:
80
93
  args["hints"] = "1" if args["hints"] in (True, "1", "true", 1) else "0"
81
94
  if args.pop("skip_read", False):
@@ -84,7 +97,21 @@ def call_tool(lib, name, args, default_book=None, default_agent=None, lang="zh",
84
97
  r = lib.act(book, agent, action, **args)
85
98
  run = lib.store.peek_run(book, agent) or {}
86
99
  lg = lang_override or ((r.get("opts") or run.get("opts") or {}).get("lang")) or lang
87
- return restyle(render_text(r, action, img, book, agent, lg), book, agent, style)
100
+ text = restyle(render_text(r, action, img, book, agent, lg, vis=vis), book, agent, style)
101
+ images = []
102
+ if with_images and (img or action == "look"):
103
+ from .imaging import load_for_model
104
+ notes = []
105
+ for lb, rel in image_picks(r, action):
106
+ p = lib.image_path(book, rel)
107
+ mime, data = load_for_model(p, max_side) if p else (None, "找不到文件 / file not found")
108
+ if mime:
109
+ images.append((lb, mime, data))
110
+ else:
111
+ notes.append("(%s:%s → %s)" % (lb, data, rel))
112
+ if notes:
113
+ text += "\n".join(notes) + "\n"
114
+ return text, images
88
115
 
89
116
 
90
117
  def main(argv=None):
@@ -96,4 +123,4 @@ def main(argv=None):
96
123
  return 0
97
124
 
98
125
 
99
- __all__ = ["TOOLS", "mcp_tools", "openai_tools", "call_tool", "msg"]
126
+ __all__ = ["TOOLS", "mcp_tools", "openai_tools", "call_tool", "call_tool_rich", "msg"]
package/galbook/web.py CHANGED
@@ -12,17 +12,17 @@ import json
12
12
  import os
13
13
  import re
14
14
  import traceback
15
- from urllib.parse import parse_qs, unquote
15
+ from urllib.parse import parse_qs, unquote, quote
16
16
 
17
17
  from . import engine, VERSION
18
18
  from .i18n import t, msg
19
- from .play import Library, ACTIONS
19
+ from .play import Library, ACTIONS, face_code, screen_picks
20
20
  from .ir import StoryError
21
21
  from .store import StoreError
22
22
 
23
23
  HERE = os.path.dirname(os.path.abspath(__file__))
24
24
  ERRS = (engine.PlayError, StoryError, StoreError, ValueError)
25
- KW = ("n", "i", "steps", "slot", "note", "reason", "h", "mode", "hints", "lang", "text")
25
+ KW = ("n", "i", "steps", "slot", "note", "reason", "h", "mode", "hints", "lang", "text", "bg")
26
26
 
27
27
  HELP = {"zh": """galbook —— 像人一样玩 galgame(%s)
28
28
  每个 agent 用自己的名字玩,各自的进度、存档、通关记录互不影响,可以很多个同时玩。
@@ -35,7 +35,10 @@ HELP = {"zh": """galbook —— 像人一样玩 galgame(%s)
35
35
  &mode=ironman 铁人模式:不能存读档和回溯,选了就认
36
36
  &hints=1 选项旁标「选过」「新内容」
37
37
  &lang=en 框架文字用英文
38
- /play/t/<书>/<你的名字>?do=next&n=20 往下读 n 句(最多 200);&skip=1 跳过读过的;&img=1 带画面
38
+ /play/t/<书>/<你的名字>?do=next&n=20 往下读 n 句(最多 200);&skip=1 跳过读过的
39
+ 画面一变就多一行〔画面〕bg=… | sp=名字:位置(表情码)(书里有图时默认开,&vis=0 关);路径拼成 /b/<书>/<路径> 就是图
40
+ &img=1 只在「第一次见到的 CG」和「结局那一屏」附图(MCP 直接给图,这里给地址)
41
+ /play/t/<书>/<你的名字>?do=look 看一眼现在的画面:CG,或在场每个人的立绘(&bg=1 连背景)
39
42
  /play/t/<书>/<你的名字>?do=choose&i=2&reason=… 选第 2 个,reason 写一句你为什么这么选(i=random 随机)
40
43
  /play/t/<书>/<你的名字>?do=save&slot=3&note=… 存档(quick 或 1-99;到选项时会自动存 auto)
41
44
  /play/t/<书>/<你的名字>?do=load&slot=3 读档(auto / quick / 1-99)
@@ -61,7 +64,11 @@ Plain-text API (GET, one action per request):
61
64
  &mode=ironman no saving/loading/rewinding — choices are final
62
65
  &hints=1 mark options "picked before" / "new"
63
66
  &lang=en English UI text
64
- /play/t/<book>/<name>?do=next&n=20 read n lines (max 200); &skip=1 skip read lines; &img=1 show visuals
67
+ /play/t/<book>/<name>?do=next&n=20 read n lines (max 200); &skip=1 skip read lines
68
+ a [screen] bg=… | sp=name:pos(face code) line whenever the picture changes (on by default if the book has images; &vis=0 off);
69
+ an image URL is /b/<book>/<path>
70
+ &img=1 attach images only for a CG you see for the first time and for the ending screen
71
+ /play/t/<book>/<name>?do=look look at the current screen: the CG, or each character on stage (&bg=1 adds the background)
65
72
  /play/t/<book>/<name>?do=choose&i=2&reason=… pick option 2 and say why (i=random for random)
66
73
  /play/t/<book>/<name>?do=save&slot=3&note=… save (quick or 1-99; slot auto is written at every choice)
67
74
  /play/t/<book>/<name>?do=load&slot=3 load (auto / quick / 1-99)
@@ -142,12 +149,13 @@ class App(object):
142
149
  if do not in ACTIONS:
143
150
  raise engine.PlayError("do 只能是:" + " ".join(ACTIONS))
144
151
  img = str(q.get("img", "")) in ("1", "true")
152
+ vis = want_vis(self.lib, book, q.get("vis"))
145
153
  kw = self._kw(q)
146
154
  if do != "new":
147
155
  kw.pop("lang", None)
148
156
  r = self.lib.act(book, agent, do, **kw)
149
157
  lg = lang or ((r.get("opts") or {}).get("lang")) or self._run_lang(book, agent) or self.default_lang
150
- return self._text(200, render_text(r, do, img, book, agent, lg))
158
+ return self._text(200, render_text(r, do, img, book, agent, lg, vis=vis))
151
159
 
152
160
  def _run_lang(self, book, agent):
153
161
  try:
@@ -170,20 +178,58 @@ class App(object):
170
178
  return 200, ct, f.read()
171
179
 
172
180
 
173
- def _img(p):
174
- return os.path.splitext(os.path.basename(p))[0] if p else None
181
+ def want_vis(lib, book, v):
182
+ """〔画面〕行开不开:显式 vis=1/0 优先;没说就看书里有没有图(没图的书不白占 token)。"""
183
+ if v not in (None, ""):
184
+ return str(v).lower() in ("1", "true", "yes", "on")
185
+ try:
186
+ return lib.has_images(book)
187
+ except Exception: # noqa
188
+ return False
175
189
 
176
190
 
177
- def _vis_text(v, lang):
178
- bits = []
191
+ def _vsig(v):
192
+ v = v or {}
179
193
  if v.get("cg"):
180
- bits.append(t(lang, "vis_cg") + _img(v["cg"]))
181
- elif v.get("bg"):
182
- bits.append(t(lang, "vis_bg") + _img(v["bg"]))
183
- sp = [s.get("name", "") for s in v.get("sprites") or []]
194
+ return ("cg", v["cg"])
195
+ return ("bg", v.get("bg"), tuple((s.get("name"), s.get("img"), s.get("pos")) for s in v.get("sprites") or []))
196
+
197
+
198
+ def vis_line(v, lang="zh"):
199
+ """〔画面〕bg=… | sp=雫:center(中_02_01_01),琥珀:right(…);有 CG 时只写 cg(立绘被盖住)。路径拼 /b/<书>/<路径> 就是图。"""
200
+ v = v or {}
201
+ hd = "[screen] " if lang == "en" else "〔画面〕"
202
+ if v.get("cg"):
203
+ return hd + "cg=" + v["cg"]
204
+ bits = []
205
+ if v.get("bg"):
206
+ bits.append("bg=" + v["bg"])
207
+ sp = [s for s in v.get("sprites") or [] if s.get("img") or s.get("name")]
184
208
  if sp:
185
- bits.append(t(lang, "vis_on") + t(lang, "sep").join(sp))
186
- return "〔" + "|".join(bits) + "〕" if bits else ""
209
+ bits.append("sp=" + ",".join("%s:%s(%s)" % (s.get("name", "?"), s.get("pos") or "center", face_code(s)) for s in sp))
210
+ return hd + (" | ".join(bits) if bits else ("(empty)" if lang == "en" else "(空)"))
211
+
212
+
213
+ def img_url(book, path):
214
+ return "/b/%s/%s" % (quote(book, safe=""), quote(path, safe="/"))
215
+
216
+
217
+ def image_picks(r, do, cap=4):
218
+ """这次该附哪些图:[(说明, 路径)]。do=look → 当前画面;img=1 时只在「第一次见到的 CG」和「结局那一屏」给。"""
219
+ if do == "look":
220
+ picks = list(r.get("look") or [])
221
+ else:
222
+ st = r.get("state") or {}
223
+ picks = [("CG(第一次)", c) for c in st.get("cg_new") or []]
224
+ e = st.get("ended") or {}
225
+ if e.get("kind") == "ending":
226
+ picks += [("结局:" + lb, p) for lb, p in screen_picks(st.get("vis"), with_bg=True)]
227
+ out, seen = [], set()
228
+ for lb, p in picks:
229
+ if p and p not in seen:
230
+ seen.add(p)
231
+ out.append((lb, p))
232
+ return out[:cap]
187
233
 
188
234
 
189
235
  def _say(x, lang="zh"):
@@ -208,8 +254,11 @@ def restyle(text, book, agent, style):
208
254
  return re.sub(r"(?<=[\s((])&(\w+=)", r"\1", text)
209
255
 
210
256
 
211
- def render_text(r, do, img, book, agent, lang="zh"):
212
- """把一次操作的结果写成给 agent 看的纯文本(一句一行,最省 token)。"""
257
+ def render_text(r, do, img, book, agent, lang="zh", vis=None):
258
+ """把一次操作的结果写成给 agent 看的纯文本(一句一行,最省 token)。
259
+ vis:画面变了才加一行〔画面〕(None = 跟 img 走,兼容旧调用);img:第一次见到的 CG / 结局那屏 / look 给图的地址。"""
260
+ if vis is None:
261
+ vis = bool(img)
213
262
  L = []
214
263
  base = "/play/t/%s/%s?do=" % (book, agent)
215
264
  st = r.get("state")
@@ -222,6 +271,11 @@ def render_text(r, do, img, book, agent, lang="zh"):
222
271
  L.append(t(lang, "you_chose", text=c["text"], i=c["i"], of=c.get("of") or "?"))
223
272
  L.append(t(lang, "cont", base=base))
224
273
  return "\n".join(L) + "\n"
274
+ if do == "look":
275
+ L.append(vis_line(r.get("vis"), lang))
276
+ for lb, p in image_picks(r, do):
277
+ L.append(("[image] %s %s" if lang == "en" else "〔图〕%s %s") % (lb, img_url(book, p)))
278
+ return "\n".join(L) + "\n"
225
279
  if do == "persona":
226
280
  p = r.get("persona") or ""
227
281
  L.append(t(lang, "persona_now", p=p) if p else t(lang, "persona_none", base=base))
@@ -275,18 +329,24 @@ def render_text(r, do, img, book, agent, lang="zh"):
275
329
  if st:
276
330
  w = st["where"]
277
331
  L.append(t(lang, "line_hdr", title=w.get("title", ""), turn=w.get("turn", 0)))
278
- if img and not st["lines"]:
279
- v = _vis_text(st.get("vis") or {}, lang)
280
- if v:
281
- L.append(v)
282
- last_vis = None
332
+ prev = st.get("vis_before")
333
+ if vis and not st["lines"] and (prev is None or _vsig(st.get("vis")) != _vsig(prev)):
334
+ if prev is not None or _vsig(st.get("vis")) != _vsig({}):
335
+ L.append(vis_line(st.get("vis"), lang))
336
+ prev = st.get("vis")
283
337
  for ln in st["lines"]:
284
- if img and ln.get("vis"):
285
- v = _vis_text(ln["vis"], lang)
286
- if v and v != last_vis:
287
- L.append(v)
288
- last_vis = v
338
+ v = ln.get("vis")
339
+ if vis and v is not None and (prev is None or _vsig(v) != _vsig(prev)):
340
+ if prev is not None or _vsig(v) != _vsig({}):
341
+ L.append(vis_line(v, lang))
342
+ prev = v
289
343
  L.append(_say(ln, lang) + (" " + ln["mark"] if ln.get("mark") else ""))
344
+ if vis and st["lines"] and (st.get("ended") or st.get("choice")) and st.get("vis") is not None \
345
+ and _vsig(st["vis"]) != _vsig(prev):
346
+ L.append(vis_line(st["vis"], lang)) # 最后一句之后画面又变了(结局 CG 常这样)
347
+ if img:
348
+ for lb, p in image_picks(r, do):
349
+ L.append(("[image] %s %s" if lang == "en" else "〔图〕%s %s") % (lb, img_url(book, p)))
290
350
  if st.get("skipped"):
291
351
  L.append(t(lang, "skipped", n=st["skipped"]))
292
352
  for e in st.get("events") or []:
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "galbook · galgame",
4
- "description": "Lets an agent read a visual novel and pick choices by itself. Adds 15 galbook_* tools."
4
+ "description": "Lets an agent read a visual novel and pick choices by itself. Adds 16 galbook_* tools."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "galbook · galgame",
4
- "description": "让 agent 自己读剧本、点选项把 galgame 玩下去。装上之后会多出 15 个 galbook_* 工具。"
4
+ "description": "让 agent 自己读剧本、点选项把 galgame 玩下去。装上之后会多出 16 个 galbook_* 工具。"
5
5
  }
6
6
  }