feat: eval/exec of strings, gallery Replay, callable actions; iNSight and SomethingBetter now pass
- pyparse.ts: parser for expressions and simple statements built at run time, producing
the sandbox's own IR; backs eval() and exec() (e.g. exec("persistent." + var + " = 0"))
- Replay/EndReplay and renpy.end_replay: the engine stashes the full game state, runs the
label with the given scope and restores everything when it ends; locked replays are
insensitive. Save loading shared as applySave()
- Screen actions are callable (MainMenu(confirm=False)())
- jump main_menu_screen is Ren'Py's own label: it opens the player's main menu
- Screens: a closing quote ends the word before a letter ("text"size 15) but not before
an operator ("a"+b); Text(...) pictures in image blocks
- README: current capabilities and limits of the Ren'Py support
Strict compatibility check on the local collection: Tomie, Twisted-Thoughts, Hooked,
iNSight-of-you and SomethingBetter pass; TheFixer (custom AlphaMask wardrobe, missing
labels) and That_New_Teacher (custom statements, Render API) do not.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3923ee72fb
commit
3ac4f0371f
11 files changed
+448
-36
No files matched your search
@@ -8,6 +8,7 @@ import { languageName, rememberLanguage, type Translator } from "./i18n";
|
||||
import { naniText } from "./naninovel";
|
||||
import { ScreenManager, type ScreenHost } from "./screens";
|
||||
import { pythonModules } from "./pymodules";
|
||||
import { parseExpression, parseStatements } from "./pyparse";
|
||||
import { DEFAULT_SETTINGS, SLOTS, Restart, el, loadJSON, saveJSON, type SaveApi, type SaveMeta, type Settings } from "./engine-util";
|
||||
import type {
|
||||
CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule,
|
||||
@@ -39,6 +40,7 @@ export class Engine {
|
||||
private waiters: (() => void)[] = [];
|
||||
private screens: ScreenManager;
|
||||
private screenFonts = new Map<string, string>();
|
||||
private replayStash: SaveData | null = null;
|
||||
private seenImages = new Set<string>();
|
||||
private running = false;
|
||||
|
||||
@@ -344,7 +346,10 @@ export class Engine {
|
||||
version: () => "pgvn",
|
||||
...this.screens.renpyApi(),
|
||||
// presentation-only: nothing to roll back or replay, and the next transition is chosen by `with`
|
||||
end_replay: () => null,
|
||||
end_replay: () => {
|
||||
this.endReplay();
|
||||
return null;
|
||||
},
|
||||
partial: (f: any, ...bound: any[]) => (...args: any[]) => f(...bound, ...args),
|
||||
loadable: (path: any) => this.fileUrl(String(path)) !== null,
|
||||
is_init_phase: () => false,
|
||||
@@ -391,6 +396,13 @@ export class Engine {
|
||||
// a read-only view: `"name" in globals()` works, writes do not reach the game
|
||||
globals: () => new PyDict(Object.entries(this.store)),
|
||||
print: () => null,
|
||||
// code built at run time ("persistent." + name + " = 0") goes through a parser producing the same
|
||||
// sandboxed IR; there is no other way to run source text
|
||||
eval: (source: any) => this.py.eval(parseExpression(pyStr(source))),
|
||||
exec: (source: any) => {
|
||||
this.py.exec(parseStatements(pyStr(source)));
|
||||
return null;
|
||||
},
|
||||
Borders: (...a: number[]) => a,
|
||||
...pythonModules(),
|
||||
_window_hide: () => { this.windowShown = false; this.textbox.classList.add("pg-hidden"); return null; },
|
||||
@@ -614,6 +626,8 @@ export class Engine {
|
||||
if (/^https?:\/\//i.test(url)) window.open(url, "_blank", "noopener,noreferrer");
|
||||
},
|
||||
setLanguage: (code) => void this.setLanguage(code ?? this.game.sourceLang ?? "und"),
|
||||
startReplay: (label, scope) => this.startReplay(label, scope),
|
||||
endReplay: () => this.endReplay(),
|
||||
quickSave: () => void this.save("quick"),
|
||||
quickLoad: () => void this.load("quick"),
|
||||
transform: (name) => this.game.transforms[name] ?? null,
|
||||
@@ -726,6 +740,7 @@ export class Engine {
|
||||
}
|
||||
|
||||
private jump(target: string, args?: { a: any[]; kw: Record<string, any> }, frame?: Frame) {
|
||||
if (target === "main_menu_screen" || target === "_main_menu_screen") target = "__main_menu__";
|
||||
if (target === "__main_menu__" || target === "__quit__") {
|
||||
throw new Restart(target);
|
||||
}
|
||||
@@ -776,6 +791,10 @@ export class Engine {
|
||||
private doReturn() {
|
||||
const frame = this.stack.pop();
|
||||
if (!frame) {
|
||||
if (this.replayStash) {
|
||||
this.endReplay(); // the replayed label ran out: back to the game
|
||||
return;
|
||||
}
|
||||
this.cur = { label: "", path: [0] };
|
||||
return;
|
||||
}
|
||||
@@ -1459,27 +1478,54 @@ export class Engine {
|
||||
frames.forEach((f, i) => Object.assign(f, mapped[i]));
|
||||
}
|
||||
if (!data.revision && !(await this.confirmAsk("Старое сохранение не содержит ревизию игры. После изменения скрипта его позиции могут быть неверны. Попробовать загрузить?", "Загрузить"))) return;
|
||||
this.replayStash = null;
|
||||
this.restart(() => this.applySave(data));
|
||||
}
|
||||
|
||||
/** Put the game into the state described by a save (also used to come back from a replay). */
|
||||
private applySave(data: SaveData) {
|
||||
this.resetStore();
|
||||
Object.assign(this.store, decodeValue(data.store));
|
||||
this.screens.restore(decodeValue(data.screens ?? []));
|
||||
this.renderer.scene();
|
||||
for (const s of data.scene) {
|
||||
const img = this.resolveImage(s.name);
|
||||
if (img) this.renderer.show(img, s.tag, s.at, { zorder: s.zorder });
|
||||
}
|
||||
this.renderer.commit();
|
||||
this.audio.stopAll();
|
||||
if (data.music) this.audio.play("music", [data.music.file], { loop: data.music.loop, fadein: 0.5 });
|
||||
this.windowShown = data.window;
|
||||
this.renderer.camera(data.camera ?? []);
|
||||
this.stack = data.stack.map((f) => ({ ...f, saved: decodeValue(f.saved) }));
|
||||
this.cur = data.cur;
|
||||
this.lastSay = null;
|
||||
this.history = [];
|
||||
}
|
||||
|
||||
/** Gallery replay: run a label with the given variables, then return to exactly where the game was. */
|
||||
private startReplay(label: string, scope: Record<string, any> | null) {
|
||||
if (!this.labels.has(label)) {
|
||||
this.notify(`Метка «${label}» не найдена`);
|
||||
return;
|
||||
}
|
||||
if (!this.replayStash) this.replayStash = this.snapshotState();
|
||||
this.restart(() => {
|
||||
this.resetStore();
|
||||
Object.assign(this.store, decodeValue(data.store));
|
||||
this.screens.restore(decodeValue(data.screens ?? []));
|
||||
this.renderer.scene();
|
||||
for (const s of data.scene) {
|
||||
const img = this.resolveImage(s.name);
|
||||
if (img) this.renderer.show(img, s.tag, s.at, { zorder: s.zorder });
|
||||
}
|
||||
this.renderer.commit();
|
||||
this.audio.stopAll();
|
||||
if (data.music) this.audio.play("music", [data.music.file], { loop: data.music.loop, fadein: 0.5 });
|
||||
this.windowShown = data.window;
|
||||
this.renderer.camera(data.camera ?? []);
|
||||
this.stack = data.stack.map((f) => ({ ...f, saved: decodeValue(f.saved) }));
|
||||
this.cur = data.cur;
|
||||
this.lastSay = null;
|
||||
this.history = [];
|
||||
for (const [key, value] of Object.entries(scope ?? {})) this.store[key] = value;
|
||||
this.store._in_replay = label;
|
||||
this.stack = [];
|
||||
this.cur = { label: "", path: [0] };
|
||||
this.guardedJump(label);
|
||||
});
|
||||
}
|
||||
|
||||
private endReplay() {
|
||||
const stash = this.replayStash;
|
||||
if (!stash) return;
|
||||
this.replayStash = null;
|
||||
this.restart(() => this.applySave(stash));
|
||||
}
|
||||
|
||||
private setSkip(on: boolean) {
|
||||
this.skip = on;
|
||||
this.renderer.fast = on;
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
// A small parser for Python source built at run time (eval/exec of strings): expressions and simple
|
||||
// statements only. It produces the same IR as the converter, so the sandbox's rules apply unchanged.
|
||||
|
||||
import type { Expr, PyStmt } from "./types";
|
||||
|
||||
type Token = { t: "num" | "str" | "name" | "op" | "end"; v: any };
|
||||
|
||||
const OPS = ["**=", "//=", ">>=", "<<=", "**", "//", "==", "!=", "<=", ">=", "<<", ">>", "+=", "-=", "*=", "/=", "%=",
|
||||
"&=", "|=", "^=", "->", "+", "-", "*", "/", "%", "<", ">", "=", "(", ")", "[", "]", "{", "}", ",", ":", ".", "&", "|", "^",
|
||||
"~", ";"];
|
||||
const MAX_SOURCE = 20000;
|
||||
|
||||
function fail(message: string): never {
|
||||
throw new SyntaxError(`Синтаксическая ошибка в строке кода: ${message}`);
|
||||
}
|
||||
|
||||
function tokenize(source: string): Token[] {
|
||||
if (source.length > MAX_SOURCE) fail("слишком длинный код");
|
||||
const out: Token[] = [];
|
||||
let i = 0;
|
||||
while (i < source.length) {
|
||||
const ch = source[i];
|
||||
if (ch === "\n") { out.push({ t: "op", v: "\n" }); i++; continue; }
|
||||
if (/\s/.test(ch)) { i++; continue; }
|
||||
if (ch === "#") { while (i < source.length && source[i] !== "\n") i++; continue; }
|
||||
if (/[0-9]/.test(ch) || (ch === "." && /[0-9]/.test(source[i + 1] ?? ""))) {
|
||||
const m = /^(0[xX][0-9a-fA-F]+|\d+\.?\d*(?:[eE][+-]?\d+)?|\.\d+(?:[eE][+-]?\d+)?)/.exec(source.slice(i))!;
|
||||
out.push({ t: "num", v: Number(m[0]) });
|
||||
i += m[0].length;
|
||||
continue;
|
||||
}
|
||||
if (/[A-Za-z_]/.test(ch)) {
|
||||
const m = /^[A-Za-z_]\w*/.exec(source.slice(i))!;
|
||||
const next = source[i + m[0].length];
|
||||
if (/^[rRuUbB]$/.test(m[0]) && (next === "'" || next === '"')) { i += m[0].length; continue; } // string prefix
|
||||
out.push({ t: "name", v: m[0] });
|
||||
i += m[0].length;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"') {
|
||||
const triple = source.startsWith(ch.repeat(3), i);
|
||||
const quote = triple ? ch.repeat(3) : ch;
|
||||
let j = i + quote.length;
|
||||
let text = "";
|
||||
while (j < source.length && !source.startsWith(quote, j)) {
|
||||
if (source[j] === "\\" && j + 1 < source.length) {
|
||||
const e = source[j + 1];
|
||||
text += ({ n: "\n", t: "\t", r: "\r", "0": "\0" } as Record<string, string>)[e] ?? e;
|
||||
j += 2;
|
||||
} else text += source[j++];
|
||||
}
|
||||
if (j >= source.length) fail("строка не закрыта");
|
||||
out.push({ t: "str", v: text });
|
||||
i = j + quote.length;
|
||||
continue;
|
||||
}
|
||||
const op = OPS.find((o) => source.startsWith(o, i));
|
||||
if (!op) fail(`неизвестный символ «${ch}»`);
|
||||
out.push({ t: "op", v: op });
|
||||
i += op.length;
|
||||
}
|
||||
out.push({ t: "end", v: null });
|
||||
return out;
|
||||
}
|
||||
|
||||
const KEYWORDS = new Set(["and", "or", "not", "in", "is", "if", "else", "lambda", "for", "while", "def", "class", "import", "from",
|
||||
"return", "yield", "with", "try", "except", "raise", "global", "nonlocal", "assert", "del", "pass", "elif", "async", "await"]);
|
||||
const BIN_LEVELS: string[][] = [["|"], ["^"], ["&"], ["<<", ">>"], ["+", "-"], ["*", "/", "//", "%"]];
|
||||
const COMPARE = new Set(["<", ">", "==", "!=", "<=", ">=", "in", "is"]);
|
||||
|
||||
class Parser {
|
||||
private pos = 0;
|
||||
constructor(private tokens: Token[]) {}
|
||||
|
||||
private peek(): Token { return this.tokens[this.pos]; }
|
||||
private next(): Token { return this.tokens[this.pos++]; }
|
||||
private isOp(v: string) { const t = this.peek(); return t.t === "op" && t.v === v; }
|
||||
private isName(v: string) { const t = this.peek(); return t.t === "name" && t.v === v; }
|
||||
private eat(v: string) { if (this.isOp(v)) { this.pos++; return true; } return false; }
|
||||
private expect(v: string) { if (!this.eat(v)) fail(`ожидалось «${v}»`); }
|
||||
atEnd() { return this.peek().t === "end"; }
|
||||
|
||||
expression(): Expr {
|
||||
const body = this.orTest();
|
||||
if (this.isName("if")) {
|
||||
this.next();
|
||||
const cond = this.orTest();
|
||||
if (!this.isName("else")) fail("ожидалось else");
|
||||
this.next();
|
||||
return { k: "if", c: cond, t: body, e: this.expression() };
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
private orTest(): Expr {
|
||||
const parts = [this.andTest()];
|
||||
while (this.isName("or")) { this.next(); parts.push(this.andTest()); }
|
||||
return parts.length === 1 ? parts[0] : { k: "bool", op: "or", v: parts };
|
||||
}
|
||||
|
||||
private andTest(): Expr {
|
||||
const parts = [this.notTest()];
|
||||
while (this.isName("and")) { this.next(); parts.push(this.notTest()); }
|
||||
return parts.length === 1 ? parts[0] : { k: "bool", op: "and", v: parts };
|
||||
}
|
||||
|
||||
private notTest(): Expr {
|
||||
if (this.isName("not")) { this.next(); return { k: "unary", op: "not", v: this.notTest() }; }
|
||||
return this.comparison();
|
||||
}
|
||||
|
||||
private comparison(): Expr {
|
||||
const left = this.binary(0);
|
||||
const ops: string[] = [];
|
||||
const rights: Expr[] = [];
|
||||
for (;;) {
|
||||
const t = this.peek();
|
||||
let op: string | null = null;
|
||||
if (t.t === "op" && COMPARE.has(t.v)) { op = t.v; this.next(); }
|
||||
else if (t.t === "name" && t.v === "in") { op = "in"; this.next(); }
|
||||
else if (t.t === "name" && t.v === "is") { this.next(); op = this.isName("not") ? (this.next(), "is not") : "is"; }
|
||||
else if (t.t === "name" && t.v === "not" && this.tokens[this.pos + 1]?.v === "in") { this.pos += 2; op = "not in"; }
|
||||
if (!op) break;
|
||||
ops.push(op);
|
||||
rights.push(this.binary(0));
|
||||
}
|
||||
return ops.length ? { k: "cmp", l: left, ops, r: rights } : left;
|
||||
}
|
||||
|
||||
private binary(level: number): Expr {
|
||||
if (level >= BIN_LEVELS.length) return this.factor();
|
||||
let left = this.binary(level + 1);
|
||||
for (;;) {
|
||||
const t = this.peek();
|
||||
if (t.t === "op" && BIN_LEVELS[level].includes(t.v)) {
|
||||
this.next();
|
||||
left = { k: "bin", op: t.v, l: left, r: this.binary(level + 1) };
|
||||
} else return left;
|
||||
}
|
||||
}
|
||||
|
||||
private factor(): Expr {
|
||||
const t = this.peek();
|
||||
if (t.t === "op" && (t.v === "-" || t.v === "+" || t.v === "~")) {
|
||||
this.next();
|
||||
return { k: "unary", op: t.v, v: this.factor() };
|
||||
}
|
||||
return this.power();
|
||||
}
|
||||
|
||||
private power(): Expr {
|
||||
const base = this.primary();
|
||||
if (this.eat("**")) return { k: "bin", op: "**", l: base, r: this.factor() };
|
||||
return base;
|
||||
}
|
||||
|
||||
private primary(): Expr {
|
||||
let e = this.atom();
|
||||
for (;;) {
|
||||
if (this.eat(".")) {
|
||||
const t = this.next();
|
||||
if (t.t !== "name") fail("ожидалось имя после точки");
|
||||
e = { k: "attr", o: e, a: t.v };
|
||||
} else if (this.eat("(")) {
|
||||
const args: Expr[] = [];
|
||||
const kw: Record<string, Expr> = {};
|
||||
while (!this.isOp(")")) {
|
||||
const t = this.peek();
|
||||
if (t.t === "name" && this.tokens[this.pos + 1]?.v === "=" && this.tokens[this.pos + 1].t === "op") {
|
||||
this.pos += 2;
|
||||
kw[t.v] = this.expression();
|
||||
} else args.push(this.expression());
|
||||
if (!this.eat(",")) break;
|
||||
}
|
||||
this.expect(")");
|
||||
e = { k: "call", f: e, a: args, kw };
|
||||
} else if (this.eat("[")) {
|
||||
let lo: Expr | null = null;
|
||||
if (!this.isOp(":")) lo = this.expression();
|
||||
if (this.eat(":")) {
|
||||
const hi = this.isOp("]") || this.isOp(":") ? null : this.expression();
|
||||
const st = this.eat(":") && !this.isOp("]") ? this.expression() : null;
|
||||
e = { k: "slice", o: e, lo, hi, st };
|
||||
} else e = { k: "sub", o: e, i: lo! };
|
||||
this.expect("]");
|
||||
} else return e;
|
||||
}
|
||||
}
|
||||
|
||||
private atom(): Expr {
|
||||
const t = this.next();
|
||||
if (t.t === "num") return { k: "const", v: t.v };
|
||||
if (t.t === "str") {
|
||||
let text = t.v as string;
|
||||
while (this.peek().t === "str") text += this.next().v;
|
||||
return { k: "const", v: text };
|
||||
}
|
||||
if (t.t === "name") {
|
||||
if (t.v === "True") return { k: "const", v: true };
|
||||
if (t.v === "False") return { k: "const", v: false };
|
||||
if (t.v === "None") return { k: "const", v: null };
|
||||
if (KEYWORDS.has(t.v)) fail(`«${t.v}» здесь не поддерживается`);
|
||||
return { k: "name", id: t.v };
|
||||
}
|
||||
if (t.t === "op" && t.v === "(") {
|
||||
if (this.eat(")")) return { k: "tuple", v: [] };
|
||||
const first = this.expression();
|
||||
if (this.eat(",")) {
|
||||
const items = [first];
|
||||
while (!this.isOp(")")) { items.push(this.expression()); if (!this.eat(",")) break; }
|
||||
this.expect(")");
|
||||
return { k: "tuple", v: items };
|
||||
}
|
||||
this.expect(")");
|
||||
return first;
|
||||
}
|
||||
if (t.t === "op" && t.v === "[") {
|
||||
const items: Expr[] = [];
|
||||
while (!this.isOp("]")) { items.push(this.expression()); if (!this.eat(",")) break; }
|
||||
this.expect("]");
|
||||
return { k: "list", v: items };
|
||||
}
|
||||
if (t.t === "op" && t.v === "{") {
|
||||
const pairs: [Expr, Expr][] = [];
|
||||
while (!this.isOp("}")) {
|
||||
const key = this.expression();
|
||||
this.expect(":");
|
||||
pairs.push([key, this.expression()]);
|
||||
if (!this.eat(",")) break;
|
||||
}
|
||||
this.expect("}");
|
||||
return { k: "dict", v: pairs };
|
||||
}
|
||||
return fail(`неожиданный элемент «${t.v ?? "конец"}»`);
|
||||
}
|
||||
|
||||
/** One simple statement: assignment, augmented assignment, pass or an expression. */
|
||||
statement(): PyStmt[] {
|
||||
if (this.isName("pass")) { this.next(); return []; }
|
||||
const first = this.expressionOrTuple();
|
||||
const t = this.peek();
|
||||
if (t.t === "op" && t.v === "=") {
|
||||
const targets: Expr[] = [first];
|
||||
let value: Expr = first;
|
||||
while (this.eat("=")) {
|
||||
value = this.expressionOrTuple();
|
||||
if (this.isOp("=")) targets.push(value);
|
||||
}
|
||||
return targets.map((target) => ({ op: "set", t: asTarget(target), v: value }));
|
||||
}
|
||||
if (t.t === "op" && /^(\*\*|\/\/|>>|<<|[+\-*/%&|^])=$/.test(t.v)) {
|
||||
this.next();
|
||||
return [{ op: "aug", t: asTarget(first), o: t.v.slice(0, -1), v: this.expressionOrTuple() }];
|
||||
}
|
||||
return [{ op: "eval", e: first }];
|
||||
}
|
||||
|
||||
private expressionOrTuple(): Expr {
|
||||
const first = this.expression();
|
||||
if (!this.isOp(",")) return first;
|
||||
const items = [first];
|
||||
while (this.eat(",")) {
|
||||
if (this.atEnd() || this.isOp("=") || this.isOp("\n") || this.isOp(";")) break;
|
||||
items.push(this.expression());
|
||||
}
|
||||
return { k: "tuple", v: items };
|
||||
}
|
||||
|
||||
separators() {
|
||||
while (this.isOp("\n") || this.isOp(";")) this.pos++;
|
||||
}
|
||||
}
|
||||
|
||||
function asTarget(e: Expr): Expr {
|
||||
if (e.k === "name" || e.k === "attr" || e.k === "sub") return e;
|
||||
if (e.k === "tuple" || e.k === "list") return { k: "tuple", v: e.v.map(asTarget) };
|
||||
return fail("в эту часть нельзя присваивать");
|
||||
}
|
||||
|
||||
export function parseExpression(source: string): Expr {
|
||||
const parser = new Parser(tokenize(source.trim()));
|
||||
const e = parser.expression();
|
||||
parser.separators();
|
||||
if (!parser.atEnd()) fail("лишний текст после выражения");
|
||||
return e;
|
||||
}
|
||||
|
||||
export function parseStatements(source: string): PyStmt[] {
|
||||
const parser = new Parser(tokenize(source));
|
||||
const out: PyStmt[] = [];
|
||||
parser.separators();
|
||||
while (!parser.atEnd()) {
|
||||
out.push(...parser.statement());
|
||||
parser.separators();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -585,6 +585,7 @@ export class Python {
|
||||
}
|
||||
const call = Object.keys(kw).length ? [...args, { __kw: kw }] : args;
|
||||
if (f instanceof PyClass) return this.instantiate(f, call);
|
||||
if (f && typeof f === "object" && typeof f.__call__ === "function") return f.__call__(...call); // screen actions
|
||||
if (typeof f !== "function") {
|
||||
this.warnOnce(`вызов не-функции (${e.f.id ?? e.f.a ?? "?"})`);
|
||||
return null;
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface ScreenHost {
|
||||
openUrl(url: string): void;
|
||||
quickSave(): void;
|
||||
quickLoad(): void;
|
||||
startReplay(label: string, scope: Record<string, any> | null): void;
|
||||
endReplay(): void;
|
||||
setLanguage(code: string | null): void;
|
||||
transform(name: string): Record<string, any> | null;
|
||||
atl(name: string): AtlBlock | null;
|
||||
@@ -49,6 +51,8 @@ class ActionAbort extends Error {}
|
||||
|
||||
/** A screen action such as Jump("x"); run when the player activates a widget. */
|
||||
export class ScreenAction {
|
||||
/** Actions are callable in Ren'Py: `MainMenu(confirm=False)()` performs the action at once. */
|
||||
__call__: () => null = () => null;
|
||||
constructor(public kind: string, public args: any[], public kw: Props, public screen: Instance | null) {}
|
||||
}
|
||||
|
||||
@@ -1084,7 +1088,8 @@ export class ScreenManager {
|
||||
|
||||
private actionSensitive(action: any): boolean {
|
||||
const list = Array.isArray(action) ? action : [action];
|
||||
return list.every((a) => !(a instanceof ScreenAction) || a.kind !== "SensitiveIf" || truthy(a.args[0]));
|
||||
return list.every((a) => !(a instanceof ScreenAction) ||
|
||||
(a.kind === "SensitiveIf" ? truthy(a.args[0]) : a.kind === "Replay" ? !truthy(a.kw.locked ?? a.args[2]) : true));
|
||||
}
|
||||
|
||||
private actionSelected(action: any): boolean {
|
||||
@@ -1169,6 +1174,12 @@ export class ScreenManager {
|
||||
}
|
||||
case "SetLocalVariable":
|
||||
case "SetScreenVariable": owner?.vars.set(a, b); return;
|
||||
case "Replay": {
|
||||
const scope = action.kw.scope ?? b;
|
||||
this.host.startReplay(String(a), scope instanceof PyDict ? Object.fromEntries([...scope.map.entries()].map(([k, v]) => [String(k), v])) : null);
|
||||
throw new ActionAbort();
|
||||
}
|
||||
case "EndReplay": this.host.endReplay(); throw new ActionAbort();
|
||||
case "QuickSave": this.host.quickSave(); return;
|
||||
case "QuickLoad": this.host.quickLoad(); throw new ActionAbort();
|
||||
case "ToggleScreenVariable": {
|
||||
@@ -1217,13 +1228,18 @@ export class ScreenManager {
|
||||
const make = (kind: string) => (...args: any[]) => {
|
||||
const last = args[args.length - 1];
|
||||
const hasKw = last && typeof last === "object" && !Array.isArray(last) && Object.hasOwn(last, "__kw");
|
||||
return new ScreenAction(kind, hasKw ? args.slice(0, -1) : args, hasKw ? last.__kw : {}, this.rendering);
|
||||
const action = new ScreenAction(kind, hasKw ? args.slice(0, -1) : args, hasKw ? last.__kw : {}, this.rendering);
|
||||
action.__call__ = () => {
|
||||
this.run(action, action.screen);
|
||||
return null;
|
||||
};
|
||||
return action;
|
||||
};
|
||||
const out: Record<string, any> = {};
|
||||
for (const kind of ["Jump", "Call", "Return", "SetVariable", "ToggleVariable", "SetDict", "SetField", "ToggleField",
|
||||
"SetScreenVariable", "ToggleScreenVariable", "Show", "Hide", "ShowTransient", "Function", "NullAction", "Play",
|
||||
"Queue", "Stop", "Notify", "Quit", "MainMenu", "SelectedIf", "SensitiveIf", "ToggleScreen", "Language", "ShowMenu",
|
||||
"OpenURL", "SetLocalVariable", "QuickSave", "QuickLoad"]) out[kind] = make(kind);
|
||||
"OpenURL", "SetLocalVariable", "QuickSave", "QuickLoad", "Replay", "EndReplay"]) out[kind] = make(kind);
|
||||
const shape = (kind: string) => (...args: any[]) => {
|
||||
const last = args[args.length - 1];
|
||||
const hasKw = last && typeof last === "object" && !Array.isArray(last) && Object.hasOwn(last, "__kw");
|
||||
|
||||
@@ -9,12 +9,12 @@ Object.assign(globalThis, { window, document });
|
||||
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 4);
|
||||
|
||||
const compiled = await build({
|
||||
stdin: { contents: `export * from "./python"; export * from "./atl"; export * from "./colormatrix"; export * from "./pymodules";`,
|
||||
stdin: { contents: `export * from "./python"; export * from "./atl"; export * from "./colormatrix"; export * from "./pymodules"; export * from "./pyparse";`,
|
||||
resolveDir: fileURLToPath(new URL("../src/player/", import.meta.url)), loader: "ts" },
|
||||
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent",
|
||||
});
|
||||
const m = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`);
|
||||
const { Python, AtlRunner, ColorMatrix, colorMatrices, legacyMatrices, matrixFilter, pythonModules, PyDict } = m;
|
||||
const { parseExpression, parseStatements, Python, AtlRunner, ColorMatrix, colorMatrices, legacyMatrices, matrixFilter, pythonModules, PyDict } = m;
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const name = (id) => ({ k: "name", id });
|
||||
const attr = (o, a) => ({ k: "attr", o, a });
|
||||
@@ -130,3 +130,23 @@ test("on hover handlers run when the button reports the event", async () => {
|
||||
await wait(20);
|
||||
assert.equal(props.alpha, 1);
|
||||
});
|
||||
|
||||
test("eval/exec of source built at run time use the sandbox's own rules", () => {
|
||||
const { py, store } = python();
|
||||
store.score = 7;
|
||||
store.persistent = new PyDict();
|
||||
const evaluate = (source) => py.eval(parseExpression(source));
|
||||
assert.equal(evaluate("score * 2 + 1"), 15);
|
||||
assert.equal(evaluate("'a' + 'b' if score > 5 else 'c'"), "ab");
|
||||
assert.equal(evaluate("not (score < 3) and score in [7, 8]"), true);
|
||||
assert.deepEqual(evaluate("[1, 2, (3, 4)][2]"), [3, 4]);
|
||||
assert.equal(evaluate("score < 10 < 20"), true);
|
||||
py.exec(parseStatements("score += 3; other = score ** 2\npersistent.seen = 1"));
|
||||
assert.equal(store.score, 10);
|
||||
assert.equal(store.other, 100);
|
||||
assert.equal(store.persistent.get("seen"), 1);
|
||||
assert.throws(() => parseExpression("score +"), /Синтаксическая ошибка/);
|
||||
assert.throws(() => parseExpression("lambda: 1"), /не поддерживается/);
|
||||
assert.throws(() => parseStatements("import os"), /не поддерживается/);
|
||||
assert.equal(py.eval(parseExpression("__import__")), null, "dunder names stay unreachable");
|
||||
});
|
||||
Reference in new issue
Block a user