feat: random generators, frozenset and __hash__/__iter__/__getitem__/__contains__ in Ren'Py python
CI / frontend (push) Failing after 18s
CI / backend (push) Failing after 35s

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 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-26 02:55:44 +03:00
1 parent f9eb5c1212
commit e1c596288e
8 files changed
+165 -18

No files matched your search

+7 -4
View File
@@ -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"}
+2 -1
View File
@@ -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"}
+62 -11
View File
@@ -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<string, any> => {
const below = (n: number) => Math.floor(next() * n);
const self: Record<string, any> = {
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);
+30 -1
View File
@@ -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<any> {
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<string, any> {
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)))),
+16
View File
@@ -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(); }
});
+1
View File
@@ -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"}]}}]
+35
View File
@@ -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
+12 -1
View File
@@ -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);
});