feat: File* save/load actions, hashlib/uuid, dict spread and small renpy helpers in the player
Games with their own save screens work: FileAction/FileSave/FileLoad/FileDelete/FilePage*, FileLoadable,
FileTime, FileSaveName, FileJson and friends use browser slots (a slot has no screenshot). Added
renpy.has_image/has_screen/list_files/get_mouse_pos/log/jump_out_of_context/random.seed,
renpy.translation.translate_string, type/id/vars/Flatten/LiveComposite/Composite, bare defaultdict/
deepcopy, md5/sha256 (checked against test vectors), uuid4, time.monotonic, re.compile().fullmatch,
math.isfinite and `{**a, "k": v}`. The converter whitelists them and accepts the hashlib/uuid imports.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5c1e1aee01
commit
16ba20816c
8 files changed
+366
-20
No files matched your search
@@ -120,6 +120,10 @@ CALLS |= {"renpy.start_predict", "renpy.stop_predict", "renpy.start_predict_scre
|
|||||||
"renpy.ui.imagebutton", "renpy.return_statement"}
|
"renpy.ui.imagebutton", "renpy.return_statement"}
|
||||||
from app.games.renpy_native import NATIVE_CLASSES, NATIVE_METHODS # noqa: E402
|
from app.games.renpy_native import NATIVE_CLASSES, NATIVE_METHODS # noqa: E402
|
||||||
CALLS |= set(NATIVE_CLASSES)
|
CALLS |= set(NATIVE_CLASSES)
|
||||||
|
CALLS |= {"type", "id", "vars", "Flatten", "LiveComposite", "Composite", "defaultdict", "OrderedDict", "deepcopy",
|
||||||
|
"hashlib.md5", "hashlib.sha256", "uuid.uuid4", "time.monotonic", "re.compile", "math.isfinite", "renpy.log",
|
||||||
|
"renpy.write_log", "renpy.get_filename_line", "renpy.has_image", "renpy.has_screen", "renpy.list_files",
|
||||||
|
"renpy.get_mouse_pos", "renpy.jump_out_of_context", "renpy.random.seed", "random.seed"}
|
||||||
CALLS |= {"dir", "datetime.date", "datetime.datetime", "datetime.timedelta", "date", "timedelta",
|
CALLS |= {"dir", "datetime.date", "datetime.datetime", "datetime.timedelta", "date", "timedelta",
|
||||||
"renpy.style.rebuild", "renpy.image_size"}
|
"renpy.style.rebuild", "renpy.image_size"}
|
||||||
CALLS |= {"map", "filter", "iter", "hash", "hasattr", "getattr", "setattr", "callable", "super"}
|
CALLS |= {"map", "filter", "iter", "hash", "hasattr", "getattr", "setattr", "callable", "super"}
|
||||||
@@ -134,6 +138,11 @@ TintMatrix BrightnessMatrix SaturationMatrix ContrastMatrix InvertMatrix Opacity
|
|||||||
ColorizeMatrix IdentityMatrix print Exception BaseException Character""".split())
|
ColorizeMatrix IdentityMatrix print Exception BaseException Character""".split())
|
||||||
SCREEN_CALLS |= set(NATIVE_CLASSES)
|
SCREEN_CALLS |= set(NATIVE_CLASSES)
|
||||||
SCREEN_CALLS |= {"SetMixer", "ImageReference"}
|
SCREEN_CALLS |= {"SetMixer", "ImageReference"}
|
||||||
|
# Save/load screens of the game's own (slots are kept in the browser; a slot has no screenshot, see screens.ts)
|
||||||
|
SCREEN_CALLS |= set("""FileAction FileSave FileLoad FileDelete FileTakeScreenshot FilePage FilePageNext FilePagePrevious
|
||||||
|
FileLoadable FileUsedSlot FileNewest FileSaveName FileTime FileJson FileScreenshot FileCurrentScreenshot
|
||||||
|
FileCurrentPage FilePageName FileSlotName""".split())
|
||||||
|
SCREEN_CALLS |= {"renpy.translation.translate_string"}
|
||||||
# Screen language helpers for audio, focus and pictures that play (see screens.ts)
|
# Screen language helpers for audio, focus and pictures that play (see screens.ts)
|
||||||
SCREEN_CALLS |= {"Movie", "MixerValue", "AudioPositionValue", "PauseAudio", "ToggleSetMembership",
|
SCREEN_CALLS |= {"Movie", "MixerValue", "AudioPositionValue", "PauseAudio", "ToggleSetMembership",
|
||||||
"GetFocusRect", "ToggleFocus", "XScrollValue", "YScrollValue", "Preference", "Start"}
|
"GetFocusRect", "ToggleFocus", "XScrollValue", "YScrollValue", "Preference", "Start"}
|
||||||
@@ -158,7 +167,7 @@ METHODS = set("append extend insert pop remove index count reverse sort clear co
|
|||||||
|
|
||||||
METHODS |= NATIVE_METHODS
|
METHODS |= NATIVE_METHODS
|
||||||
# Methods of generators made by `renpy.random.Random()` / `random.Random()`
|
# Methods of generators made by `renpy.random.Random()` / `random.Random()`
|
||||||
METHODS |= {"randint", "randrange", "random", "choice", "choices", "sample", "shuffle", "uniform"}
|
METHODS |= {"fullmatch", "hexdigest", "randint", "randrange", "random", "choice", "choices", "sample", "shuffle", "uniform"}
|
||||||
|
|
||||||
|
|
||||||
def _bound_names(root) -> tuple[set[str], set[str]]:
|
def _bound_names(root) -> tuple[set[str], set[str]]:
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ UNARY_OPS = {ast.Not: "not", ast.USub: "-", ast.UAdd: "+", ast.Invert: "~"}
|
|||||||
|
|
||||||
# Modules whose import is harmless: the player provides the functions it supports.
|
# Modules whose import is harmless: the player provides the functions it supports.
|
||||||
KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime", "re", "copy", "collections",
|
KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime", "re", "copy", "collections",
|
||||||
"itertools", "string", "functools", "operator", "json", "gc"}
|
"itertools", "string", "functools", "operator", "json", "gc", "hashlib", "uuid"}
|
||||||
|
|
||||||
|
|
||||||
class Unsupported(Exception):
|
class Unsupported(Exception):
|
||||||
@@ -146,9 +146,9 @@ def expr(node: ast.AST) -> Expr:
|
|||||||
case ast.Tuple(elts=elts):
|
case ast.Tuple(elts=elts):
|
||||||
return {"k": "tuple", "v": [expr(e) for e in elts]}
|
return {"k": "tuple", "v": [expr(e) for e in elts]}
|
||||||
case ast.Dict(keys=keys, values=values):
|
case ast.Dict(keys=keys, values=values):
|
||||||
if any(k is None for k in keys):
|
# `{**other, "k": v}`: a spread entry is a star key with an empty value
|
||||||
raise Unsupported("распаковка ** в словаре")
|
return {"k": "dict", "v": [[{"k": "star", "v": expr(v)}, {"k": "const", "v": None}] if k is None
|
||||||
return {"k": "dict", "v": [[expr(k), expr(v)] for k, v in zip(keys, values)]}
|
else [expr(k), expr(v)] for k, v in zip(keys, values)]}
|
||||||
case ast.Call(func=func, args=args, keywords=kws):
|
case ast.Call(func=func, args=args, keywords=kws):
|
||||||
call = {"k": "call", "f": expr(func),
|
call = {"k": "call", "f": expr(func),
|
||||||
"a": [{"k": "star", "v": expr(a.value)} if isinstance(a, ast.Starred) else expr(a)
|
"a": [{"k": "star", "v": expr(a.value)} if isinstance(a, ast.Starred) else expr(a)
|
||||||
|
|||||||
@@ -205,3 +205,21 @@ def test_hide_windows_context_call_is_a_click_wait():
|
|||||||
assert not data.blockers
|
assert not data.blockers
|
||||||
other = parse('label start:\n $ renpy.call_in_new_context("something_else")\n return\n')
|
other = parse('label start:\n $ renpy.call_in_new_context("something_else")\n return\n')
|
||||||
assert other.modules["t.rpy"][0]["body"][0]["op"] == "exec"
|
assert other.modules["t.rpy"][0]["body"][0]["op"] == "exec"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dict_spread_and_new_modules_are_accepted():
|
||||||
|
from app.converter import pyexpr
|
||||||
|
|
||||||
|
code = pyexpr.parse_stmts("import hashlib\nimport uuid\nmerged = {**base, 'k': 1}\n")
|
||||||
|
entries = code[0]["v"]["v"]
|
||||||
|
assert entries[0][0] == {"k": "star", "v": {"k": "name", "id": "base"}}
|
||||||
|
assert entries[1][0] == {"k": "const", "v": "k"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_translated_speaker_name_and_capitalised_parameterised_transform():
|
||||||
|
data = parse('transform FadeIn():\n alpha 0.0\n linear 0.5 alpha 1.0\n'
|
||||||
|
'screen s():\n frame:\n at FadeIn()\n'
|
||||||
|
'label start:\n __("Girl") "Hello there"\n call screen s\n')
|
||||||
|
say = data.modules["t.rpy"][0]["body"][0]
|
||||||
|
assert say["op"] == "say" and say["what"] == "Hello there"
|
||||||
|
assert not data.blockers
|
||||||
@@ -170,9 +170,19 @@ export class Engine {
|
|||||||
this.renderer.fit(this.container);
|
this.renderer.fit(this.container);
|
||||||
window.addEventListener("keydown", this.onKey);
|
window.addEventListener("keydown", this.onKey);
|
||||||
window.addEventListener("keyup", this.onKeyUp);
|
window.addEventListener("keyup", this.onKeyUp);
|
||||||
|
window.addEventListener("pointermove", this.onPointer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The pointer in the game's own coordinates (`renpy.get_mouse_pos`). */
|
||||||
|
private mousePos: [number, number] = [0, 0];
|
||||||
|
private onPointer = (event: PointerEvent) => {
|
||||||
|
const box = this.container.getBoundingClientRect();
|
||||||
|
const [w, h] = this.game.resolution ?? [1920, 1080];
|
||||||
|
if (box.width && box.height) this.mousePos = [Math.round((event.clientX - box.left) * w / box.width), Math.round((event.clientY - box.top) * h / box.height)];
|
||||||
|
};
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
|
window.removeEventListener("pointermove", this.onPointer);
|
||||||
this.liveImages?.destroy();
|
this.liveImages?.destroy();
|
||||||
this.screens.destroy();
|
this.screens.destroy();
|
||||||
this.gen++;
|
this.gen++;
|
||||||
@@ -338,7 +348,18 @@ export class Engine {
|
|||||||
const kwless = (args: any[]) =>
|
const kwless = (args: any[]) =>
|
||||||
args.length && args[args.length - 1]?.__kw ? args.slice(0, -1) : args;
|
args.length && args[args.length - 1]?.__kw ? args.slice(0, -1) : args;
|
||||||
const kwOf = (args: any[]) => (args.length && args[args.length - 1]?.__kw) || {};
|
const kwOf = (args: any[]) => (args.length && args[args.length - 1]?.__kw) || {};
|
||||||
const makeRandom = (next: () => number): Record<string, any> => {
|
const seeded = (seed: any) => {
|
||||||
|
let state = [...String(seed)].reduce((h, c) => (Math.imul(h, 31) + c.charCodeAt(0)) | 0, 7) >>> 0;
|
||||||
|
return () => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const makeRandom = (source: () => number): Record<string, any> => {
|
||||||
|
let next = source;
|
||||||
const below = (n: number) => Math.floor(next() * n);
|
const below = (n: number) => Math.floor(next() * n);
|
||||||
const self: Record<string, any> = {
|
const self: Record<string, any> = {
|
||||||
randint: (a: number, b: number) => a + below(b - a + 1),
|
randint: (a: number, b: number) => a + below(b - a + 1),
|
||||||
@@ -383,19 +404,11 @@ export class Engine {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
uniform: (a: number, b: number) => a + next() * (b - a),
|
uniform: (a: number, b: number) => a + next() * (b - a),
|
||||||
seed: () => null,
|
seed: (seed?: any) => { next = seed === undefined || seed === null ? Math.random : seeded(seed); return null; },
|
||||||
};
|
};
|
||||||
// `rng = renpy.random.Random(seed)`: an independent generator; a given seed makes it repeatable
|
// `rng = renpy.random.Random(seed)`: an independent generator; a given seed makes it repeatable
|
||||||
self.Random = (seed?: any) => {
|
self.Random = (seed?: any) => {
|
||||||
if (seed === undefined || seed === null) return makeRandom(Math.random);
|
return makeRandom(seed === undefined || seed === null ? Math.random : seeded(seed));
|
||||||
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;
|
return self;
|
||||||
};
|
};
|
||||||
@@ -427,7 +440,19 @@ export class Engine {
|
|||||||
});
|
});
|
||||||
const renpyImpl: Record<string, any> = {
|
const renpyImpl: Record<string, any> = {
|
||||||
random,
|
random,
|
||||||
|
translation: { translate_string: (text: any, ...a: any[]) => this.tr.translateIn(pyStr(text), kwOf(a).language ?? kwless(a)[0] ?? null) },
|
||||||
style: { rebuild: () => null },
|
style: { rebuild: () => null },
|
||||||
|
// diagnostics only: nothing is written anywhere
|
||||||
|
log: () => null,
|
||||||
|
write_log: () => null,
|
||||||
|
get_filename_line: () => ["", 0],
|
||||||
|
has_image: (name: any) => this.resolveImage(pyStr(name).split(" ")) !== null,
|
||||||
|
has_screen: (name: any) => Boolean(this.game.screens?.[pyStr(name)]),
|
||||||
|
list_files: () => Object.keys(this.game.files ?? {}),
|
||||||
|
get_mouse_pos: () => [...this.mousePos],
|
||||||
|
jump_out_of_context: (label: string) => {
|
||||||
|
throw new JumpSignal("jump", label);
|
||||||
|
},
|
||||||
image_size: (name: any) => {
|
image_size: (name: any) => {
|
||||||
const text = pyStr(name);
|
const text = pyStr(name);
|
||||||
const resolved = this.resolveImage(text.split(" "));
|
const resolved = this.resolveImage(text.split(" "));
|
||||||
@@ -584,7 +609,7 @@ export class Engine {
|
|||||||
log10: Math.log10, fmod: (a: number, b: number) => a % b,
|
log10: Math.log10, fmod: (a: number, b: number) => a % b,
|
||||||
modf: (x: number) => [x - Math.trunc(x), Math.trunc(x)],
|
modf: (x: number) => [x - Math.trunc(x), Math.trunc(x)],
|
||||||
degrees: (x: number) => (x * 180) / Math.PI, radians: (x: number) => (x * Math.PI) / 180,
|
degrees: (x: number) => (x * 180) / Math.PI, radians: (x: number) => (x * Math.PI) / 180,
|
||||||
isnan: Number.isNaN, isinf: (x: number) => x === Infinity || x === -Infinity,
|
isfinite: Number.isFinite, isnan: Number.isNaN, isinf: (x: number) => x === Infinity || x === -Infinity,
|
||||||
copysign: (a: number, b: number) => Math.abs(a) * (b < 0 || Object.is(b, -0) ? -1 : 1),
|
copysign: (a: number, b: number) => Math.abs(a) * (b < 0 || Object.is(b, -0) ? -1 : 1),
|
||||||
},
|
},
|
||||||
len: (x: any) => (x instanceof PyDict ? x.map.size : x?.length ?? 0),
|
len: (x: any) => (x instanceof PyDict ? x.map.size : x?.length ?? 0),
|
||||||
@@ -911,6 +936,16 @@ export class Engine {
|
|||||||
endReplay: () => this.endReplay(),
|
endReplay: () => this.endReplay(),
|
||||||
quickSave: () => void this.save("quick"),
|
quickSave: () => void this.save("quick"),
|
||||||
quickLoad: () => void this.load("quick"),
|
quickLoad: () => void this.load("quick"),
|
||||||
|
slotInfo: (slot) => {
|
||||||
|
if (!this.slotsRead) { this.slotsRead = true; void this.refreshSlots(); }
|
||||||
|
const meta = this.slotCache.get(slot);
|
||||||
|
return meta ? { title: meta.title, time: meta.updated_at } : null;
|
||||||
|
},
|
||||||
|
saveSlot: (slot, confirm) => void this.saveFileSlot(slot, confirm),
|
||||||
|
loadSlot: (slot) => void this.load(slot),
|
||||||
|
deleteSlot: (slot, confirm) => void this.deleteFileSlot(slot, confirm),
|
||||||
|
get filePage() { return String(engine.persistent._file_page ?? "1"); },
|
||||||
|
set filePage(page: string) { engine.persistent._file_page = page; engine.screens.refresh(); },
|
||||||
transform: (name) => this.game.transforms[name] ?? null,
|
transform: (name) => this.game.transforms[name] ?? null,
|
||||||
atl: (name) => this.game.atl?.[name] ?? null,
|
atl: (name) => this.game.atl?.[name] ?? null,
|
||||||
};
|
};
|
||||||
@@ -1730,6 +1765,30 @@ export class Engine {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private slotCache = new Map<string, { title: string; updated_at: string }>();
|
||||||
|
private slotsRead = false;
|
||||||
|
|
||||||
|
/** Slot metadata for the File* actions of a game's own save screens (read once, then kept up to date). */
|
||||||
|
private async refreshSlots() {
|
||||||
|
try {
|
||||||
|
this.slotCache = new Map((await this.saves.list()).map((meta) => [meta.slot, meta]));
|
||||||
|
} catch { /* storage unavailable: slots look empty */ }
|
||||||
|
this.screens.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveFileSlot(slot: string, confirm: boolean) {
|
||||||
|
if (confirm && this.slotCache.has(slot) && !(await this.confirmAsk("Перезаписать сохранение?", "Перезаписать"))) return;
|
||||||
|
await this.save(slot);
|
||||||
|
await this.refreshSlots();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteFileSlot(slot: string, confirm: boolean) {
|
||||||
|
if (!this.slotCache.has(slot)) return;
|
||||||
|
if (confirm && !(await this.confirmAsk("Удалить сохранение?", "Удалить"))) return;
|
||||||
|
await this.saves.remove(slot).catch(() => {});
|
||||||
|
await this.refreshSlots();
|
||||||
|
}
|
||||||
|
|
||||||
private async save(slot: string, silent = false) {
|
private async save(slot: string, silent = false) {
|
||||||
if (!this.running || !this.cur.label) return;
|
if (!this.running || !this.cur.label) return;
|
||||||
const data = this.snapshotState();
|
const data = this.snapshotState();
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ class DateTime {
|
|||||||
isoformat() { return `${this.year}-${pad(this.month)}-${pad(this.day)}T${pad(this.hour)}:${pad(this.minute)}:${pad(this.second)}`; }
|
isoformat() { return `${this.year}-${pad(this.month)}-${pad(this.day)}T${pad(this.hour)}:${pad(this.minute)}:${pad(this.second)}`; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function strftime(format: string, d: Date): string {
|
export function strftime(format: string, d: Date): string {
|
||||||
const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||||
const months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
const months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||||
return format.replace(/%([a-zA-Z%])/g, (_m, c: string) => {
|
return format.replace(/%([a-zA-Z%])/g, (_m, c: string) => {
|
||||||
@@ -176,6 +176,83 @@ Object.assign(DateTime.prototype, {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function md5(bytes: Uint8Array): string {
|
||||||
|
const K = Array.from({ length: 64 }, (_, i) => Math.floor(Math.abs(Math.sin(i + 1)) * 2 ** 32) >>> 0);
|
||||||
|
const S = [7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21];
|
||||||
|
let a0 = 0x67452301, b0 = 0xefcdab89, c0 = 0x98badcfe, d0 = 0x10325476;
|
||||||
|
const padded = new Uint8Array(((bytes.length + 8) >> 6 << 6) + 64);
|
||||||
|
padded.set(bytes);
|
||||||
|
padded[bytes.length] = 0x80;
|
||||||
|
const view = new DataView(padded.buffer);
|
||||||
|
view.setUint32(padded.length - 8, (bytes.length * 8) >>> 0, true);
|
||||||
|
view.setUint32(padded.length - 4, Math.floor(bytes.length * 8 / 2 ** 32), true);
|
||||||
|
for (let off = 0; off < padded.length; off += 64) {
|
||||||
|
const m = Array.from({ length: 16 }, (_, i) => view.getUint32(off + i * 4, true));
|
||||||
|
let a = a0, b = b0, c = c0, d = d0;
|
||||||
|
for (let i = 0; i < 64; i++) {
|
||||||
|
let f: number, g: number;
|
||||||
|
if (i < 16) { f = (b & c) | (~b & d); g = i; }
|
||||||
|
else if (i < 32) { f = (d & b) | (~d & c); g = (5 * i + 1) % 16; }
|
||||||
|
else if (i < 48) { f = b ^ c ^ d; g = (3 * i + 5) % 16; }
|
||||||
|
else { f = c ^ (b | ~d); g = (7 * i) % 16; }
|
||||||
|
f = (f + a + K[i] + m[g]) >>> 0;
|
||||||
|
a = d; d = c; c = b;
|
||||||
|
const s = S[(i >> 4) * 4 + (i % 4)];
|
||||||
|
b = (b + ((f << s) | (f >>> (32 - s)))) >>> 0;
|
||||||
|
}
|
||||||
|
a0 = (a0 + a) >>> 0; b0 = (b0 + b) >>> 0; c0 = (c0 + c) >>> 0; d0 = (d0 + d) >>> 0;
|
||||||
|
}
|
||||||
|
return [a0, b0, c0, d0].map((v) => [0, 8, 16, 24].map((sh) => ((v >>> sh) & 255).toString(16).padStart(2, "0")).join("")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(bytes: Uint8Array): string {
|
||||||
|
const K = Array.from({ length: 64 }, (_, i) => {
|
||||||
|
let n = 2, count = 0;
|
||||||
|
for (; count <= i; n++) if (Array.from({ length: n - 2 }, (_, d) => d + 2).every((d) => n % d)) count++;
|
||||||
|
return Math.floor((Math.cbrt(n - 1) % 1) * 2 ** 32) >>> 0;
|
||||||
|
});
|
||||||
|
const h = [2, 3, 5, 7, 11, 13, 17, 19].map((p) => Math.floor((Math.sqrt(p) % 1) * 2 ** 32) >>> 0);
|
||||||
|
const padded = new Uint8Array(((bytes.length + 8) >> 6 << 6) + 64);
|
||||||
|
padded.set(bytes);
|
||||||
|
padded[bytes.length] = 0x80;
|
||||||
|
const view = new DataView(padded.buffer);
|
||||||
|
view.setUint32(padded.length - 8, Math.floor(bytes.length * 8 / 2 ** 32));
|
||||||
|
view.setUint32(padded.length - 4, (bytes.length * 8) >>> 0);
|
||||||
|
const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
|
||||||
|
for (let off = 0; off < padded.length; off += 64) {
|
||||||
|
const w = Array.from({ length: 64 }, (_, i) => (i < 16 ? view.getUint32(off + i * 4) : 0));
|
||||||
|
for (let i = 16; i < 64; i++) {
|
||||||
|
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
|
||||||
|
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
|
||||||
|
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
|
||||||
|
}
|
||||||
|
let [a, b, c, d, e, f, g, hh] = h;
|
||||||
|
for (let i = 0; i < 64; i++) {
|
||||||
|
const t1 = (hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0;
|
||||||
|
const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
|
||||||
|
hh = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
|
||||||
|
}
|
||||||
|
[a, b, c, d, e, f, g, hh].forEach((v, i) => { h[i] = (h[i] + v) >>> 0; });
|
||||||
|
}
|
||||||
|
return h.map((v) => v.toString(16).padStart(8, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `hashlib.md5(data)`: the data may come later through `update`. */
|
||||||
|
class Hash {
|
||||||
|
private parts: Uint8Array[] = [];
|
||||||
|
constructor(private digest: (bytes: Uint8Array) => string, data?: any) { if (data !== undefined) this.update(data); }
|
||||||
|
update(data: any) {
|
||||||
|
this.parts.push(typeof data === "string" ? new TextEncoder().encode(data) : Uint8Array.from(data ?? []));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
hexdigest() {
|
||||||
|
const all = new Uint8Array(this.parts.reduce((n, p) => n + p.length, 0));
|
||||||
|
let at = 0;
|
||||||
|
for (const p of this.parts) { all.set(p, at); at += p.length; }
|
||||||
|
return this.digest(all);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Namespaces added to the sandbox's builtins. */
|
/** Namespaces added to the sandbox's builtins. */
|
||||||
export function pythonModules(): Record<string, any> {
|
export function pythonModules(): Record<string, any> {
|
||||||
const re = {
|
const re = {
|
||||||
@@ -186,6 +263,7 @@ export function pythonModules(): Record<string, any> {
|
|||||||
match: (s: string) => re.match(pattern, s, flags),
|
match: (s: string) => re.match(pattern, s, flags),
|
||||||
sub: (r: string, s: string) => re.sub(pattern, r, s, 0, flags),
|
sub: (r: string, s: string) => re.sub(pattern, r, s, 0, flags),
|
||||||
findall: (s: string) => re.findall(pattern, s, flags),
|
findall: (s: string) => re.findall(pattern, s, flags),
|
||||||
|
fullmatch: (s: string) => re.fullmatch(pattern, s, flags),
|
||||||
split: (s: string) => re.split(pattern, s, 0, flags),
|
split: (s: string) => re.split(pattern, s, 0, flags),
|
||||||
}),
|
}),
|
||||||
search: (pattern: string, s: string, flags = 0) => {
|
search: (pattern: string, s: string, flags = 0) => {
|
||||||
@@ -196,6 +274,10 @@ export function pythonModules(): Record<string, any> {
|
|||||||
const m = compile(`^(?:${pattern})`, flags).exec(String(s));
|
const m = compile(`^(?:${pattern})`, flags).exec(String(s));
|
||||||
return m ? new ReMatch(m) : null;
|
return m ? new ReMatch(m) : null;
|
||||||
},
|
},
|
||||||
|
fullmatch: (pattern: string, s: string, flags = 0) => {
|
||||||
|
const m = compile(`^(?:${pattern})$`, flags).exec(String(s));
|
||||||
|
return m ? new ReMatch(m) : null;
|
||||||
|
},
|
||||||
findall: (pattern: string, s: string, flags = 0) =>
|
findall: (pattern: string, s: string, flags = 0) =>
|
||||||
[...String(s).matchAll(compile(pattern, flags, true))].map((m) => (m.length > 2 ? m.slice(1) : m[1] ?? m[0])),
|
[...String(s).matchAll(compile(pattern, flags, true))].map((m) => (m.length > 2 ? m.slice(1) : m[1] ?? m[0])),
|
||||||
sub: (pattern: string, replacement: any, s: string, count = 0, flags = 0) => {
|
sub: (pattern: string, replacement: any, s: string, count = 0, flags = 0) => {
|
||||||
@@ -220,6 +302,7 @@ export function pythonModules(): Record<string, any> {
|
|||||||
copy: { copy: (v: any) => (Array.isArray(v) ? [...v] : v instanceof PyDict ? new PyDict(v.map.entries()) : v), deepcopy: (v: any) => deepcopy(v) },
|
copy: { copy: (v: any) => (Array.isArray(v) ? [...v] : v instanceof PyDict ? new PyDict(v.map.entries()) : v), deepcopy: (v: any) => deepcopy(v) },
|
||||||
time: {
|
time: {
|
||||||
time: () => Date.now() / 1000,
|
time: () => Date.now() / 1000,
|
||||||
|
monotonic: () => performance.now() / 1000,
|
||||||
sleep: () => null,
|
sleep: () => null,
|
||||||
strftime: (format: string, t?: any) => strftime(format, t instanceof DateTime ? t.d : new Date()),
|
strftime: (format: string, t?: any) => strftime(format, t instanceof DateTime ? t.d : new Date()),
|
||||||
},
|
},
|
||||||
@@ -229,5 +312,18 @@ export function pythonModules(): Record<string, any> {
|
|||||||
OrderedDict: () => new PyDict(),
|
OrderedDict: () => new PyDict(),
|
||||||
},
|
},
|
||||||
functools: functionModules,
|
functools: functionModules,
|
||||||
|
hashlib: {
|
||||||
|
md5: (data?: any) => new Hash(md5, data),
|
||||||
|
sha256: (data?: any) => new Hash(sha256, data),
|
||||||
|
},
|
||||||
|
uuid: {
|
||||||
|
uuid4: () => ({ hex: crypto.randomUUID().replaceAll("-", ""), toString: () => crypto.randomUUID() }),
|
||||||
|
},
|
||||||
|
// `from collections import defaultdict` and friends leave the bare names
|
||||||
|
defaultdict: (factory?: any) => new PyDefaultDict(typeof factory === "function" ? () => factory() : null),
|
||||||
|
OrderedDict: () => new PyDict(),
|
||||||
|
deepcopy: (v: any) => deepcopy(v),
|
||||||
|
partial,
|
||||||
|
timedelta,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -680,7 +680,17 @@ export class Python {
|
|||||||
case "tuple":
|
case "tuple":
|
||||||
return e.v.map((x: Expr) => this.eval(x));
|
return e.v.map((x: Expr) => this.eval(x));
|
||||||
case "dict":
|
case "dict":
|
||||||
return new PyDict(e.v.map(([k, v]: [Expr, Expr]) => [this.eval(k), this.eval(v)]));
|
{
|
||||||
|
const out = new PyDict();
|
||||||
|
for (const [k, v] of e.v as [Expr, Expr][]) {
|
||||||
|
if (k.k === "star") {
|
||||||
|
const source = this.eval(k.v);
|
||||||
|
if (source instanceof PyDict) source.map.forEach((x, key) => out.map.set(key, x));
|
||||||
|
else if (source !== null) throw new Error("** ожидает словарь");
|
||||||
|
} else out.set(this.eval(k), this.eval(v));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
case "fmt":
|
case "fmt":
|
||||||
return e.v.map((p: any) => (typeof p === "string" ? p : pyStr(this.eval(p)))).join("");
|
return e.v.map((p: any) => (typeof p === "string" ? p : pyStr(this.eval(p)))).join("");
|
||||||
case "comp": {
|
case "comp": {
|
||||||
@@ -1229,6 +1239,11 @@ export class Python {
|
|||||||
case "dict": {
|
case "dict": {
|
||||||
const out = new PyDict();
|
const out = new PyDict();
|
||||||
for (const [k, v] of e.v as [Expr, Expr][]) {
|
for (const [k, v] of e.v as [Expr, Expr][]) {
|
||||||
|
if (k.k === "star") {
|
||||||
|
const source = yield* this.evalG(k.v);
|
||||||
|
if (source instanceof PyDict) source.map.forEach((x, key) => out.map.set(key, x));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const key = yield* this.evalG(k);
|
const key = yield* this.evalG(k);
|
||||||
out.set(key, yield* this.evalG(v));
|
out.set(key, yield* this.evalG(v));
|
||||||
}
|
}
|
||||||
@@ -1461,6 +1476,9 @@ export function decodeValue(v: any): any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Builtins that need the interpreter: type checks, attribute access, callbacks. */
|
/** Builtins that need the interpreter: type checks, attribute access, callbacks. */
|
||||||
|
const identities = new WeakMap<object, number>();
|
||||||
|
let lastIdentity = 0;
|
||||||
|
|
||||||
export function userBuiltins(py: Python): Record<string, any> {
|
export function userBuiltins(py: Python): Record<string, any> {
|
||||||
const split = (args: any[]) => {
|
const split = (args: any[]) => {
|
||||||
const last = args[args.length - 1];
|
const last = args[args.length - 1];
|
||||||
@@ -1522,6 +1540,21 @@ export function userBuiltins(py: Python): Record<string, any> {
|
|||||||
py.bind({ k: "attr", o: { k: "const", v: o }, a: name }, value);
|
py.bind({ k: "attr", o: { k: "const", v: o }, a: name }, value);
|
||||||
},
|
},
|
||||||
iter: (x: any) => [...iterate(x)],
|
iter: (x: any) => [...iterate(x)],
|
||||||
|
type: (x: any) => {
|
||||||
|
if (x instanceof PyInstance) return x.cls;
|
||||||
|
if (x instanceof PyDict) return typeOf("dict");
|
||||||
|
if (Array.isArray(x)) return typeOf("list");
|
||||||
|
if (typeof x === "boolean") return typeOf("bool");
|
||||||
|
if (typeof x === "number") return typeOf(Number.isInteger(x) ? "int" : "float");
|
||||||
|
if (typeof x === "string") return typeOf("str");
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
id: (x: any) => {
|
||||||
|
if (x === null || typeof x !== "object") return typeof x === "number" ? x : 0;
|
||||||
|
if (!identities.has(x)) identities.set(x, ++lastIdentity);
|
||||||
|
return identities.get(x)!;
|
||||||
|
},
|
||||||
|
vars: (x: any) => (x instanceof PyInstance ? new PyDict(Object.entries(x.attrs)) : new PyDict()),
|
||||||
hash: (x: any) => {
|
hash: (x: any) => {
|
||||||
const text = String(key(x));
|
const text = String(key(x));
|
||||||
return [...text].reduce((h, c) => (Math.imul(h, 31) + c.charCodeAt(0)) | 0, 7);
|
return [...text].reduce((h, c) => (Math.imul(h, 31) + c.charCodeAt(0)) | 0, 7);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { isNativeAction } from "./musicroom";
|
|||||||
import { galleryClass } from "./gallery";
|
import { galleryClass } from "./gallery";
|
||||||
import { parseExpression } from "./pyparse";
|
import { parseExpression } from "./pyparse";
|
||||||
import { movieDisplayable } from "./movie";
|
import { movieDisplayable } from "./movie";
|
||||||
|
import { strftime } from "./pymodules";
|
||||||
import { PyDict, PyInstance, callableOf, pyEq, pyStr, truthy, type Python } from "./python";
|
import { PyDict, PyInstance, callableOf, pyEq, pyStr, truthy, type Python } from "./python";
|
||||||
import type { AtSpec, Expr } from "./types";
|
import type { AtSpec, Expr } from "./types";
|
||||||
|
|
||||||
@@ -43,6 +44,13 @@ export interface ScreenHost {
|
|||||||
openUrl(url: string): void;
|
openUrl(url: string): void;
|
||||||
quickSave(): void;
|
quickSave(): void;
|
||||||
quickLoad(): void;
|
quickLoad(): void;
|
||||||
|
/** Slot metadata for File* actions; null when empty or not yet read. */
|
||||||
|
slotInfo(slot: string): { title: string; time: string } | null;
|
||||||
|
saveSlot(slot: string, confirm: boolean): void;
|
||||||
|
loadSlot(slot: string): void;
|
||||||
|
deleteSlot(slot: string, confirm: boolean): void;
|
||||||
|
/** The page of the save/load screen (`persistent._file_page`). */
|
||||||
|
filePage: string;
|
||||||
startReplay(label: string, scope: Record<string, any> | null): void;
|
startReplay(label: string, scope: Record<string, any> | null): void;
|
||||||
endReplay(): void;
|
endReplay(): void;
|
||||||
setLanguage(code: string | null): void;
|
setLanguage(code: string | null): void;
|
||||||
@@ -1406,6 +1414,11 @@ export class ScreenManager {
|
|||||||
this.refresh();
|
this.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The save slot a File* action means: its name on the current (or the given) page. */
|
||||||
|
private slotId(name: any, page?: any): string {
|
||||||
|
return `file:${page ?? this.host.filePage}-${pyStr(name)}`;
|
||||||
|
}
|
||||||
|
|
||||||
private perform(action: any, screen: Instance | null) {
|
private perform(action: any, screen: Instance | null) {
|
||||||
if (action === null || action === undefined || action === false) return;
|
if (action === null || action === undefined || action === false) return;
|
||||||
if (Array.isArray(action)) {
|
if (Array.isArray(action)) {
|
||||||
@@ -1454,6 +1467,33 @@ export class ScreenManager {
|
|||||||
throw new ActionAbort();
|
throw new ActionAbort();
|
||||||
}
|
}
|
||||||
case "EndReplay": this.host.endReplay(); throw new ActionAbort();
|
case "EndReplay": this.host.endReplay(); throw new ActionAbort();
|
||||||
|
case "FileAction":
|
||||||
|
case "FileSave":
|
||||||
|
case "FileLoad": {
|
||||||
|
const slot = this.slotId(a, action.kw.page);
|
||||||
|
const loading = action.kind === "FileLoad" || (action.kind === "FileAction" && /load/i.test(owner?.name ?? ""));
|
||||||
|
const sure = truthy(action.kw.confirm ?? (action.kind === "FileLoad" ? b : false) ?? false);
|
||||||
|
if (loading) { this.host.loadSlot(slot); throw new ActionAbort(); }
|
||||||
|
this.host.saveSlot(slot, sure);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "FileDelete": this.host.deleteSlot(this.slotId(a, action.kw.page), truthy(action.kw.confirm ?? true)); return;
|
||||||
|
case "FileTakeScreenshot": return;
|
||||||
|
case "FilePage": this.host.filePage = String(a); return;
|
||||||
|
case "FilePageNext":
|
||||||
|
case "FilePagePrevious": {
|
||||||
|
const kw = action.kw;
|
||||||
|
const max = typeof kw.max === "number" ? kw.max : typeof a === "number" ? a : null;
|
||||||
|
const order = [...(kw.auto ?? true ? ["auto"] : []), ...(kw.quick ?? true ? ["quick"] : [])];
|
||||||
|
const numbered = max === null ? 0 : max;
|
||||||
|
const pages = [...order, ...Array.from({ length: numbered || 99 }, (_, i) => String(i + 1))];
|
||||||
|
const here = pages.indexOf(this.host.filePage);
|
||||||
|
const step = action.kind === "FilePageNext" ? 1 : -1;
|
||||||
|
let next = (here < 0 ? pages.indexOf("1") : here) + step;
|
||||||
|
if (next < 0 || next >= pages.length) next = truthy(kw.wrap) ? (next + pages.length) % pages.length : Math.max(0, Math.min(pages.length - 1, next));
|
||||||
|
this.host.filePage = pages[next];
|
||||||
|
return;
|
||||||
|
}
|
||||||
case "QuickSave": this.host.quickSave(); return;
|
case "QuickSave": this.host.quickSave(); return;
|
||||||
case "QuickLoad": this.host.quickLoad(); throw new ActionAbort();
|
case "QuickLoad": this.host.quickLoad(); throw new ActionAbort();
|
||||||
case "ToggleScreenVariable": {
|
case "ToggleScreenVariable": {
|
||||||
@@ -1532,7 +1572,37 @@ export class ScreenManager {
|
|||||||
"SetScreenVariable", "ToggleScreenVariable", "Show", "Hide", "ShowTransient", "Function", "NullAction", "Play",
|
"SetScreenVariable", "ToggleScreenVariable", "Show", "Hide", "ShowTransient", "Function", "NullAction", "Play",
|
||||||
"Queue", "Stop", "Notify", "Quit", "MainMenu", "SelectedIf", "SensitiveIf", "ToggleScreen", "Language", "ShowMenu",
|
"Queue", "Stop", "Notify", "Quit", "MainMenu", "SelectedIf", "SensitiveIf", "ToggleScreen", "Language", "ShowMenu",
|
||||||
"OpenURL", "SetLocalVariable", "QuickSave", "QuickLoad", "Replay", "EndReplay", "ToggleSetMembership",
|
"OpenURL", "SetLocalVariable", "QuickSave", "QuickLoad", "Replay", "EndReplay", "ToggleSetMembership",
|
||||||
"ToggleFocus", "PauseAudio", "SetMixer"]) out[kind] = make(kind);
|
"ToggleFocus", "PauseAudio", "SetMixer", "FileAction", "FileSave", "FileLoad", "FileDelete", "FileTakeScreenshot",
|
||||||
|
"FilePage", "FilePageNext", "FilePagePrevious"]) out[kind] = make(kind);
|
||||||
|
// values of the save/load screens; screenshots are not stored, so a slot has none
|
||||||
|
const kwOf = (a: any[]) => (a.length && a[a.length - 1]?.__kw) || {};
|
||||||
|
const slotOf = (a: any[]) => this.slotId(a[0], kwOf(a).page);
|
||||||
|
out.FileLoadable = (...a: any[]) => this.host.slotInfo(slotOf(a)) !== null;
|
||||||
|
out.FileUsedSlot = out.FileLoadable;
|
||||||
|
out.FileNewest = () => false;
|
||||||
|
out.FileSaveName = (...a: any[]) => this.host.slotInfo(slotOf(a))?.title ?? "";
|
||||||
|
out.FileTime = (...a: any[]) => {
|
||||||
|
const kw = kwOf(a), info = this.host.slotInfo(slotOf(a));
|
||||||
|
if (!info) return kw.empty ?? a[2] ?? "";
|
||||||
|
return strftime(String(kw.format ?? (typeof a[1] === "string" ? a[1] : "%b %d, %H:%M")), new Date(info.time));
|
||||||
|
};
|
||||||
|
out.FileJson = (...a: any[]) => {
|
||||||
|
const kw = kwOf(a), info = this.host.slotInfo(slotOf(a));
|
||||||
|
if (!info) return kw.empty ?? null;
|
||||||
|
return kw.key !== undefined ? kw.missing ?? null : new PyDict();
|
||||||
|
};
|
||||||
|
out.FileScreenshot = (...a: any[]) => kwOf(a).empty ?? null;
|
||||||
|
out.FileCurrentScreenshot = (...a: any[]) => kwOf(a).empty ?? null;
|
||||||
|
out.FileCurrentPage = () => this.host.filePage;
|
||||||
|
out.FilePageName = (...a: any[]) => {
|
||||||
|
const kw = kwOf(a), page = this.host.filePage;
|
||||||
|
return page === "auto" ? kw.auto ?? a[0] ?? "Auto " : page === "quick" ? kw.quick ?? a[1] ?? "Quick " : page;
|
||||||
|
};
|
||||||
|
out.FileSlotName = (slot: any, perPage: any) => {
|
||||||
|
const n = Number(slot) - 1, size = Number(perPage) || 1;
|
||||||
|
const page = Math.floor(n / size) + 1;
|
||||||
|
return `${page}-${(n % size) + 1}`;
|
||||||
|
};
|
||||||
out.GetFocusRect = (id: any) => (this.focus?.id === pyStr(id) ? this.focus.rect : null);
|
out.GetFocusRect = (id: any) => (this.focus?.id === pyStr(id) ? this.focus.rect : null);
|
||||||
out.MixerValue = (mixer: any) => {
|
out.MixerValue = (mixer: any) => {
|
||||||
return new BarValue(() => this.host.audio.mixerVolume(String(mixer)), () => 1, (v) => this.host.setMixer(String(mixer), v));
|
return new BarValue(() => this.host.audio.mixerVolume(String(mixer)), () => 1, (v) => this.host.setMixer(String(mixer), v));
|
||||||
@@ -1589,6 +1659,9 @@ export class ScreenManager {
|
|||||||
{ ...properties, idle: image, insensitive: image, sensitive: enabled, action }),
|
{ ...properties, idle: image, insensitive: image, sensitive: enabled, action }),
|
||||||
});
|
});
|
||||||
out.Fixed = shape("composite");
|
out.Fixed = shape("composite");
|
||||||
|
out.Composite = shape("composite");
|
||||||
|
out.LiveComposite = shape("composite");
|
||||||
|
out.Flatten = (child: any) => child; // flattening only changes how the browser composites, not what is drawn
|
||||||
out.AlphaMask = shape("alphamask");
|
out.AlphaMask = shape("alphamask");
|
||||||
out.TrackCursor = shape("trackcursor");
|
out.TrackCursor = shape("trackcursor");
|
||||||
out.At = (child: any) => child;
|
out.At = (child: any) => child;
|
||||||
|
|||||||
@@ -109,3 +109,61 @@ test("datetime date arithmetic, dir() and style.rebuild", () => {
|
|||||||
assert.throws(() => renpy.image_size("missing"), /IOError/);
|
assert.throws(() => renpy.image_size("missing"), /IOError/);
|
||||||
} finally { engine.destroy(); }
|
} finally { engine.destroy(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("File* actions work on slots of the game's own save screens", async () => {
|
||||||
|
const { engine } = makeEngine(runtime());
|
||||||
|
try {
|
||||||
|
engine.newStore();
|
||||||
|
const b = engine.builtins;
|
||||||
|
assert.equal(b.FileLoadable(1), false);
|
||||||
|
assert.equal(b.FileTime(1, { __kw: { empty: "—" } }), "—");
|
||||||
|
engine.slotCache.set("file:1-1", { title: "Привет", updated_at: "2026-01-02T03:04:05Z" });
|
||||||
|
assert.equal(b.FileLoadable(1), true);
|
||||||
|
assert.equal(b.FileSaveName(1), "Привет");
|
||||||
|
assert.match(b.FileTime(1), /^Jan 0[12], \d\d:\d\d$/);
|
||||||
|
assert.equal(b.FileScreenshot(1), null);
|
||||||
|
assert.equal(engine.screens.host.filePage, "1");
|
||||||
|
engine.screens.run(b.FilePage("3"), null);
|
||||||
|
assert.equal(b.FilePageName(), "3");
|
||||||
|
engine.screens.run(b.FilePageNext(), null);
|
||||||
|
assert.equal(b.FileCurrentPage(), "4");
|
||||||
|
engine.screens.run(b.FilePagePrevious(), null);
|
||||||
|
engine.screens.run(b.FilePagePrevious(), null);
|
||||||
|
engine.screens.run(b.FilePagePrevious(), null);
|
||||||
|
assert.equal(b.FileCurrentPage(), "1");
|
||||||
|
engine.screens.run(b.FilePagePrevious(), null);
|
||||||
|
assert.equal(b.FilePageName({ __kw: { auto: "A" } }), "quick" === b.FileCurrentPage() ? "Quick " : b.FileCurrentPage());
|
||||||
|
assert.equal(typeof engine.builtins.renpy.translation.translate_string, "function");
|
||||||
|
} finally { engine.destroy(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hashlib, re.fullmatch, type/id/vars, dict spread and small renpy helpers", () => {
|
||||||
|
const { engine } = makeEngine(runtime());
|
||||||
|
try {
|
||||||
|
engine.newStore();
|
||||||
|
const b = engine.builtins;
|
||||||
|
assert.equal(b.hashlib.md5("").hexdigest(), "d41d8cd98f00b204e9800998ecf8427e");
|
||||||
|
assert.equal(b.hashlib.md5("abc").hexdigest(), "900150983cd24fb0d6963f7d28e17f72");
|
||||||
|
assert.equal(b.hashlib.sha256("abc").hexdigest(), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||||
|
const pattern = b.re.compile("[a-z]+");
|
||||||
|
assert.ok(pattern.fullmatch("abc"));
|
||||||
|
assert.equal(pattern.fullmatch("abc1"), null);
|
||||||
|
assert.equal(b.type(3), b.int);
|
||||||
|
assert.equal(b.type("x"), b.str);
|
||||||
|
assert.equal(b.id([]) === b.id([]), false);
|
||||||
|
const same = {};
|
||||||
|
assert.equal(b.id(same), b.id(same));
|
||||||
|
const spread = engine.py.eval({ k: "dict", v: [[{ k: "star", v: { k: "dict", v: [[{ k: "const", v: "a" }, { k: "const", v: 1 }]] } }, { k: "const", v: null }], [{ k: "const", v: "b" }, { k: "const", v: 2 }]] });
|
||||||
|
assert.deepEqual([...spread.map.entries()], [["a", 1], ["b", 2]]);
|
||||||
|
assert.equal(b.renpy.has_screen("nothing"), false);
|
||||||
|
assert.deepEqual(b.renpy.get_mouse_pos(), [0, 0]);
|
||||||
|
assert.equal(b.renpy.log("x"), null);
|
||||||
|
assert.equal(b.math.isfinite(1), true);
|
||||||
|
b.renpy.random.seed("a");
|
||||||
|
const one = b.renpy.random.randint(1, 1000000);
|
||||||
|
b.renpy.random.seed("a");
|
||||||
|
assert.equal(b.renpy.random.randint(1, 1000000), one);
|
||||||
|
assert.equal(typeof b.defaultdict, "function");
|
||||||
|
assert.equal(typeof b.LiveComposite, "function");
|
||||||
|
} finally { engine.destroy(); }
|
||||||
|
});
|
||||||
Reference in new issue
Block a user