From e1c596288e868d8ac25ee6b7e534bf884e740c2e Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:55:44 +0300 Subject: [PATCH] feat: random generators, frozenset and __hash__/__iter__/__getitem__/__contains__ in Ren'Py python renpy.random/random get choices, sample, randrange and Random(seed); frozenset, iter and hash are builtins. User classes may define __hash__ (dict keys), __iter__, __getitem__ and __contains__; the converter accepts them instead of rejecting the whole class, which removes the Perk/Skill/Status wall in SaintlyCorruption. Co-Authored-By: Claude Sonnet 5.5 --- backend/app/converter/compatibility.py | 11 ++-- backend/app/converter/pyexpr.py | 3 +- frontend/src/player/engine.ts | 73 +++++++++++++++++++++---- frontend/src/player/python.ts | 31 ++++++++++- frontend/tests/engine-behavior.test.mjs | 16 ++++++ frontend/tests/fixtures/dunders.ir.json | 1 + frontend/tests/fixtures/dunders.py | 35 ++++++++++++ frontend/tests/python-classes.test.mjs | 13 ++++- 8 files changed, 165 insertions(+), 18 deletions(-) create mode 100644 frontend/tests/fixtures/dunders.ir.json create mode 100644 frontend/tests/fixtures/dunders.py diff --git a/backend/app/converter/compatibility.py b/backend/app/converter/compatibility.py index 8ad2401..1b6aa5a 100644 --- a/backend/app/converter/compatibility.py +++ b/backend/app/converter/compatibility.py @@ -91,9 +91,10 @@ def require_supported_scripts(data, engine: str = "renpy") -> None: raise CompatibilityError(engine, issues) -CALLS = set("len str int float bool abs round min max sum any all range list tuple set dict sorted reversed enumerate zip isinstance _ __".split()) +CALLS = set("len str int float bool abs round min max sum any all range list tuple set frozenset dict sorted reversed enumerate zip isinstance _ __".split()) CALLS |= {prefix + method for prefix in ("random.", "renpy.random.") - for method in ("randint", "random", "choice", "shuffle", "uniform")} + for method in ("randint", "randrange", "random", "choice", "choices", "sample", "shuffle", "uniform", + "Random")} CALLS |= {"math." + method for method in ("floor", "ceil", "sqrt", "pow")} CALLS |= {"renpy." + method for method in ("play", "notify", "jump", "call", "input", "has_label", "seen_label", "showing", "get_attributes", "full_restart", "quit", "version")} @@ -119,7 +120,7 @@ CALLS |= {"renpy.start_predict", "renpy.stop_predict", "renpy.start_predict_scre "renpy.ui.imagebutton", "renpy.return_statement"} from app.games.renpy_native import NATIVE_CLASSES, NATIVE_METHODS # noqa: E402 CALLS |= set(NATIVE_CLASSES) -CALLS |= {"map", "filter", "hasattr", "getattr", "setattr", "callable", "super"} +CALLS |= {"map", "filter", "iter", "hash", "hasattr", "getattr", "setattr", "callable", "super"} # Screen actions and values the player implements (see frontend/src/player/screens.ts) SCREEN_CALLS = set("""Jump Call Return SetVariable ToggleVariable SetDict SetField ToggleField SetScreenVariable ToggleScreenVariable Show Hide ShowTransient Function NullAction Play Stop Notify @@ -154,6 +155,8 @@ CALLS |= SCREEN_CALLS | {"globals", "renpy.variant", "renpy.change_language", "r METHODS = set("append extend insert pop remove index count reverse sort clear copy add discard get keys values items setdefault update lower upper capitalize title strip lstrip rstrip startswith endswith replace split join format find isdigit isalpha zfill ljust rjust center partition rpartition rsplit splitlines isupper islower isspace isalnum swapcase rfind has_key iteritems iterkeys itervalues strftime isoformat weekday group groups groupdict start end".split()) METHODS |= NATIVE_METHODS +# Methods of generators made by `renpy.random.Random()` / `random.Random()` +METHODS |= {"randint", "randrange", "random", "choice", "choices", "sample", "shuffle", "uniform"} def _bound_names(root) -> tuple[set[str], set[str]]: @@ -315,7 +318,7 @@ def mark_screen_calls(data) -> None: visit([live, data.defaults, [data.screens[n] for n in data.screen_used if n in data.screens]]) -IMPLICIT_METHODS = {"__init__", "__str__", "__repr__"} +IMPLICIT_METHODS = {"__init__", "__str__", "__repr__", "__hash__", "__eq__", "__iter__", "__getitem__", "__contains__"} # Labels Ren'Py itself defines (renpy/common); jumping to them goes to the player's own menu ENGINE_LABELS = {"main_menu_screen", "_main_menu_screen", "main_menu"} diff --git a/backend/app/converter/pyexpr.py b/backend/app/converter/pyexpr.py index bb9bf87..a1201e1 100644 --- a/backend/app/converter/pyexpr.py +++ b/backend/app/converter/pyexpr.py @@ -264,7 +264,8 @@ def stmt(node: ast.stmt) -> list[Stmt]: # Methods the player can run implicitly. Any other dunder method would silently # never be called, so the converter refuses it instead. -SUPPORTED_DUNDERS = {"__init__", "__str__", "__repr__", "__call__", "__eq__"} +SUPPORTED_DUNDERS = {"__init__", "__str__", "__repr__", "__call__", "__eq__", "__hash__", "__iter__", "__getitem__", + "__contains__"} INACTIVE_VARIANTS = {"gui.variant", "renpy.variant"} TRANSPARENT_DECORATORS = {"renpy.pure"} # marks a function as side-effect free; no runtime meaning DECORATORS = {"staticmethod", "classmethod", "property"} diff --git a/frontend/src/player/engine.ts b/frontend/src/player/engine.ts index 9b17eac..17678f9 100644 --- a/frontend/src/player/engine.ts +++ b/frontend/src/player/engine.ts @@ -338,18 +338,68 @@ export class Engine { const kwless = (args: any[]) => args.length && args[args.length - 1]?.__kw ? args.slice(0, -1) : args; const kwOf = (args: any[]) => (args.length && args[args.length - 1]?.__kw) || {}; - const random = { - randint: (a: number, b: number) => a + Math.floor(Math.random() * (b - a + 1)), - random: () => Math.random(), - choice: (xs: any[]) => xs[Math.floor(Math.random() * xs.length)], - shuffle: (xs: any[]) => { - for (let i = xs.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [xs[i], xs[j]] = [xs[j], xs[i]]; - } - }, - uniform: (a: number, b: number) => a + Math.random() * (b - a), + const makeRandom = (next: () => number): Record => { + const below = (n: number) => Math.floor(next() * n); + const self: Record = { + randint: (a: number, b: number) => a + below(b - a + 1), + randrange: (a: number, b?: number, step = 1) => { + const [lo, hi] = b === undefined ? [0, a] : [a, b]; + const count = Math.max(0, Math.ceil((hi - lo) / step)); + if (!count) throw new Error("randrange: пустой диапазон"); + return lo + below(count) * step; + }, + random: () => next(), + choice: (xs: any[]) => { + if (!xs.length) throw new Error("choice: пустая последовательность"); + return xs[below(xs.length)]; + }, + choices: (xs: any[], ...a: any[]) => { + const kw = kwOf(a); + const weights: number[] | null = kw.weights ?? kwless(a)[0] ?? null; + const k = kw.k ?? 1; + const total = weights ? weights.reduce((s, w) => s + w, 0) : xs.length; + const out: any[] = []; + for (let n = 0; n < k; n++) { + if (!weights) { out.push(xs[below(xs.length)]); continue; } + let roll = next() * total, i = 0; + while (i < xs.length - 1 && roll >= weights[i]) roll -= weights[i++]; + out.push(xs[i]); + } + return out; + }, + sample: (xs: any[], k: number) => { + const pool = [...xs]; + if (k > pool.length) throw new Error("sample: выборка больше совокупности"); + for (let i = 0; i < k; i++) { + const j = i + below(pool.length - i); + [pool[i], pool[j]] = [pool[j], pool[i]]; + } + return pool.slice(0, k); + }, + shuffle: (xs: any[]) => { + for (let i = xs.length - 1; i > 0; i--) { + const j = below(i + 1); + [xs[i], xs[j]] = [xs[j], xs[i]]; + } + }, + uniform: (a: number, b: number) => a + next() * (b - a), + seed: () => null, + }; + // `rng = renpy.random.Random(seed)`: an independent generator; a given seed makes it repeatable + self.Random = (seed?: any) => { + if (seed === undefined || seed === null) return makeRandom(Math.random); + let state = [...String(seed)].reduce((h, c) => (Math.imul(h, 31) + c.charCodeAt(0)) | 0, 7) >>> 0; + return makeRandom(() => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }); + }; + return self; }; + const random = makeRandom(Math.random); const music = (ch: string) => ({ play: (...a: any[]) => { const kw = kwOf(a); @@ -573,6 +623,7 @@ export class Engine { list: (xs: any = []) => [...(xs instanceof PyDict ? xs.map.keys() : xs)], tuple: (xs: any = []) => [...xs], set: (xs: any = []) => [...new Set(xs)], + frozenset: (xs: any = []) => [...new Set(xs)], dict: (...a: any[]) => { const d = new PyDict(Array.isArray(kwless(a)[0]) ? kwless(a)[0] : []); for (const [k, v] of Object.entries(kwOf(a))) d.set(k, v); diff --git a/frontend/src/player/python.ts b/frontend/src/player/python.ts index afef282..be2184f 100644 --- a/frontend/src/player/python.ts +++ b/frontend/src/player/python.ts @@ -25,9 +25,21 @@ export class PyDict { // tuples used as dict keys must compare by value function key(k: any) { + if (k instanceof PyInstance && activePython) { + // `__hash__` decides the key; instances whose hashes match are taken as equal (as with equal `__eq__`) + const found = k.cls.find("__hash__"); + if (found && typeof found.value === "function") return `pyhash:${pyStr(found.value(k))}`; + } return Array.isArray(k) ? JSON.stringify(k) : k; } +/** A user class method for a special name (`__iter__`, `__getitem__`, `__contains__`), or null. */ +function userMethod(o: any, name: string): ((...a: any[]) => any) | null { + if (!(o instanceof PyInstance) || !activePython) return null; + const found = o.cls.find(name); + return found && typeof found.value === "function" ? found.value : null; +} + export class JumpSignal { constructor(public kind: "jump" | "call" | "return", public target: string) {} } @@ -54,7 +66,8 @@ export type Builtin = ((...args: any[]) => any) & { __py?: true }; // `class` statements of the game become PyClass objects. Instances keep their data in // `attrs`, so a plain JSON round trip (saves) restores them given the class registry. -const SUPPORTED_DUNDERS = new Set(["__init__", "__str__", "__repr__", "__call__", "__eq__"]); +const SUPPORTED_DUNDERS = new Set(["__init__", "__str__", "__repr__", "__call__", "__eq__", "__hash__", "__iter__", + "__getitem__", "__contains__"]); const MAX_CALL_DEPTH = 150; export class PyClass { @@ -369,6 +382,10 @@ function contains(container: any, item: any): boolean { if (typeof container === "string") return container.includes(item); if (Array.isArray(container)) return container.some((x) => pyEq(x, item)); if (container instanceof PyDict) return container.has(item); + const own = userMethod(container, "__contains__"); + if (own) return truthy(own(container, item)); + const walk = userMethod(container, "__iter__"); + if (walk) return [...iterate(container)].some((x) => pyEq(x, item)); if (container && typeof container === "object") return item in container; return false; } @@ -610,6 +627,8 @@ export class Python { if (this.strict && !o.has(i)) this.raise("KeyError", pyStr(i)); return o.has(i) ? o.get(i) : null; } + const getter = userMethod(o, "__getitem__"); + if (getter) return getter(o, i); if (!safeProperty(i) || typeof o === "function") return null; if (this.strict && (Array.isArray(o) || typeof o === "string")) { const at = index(o.length, i); @@ -1408,6 +1427,11 @@ export class Python { } export function* iterate(v: any): Iterable { + const own = userMethod(v, "__iter__"); + if (own) { + yield* iterate(own(v)); + return; + } if (Array.isArray(v) || typeof v === "string") yield* v; else if (v instanceof PyDict) yield* v.map.keys(); else if (v && typeof v[Symbol.iterator] === "function") yield* v; @@ -1497,6 +1521,11 @@ export function userBuiltins(py: Python): Record { if (!safePythonProperty(o, name)) throw new Error("Служебный атрибут недоступен"); py.bind({ k: "attr", o: { k: "const", v: o }, a: name }, value); }, + iter: (x: any) => [...iterate(x)], + hash: (x: any) => { + const text = String(key(x)); + return [...text].reduce((h, c) => (Math.imul(h, 31) + c.charCodeAt(0)) | 0, 7); + }, callable: (x: any) => typeof x === "function" || x instanceof PyClass || callableOf(x) !== null, map: (f: any, xs: any) => [...iterate(xs)].map((x) => f(x)), filter: (f: any, xs: any) => [...iterate(xs)].filter((x) => (f === null ? truthy(x) : truthy(f(x)))), diff --git a/frontend/tests/engine-behavior.test.mjs b/frontend/tests/engine-behavior.test.mjs index a001c38..8b3509d 100644 --- a/frontend/tests/engine-behavior.test.mjs +++ b/frontend/tests/engine-behavior.test.mjs @@ -73,3 +73,19 @@ test("an animated preview discarded before its first timer does not keep renderi assert.equal(rendered, 1); } finally { engine.destroy(); } }); + +test("random generators: choices, sample, seeded Random and frozenset", () => { + const { engine } = makeEngine(runtime()); + try { + engine.newStore(); + const { renpy, frozenset } = engine.builtins; + assert.deepEqual(frozenset([1, 2, 2, 3]), [1, 2, 3]); + const picked = renpy.random.choices(["a", "b"], { __kw: { weights: [0, 1], k: 5 } }); + assert.deepEqual(picked, ["b", "b", "b", "b", "b"]); + const sample = renpy.random.sample([1, 2, 3, 4], 3); + assert.equal(new Set(sample).size, 3); + const first = renpy.random.Random("seed"), second = renpy.random.Random("seed"); + assert.deepEqual([first.randint(1, 1000), first.randint(1, 1000)], [second.randint(1, 1000), second.randint(1, 1000)]); + assert.ok(renpy.random.randrange(0, 10, 5) % 5 === 0); + } finally { engine.destroy(); } +}); diff --git a/frontend/tests/fixtures/dunders.ir.json b/frontend/tests/fixtures/dunders.ir.json new file mode 100644 index 0000000..575ec0a --- /dev/null +++ b/frontend/tests/fixtures/dunders.ir.json @@ -0,0 +1 @@ +[{"op":"class","name":"Perk","bases":[],"body":[{"op":"def","name":"__init__","params":[{"n":"self","d":null,"kind":"p"},{"n":"key","d":null,"kind":"p"}],"body":[{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"key"},"v":{"k":"name","id":"key"}}],"decos":[]},{"op":"def","name":"__hash__","params":[{"n":"self","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"call","f":{"k":"name","id":"hash"},"a":[{"k":"attr","o":{"k":"name","id":"self"},"a":"key"}],"kw":{}}}],"decos":[]},{"op":"def","name":"__eq__","params":[{"n":"self","d":null,"kind":"p"},{"n":"other","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"cmp","l":{"k":"attr","o":{"k":"name","id":"self"},"a":"key"},"ops":["=="],"r":[{"k":"attr","o":{"k":"name","id":"other"},"a":"key"}]}}],"decos":[]}]},{"op":"class","name":"Bag","bases":[],"body":[{"op":"def","name":"__init__","params":[{"n":"self","d":null,"kind":"p"},{"n":"items","d":null,"kind":"p"}],"body":[{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"items"},"v":{"k":"name","id":"items"}}],"decos":[]},{"op":"def","name":"__iter__","params":[{"n":"self","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"call","f":{"k":"name","id":"iter"},"a":[{"k":"attr","o":{"k":"name","id":"self"},"a":"items"}],"kw":{}}}],"decos":[]},{"op":"def","name":"__getitem__","params":[{"n":"self","d":null,"kind":"p"},{"n":"i","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"sub","o":{"k":"attr","o":{"k":"name","id":"self"},"a":"items"},"i":{"k":"name","id":"i"}}}],"decos":[]},{"op":"def","name":"__contains__","params":[{"n":"self","d":null,"kind":"p"},{"n":"x","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"cmp","l":{"k":"name","id":"x"},"ops":["in"],"r":[{"k":"attr","o":{"k":"name","id":"self"},"a":"items"}]}}],"decos":[]}]},{"op":"set","t":{"k":"name","id":"table"},"v":{"k":"dict","v":[]}},{"op":"set","t":{"k":"sub","o":{"k":"name","id":"table"},"i":{"k":"call","f":{"k":"name","id":"Perk"},"a":[{"k":"const","v":"a"}],"kw":{}}},"v":{"k":"const","v":1}},{"op":"set","t":{"k":"sub","o":{"k":"name","id":"table"},"i":{"k":"call","f":{"k":"name","id":"Perk"},"a":[{"k":"const","v":"a"}],"kw":{}}},"v":{"k":"const","v":2}},{"op":"set","t":{"k":"name","id":"result_size"},"v":{"k":"call","f":{"k":"name","id":"len"},"a":[{"k":"name","id":"table"}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_value"},"v":{"k":"sub","o":{"k":"name","id":"table"},"i":{"k":"call","f":{"k":"name","id":"Perk"},"a":[{"k":"const","v":"a"}],"kw":{}}}},{"op":"set","t":{"k":"name","id":"bag"},"v":{"k":"call","f":{"k":"name","id":"Bag"},"a":[{"k":"list","v":[{"k":"const","v":3},{"k":"const","v":4},{"k":"const","v":5}]}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_loop"},"v":{"k":"comp","e":{"k":"bin","op":"*","l":{"k":"name","id":"x"},"r":{"k":"const","v":2}},"gens":[{"t":{"k":"name","id":"x"},"it":{"k":"name","id":"bag"},"if":[]}]}},{"op":"set","t":{"k":"name","id":"result_item"},"v":{"k":"sub","o":{"k":"name","id":"bag"},"i":{"k":"const","v":1}}},{"op":"set","t":{"k":"name","id":"result_in"},"v":{"k":"cmp","l":{"k":"const","v":4},"ops":["in"],"r":[{"k":"name","id":"bag"}]}},{"op":"set","t":{"k":"name","id":"result_out"},"v":{"k":"cmp","l":{"k":"const","v":9},"ops":["in"],"r":[{"k":"name","id":"bag"}]}}] \ No newline at end of file diff --git a/frontend/tests/fixtures/dunders.py b/frontend/tests/fixtures/dunders.py new file mode 100644 index 0000000..3d9fe4a --- /dev/null +++ b/frontend/tests/fixtures/dunders.py @@ -0,0 +1,35 @@ +class Perk: + def __init__(self, key): + self.key = key + + def __hash__(self): + return hash(self.key) + + def __eq__(self, other): + return self.key == other.key + + +class Bag: + def __init__(self, items): + self.items = items + + def __iter__(self): + return iter(self.items) + + def __getitem__(self, i): + return self.items[i] + + def __contains__(self, x): + return x in self.items + + +table = {} +table[Perk("a")] = 1 +table[Perk("a")] = 2 +result_size = len(table) +result_value = table[Perk("a")] +bag = Bag([3, 4, 5]) +result_loop = [x * 2 for x in bag] +result_item = bag[1] +result_in = 4 in bag +result_out = 9 in bag diff --git a/frontend/tests/python-classes.test.mjs b/frontend/tests/python-classes.test.mjs index d2872d9..dba6216 100644 --- a/frontend/tests/python-classes.test.mjs +++ b/frontend/tests/python-classes.test.mjs @@ -14,7 +14,7 @@ const ir = (name) => JSON.parse(readFileSync(new URL(`./fixtures/${name}.ir.json function runtime() { const store = Object.create(null); const builtins = { - any: (xs) => xs.some(Boolean), range: (n) => Array.from({ length: n }, (_, i) => i), max: Math.max, int: Number, str: pyStr, list: (xs = []) => [...xs], + any: (xs) => xs.some(Boolean), len: (x) => (x.map ? x.map.size : x.length), range: (n) => Array.from({ length: n }, (_, i) => i), max: Math.max, int: Number, str: pyStr, list: (xs = []) => [...xs], }; const scope = { get: (n) => (n in store ? store[n] : builtins[n]), @@ -110,3 +110,14 @@ test("lookups stay lenient outside try, uncaught exceptions are explicit errors" assert.equal(py.strict, 0); void store; }); + +test("__hash__, __iter__, __getitem__ and __contains__ work on user classes", () => { + const { py, store } = runtime(); + py.exec(ir("dunders")); + assert.equal(store.result_size, 1); + assert.equal(store.result_value, 2); + assert.deepEqual(store.result_loop, [6, 8, 10]); + assert.equal(store.result_item, 4); + assert.equal(store.result_in, true); + assert.equal(store.result_out, false); +});