inscript-lang 2.12.0__tar.gz → 2.13.0__tar.gz

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 (39) hide show
  1. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/PKG-INFO +1 -1
  2. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/export_pipeline.py +112 -1
  3. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/inscript.py +1 -1
  4. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/inscript_lang.egg-info/PKG-INFO +1 -1
  5. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/stdlib_game.py +40 -0
  6. inscript_lang-2.13.0/studio_bridge.py +585 -0
  7. inscript_lang-2.12.0/studio_bridge.py +0 -239
  8. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/README.md +0 -0
  9. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/analyzer.py +0 -0
  10. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/ast_nodes.py +0 -0
  11. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/compiler.py +0 -0
  12. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/environment.py +0 -0
  13. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/errors.py +0 -0
  14. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/hot_reload.py +0 -0
  15. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/inscript_dap.py +0 -0
  16. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/inscript_fmt.py +0 -0
  17. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/inscript_lang.egg-info/SOURCES.txt +0 -0
  18. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/inscript_lang.egg-info/dependency_links.txt +0 -0
  19. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/inscript_lang.egg-info/entry_points.txt +0 -0
  20. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/inscript_lang.egg-info/requires.txt +0 -0
  21. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/inscript_lang.egg-info/top_level.txt +0 -0
  22. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/inscript_studio_api.py +0 -0
  23. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/inscript_test.py +0 -0
  24. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/interpreter.py +0 -0
  25. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/lexer.py +0 -0
  26. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/parser.py +0 -0
  27. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/pygame_backend.py +0 -0
  28. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/pyproject.toml +0 -0
  29. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/repl.py +0 -0
  30. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/scene_tree.py +0 -0
  31. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/setup.cfg +0 -0
  32. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/setup.py +0 -0
  33. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/stdlib.py +0 -0
  34. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/stdlib_assets.py +0 -0
  35. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/stdlib_extended.py +0 -0
  36. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/stdlib_extended_2.py +0 -0
  37. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/stdlib_values.py +0 -0
  38. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/studio_readiness.py +0 -0
  39. {inscript_lang-2.12.0 → inscript_lang-2.13.0}/vm.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inscript-lang
3
- Version: 2.12.0
3
+ Version: 2.13.0
4
4
  Summary: InScript — a game-focused scripting language with 59 game modules and a bytecode VM
5
5
  Author: Shreyasi Sarkar
6
6
  License: MIT
@@ -42,7 +42,7 @@ MANIFEST_FILE = "inscript.toml"
42
42
  ENTRY_DEFAULT = os.path.join("src", "main.ins")
43
43
  BUILD_MANIFEST = "build.toml"
44
44
 
45
- VALID_TARGETS = ("desktop", "web", "android", "ibc")
45
+ VALID_TARGETS = ("desktop", "web", "android", "ios", "ibc")
46
46
 
47
47
  # Pyodide CDN base used in web builds
48
48
  PYODIDE_CDN = "https://cdn.jsdelivr.net/pyodide/v0.27.0/full/"
@@ -645,3 +645,114 @@ def run_build(target: str, project_dir: str = ".") -> int:
645
645
  for e in r.errors:
646
646
  print(f"[build] {e}", file=sys.stderr)
647
647
  return 0 if r.success else 1
