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 <noreply@anthropic.com>
This commit is contained in:
1 parent
f9eb5c1212
commit
e1c596288e
8 files changed
+160
-13
No files matched your search
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)],
|
||||
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 = Math.floor(Math.random() * (i + 1));
|
||||
const j = below(i + 1);
|
||||
[xs[i], xs[j]] = [xs[j], xs[i]];
|
||||
}
|
||||
},
|
||||
uniform: (a: number, b: number) => a + Math.random() * (b - a),
|
||||
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);
|
||||
|
||||
@@ -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)))),
|
||||
|
||||
@@ -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
@@ -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"}]}}]
|
||||
Vendored
+35
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user