avrae-ls 0.6.0__py3-none-any.whl → 0.6.2__py3-none-any.whl

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.
avrae_ls/runtime.py ADDED
@@ -0,0 +1,750 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import io
5
+ import json
6
+ import logging
7
+ import math
8
+ import random
9
+ import re
10
+ import time
11
+ from types import SimpleNamespace
12
+ from collections import UserList
13
+ import yaml
14
+ from dataclasses import dataclass
15
+ from typing import Any, Dict, Set, Callable
16
+
17
+ import d20
18
+ import draconic
19
+ import httpx
20
+ from draconic.interpreter import _Break, _Continue, _Return
21
+
22
+ from .context import ContextData, GVarResolver
23
+ from .config import AvraeServiceConfig, VarSources
24
+ from .api import AliasContextAPI, CharacterAPI, SimpleCombat, SimpleRollResult
25
+ from . import argparser as avrae_argparser
26
+
27
+ _VERIFY_SIGNATURE_TIMEOUT = 5.0
28
+ _VERIFY_SIGNATURE_RETRIES = 0
29
+ # Minimal stand-in for Avrae's AliasException
30
+ class AliasException(Exception):
31
+ def __init__(self, msg, pm_user):
32
+ super().__init__(msg)
33
+ self.pm_user = pm_user
34
+
35
+
36
+ try:
37
+ from avrae.aliasing.errors import FunctionRequiresCharacter # type: ignore
38
+ except Exception: # pragma: no cover - fallback when avrae is unavailable
39
+ class FunctionRequiresCharacter(Exception):
40
+ def __init__(self, msg: str | None = None):
41
+ super().__init__(msg or "This alias requires an active character.")
42
+
43
+ log = logging.getLogger(__name__)
44
+
45
+
46
+ class MockNamespace:
47
+ """A minimal attribute-friendly namespace used for ctx/combat/character."""
48
+
49
+ def __init__(self, data: Dict[str, Any] | None = None):
50
+ self._data = data or {}
51
+
52
+ def __getattr__(self, item: str) -> Any:
53
+ return self._data.get(item)
54
+
55
+ def __getitem__(self, item: str) -> Any:
56
+ return self._data.get(item)
57
+
58
+ def __repr__(self) -> str: # pragma: no cover - debugging helper
59
+ return f"MockNamespace({self._data})"
60
+
61
+ def to_dict(self) -> Dict[str, Any]:
62
+ return dict(self._data)
63
+
64
+
65
+ class ModuleExecutionError(Exception):
66
+ def __init__(self, module: str, original: BaseException):
67
+ super().__init__(f"Error in module {module}: {original}")
68
+ self.module = module
69
+ self.original = original
70
+
71
+
72
+ @dataclass
73
+ class ExecutionResult:
74
+ stdout: str
75
+ value: Any = None
76
+ error: BaseException | None = None
77
+
78
+
79
+ def _roll_dice(dice: str) -> int:
80
+ roller = d20.Roller()
81
+ try:
82
+ result = roller.roll(str(dice))
83
+ except d20.RollError:
84
+ return 0
85
+ return result.total
86
+
87
+
88
+ def _load_yaml(data: Any) -> Any:
89
+ """Parse YAML, accepting JSON."""
90
+ if data is None:
91
+ return None
92
+ if isinstance(data, (dict, list, tuple, set)):
93
+ return data
94
+ text = str(data)
95
+ return yaml.safe_load(text)
96
+
97
+
98
+ def _yaml_dumper():
99
+ """Create a dumper that knows how to serialize draconic SafeList/SafeDict/SafeSet."""
100
+ class DraconicDumper(yaml.SafeDumper):
101
+ pass
102
+
103
+ def _represent_user_list(dumper: yaml.Dumper, data: UserList):
104
+ return dumper.represent_sequence(dumper.DEFAULT_SEQUENCE_TAG, list(data))
105
+
106
+ def _represent_dict(dumper: yaml.Dumper, data: dict):
107
+ return dumper.represent_dict(dict(data))
108
+
109
+ def _represent_set(dumper: yaml.Dumper, data: set):
110
+ return dumper.represent_sequence(dumper.DEFAULT_SEQUENCE_TAG, list(data))
111
+
112
+ DraconicDumper.add_multi_representer(UserList, _represent_user_list)
113
+ DraconicDumper.add_multi_representer(dict, _represent_dict)
114
+ DraconicDumper.add_multi_representer(set, _represent_set)
115
+ return DraconicDumper
116
+
117
+
118
+ _YAML_DUMPER = _yaml_dumper()
119
+
120
+
121
+ def _dump_yaml(obj: Any, indent: int = 2) -> str:
122
+ return yaml.dump(obj, Dumper=_YAML_DUMPER, default_flow_style=False, indent=indent, sort_keys=False)
123
+
124
+
125
+ def _json_default(obj: Any) -> Any:
126
+ """Coerce SafeList/SafeDict or other iterables into JSON-serializable forms."""
127
+ try:
128
+ if hasattr(obj, "to_dict"):
129
+ return obj.to_dict()
130
+ if isinstance(obj, dict):
131
+ return dict(obj)
132
+ if isinstance(obj, (list, tuple, set)):
133
+ return list(obj)
134
+ if hasattr(obj, "__iter__") and not isinstance(obj, (str, bytes)):
135
+ return list(obj)
136
+ except Exception:
137
+ pass
138
+ return str(obj)
139
+
140
+
141
+ def _vroll_dice(dice: str, multiply: int = 1, add: int = 0) -> SimpleRollResult | None:
142
+ roller = d20.Roller()
143
+ try:
144
+ dice_ast = roller.parse(str(dice))
145
+ except d20.RollError:
146
+ return None
147
+
148
+ if multiply != 1 or add != 0:
149
+ def _scale(node):
150
+ if isinstance(node, d20.ast.Dice):
151
+ node.num = (node.num * multiply) + add
152
+ return node
153
+
154
+ dice_ast = d20.utils.tree_map(_scale, dice_ast)
155
+
156
+ try:
157
+ rolled = roller.roll(dice_ast)
158
+ except d20.RollError:
159
+ return None
160
+ return SimpleRollResult(rolled)
161
+
162
+
163
+ @dataclass
164
+ class _CoinsArgs:
165
+ pp: int = 0
166
+ gp: int = 0
167
+ ep: int = 0
168
+ sp: int = 0
169
+ cp: int = 0
170
+ explicit: bool = False
171
+
172
+ @property
173
+ def total(self) -> float:
174
+ return (self.pp * 10) + self.gp + (self.ep * 0.5) + (self.sp * 0.1) + (self.cp * 0.01)
175
+
176
+
177
+ def _parse_coin_args(args: str) -> _CoinsArgs:
178
+ cleaned = str(args).replace(",", "")
179
+ try:
180
+ return _parse_coin_args_float(float(cleaned))
181
+ except ValueError:
182
+ return _parse_coin_args_re(cleaned)
183
+
184
+
185
+ def _parse_coin_args_float(coins: float) -> _CoinsArgs:
186
+ total_copper = int(round(coins * 100, 1))
187
+ if coins < 0:
188
+ return _CoinsArgs(cp=total_copper)
189
+ return _CoinsArgs(
190
+ gp=total_copper // 100,
191
+ sp=(total_copper % 100) // 10,
192
+ cp=total_copper % 10,
193
+ )
194
+
195
+
196
+ def _parse_coin_args_re(args: str) -> _CoinsArgs:
197
+ is_valid = re.fullmatch(r"(([+-]?\d+)\s*([pgesc]p)\s*)+", args, re.IGNORECASE)
198
+ if not is_valid:
199
+ raise avrae_argparser.InvalidArgument(
200
+ "Coins must be a number or a currency string, e.g. `+101.2` or `10cp +101gp -2sp`."
201
+ )
202
+
203
+ out = _CoinsArgs(explicit=True)
204
+ for coin_match in re.finditer(r"(?P<amount>[+-]?\d+)\s*(?P<currency>[pgesc]p)", args, re.IGNORECASE):
205
+ amount = int(coin_match["amount"])
206
+ currency = coin_match["currency"].lower()
207
+
208
+ if currency == "pp":
209
+ out.pp += amount
210
+ elif currency == "gp":
211
+ out.gp += amount
212
+ elif currency == "ep":
213
+ out.ep += amount
214
+ elif currency == "sp":
215
+ out.sp += amount
216
+ else:
217
+ out.cp += amount
218
+
219
+ return out
220
+
221
+
222
+ def _parse_coins(args: str, include_total: bool = True):
223
+ try:
224
+ from avrae.aliasing.api.functions import parse_coins as avrae_parse_coins
225
+ except Exception:
226
+ avrae_parse_coins = None
227
+
228
+ if avrae_parse_coins:
229
+ try:
230
+ return avrae_parse_coins(str(args), include_total=include_total)
231
+ except Exception:
232
+ pass
233
+
234
+ coin_args = _parse_coin_args(str(args))
235
+ parsed = {
236
+ "pp": coin_args.pp,
237
+ "gp": coin_args.gp,
238
+ "ep": coin_args.ep,
239
+ "sp": coin_args.sp,
240
+ "cp": coin_args.cp,
241
+ }
242
+ if include_total:
243
+ parsed["total"] = coin_args.total
244
+ return parsed
245
+
246
+
247
+ def _default_builtins() -> Dict[str, Any]:
248
+ return {
249
+ "len": len,
250
+ "min": min,
251
+ "max": max,
252
+ "sum": sum,
253
+ "any": any,
254
+ "all": all,
255
+ "abs": abs,
256
+ "range": range,
257
+ "enumerate": enumerate,
258
+ "int": int,
259
+ "float": float,
260
+ "str": str,
261
+ "round": round,
262
+ "ceil": math.ceil,
263
+ "floor": math.floor,
264
+ "sqrt": math.sqrt,
265
+ "time": time.time,
266
+ "roll": _roll_dice,
267
+ "vroll": _vroll_dice,
268
+ "rand": random.random,
269
+ "randint": random.randrange,
270
+ "randchoice": random.choice,
271
+ "randchoices": random.choices,
272
+ "typeof": lambda inst: type(inst).__name__,
273
+ "parse_coins": _parse_coins,
274
+ "load_json": lambda s: json.loads(str(s)),
275
+ "dump_json": lambda obj: json.dumps(obj, default=_json_default),
276
+ "load_yaml": _load_yaml,
277
+ "dump_yaml": _dump_yaml,
278
+ }
279
+
280
+
281
+ class MockExecutor:
282
+ def __init__(self, service_config: AvraeServiceConfig | None = None):
283
+ self._base_builtins = _default_builtins()
284
+ self._service_config = service_config or AvraeServiceConfig()
285
+
286
+ def available_names(self, ctx_data: ContextData) -> Set[str]:
287
+ builtin_names = set(self._base_builtins.keys())
288
+ runtime_names = {
289
+ "ctx",
290
+ "combat",
291
+ "character",
292
+ "roll",
293
+ "vroll",
294
+ "rand",
295
+ "randint",
296
+ "randchoice",
297
+ "randchoices",
298
+ "typeof",
299
+ "parse_coins",
300
+ "load_json",
301
+ "dump_json",
302
+ "load_yaml",
303
+ "dump_yaml",
304
+ "get_gvar",
305
+ "get_svar",
306
+ "get_uvar",
307
+ "get_uvars",
308
+ "set_uvar",
309
+ "set_uvar_nx",
310
+ "delete_uvar",
311
+ "uvar_exists",
312
+ "print",
313
+ "argparse",
314
+ "err",
315
+ "exists",
316
+ "get",
317
+ "using",
318
+ "signature",
319
+ "verify_signature",
320
+ }
321
+ variable_names = set(ctx_data.vars.to_initial_names().keys())
322
+ return builtin_names | runtime_names | variable_names
323
+
324
+ async def run(
325
+ self,
326
+ code: str,
327
+ ctx_data: ContextData,
328
+ gvar_resolver: GVarResolver | None = None,
329
+ ) -> ExecutionResult:
330
+ buffer = io.StringIO()
331
+ resolver = gvar_resolver
332
+ interpreter_ref: dict[str, draconic.DraconicInterpreter | None] = {"interpreter": None}
333
+ runtime_character: CharacterAPI | None = None
334
+
335
+ def _character_provider() -> CharacterAPI:
336
+ nonlocal runtime_character
337
+ interp = interpreter_ref["interpreter"]
338
+ if not ctx_data.character:
339
+ raise FunctionRequiresCharacter()
340
+ if runtime_character is None and interp is not None:
341
+ runtime_character = _RuntimeCharacter(ctx_data.character, ctx_data.vars, interp)
342
+ if runtime_character is None:
343
+ runtime_character = CharacterAPI(ctx_data.character)
344
+ return runtime_character # type: ignore[return-value]
345
+
346
+ import_cache: dict[str, SimpleNamespace] = {}
347
+ import_stack: list[str] = []
348
+ builtins = self._build_builtins(
349
+ ctx_data,
350
+ resolver,
351
+ buffer,
352
+ character_provider=_character_provider,
353
+ interpreter_ref=interpreter_ref,
354
+ import_cache=import_cache,
355
+ import_stack=import_stack,
356
+ )
357
+ interpreter = draconic.DraconicInterpreter(
358
+ builtins=builtins,
359
+ initial_names=ctx_data.vars.to_initial_names(),
360
+ )
361
+ interpreter_ref["interpreter"] = interpreter
362
+
363
+ value = None
364
+ error: BaseException | None = None
365
+ code_to_run = code
366
+ try:
367
+ parsed = interpreter.parse(code_to_run)
368
+ except BaseException:
369
+ wrapped, _ = _wrap_draconic(code_to_run)
370
+ code_to_run = wrapped
371
+ try:
372
+ parsed = interpreter.parse(code_to_run)
373
+ except BaseException as exc:
374
+ error = exc
375
+ log.debug("Mock execution error: %s", exc, exc_info=exc)
376
+ return ExecutionResult(stdout=buffer.getvalue(), value=value, error=error)
377
+
378
+ if resolver:
379
+ await _ensure_literal_gvars(code_to_run, resolver)
380
+ await _ensure_nested_gvars(resolver)
381
+ await _ensure_nested_gvars(resolver)
382
+
383
+ try:
384
+ interpreter._preflight()
385
+ value = self._exec_with_value(interpreter, parsed)
386
+ except BaseException as exc: # draconic raises BaseException subclasses
387
+ error = exc
388
+ log.debug("Mock execution error: %s", exc, exc_info=exc)
389
+ return ExecutionResult(stdout=buffer.getvalue(), value=value, error=error)
390
+
391
+ def _build_builtins(
392
+ self,
393
+ ctx_data: ContextData,
394
+ resolver: GVarResolver | None,
395
+ buffer: io.StringIO,
396
+ character_provider: Callable[[], CharacterAPI] | None = None,
397
+ interpreter_ref: Dict[str, draconic.DraconicInterpreter | None] | None = None,
398
+ import_cache: Dict[str, SimpleNamespace] | None = None,
399
+ import_stack: list[str] | None = None,
400
+ ) -> Dict[str, Any]:
401
+ builtins = dict(self._base_builtins)
402
+ var_store = ctx_data.vars
403
+ interpreter_ref = interpreter_ref or {"interpreter": None}
404
+ import_cache = import_cache or {}
405
+ import_stack = import_stack or []
406
+ service_cfg = self._service_config
407
+ verify_cache_sig: str | None = None
408
+ verify_cache_result: Dict[str, Any] | None = None
409
+ verify_cache_error: ValueError | None = None
410
+
411
+ def _print(*args, sep=" ", end="\n"):
412
+ buffer.write(sep.join(map(str, args)) + end)
413
+
414
+ def _get_gvar(address: str):
415
+ if resolver is None:
416
+ return None
417
+ return resolver.get_local(address)
418
+
419
+ def _get_svar(name: str, default=None):
420
+ return var_store.svars.get(str(name), default)
421
+
422
+ def _get_uvar(name: str, default=None):
423
+ val = var_store.uvars.get(str(name), default)
424
+ return str(val) if val is not None else default
425
+
426
+ def _get_uvars():
427
+ return {k: (str(v) if v is not None else v) for k, v in var_store.uvars.items()}
428
+
429
+ def _set_uvar(name: str, value: Any):
430
+ str_val = str(value) if value is not None else None
431
+ var_store.uvars[str(name)] = str_val
432
+ return str_val
433
+
434
+ def _set_uvar_nx(name: str, value: Any):
435
+ key = str(name)
436
+ if key not in var_store.uvars:
437
+ var_store.uvars[key] = str(value) if value is not None else None
438
+ return var_store.uvars[key]
439
+
440
+ def _delete_uvar(name: str):
441
+ return var_store.uvars.pop(str(name), None)
442
+
443
+ def _uvar_exists(name: str) -> bool:
444
+ return str(name) in var_store.uvars
445
+
446
+ def _resolve_name(key: str) -> tuple[bool, Any]:
447
+ key = str(key)
448
+ interp = interpreter_ref.get("interpreter")
449
+ if interp is not None:
450
+ names = getattr(interp, "_names", {})
451
+ if key in names:
452
+ return True, names[key]
453
+
454
+ if key in var_store.cvars:
455
+ return True, var_store.cvars[key]
456
+
457
+ if key in var_store.uvars:
458
+ return True, var_store.uvars[key]
459
+
460
+ return False, None
461
+
462
+ def _exists(name: str) -> bool:
463
+ found, _ = _resolve_name(name)
464
+ return found
465
+
466
+ def _get(name: str, default=None):
467
+ found, value = _resolve_name(name)
468
+ return value if found else default
469
+
470
+ def _using(**imports):
471
+ interp = interpreter_ref.get("interpreter")
472
+ if interp is None:
473
+ return None
474
+ user_ns = getattr(interp, "_names", {})
475
+
476
+ def _load_module(addr: str) -> SimpleNamespace:
477
+ if addr in import_cache:
478
+ return import_cache[addr]
479
+ if resolver is None:
480
+ raise ModuleNotFoundError(f"No gvar named {addr!r}")
481
+ mod_contents = resolver.get_local(addr)
482
+ if mod_contents is None:
483
+ try:
484
+ resolver.ensure_blocking(addr)
485
+ except Exception as exc: # pragma: no cover - defensive
486
+ log.debug("Blocking gvar fetch failed for %s: %s", addr, exc)
487
+ mod_contents = resolver.get_local(addr)
488
+ if mod_contents is None:
489
+ raise ModuleNotFoundError(f"No gvar named {addr!r}")
490
+
491
+ old_names = getattr(interp, "_names", {})
492
+ depth_increased = False
493
+ try:
494
+ interp._names = {}
495
+ interp._depth += 1
496
+ depth_increased = True
497
+ if interp._depth > interp._config.max_recursion_depth:
498
+ raise RecursionError("Maximum recursion depth exceeded")
499
+ try:
500
+ interp.execute_module(str(mod_contents), module_name=addr)
501
+ except Exception as exc:
502
+ raise ModuleExecutionError(addr, exc) from exc
503
+ mod_ns = SimpleNamespace(**getattr(interp, "_names", {}))
504
+ import_cache[addr] = mod_ns
505
+ return mod_ns
506
+ finally:
507
+ if depth_increased:
508
+ interp._depth -= 1
509
+ interp._names = old_names
510
+
511
+ for ns, addr in imports.items():
512
+ addr_str = str(addr)
513
+ if addr_str in import_stack:
514
+ circle = " imports\n".join(import_stack)
515
+ raise ImportError(f"Circular import detected!\n{circle} imports\n{addr_str}")
516
+ import_stack.append(addr_str)
517
+ try:
518
+ mod_ns = _load_module(addr_str)
519
+ finally:
520
+ import_stack.pop()
521
+ name = str(ns)
522
+ if name in interp.builtins:
523
+ raise ValueError(f"{name} is already builtin (no shadow assignments).")
524
+ user_ns[name] = mod_ns
525
+
526
+ interp._names = user_ns
527
+ return None
528
+
529
+ def _signature(data=0):
530
+ try:
531
+ data = int(data)
532
+ except ValueError:
533
+ raise TypeError(f"Data {data} could not be converted to integer.")
534
+ return f"mock-signature:{int(data)}"
535
+
536
+ def _verify_signature(sig):
537
+ nonlocal verify_cache_sig, verify_cache_result, verify_cache_error
538
+ sig_str = str(sig)
539
+ if sig_str == verify_cache_sig:
540
+ if verify_cache_error:
541
+ raise verify_cache_error
542
+ return verify_cache_result
543
+
544
+ verify_cache_sig = sig_str
545
+ verify_cache_error = None
546
+ verify_cache_result = None
547
+ timeout = float(_VERIFY_SIGNATURE_TIMEOUT)
548
+ retries = max(0, _VERIFY_SIGNATURE_RETRIES)
549
+
550
+ def _call_verify_api(signature: str) -> Dict[str, Any]:
551
+ base_url = (service_cfg.base_url if service_cfg else AvraeServiceConfig.base_url).rstrip("/")
552
+ url = f"{base_url}/bot/signature/verify"
553
+ headers = {"Content-Type": "application/json"}
554
+ if service_cfg and service_cfg.token:
555
+ headers["Authorization"] = str(service_cfg.token)
556
+ last_exc: Exception | None = None
557
+ for attempt in range(retries + 1):
558
+ try:
559
+ resp = httpx.post(url, json={"signature": signature}, headers=headers, timeout=timeout)
560
+ break
561
+ except Exception as exc:
562
+ last_exc = exc
563
+ if attempt >= retries:
564
+ raise ValueError(f"Failed to verify signature: {exc}") from exc
565
+ continue
566
+ else: # pragma: no cover - defensive
567
+ raise ValueError(f"Failed to verify signature: {last_exc}") from last_exc
568
+
569
+ try:
570
+ payload = resp.json()
571
+ except Exception as exc:
572
+ raise ValueError("Failed to verify signature: invalid response body") from exc
573
+
574
+ if resp.status_code != 200:
575
+ message = None
576
+ if isinstance(payload, dict):
577
+ message = payload.get("error") or payload.get("message")
578
+ detail = f"{message} (HTTP {resp.status_code})" if message else f"HTTP {resp.status_code}"
579
+ raise ValueError(f"Failed to verify signature: {detail}")
580
+
581
+ if not isinstance(payload, dict):
582
+ raise ValueError("Failed to verify signature: invalid response")
583
+ if payload.get("success") is not True:
584
+ message = payload.get("error")
585
+ raise ValueError(f"Failed to verify signature: {message or 'unsuccessful response'}")
586
+
587
+ data = payload.get("data")
588
+ if not isinstance(data, dict):
589
+ raise ValueError("Failed to verify signature: malformed response")
590
+ return data
591
+
592
+ try:
593
+ verify_cache_result = _call_verify_api(sig_str)
594
+ except ValueError as exc:
595
+ verify_cache_error = exc
596
+ raise
597
+ return verify_cache_result
598
+
599
+ def _argparse(args, character=None, splitter=avrae_argparser.argsplit, parse_ephem=True):
600
+ return avrae_argparser.argparse(args, character=character, splitter=splitter, parse_ephem=parse_ephem)
601
+
602
+ def _err(reason, pm_user: bool = False):
603
+ raise AliasException(str(reason), pm_user)
604
+
605
+ ns_ctx = AliasContextAPI(ctx_data.ctx)
606
+ ns_combat = SimpleCombat(ctx_data.combat) if ctx_data.combat else None
607
+ if character_provider:
608
+ character_fn = character_provider
609
+ else:
610
+ ns_character = CharacterAPI(ctx_data.character) if ctx_data.character else None
611
+
612
+ def character_fn():
613
+ if ns_character is None:
614
+ raise FunctionRequiresCharacter()
615
+ return ns_character
616
+
617
+ builtins.update(
618
+ print=_print,
619
+ roll=_roll_dice,
620
+ vroll=_vroll_dice,
621
+ ctx=ns_ctx,
622
+ combat=lambda: ns_combat,
623
+ character=lambda: character_fn(),
624
+ get_gvar=_get_gvar,
625
+ get_svar=_get_svar,
626
+ get_uvar=_get_uvar,
627
+ get_uvars=_get_uvars,
628
+ set_uvar=_set_uvar,
629
+ set_uvar_nx=_set_uvar_nx,
630
+ delete_uvar=_delete_uvar,
631
+ uvar_exists=_uvar_exists,
632
+ argparse=_argparse,
633
+ err=_err,
634
+ exists=_exists,
635
+ get=_get,
636
+ using=_using,
637
+ signature=_signature,
638
+ verify_signature=_verify_signature,
639
+ )
640
+ return builtins
641
+
642
+ def _exec_with_value(self, interpreter: draconic.DraconicInterpreter, body) -> Any:
643
+ last_val = None
644
+ for expression in body:
645
+ retval = interpreter._eval(expression) # type: ignore[attr-defined]
646
+ if isinstance(retval, (_Break, _Continue)):
647
+ raise draconic.DraconicSyntaxError.from_node(retval.node, msg="Loop control outside loop", expr=interpreter._expr) # type: ignore[attr-defined]
648
+ if isinstance(retval, _Return):
649
+ return retval.value
650
+ last_val = retval
651
+ return last_val
652
+
653
+
654
+ class _RuntimeCharacter(CharacterAPI):
655
+ """Character wrapper that keeps mock runtime bindings in sync with cvar mutations."""
656
+
657
+ def __init__(self, data: Dict[str, Any], var_store: VarSources, interpreter: draconic.DraconicInterpreter):
658
+ super().__init__(data)
659
+ self._var_store = var_store
660
+ self._interpreter = interpreter
661
+
662
+ def set_cvar(self, name: str, val: Any) -> Any:
663
+ bound_val = super().set_cvar(name, val)
664
+ key = str(name)
665
+ self._var_store.cvars[key] = bound_val
666
+ try:
667
+ # Mirror Avrae behavior: new cvars are available as locals immediately.
668
+ self._interpreter._names[key] = bound_val # type: ignore[attr-defined]
669
+ except Exception:
670
+ pass
671
+ return bound_val
672
+
673
+ def set_cvar_nx(self, name: str, val: Any) -> Any:
674
+ key = str(name)
675
+ if key in self._var_store.cvars:
676
+ return self._var_store.cvars[key]
677
+ return self.set_cvar(key, val)
678
+
679
+ # delete_cvar intentionally does not unbind runtime names, matching Avrae's docs.
680
+
681
+
682
+ def _wrap_draconic(code: str) -> tuple[str, int]:
683
+ indented = "\n".join(f" {line}" for line in code.splitlines())
684
+ wrapped = f"def __alias_main__():\n{indented}\n__alias_main__()"
685
+ return wrapped, 1
686
+
687
+
688
+ async def _ensure_literal_gvars(code: str, resolver: GVarResolver) -> None:
689
+ keys = _literal_gvars(code)
690
+ try:
691
+ await resolver.ensure_many(keys)
692
+ except Exception as exc: # pragma: no cover - defensive
693
+ log.debug("Failed to prefetch gvars %s: %s", keys, exc)
694
+
695
+
696
+ async def _ensure_nested_gvars(resolver: GVarResolver) -> None:
697
+ """Recursively prefetch gvars referenced by already-fetched gvar modules."""
698
+ visited: set[str] = set()
699
+ while True:
700
+ snapshot = resolver.snapshot()
701
+ newly_fetched: set[str] = set()
702
+ for key, value in snapshot.items():
703
+ if key in visited:
704
+ continue
705
+ visited.add(key)
706
+ if not isinstance(value, str):
707
+ continue
708
+ for nested in _literal_gvars(value):
709
+ if nested in snapshot or nested in newly_fetched:
710
+ continue
711
+ try:
712
+ results = await resolver.ensure_many([nested])
713
+ except Exception as exc: # pragma: no cover - defensive
714
+ log.debug("Failed to prefetch nested gvar %s: %s", nested, exc)
715
+ continue
716
+ if results.get(nested):
717
+ newly_fetched.add(nested)
718
+ if not newly_fetched:
719
+ break
720
+
721
+
722
+ def _literal_gvars(code: str) -> Set[str]:
723
+ try:
724
+ tree = ast.parse(code)
725
+ except SyntaxError:
726
+ wrapped, _ = _wrap_draconic(code)
727
+ try:
728
+ tree = ast.parse(wrapped)
729
+ except SyntaxError:
730
+ return set()
731
+
732
+ gvars: set[str] = set()
733
+ for node in ast.walk(tree):
734
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
735
+ if node.func.id == "get_gvar":
736
+ if not node.args:
737
+ continue
738
+ arg = node.args[0]
739
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
740
+ gvars.add(arg.value)
741
+ elif isinstance(arg, ast.Str):
742
+ gvars.add(arg.s)
743
+ elif node.func.id == "using":
744
+ for kw in node.keywords:
745
+ val = kw.value
746
+ if isinstance(val, ast.Constant) and isinstance(val.value, str):
747
+ gvars.add(val.value)
748
+ elif isinstance(val, ast.Str):
749
+ gvars.add(val.s)
750
+ return gvars