648
+
649
+
650
+ # ─────────────────────────────────────────────────────────────────────────────
651
+ # v2.13.0: Target: iOS
652
+ # ─────────────────────────────────────────────────────────────────────────────
653
+
654
+ def build_ios(project_dir: str = ".") -> BuildResult:
655
+ """
656
+ Build an iOS app via BeeWare Briefcase.
657
+ Requires: macOS + Xcode + pip install briefcase
658
+
659
+ Output layout:
660
+ build/ios/
661
+ pyproject.toml — Briefcase config
662
+ app.py — InScript iOS entry point
663
+ build.toml — build manifest
664
+ """
665
+ import time, subprocess
666
+ t0 = time.perf_counter()
667
+ errors: List[str] = []
668
+ artifacts: List[str] = []
669
+
670
+ manifest = _read_manifest(project_dir)
671
+ pkg = manifest.get("package", {})
672
+ version = pkg.get("version", "0.0.0")
673
+ name = pkg.get("name", "mygame")
674
+ entry = pkg.get("entry", ENTRY_DEFAULT)
675
+ bundle = pkg.get("bundle", f"dev.inscript.{name}")
676
+
677
+ out_dir = os.path.join(project_dir, BUILD_DIR, "ios")
678
+ os.makedirs(out_dir, exist_ok=True)
679
+
680
+ bp_path = os.path.join(out_dir, "pyproject.toml")
681
+ with open(bp_path, "w") as f:
682
+ f.write(textwrap.dedent(f"""\
683
+ [tool.briefcase]
684
+ project_name = "{name}"
685
+ bundle = "{bundle}"
686
+ version = "{version}"
687
+ description = "Built with InScript"
688
+ license = "MIT"
689
+
690
+ [tool.briefcase.app.{name}]
691
+ formal_name = "{name}"
692
+ description = "Built with InScript"
693
+ sources = ["."]
694
+ requires = ["inscript-lang"]
695
+
696
+ [tool.briefcase.app.{name}.iOS]
697
+ requires = ["toga-iOS"]
698
+ """))
699
+ artifacts.append(bp_path)
700
+
701
+ app_py = os.path.join(out_dir, "app.py")
702
+ with open(app_py, "w") as f:
703
+ f.write(textwrap.dedent(f"""\
704
+ # InScript iOS entry — generated by inscript build --target ios
705
+ import sys
706
+ try:
707
+ import inscript
708
+ inscript.main(["{entry}"])
709
+ except Exception as e:
710
+ print(f"InScript launch error: {{e}}", file=sys.stderr)
711
+ """))
712
+ artifacts.append(app_py)
713
+
714
+ for sub in ("src", "assets", "scenes"):
715
+ s = os.path.join(project_dir, sub)
716
+ d = os.path.join(out_dir, sub)
717
+ if os.path.isdir(s):
718
+ if os.path.exists(d): shutil.rmtree(d)
719
+ shutil.copytree(s, d)
720
+
721
+ from inscript import VERSION as _IV
722
+ bm = _write_build_manifest(out_dir, {
723
+ "target": "ios", "version": version, "entry": entry,
724
+ "inscript_version": _IV, "bundle": bundle,
725
+ "artifacts": {"pyproject": "pyproject.toml", "app": "app.py"},
726
+ "asset_hashes": {},
727
+ })
728
+ artifacts.append(bm)
729
+
730
+ # Check platform and briefcase
731
+ if sys.platform != "darwin":
732
+ msg = "iOS builds require macOS. Current platform: " + sys.platform
733
+ errors.append(msg)
734
+ print(f"[build:ios] ⚠ {msg}")
735
+ elapsed = (time.perf_counter() - t0) * 1000
736
+ return BuildResult("ios", False, out_dir, artifacts, errors, elapsed)
737
+
738
+ briefcase_ok = shutil.which("briefcase") is not None
739
+ if not briefcase_ok:
740
+ msg = ("BeeWare Briefcase not installed. "
741
+ "Install: pip install briefcase "
742
+ "Then: cd build/ios && briefcase create iOS && briefcase build iOS")
743
+ errors.append(msg)
744
+ print(f"[build:ios] ⚠ {msg}")
745
+ elapsed = (time.perf_counter() - t0) * 1000
746
+ return BuildResult("ios", False, out_dir, artifacts, errors, elapsed)
747
+
748
+ try:
749
+ subprocess.run([sys.executable, "-m", "briefcase", "create", "iOS"],
750
+ cwd=out_dir, check=True)
751
+ subprocess.run([sys.executable, "-m", "briefcase", "build", "iOS"],
752
+ cwd=out_dir, check=True)
753
+ print(f"[build:ios] ✅ iOS app built → {out_dir}")
754
+ except subprocess.CalledProcessError as e:
755
+ errors.append(f"briefcase failed: {e}")
756
+
757
+ elapsed = (time.perf_counter() - t0) * 1000
758
+ return BuildResult("ios", len(errors) == 0, out_dir, artifacts, errors, elapsed)
@@ -24,7 +24,7 @@ from errors import (InScriptError, LexerError, ParseError,
24
24
  SemanticError, InScriptRuntimeError,
25
25
  MultiError, InScriptWarning)
26
26
 
27
- VERSION = "2.12.0"
27
+ VERSION = "2.13.0"
28
28
 
29
29
  MANIFEST_FILENAME = "inscript.toml"
30
30
  LOCK_FILENAME = "inscript.lock"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inscript-lang
3
- Version: 2.12.0
3
+ Version: 2.13.0
4
4
  Summary: InScript — a game-focused scripting language with 59 game modules and a bytecode VM
5
5
  Author: Shreyasi Sarkar
6
6
  License: MIT
@@ -1121,6 +1121,36 @@ class _InputManager:
1121
1121
  self._key_state = {} # key -> (pressed_this_frame, held, released_this_frame)
1122
1122
  self._mouse_x = 0.0; self._mouse_y = 0.0
1123
1123
  self._mouse_buttons = {}
1124
+ # v2.13.0: headless emulation — inject key/mouse state without pygame
1125
+ self._emulated_keys: dict = {} # key_name -> bool (held)
1126
+ self._emulated_mouse: tuple = (0.0, 0.0)
1127
+ self._emulated_buttons: dict = {}
1128
+ self._headless = False # set True when pygame unavailable
1129
+
1130
+ def _try_pygame(self) -> bool:
1131
+ try:
1132
+ import pygame
1133
+ return pygame.get_init()
1134
+ except Exception:
1135
+ return False
1136
+
1137
+ # v2.13.0: headless injection API
1138
+ def emulate_key(self, key: str, held: bool = True):
1139
+ """Inject a key state for headless/Studio preview mode."""
1140
+ self._emulated_keys[key.lower()] = bool(held)
1141
+ self._headless = True
1142
+
1143
+ def emulate_mouse(self, x: float, y: float, buttons: dict | None = None):
1144
+ """Inject mouse position and button state."""
1145
+ self._emulated_mouse = (float(x), float(y))
1146
+ self._emulated_buttons = buttons or {}
1147
+ self._headless = True
1148
+
1149
+ def clear_emulation(self):
1150
+ """Clear all emulated input state."""
1151
+ self._emulated_keys.clear()
1152
+ self._emulated_buttons.clear()
1153
+ self._headless = False
1124
1154
 
1125
1155
  def map(self, action, keys=None, axes=None, gamepad=None, gamepad_axis=None):
1126
1156
  self._actions[action] = {
@@ -1129,6 +1159,9 @@ class _InputManager:
1129
1159
  }
1130
1160
 
1131
1161
  def _is_key_down(self, key):
1162
+ # v2.13.0: headless emulation takes priority over pygame
1163
+ if self._headless or self._emulated_keys:
1164
+ return self._emulated_keys.get(key.lower(), False)
1132
1165
  try:
1133
1166
  import pygame
