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
+165
-18
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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