1134
1167
  kmap = {
@@ -1169,12 +1202,19 @@ class _InputManager:
1169
1202
  return max(-1.0, min(1.0, value))
1170
1203
 
1171
1204
  def mouse_pos(self):
1205
+ # v2.13.0: headless emulation
1206
+ if self._headless or self._emulated_mouse != (0.0, 0.0):
1207
+ x, y = self._emulated_mouse
1208
+ return {"x": float(x), "y": float(y)}
1172
1209
  try:
1173
1210
  import pygame; x, y = pygame.mouse.get_pos(); return {"x": float(x), "y": float(y)}
1174
1211
  except Exception:
1175
1212
  return {"x": self._mouse_x, "y": self._mouse_y}
1176
1213
 
1177
1214
  def mouse_pressed(self, button=0):
1215
+ # v2.13.0: headless emulation
1216
+ if self._headless or self._emulated_buttons:
1217
+ return self._emulated_buttons.get(int(button), False)
1178
1218
  try:
1179
1219
  import pygame; return bool(pygame.mouse.get_pressed()[int(button)])
1180
1220
  except Exception:
@@ -0,0 +1,585 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ studio_bridge.py — InScript v2.12.0 Electron Bridge
4
+ ================================================================
5
+ A lightweight JSON-RPC HTTP server that the InScript Studio Electron
6
+ shell calls to drive the runtime.
7
+
8
+ Start from CLI:
9
+ inscript --studio-bridge [--bridge-port 8765]
10
+
11
+ Or from Python:
12
+ from studio_bridge import StudioBridge
13
+ bridge = StudioBridge(port=8765)
14
+ bridge.start() # non-blocking daemon thread
15
+ bridge.stop()
16
+
17
+ JSON-RPC protocol (HTTP POST to /rpc):
18
+ Request: { "method": "run", "id": 1, "params": {"file": "src/main.ins"} }
19
+ Response: { "result": "ok", "id": 1 }
20
+ Error: { "error": "...", "id": 1 }
21
+
22
+ Methods
23
+ ───────
24
+ run — execute a .ins file; returns {"status": "ok"|"error", "output": [...]}
25
+ stop — stop a running interpreter
26
+ hot_reload — trigger hot reload on a file
27
+ scene_list — return all scene/node declarations in project
28
+ inspect — full project introspection (scenes, nodes, fns, assets)
29
+ eval — evaluate an InScript expression in live env
30
+ ping — health check; returns {"pong": true}
31
+ version — returns {"version": "2.12.0"}
32
+ """
33
+
34
+ from __future__ import annotations
35
+ import json, threading, sys, os, io
36
+ from http.server import BaseHTTPRequestHandler, HTTPServer
37
+ from typing import Any, Dict, Optional
38
+
39
+ DEFAULT_PORT = 8765
40
+
41
+
42
+ class _RpcHandler(BaseHTTPRequestHandler):
43
+ """HTTP handler — all requests are POST to /rpc."""
44
+
45
+ def log_message(self, fmt, *args):
46
+ pass # suppress default access log
47
+
48
+ def do_POST(self):
49
+ if self.path != "/rpc":
50
+ self._reply(404, {"error": "Not found — POST to /rpc"})
51
+ return
52
+ try:
53
+ length = int(self.headers.get("Content-Length", 0))
54
+ body = self.rfile.read(length)
55
+ request = json.loads(body)
56
+ except Exception as e:
57
+ self._reply(400, {"error": f"Bad JSON: {e}", "id": None})
58
+ return
59
+
60
+ req_id = request.get("id")
61
+ method = request.get("method", "")
62
+ params = request.get("params", {})
63
+
64
+ try:
65
+ result = self.server._bridge._dispatch(method, params)
66
+ self._reply(200, {"result": result, "id": req_id})
67
+ except Exception as e:
68
+ self._reply(200, {"error": str(e), "id": req_id})
69
+
70
+ def _reply(self, status: int, data: dict):
71
+ body = json.dumps(data, default=str).encode()
72
+ self.send_response(status)
73
+ self.send_header("Content-Type", "application/json")
74
+ self.send_header("Content-Length", str(len(body)))
75
+ self.send_header("Access-Control-Allow-Origin", "*")
76
+ self.end_headers()
77
+ self.wfile.write(body)
78
+
79
+
80
+ class StudioBridge:
81
+ """
82
+ Lightweight JSON-RPC bridge between the InScript runtime and
83
+ the Electron Studio shell.
84
+ """
85
+ def __init__(self, port: int = DEFAULT_PORT):
86
+ self._port = port
87
+ self._server: Optional[HTTPServer] = None
88
+ self._thread: Optional[threading.Thread] = None
89
+ self._interp = None # live Interpreter (created on first run)
90
+ self._lock = threading.Lock()
91
+ self._running = False
92
+
93
+ # ── lifecycle ──────────────────────────────────────────────────────────────
94
+
95
+ def start(self) -> "StudioBridge":
96
+ """Start the HTTP server in a daemon thread. Non-blocking."""
97
+ self._server = HTTPServer(("localhost", self._port), _RpcHandler)
98
+ self._server._bridge = self
99
+ self._running = True
100
+ self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
101
+ self._thread.start()
102
+ return self
103
+
104
+ def stop(self):
105
+ """Shut down the HTTP server."""
106
+ self._running = False
107
+ if self._server:
108
+ self._server.shutdown()
109
+
110
+ @property
111
+ def port(self) -> int:
112
+ return self._port
113
+
114
+ @property
115
+ def url(self) -> str:
116
+ return f"http://localhost:{self._port}/rpc"
117
+
118
+ # ── RPC dispatch ──────────────────────────────────────────────────────────
119
+
120
+ def _dispatch(self, method: str, params: dict) -> Any:
121
+ handlers = {
122
+ "ping": self._rpc_ping,
123
+ "version": self._rpc_version,
124
+ "run": self._rpc_run,
125
+ "stop": self._rpc_stop,
126
+ "hot_reload": self._rpc_hot_reload,
127
+ "scene_list": self._rpc_scene_list,
128
+ "inspect": self._rpc_inspect,
129
+ "eval": self._rpc_eval,
130
+ }
131
+ handler = handlers.get(method)
132
+ if handler is None:
133
+ raise ValueError(f"Unknown method '{method}'. Available: {sorted(handlers)}")
134
+ return handler(params)
135
+
136
+ # ── RPC handlers ──────────────────────────────────────────────────────────
137
+
138
+ def _rpc_ping(self, _params: dict) -> dict:
139
+ return {"pong": True, "port": self._port}
140
+
141
+ def _rpc_version(self, _params: dict) -> dict:
142
+ from inscript import VERSION
143
+ return {"version": VERSION, "bridge": "1.0"}
144
+
145
+ def _rpc_run(self, params: dict) -> dict:
146
+ """Run a .ins file and return captured output + errors."""
147
+ file_path = params.get("file", "")
148
+ if not file_path:
149
+ raise ValueError("'file' parameter required")
150
+ if not os.path.isfile(file_path):
151
+ raise FileNotFoundError(f"File not found: {file_path}")
152
+
153
+ with open(file_path, encoding="utf-8") as f:
154
+ source = f.read()
155
+
156
+ output_lines: list = []
157
+ errors: list = []
158
+
159
+ # Redirect stdout
160
+ old_stdout = sys.stdout
161
+ sys.stdout = io.StringIO()
162
+
163
+ try:
164
+ from interpreter import Interpreter
165
+ from errors import InScriptError
166
+ with self._lock:
167
+ self._interp = Interpreter()
168
+ self._interp.execute(source)
169
+ output_lines = sys.stdout.getvalue().splitlines()
170
+ except Exception as e:
171
+ from errors import InScriptError
172
+ if isinstance(e, InScriptError):
173
+ errors.append(e.to_dict())
174
+ else:
175
+ errors.append({"type": "error", "code": "E0000",
176
+ "class": type(e).__name__, "message": str(e),
177
+ "line": 0, "col": 0})
178
+ output_lines = sys.stdout.getvalue().splitlines()
179
+ finally:
180
+ sys.stdout = old_stdout
181
+
182
+ return {
183
+ "status": "error" if errors else "ok",
184
+ "output": output_lines,
185
+ "errors": errors,
186
+ }
187
+
188
+ def _rpc_stop(self, _params: dict) -> dict:
189
+ with self._lock:
190
+ self._interp = None
191
+ return {"stopped": True}
192
+
193
+ def _rpc_hot_reload(self, params: dict) -> dict:
194
+ """Trigger a hot reload on a running file."""
195
+ file_path = params.get("file", "")
196
+ if not file_path:
197
+ raise ValueError("'file' parameter required")
198
+ with self._lock:
199
+ if self._interp is None:
200
+ raise RuntimeError("No running interpreter — call 'run' first")
201
+ from hot_reload import HotReloader
202
+ reloader = HotReloader(file_path, self._interp)
203
+ result = reloader.tick()
204
+ return {
205
+ "changed": result.changed,
206
+ "success": result.success,
207
+ "patched": result.patched,
208
+ "restored": len(result.restored),
209
+ "errors": result.errors,
210
+ "elapsed_ms": result.elapsed_ms,
211
+ }
212
+
213
+ def _rpc_scene_list(self, params: dict) -> dict:
214
+ """Return all scene and node declarations in a project directory."""
215
+ project_dir = params.get("dir", ".")
216
+ from inscript_studio_api import project_inspect
217
+ data = project_inspect(project_dir)
218
+ return {"scenes": data["scenes"], "nodes": data["nodes"]}
219
+
220
+ def _rpc_inspect(self, params: dict) -> dict:
221
+ """Full project introspection."""
222
+ project_dir = params.get("dir", ".")
223
+ from inscript_studio_api import project_inspect
224
+ return project_inspect(project_dir)
225
+
226
+ def _rpc_eval(self, params: dict) -> dict:
227
+ """Evaluate an InScript expression in the live interpreter environment."""
228
+ expr = params.get("expr", "")
229
+ if not expr:
230
+ raise ValueError("'expr' parameter required")
231
+ with self._lock:
232
+ if self._interp is None:
233
+ raise RuntimeError("No running interpreter — call 'run' first")
234
+ result = self._interp.execute(expr)
235
+ return {"result": result, "type": type(result).__name__}
236
+
237
+ def __repr__(self):
238
+ status = "running" if self._running else "stopped"
239
+ return f"<StudioBridge port={self._port} {status}>"
240
+
241
+
242
+ # ═══════════════════════════════════════════════════════════════════════════
243
+ # v2.13.0 — StudioBridge v2 additions
244
+ # ═══════════════════════════════════════════════════════════════════════════
245
+
246
+ import subprocess as _subprocess
247
+
248
+
249
+ class _GameProcess:
250
+ """
251
+ Manages a single game process launched by `start_game`.
252
+ The game runs in a subprocess; the bridge stays responsive.
253
+ """
254
+ def __init__(self, file_path: str, python_exe: str = None):
255
+ self.file_path = file_path
256
+ self._python = python_exe or sys.executable
257
+ self._proc: Optional[_subprocess.Popen] = None
258
+ self._output: list = [] # captured stdout lines
259
+ self._errors: list = []
260
+ self._thread: Optional[threading.Thread] = None
261
+ self._running = False
262
+
263
+ def start(self) -> bool:
264
+ if self._proc is not None and self._proc.poll() is None:
265
+ return False # already running
266
+ inscript_path = os.path.join(os.path.dirname(__file__), "inscript.py")
267
+ self._proc = _subprocess.Popen(
268
+ [self._python, inscript_path, self.file_path],
269
+ stdout=_subprocess.PIPE,
270
+ stderr=_subprocess.STDOUT,
271
+ text=True,
272
+ bufsize=1,
273
+ )
274
+ self._running = True
275
+ self._output = []
276
+ self._errors = []
277
+ self._thread = threading.Thread(target=self._read_output, daemon=True)
278
+ self._thread.start()
279
+ return True
280
+
281
+ def stop(self) -> bool:
282
+ if self._proc is None:
283
+ return False
284
+ self._running = False
285
+ try:
286
+ self._proc.terminate()
287
+ self._proc.wait(timeout=2)
288
+ except Exception:
289
+ try: self._proc.kill()
290
+ except Exception: pass
291
+ self._proc = None
292
+ return True
293
+
294
+ def is_running(self) -> bool:
295
+ return self._proc is not None and self._proc.poll() is None
296
+
297
+ def get_output(self, since: int = 0) -> list:
298
+ return self._output[since:]
299
+
300
+ def _read_output(self):
301
+ try:
302
+ for line in self._proc.stdout:
303
+ self._output.append(line.rstrip("\n"))
304
+ except Exception:
305
+ pass
306
+ self._running = False
307
+
308
+ def __repr__(self):
309
+ status = "running" if self.is_running() else "stopped"
310
+ return f"<GameProcess '{os.path.basename(self.file_path)}' {status}>"
311
+
312
+
313
+ def _extend_bridge(bridge_cls):
314
+ """
315
+ Monkey-patch v2.13.0 methods onto StudioBridge.
316
+ Called once at module load.
317
+ """
318
+ # ── Game process management ───────────────────────────────────────────────
319
+ bridge_cls._game_process = None
320
+
321
+ def _rpc_start_game(self, params: dict) -> dict:
322
+ """
323
+ Launch a .ins game as a subprocess (non-blocking).
324
+ Bridge stays responsive during game execution.
325
+ """
326
+ file_path = params.get("file", "")
327
+ if not file_path or not os.path.isfile(file_path):
328
+ raise FileNotFoundError(f"File not found: {file_path}")
329
+ with self._lock:
330
+ if self._game_process and self._game_process.is_running():
331
+ self._game_process.stop()
332
+ self._game_process = _GameProcess(file_path)
333
+ started = self._game_process.start()
334
+ return {"started": started, "file": file_path,
335
+ "pid": self._game_process._proc.pid if started else None}
336
+
337
+ def _rpc_stop_game(self, params: dict) -> dict:
338
+ with self._lock:
339
+ if self._game_process is None:
340
+ return {"stopped": False, "reason": "no game running"}
341
+ stopped = self._game_process.stop()
342
+ self._game_process = None
343
+ return {"stopped": stopped}
344
+
345
+ def _rpc_game_status(self, params: dict) -> dict:
346
+ with self._lock:
347
+ if self._game_process is None:
348
+ return {"running": False, "output_lines": 0}
349
+ since = int(params.get("since", 0))
350
+ return {
351
+ "running": self._game_process.is_running(),
352
+ "output": self._game_process.get_output(since),
353
+ "output_lines": len(self._game_process._output),
354
+ "file": self._game_process.file_path,
355
+ }
356
+
357
+ # ── Live scene editing ────────────────────────────────────────────────────
358
+
359
+ def _rpc_set_node_prop(self, params: dict) -> dict:
360
+ """
361
+ Set a property on a live NodeInstance.
362
+ Requires a running interpreter (from `run` RPC, not `start_game`).
363
+ """
364
+ node_name = params.get("node", "")
365
+ prop = params.get("prop", "")
366
+ value = params.get("value")
367
+ if not node_name or not prop:
368
+ raise ValueError("'node' and 'prop' are required")
369
+ with self._lock:
370
+ if self._interp is None:
371
+ raise RuntimeError("No live interpreter — use 'run' first")
372
+ from scene_tree import SceneManager, NodeInstance
373
+ # Try to find node in global env first
374
+ try:
375
+ obj = self._interp._globals._store.get(node_name)
376
+ except Exception:
377
+ obj = None
378
+ if isinstance(obj, NodeInstance):
379
+ obj[prop] = value
380
+ return {"node": node_name, "prop": prop, "value": value, "ok": True}
381
+ # Fall back: set in global env
382
+ try:
383
+ self._interp._globals.set(prop, value)
384
+ return {"prop": prop, "value": value, "ok": True, "global": True}
385
+ except Exception as e:
386
+ raise RuntimeError(f"Cannot set {node_name}.{prop}: {e}")
387
+
388
+ def _rpc_add_node(self, params: dict) -> dict:
389
+ """
390
+ Instantiate a NodeBlueprint and add it to the live scene.
391
+ """
392
+ blueprint_name = params.get("blueprint", "")
393
+ node_name = params.get("name", blueprint_name)
394
+ parent_name = params.get("parent", None)
395
+ if not blueprint_name:
396
+ raise ValueError("'blueprint' required")
397
+ with self._lock:
398
+ if self._interp is None:
399
+ raise RuntimeError("No live interpreter")
400
+ from scene_tree import NodeBlueprint, NodeInstance
401
+ bp = self._interp._globals._store.get(blueprint_name)
402
+ if not isinstance(bp, NodeBlueprint):
403
+ raise KeyError(f"No NodeBlueprint '{blueprint_name}' in env")
404
+ inst = bp.instantiate(node_name)
405
+ if parent_name:
406
+ parent = self._interp._globals._store.get(parent_name)
407
+ if isinstance(parent, NodeInstance):
408
+ parent.add_child(inst)
409
+ self._interp._globals.define(node_name, inst)
410
+ return {"added": node_name, "blueprint": blueprint_name, "ok": True}
411
+
412
+ def _rpc_remove_node(self, params: dict) -> dict:
413
+ """Remove a NodeInstance from its parent in the live scene."""
414
+ node_name = params.get("node", "")
415
+ if not node_name:
416
+ raise ValueError("'node' required")
417
+ with self._lock:
418
+ if self._interp is None:
419
+ raise RuntimeError("No live interpreter")
420
+ from scene_tree import NodeInstance
421
+ inst = self._interp._globals._store.get(node_name)
422
+ if not isinstance(inst, NodeInstance):
423
+ raise KeyError(f"No NodeInstance '{node_name}' in env")
424
+ if inst._parent:
425
+ inst._parent.remove_child(inst)
426
+ return {"removed": node_name, "ok": True}
427
+
428
+ def _rpc_get_live_scene(self, params: dict) -> dict:
429
+ """
430
+ Introspect the live interpreter's scene state.
431
+ Returns all NodeInstance names, their props, and parent relationships.
432
+ """
433
+ with self._lock:
434
+ if self._interp is None:
435
+ return {"nodes": [], "count": 0, "reason": "no live interpreter"}
436
+ from scene_tree import NodeInstance
437
+ nodes = []
438
+ for name, val in self._interp._globals._store.items():
439
+ if isinstance(val, NodeInstance):
440
+ nodes.append({
441
+ "name": name,
442
+ "blueprint": val.blueprint.name,
443
+ "parent": val._parent.name if val._parent else None,
444
+ "children": [c.name for c in val.get_children()],
445
+ "props": {k: str(v) for k, v in val._props.items()
446
+ if not callable(v)},
447
+ })
448
+ return {"nodes": nodes, "count": len(nodes)}
449
+
450
+ # ── DAP debugger wiring ───────────────────────────────────────────────────
451
+
452
+ def _rpc_set_breakpoints(self, params: dict) -> dict:
453
+ """Set breakpoints for the next debug run."""
454
+ lines = [int(l) for l in params.get("lines", [])]
455
+ file_path = params.get("file", "")
456
+ with self._lock:
457
+ if not hasattr(self, '_debug_interp') or self._debug_interp is None:
458
+ # Lazy-create debug interpreter on first breakpoint set
459
+ self._debug_interp = None
460
+ self._debug_bp_lines = lines
461
+ self._debug_bp_file = file_path
462
+ else:
463
+ self._debug_interp.set_breakpoints(lines)
464
+ return {"breakpoints": lines, "file": file_path}
465
+
466
+ def _rpc_debug_run(self, params: dict) -> dict:
467
+ """Run a file with debug hooks enabled."""
468
+ file_path = params.get("file", "")
469
+ if not file_path or not os.path.isfile(file_path):
470
+ raise FileNotFoundError(f"File not found: {file_path}")
471
+ from inscript_dap import DebugInterpreter
472
+ with open(file_path, encoding="utf-8") as f:
473
+ source = f.read()
474
+ with self._lock:
475
+ self._debug_interp = DebugInterpreter(source, file_path)
476
+ bp_lines = getattr(self, '_debug_bp_lines', [])
477
+ self._debug_interp.set_breakpoints(bp_lines)
478
+ self._debug_interp.launch()
479
+ return {"debug_started": True, "file": file_path}
480
+
481
+ def _rpc_debug_continue(self, _params: dict) -> dict:
482
+ with self._lock:
483
+ di = getattr(self, '_debug_interp', None)
484
+ if di is None: raise RuntimeError("No debug session")
485
+ di.do_continue()
486
+ return {"continued": True}
487
+
488
+ def _rpc_debug_step(self, params: dict) -> dict:
489
+ mode = params.get("mode", "over") # over | into | out
490
+ with self._lock:
491
+ di = getattr(self, '_debug_interp', None)
492
+ if di is None: raise RuntimeError("No debug session")
493
+ if mode == "into": di.do_step_into()
494
+ elif mode == "out": di.do_step_out()
495
+ else: di.do_step_over()
496
+ return {"stepped": mode}
497
+
498
+ def _rpc_debug_vars(self, _params: dict) -> dict:
499
+ with self._lock:
500
+ di = getattr(self, '_debug_interp', None)
501
+ if di is None: raise RuntimeError("No debug session")
502
+ return {
503
+ "locals": {v["name"]: v["value"] for v in di.get_variables("locals")},
504
+ "globals": {v["name"]: v["value"] for v in di.get_variables("globals")},
505
+ "line": di._current_line,
506
+ "frames": di.get_stack_frames(),
507
+ "paused": di._stopped,
508
+ }
509
+
510
+ def _rpc_debug_stop(self, _params: dict) -> dict:
511
+ with self._lock:
512
+ di = getattr(self, '_debug_interp', None)
513
+ if di:
514
+ try: di.do_continue() # unblock any waiting thread
515
+ except Exception: pass
516
+ self._debug_interp = None
517
+ return {"stopped": True}
518
+
519
+ # ── Input emulation ───────────────────────────────────────────────────────
520
+
521
+ def _rpc_emulate_input(self, params: dict) -> dict:
522
+ """
523
+ Inject headless input state so Studio preview can simulate key/mouse events
524
+ without a pygame window.
525
+ """
526
+ from stdlib_game import _INPUT_MANAGER_INSTANCE
527
+ mgr = _INPUT_MANAGER_INSTANCE()
528
+ if mgr is None:
529
+ return {"ok": False, "reason": "input manager not initialised"}
530
+ keys = params.get("keys", {}) # {"space": true, "left": false}
531
+ mouse = params.get("mouse", None) # {"x": 100, "y": 200}
532
+ buttons = params.get("buttons", {}) # {0: true}
533
+ for k, v in keys.items():
534
+ mgr.emulate_key(k, bool(v))
535
+ if mouse:
536
+ mgr.emulate_mouse(float(mouse.get("x", 0)),
537
+ float(mouse.get("y", 0)),
538
+ {int(k): bool(v) for k, v in buttons.items()})
539
+ return {"ok": True, "emulated_keys": list(keys.keys())}
540
+
541
+ # ── Patch dispatch table ──────────────────────────────────────────────────
542
+
543
+ _NEW_METHODS = {
544
+ "start_game": _rpc_start_game,
545
+ "stop_game": _rpc_stop_game,
546
+ "game_status": _rpc_game_status,
547
+ "set_node_prop": _rpc_set_node_prop,
548
+ "add_node": _rpc_add_node,
549
+ "remove_node": _rpc_remove_node,
550
+ "get_live_scene": _rpc_get_live_scene,
551
+ "set_breakpoints": _rpc_set_breakpoints,
552
+ "debug_run": _rpc_debug_run,
553
+ "debug_continue": _rpc_debug_continue,
554
+ "debug_step": _rpc_debug_step,
555
+ "debug_vars": _rpc_debug_vars,
556
+ "debug_stop": _rpc_debug_stop,
557
+ "emulate_input": _rpc_emulate_input,
558
+ }
559
+
560
+ # Patch _dispatch to include new methods
561
+ _orig_dispatch = bridge_cls._dispatch
562
+
563
+ def _new_dispatch(self, method, params):
564
+ if method in _NEW_METHODS:
565
+ return _NEW_METHODS[method](self, params)
566
+ return _orig_dispatch(self, method, params)
567
+
568
+ bridge_cls._dispatch = _new_dispatch
569
+
570
+
571
+ # Apply patches
572
+ _extend_bridge(StudioBridge)
573
+
574
+
575
+ # ─────────────────────────────────────────────────────────────────────────────
576
+ # Input manager instance getter (for emulation)
577
+ # ─────────────────────────────────────────────────────────────────────────────
578
+
579
+ def _INPUT_MANAGER_INSTANCE():
580
+ """Return the global _InputManager if initialised, else None."""
581
+ try:
582
+ from stdlib_game import _INPUT_MANAGER_INSTANCE as _imi
583
+ return _imi
584
+ except ImportError:
585
+ return None
@@ -1,239 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
- """
3
- studio_bridge.py — InScript v2.12.0 Electron Bridge
4
- ================================================================
5
- A lightweight JSON-RPC HTTP server that the InScript Studio Electron
6
- shell calls to drive the runtime.
7
-
8
- Start from CLI:
9
- inscript --studio-bridge [--bridge-port 8765]
10
-
11
- Or from Python:
12
- from studio_bridge import StudioBridge
13
- bridge = StudioBridge(port=8765)
14
- bridge.start() # non-blocking daemon thread
15
- bridge.stop()
16
-
17
- JSON-RPC protocol (HTTP POST to /rpc):
18
- Request: { "method": "run", "id": 1, "params": {"file": "src/main.ins"} }
19
- Response: { "result": "ok", "id": 1 }
20
- Error: { "error": "...", "id": 1 }
21
-
22
- Methods
23
- ───────
24
- run — execute a .ins file; returns {"status": "ok"|"error", "output": [...]}
25
- stop — stop a running interpreter
26
- hot_reload — trigger hot reload on a file
27
- scene_list — return all scene/node declarations in project
28
- inspect — full project introspection (scenes, nodes, fns, assets)
29
- eval — evaluate an InScript expression in live env
30
- ping — health check; returns {"pong": true}
31
- version — returns {"version": "2.12.0"}
32
- """
33
-
34
- from __future__ import annotations
35
- import json, threading, sys, os, io
36
- from http.server import BaseHTTPRequestHandler, HTTPServer
37
- from typing import Any, Dict, Optional
38
-
39
- DEFAULT_PORT = 8765
40
-
41
-
42
- class _RpcHandler(BaseHTTPRequestHandler):
43
- """HTTP handler — all requests are POST to /rpc."""
44
-
45
- def log_message(self, fmt, *args):
46
- pass # suppress default access log
47
-
48
- def do_POST(self):
49
- if self.path != "/rpc":
50
- self._reply(404, {"error": "Not found — POST to /rpc"})
51
- return
52
- try:
53
- length = int(self.headers.get("Content-Length", 0))
54
- body = self.rfile.read(length)
55
- request = json.loads(body)
56
- except Exception as e:
57
- self._reply(400, {"error": f"Bad JSON: {e}", "id": None})
58
- return
59
-
60
- req_id = request.get("id")
61
- method = request.get("method", "")
62
- params = request.get("params", {})
63
-
64
- try:
65
- result = self.server._bridge._dispatch(method, params)
66
- self._reply(200, {"result": result, "id": req_id})
67
- except Exception as e:
68
- self._reply(200, {"error": str(e), "id": req_id})
69
-
70
- def _reply(self, status: int, data: dict):
71
- body = json.dumps(data, default=str).encode()
72
- self.send_response(status)
73
- self.send_header("Content-Type", "application/json")
74
- self.send_header("Content-Length", str(len(body)))
75
- self.send_header("Access-Control-Allow-Origin", "*")
76
- self.end_headers()
77
- self.wfile.write(body)
78
-
79
-
80
- class StudioBridge:
81
- """
82
- Lightweight JSON-RPC bridge between the InScript runtime and
83
- the Electron Studio shell.
84
- """
85
- def __init__(self, port: int = DEFAULT_PORT):
86
- self._port = port
87
- self._server: Optional[HTTPServer] = None
88
- self._thread: Optional[threading.Thread] = None
89
- self._interp = None # live Interpreter (created on first run)
90
- self._lock = threading.Lock()
91
- self._running = False
92
-
93
- # ── lifecycle ──────────────────────────────────────────────────────────────
94
-
95
- def start(self) -> "StudioBridge":
96
- """Start the HTTP server in a daemon thread. Non-blocking."""
97
- self._server = HTTPServer(("localhost", self._port), _RpcHandler)
98
- self._server._bridge = self
99
- self._running = True
100
- self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
101
- self._thread.start()
102
- return self
103
-
104
- def stop(self):
105
- """Shut down the HTTP server."""
106
- self._running = False
107
- if self._server:
108
- self._server.shutdown()
109
-
110
- @property
111
- def port(self) -> int:
112
- return self._port
113
-
114
- @property
115
- def url(self) -> str:
116
- return f"http://localhost:{self._port}/rpc"
117
-
118
- # ── RPC dispatch ──────────────────────────────────────────────────────────
119
-
120
- def _dispatch(self, method: str, params: dict) -> Any:
121
- handlers = {
122
- "ping": self._rpc_ping,
123
- "version": self._rpc_version,
124
- "run": self._rpc_run,
125
- "stop": self._rpc_stop,
126
- "hot_reload": self._rpc_hot_reload,
127
- "scene_list": self._rpc_scene_list,
128
- "inspect": self._rpc_inspect,
129
- "eval": self._rpc_eval,
130
- }
131
- handler = handlers.get(method)
132
- if handler is None:
133
- raise ValueError(f"Unknown method '{method}'. Available: {sorted(handlers)}")
134
- return handler(params)
135
-
136
- # ── RPC handlers ──────────────────────────────────────────────────────────
137
-
138
- def _rpc_ping(self, _params: dict) -> dict:
139
- return {"pong": True, "port": self._port}
140
-
141
- def _rpc_version(self, _params: dict) -> dict:
142
- from inscript import VERSION
143
- return {"version": VERSION, "bridge": "1.0"}
144
-
145
- def _rpc_run(self, params: dict) -> dict:
146
- """Run a .ins file and return captured output + errors."""
147
- file_path = params.get("file", "")
148
- if not file_path:
149
- raise ValueError("'file' parameter required")
150
- if not os.path.isfile(file_path):
151
- raise FileNotFoundError(f"File not found: {file_path}")
152
-
153
- with open(file_path, encoding="utf-8") as f:
154
- source = f.read()
155
-
156
- output_lines: list = []
157
- errors: list = []
158
-
159
- # Redirect stdout
160
- old_stdout = sys.stdout
161
- sys.stdout = io.StringIO()
162
-
163
- try:
164
- from interpreter import Interpreter
165
- from errors import InScriptError
166
- with self._lock:
167
- self._interp = Interpreter()
168
- self._interp.execute(source)
169
- output_lines = sys.stdout.getvalue().splitlines()
170
- except Exception as e:
171
- from errors import InScriptError
172
- if isinstance(e, InScriptError):
173
- errors.append(e.to_dict())
174
- else:
175
- errors.append({"type": "error", "code": "E0000",
176
- "class": type(e).__name__, "message": str(e),
177
- "line": 0, "col": 0})
178
- output_lines = sys.stdout.getvalue().splitlines()
179
- finally:
180
- sys.stdout = old_stdout
181
-
182
- return {
183
- "status": "error" if errors else "ok",
184
- "output": output_lines,
185
- "errors": errors,
186
- }
187
-
188
- def _rpc_stop(self, _params: dict) -> dict:
189
- with self._lock:
190
- self._interp = None
191
- return {"stopped": True}
192
-
193
- def _rpc_hot_reload(self, params: dict) -> dict:
194
- """Trigger a hot reload on a running file."""
195
- file_path = params.get("file", "")
196
- if not file_path:
197
- raise ValueError("'file' parameter required")
198
- with self._lock:
199
- if self._interp is None:
200
- raise RuntimeError("No running interpreter — call 'run' first")
201
- from hot_reload import HotReloader
202
- reloader = HotReloader(file_path, self._interp)
203
- result = reloader.tick()
204
- return {
205
- "changed": result.changed,
206
- "success": result.success,
207
- "patched": result.patched,
208
- "restored": len(result.restored),
209
- "errors": result.errors,
210
- "elapsed_ms": result.elapsed_ms,
211
- }
212
-
213
- def _rpc_scene_list(self, params: dict) -> dict:
214
- """Return all scene and node declarations in a project directory."""
215
- project_dir = params.get("dir", ".")
216
- from inscript_studio_api import project_inspect
217
- data = project_inspect(project_dir)
218
- return {"scenes": data["scenes"], "nodes": data["nodes"]}
219
-
220
- def _rpc_inspect(self, params: dict) -> dict:
221
- """Full project introspection."""
222
- project_dir = params.get("dir", ".")
223
- from inscript_studio_api import project_inspect
224
- return project_inspect(project_dir)
225
-
226
- def _rpc_eval(self, params: dict) -> dict:
227
- """Evaluate an InScript expression in the live interpreter environment."""
228
- expr = params.get("expr", "")
229
- if not expr:
230
- raise ValueError("'expr' parameter required")
231
- with self._lock:
232
- if self._interp is None:
233
- raise RuntimeError("No running interpreter — call 'run' first")
234
- result = self._interp.execute(expr)
235
- return {"result": result, "type": type(result).__name__}
236
-
237
- def __repr__(self):
238
- status = "running" if self._running else "stopped"
239
- return f"<StudioBridge port={self._port} {status}>"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes