feat: unlock in-game galleries per owned version
This commit is contained in:
1 parent
e14184435c
commit
19924e1c5a
21 files changed
+977
-395
No files matched your search
@@ -0,0 +1,111 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test } from "node:test";
|
||||
import { build } from "esbuild";
|
||||
import { makeEngine, until } from "./helpers/renpy-engine.mjs";
|
||||
|
||||
const built = await build({ stdin: { contents: `export {parseStatements} from './pyparse'; export {galleryScreens} from './gallery-access';`,
|
||||
resolveDir: fileURLToPath(new URL("../src/player/", import.meta.url)), loader: "ts" },
|
||||
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
|
||||
const { parseStatements, galleryScreens } = await import(`data:text/javascript;base64,${Buffer.from(built.outputFiles[0].text).toString("base64")}`);
|
||||
const runtime = () => ({ game: { resolution: [640, 480], entry: "start", ui: {}, files: {},
|
||||
defaults: [], init: parseStatements('g = Gallery()\ng.button("Art")\ng.image("picture")\ng.condition("False")'),
|
||||
characters: {}, channels: {}, images: { picture: { color: "#fff" } }, transforms: {}, atl: {}, transitions: {}, screens: {}, styles: {}, audio: {} },
|
||||
modules: [{ labels: [{ name: "start", body: [{ op: "pause", seconds: null }] }], anchors: {} }] });
|
||||
|
||||
test("Gallery conditions stay locked normally and are bypassed only for the opted-in version", () => {
|
||||
for (const enabled of [false, true]) {
|
||||
const { engine } = makeEngine(runtime(), { galleryUnlocked: enabled });
|
||||
try {
|
||||
engine.newStore();
|
||||
const action = engine.py.attrOf(engine.store.g, "Action")("Art");
|
||||
assert.equal(action !== null, enabled);
|
||||
engine.openUnlockedGallery();
|
||||
assert.equal(engine.gallerySession, enabled);
|
||||
} finally { engine.destroy(); }
|
||||
}
|
||||
});
|
||||
|
||||
test("gallery browsing does not write saves or persistent progress and restores the running story", async () => {
|
||||
const { engine, root } = makeEngine(runtime(), { galleryUnlocked: true });
|
||||
let saves = 0;
|
||||
engine.saves.put = async () => { saves++; };
|
||||
try {
|
||||
engine.newStore();
|
||||
engine.persistent.example = ["story"];
|
||||
engine.store.counter = 7;
|
||||
engine.history = [{ name: "Player", text: "Story history" }];
|
||||
engine.seenImages.add("story");
|
||||
engine.cur = { label: "start", path: [0] };
|
||||
engine.running = true;
|
||||
engine.openUnlockedGallery();
|
||||
assert.match(root.textContent, /Art/);
|
||||
engine.persistent.example.push("gallery");
|
||||
engine.persistent.other = true;
|
||||
engine.seenImages.add("gallery");
|
||||
await engine.save("auto");
|
||||
assert.equal(saves, 0);
|
||||
engine.closeUnlockedGallery();
|
||||
assert.deepEqual(engine.persistent.example, ["story"]);
|
||||
assert.equal(engine.persistent.other, null);
|
||||
assert.equal(engine.store.counter, 7);
|
||||
assert.deepEqual(engine.history, [{ name: "Player", text: "Story history" }]);
|
||||
assert.deepEqual([...engine.seenImages], ["story"]);
|
||||
assert.equal(engine.gallerySession, false);
|
||||
} finally { engine.destroy(); }
|
||||
});
|
||||
|
||||
test("MIST unlock modifies only gallery expressions and keeps page and story conditions intact", () => {
|
||||
const condition = { k: "attr", o: { k: "name", id: "scene" }, a: "watched" };
|
||||
const page = { k: "cmp", l: { k: "name", id: "curpage" }, ops: ["=="], r: [{ k: "const", v: "Page 1" }] };
|
||||
const game = { build: { name: "MIST" }, screens: { rewatchgirlselectscreen: {},
|
||||
robgallery: { condition, page }, story: { condition } } };
|
||||
const unlocked = galleryScreens(game, true);
|
||||
assert.deepEqual(unlocked.robgallery.condition, { k: "const", v: true });
|
||||
assert.deepEqual(unlocked.robgallery.page, page);
|
||||
assert.equal(unlocked.story, game.screens.story);
|
||||
assert.deepEqual(game.screens.robgallery.condition, condition);
|
||||
assert.equal(galleryScreens(game, false), game.screens);
|
||||
});
|
||||
|
||||
test("MIST's actual gallery opens all character and scene buttons without completing quests", {
|
||||
skip: !process.env.PGAMES_MIST_RUNTIME,
|
||||
}, async () => {
|
||||
const original = JSON.parse(readFileSync(process.env.PGAMES_MIST_RUNTIME, "utf8"));
|
||||
const { engine, root } = makeEngine(original, { galleryUnlocked: true });
|
||||
engine.fileUrl = (path) => `/mock/${path}`;
|
||||
engine.audio.play = () => {};
|
||||
try {
|
||||
engine.openUnlockedGallery();
|
||||
await until(() => root.querySelectorAll(".pg-simgbtn").length >= 5);
|
||||
assert.equal(engine.py.attrOf(engine.store.searchlodge07, "is_done"), false);
|
||||
const robin = root.querySelectorAll(".pg-simgbtn")[1];
|
||||
robin.onclick({ stopPropagation() {} });
|
||||
await until(() => engine.screens.shown.has("robgallery"));
|
||||
const images = [...engine.screens.shown.get("robgallery").el.querySelectorAll("img")].map((image) => image.src);
|
||||
assert.ok(images.some((path) => path.includes("g_r01")));
|
||||
assert.equal(images.some((path) => path.includes("gallocked")), false);
|
||||
assert.equal(engine.py.attrOf(engine.store.rscene01, "watched"), false);
|
||||
for (const [name, pages] of [["robgallery", 3], ["miagallery", 2], ["lilgallery", 2], ["norgallery", 2], ["groupgallery", 1]]) {
|
||||
engine.screens.show(name);
|
||||
const instance = engine.screens.shown.get(name);
|
||||
for (let page = 1; page <= pages; page++) {
|
||||
instance.vars.set("curpage", `Page ${page}`);
|
||||
engine.screens.render(instance);
|
||||
const pictures = [...instance.el.querySelectorAll("img")].map((image) => image.src);
|
||||
assert.equal(pictures.some((path) => path.includes("gallocked")), false, `${name}, page ${page}`);
|
||||
}
|
||||
}
|
||||
engine.store.replaywatch = true;
|
||||
engine.jump("myroomcontrol");
|
||||
assert.equal(engine.cur.label, "rewatchscene");
|
||||
engine.screens.show("rewatchgirlselectscreen");
|
||||
engine.store.replaywatch = false;
|
||||
engine.screens.shown.get("rewatchgirlselectscreen").el.querySelector(".pg-simgbtn").onclick({ stopPropagation() {} });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(engine.gallerySession, false);
|
||||
assert.equal(engine.running, false);
|
||||
assert.equal(engine.cur.label, "");
|
||||
} finally { engine.destroy(); }
|
||||
});
|
||||
@@ -7,7 +7,7 @@ const bundle = await build({ entryPoints: [fileURLToPath(new URL("../../src/play
|
||||
const { Engine } = await import(`data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString("base64")}`);
|
||||
Error.stackTraceLimit = 2; // data-URL bundle frames otherwise print the entire bundle on a failure.
|
||||
|
||||
export function makeEngine(runtime) {
|
||||
export function makeEngine(runtime, manifest = {}) {
|
||||
const { window, document } = parseHTML("<!doctype html><html><body></body></html>");
|
||||
const storage = new Map();
|
||||
Object.assign(globalThis, {
|
||||
@@ -19,7 +19,7 @@ export function makeEngine(runtime) {
|
||||
const tr = { lang: "en", renpyName: null, str: (s) => s, line: (_id, s) => s };
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
const engine = new Engine(root, structuredClone(runtime.game), { files: {} }, structuredClone(runtime.modules),
|
||||
const engine = new Engine(root, structuredClone(runtime.game), { files: {}, ...manifest }, structuredClone(runtime.modules),
|
||||
{ list: async () => [], get: async () => null, put: async () => {}, remove: async () => {} },
|
||||
"runtime-test", () => {}, tr, async () => tr);
|
||||
return { engine, root, window, document };
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { test } from "node:test";
|
||||
import { build } from "esbuild";
|
||||
|
||||
test("the owner can toggle the gallery, everyone sees its badge and direct entry", {
|
||||
skip: !process.env.PGAMES_PLAYWRIGHT_PATH,
|
||||
}, async () => {
|
||||
const { chromium } = await import(pathToFileURL(process.env.PGAMES_PLAYWRIGHT_PATH + "/index.mjs").href);
|
||||
const compiled = await build({ stdin: { contents: `
|
||||
import {createElement as h} from 'react'; import {createRoot} from 'react-dom/client';
|
||||
import {MemoryRouter, Routes, Route, useLocation} from 'react-router-dom';
|
||||
import {AuthProvider} from './auth'; import {UiProvider} from './ui/Ui';
|
||||
import {TranslationRunProvider} from './translator/runner'; import {UploadProvider} from './uploads';
|
||||
import {GamePage} from './pages/GamePage';
|
||||
function Destination() {const at = useLocation(); return h('output', null, at.pathname + at.search);}
|
||||
createRoot(document.querySelector('main')).render(h(MemoryRouter, {initialEntries: ['/games/1']},
|
||||
h(UiProvider, null, h(AuthProvider, null, h(TranslationRunProvider, null, h(UploadProvider, null,
|
||||
h(Routes, null, h(Route, {path: '/games/:gameId', element: h(GamePage)}),
|
||||
h(Route, {path: '/play/:versionId', element: h(Destination)}))))))));`,
|
||||
resolveDir: fileURLToPath(new URL("../src/", import.meta.url)), loader: "tsx" },
|
||||
bundle: true, write: false, format: "iife", platform: "browser", logLevel: "silent" });
|
||||
const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8");
|
||||
const version = { id: 1, game_id: 1, parent_id: null, owner: { id: 7, username: "author" }, name: "Моя версия",
|
||||
status: "ready", kind: "mod", visibility: "draft", created_at: new Date().toISOString(), size_bytes: 1,
|
||||
gallery_available: true, gallery_unlocked: false, languages: [] };
|
||||
let userId = 7, fail = false;
|
||||
const writes = [];
|
||||
const server = createServer(async (req, res) => {
|
||||
if (req.url === "/bundle.js") { res.setHeader("Content-Type", "text/javascript"); res.end(compiled.outputFiles[0].text); return; }
|
||||
if (req.url === "/style.css") { res.setHeader("Content-Type", "text/css"); res.end(css); return; }
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
if (req.url === "/api/auth/me") { res.end(JSON.stringify({ id: userId, username: "author", permissions: ["games.play"] })); return; }
|
||||
if (req.url === "/api/games/1") { res.end(JSON.stringify({ id: 1, title: "MIST", tags: [], gallery_hashes: [], versions: [version] })); return; }
|
||||
if (req.url === "/api/versions/1/gallery" && req.method === "PATCH") {
|
||||
let body = "";
|
||||
for await (const chunk of req) body += chunk;
|
||||
const settings = JSON.parse(body);
|
||||
writes.push(settings);
|
||||
if (fail) { res.statusCode = 500; res.end(JSON.stringify({ detail: "Не удалось сохранить настройку" })); return; }
|
||||
version.gallery_unlocked = settings.gallery_unlocked;
|
||||
res.end(JSON.stringify(version)); return;
|
||||
}
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.end('<!doctype html><meta charset="utf-8"><link rel="stylesheet" href="/style.css"><main></main><script src="/bundle.js"></script>');
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true, channel: process.env.PGAMES_BROWSER_CHANNEL });
|
||||
const page = await browser.newPage({ viewport: { width: 1100, height: 800 } });
|
||||
page.setDefaultTimeout(5000);
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
await page.goto(origin);
|
||||
const checkbox = page.getByRole("checkbox", { name: "Открыть всю галерею в этой версии" });
|
||||
await checkbox.check();
|
||||
await page.getByText("Галерея открыта", { exact: true }).waitFor();
|
||||
await page.getByRole("button", { name: "Открыть галерею", exact: true }).click();
|
||||
await page.getByText("/play/1?gallery=1", { exact: true }).waitFor();
|
||||
await page.reload();
|
||||
await checkbox.waitFor();
|
||||
assert.equal(await checkbox.isChecked(), true);
|
||||
await checkbox.uncheck();
|
||||
await page.getByText("Галерея открыта", { exact: true }).waitFor({ state: "detached" });
|
||||
fail = true;
|
||||
await checkbox.click();
|
||||
await page.getByText("Не удалось сохранить настройку", { exact: true }).waitFor();
|
||||
assert.equal(await checkbox.isChecked(), false);
|
||||
assert.deepEqual(writes, [{ gallery_unlocked: true }, { gallery_unlocked: false }, { gallery_unlocked: true }]);
|
||||
version.gallery_unlocked = true;
|
||||
userId = 8;
|
||||
await page.reload();
|
||||
await page.getByText("Галерея открыта", { exact: true }).waitFor();
|
||||
assert.equal(await checkbox.count(), 0);
|
||||
await page.setViewportSize({ width: 375, height: 800 });
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test("the unlocked Gallery viewer opens above its index and closes back to the main menu", {
|
||||
skip: !process.env.PGAMES_PLAYWRIGHT_PATH,
|
||||
}, async () => {
|
||||
const { chromium } = await import(pathToFileURL(process.env.PGAMES_PLAYWRIGHT_PATH + "/index.mjs").href);
|
||||
const compiled = await build({ stdin: { contents: `
|
||||
import {Engine} from './player/engine'; import {parseStatements} from './player/pyparse';
|
||||
const game = {resolution: [640, 480], entry: 'start', title: 'Gallery test', ui: {}, files: {}, defaults: [],
|
||||
init: parseStatements('g = Gallery()\\ng.button("Art")\\ng.image("picture")\\ng.condition("False")'),
|
||||
characters: {}, channels: {}, images: {picture: {color: '#ff00ff'}}, transforms: {}, atl: {}, transitions: {}, screens: {}, styles: {}, audio: {}};
|
||||
const tr = {lang: 'en', renpyName: null, str: s => s, line: (_, s) => s};
|
||||
const saves = {list: async () => [], get: async () => null, put: async () => {}, remove: async () => {}};
|
||||
const engine = new Engine(document.querySelector('main'), game, {files: {}, galleryUnlocked: true},
|
||||
[{labels: [{name: 'start', body: [{op: 'return'}]}], anchors: {}}], saves, 'gallery-browser', () => {}, tr, async () => tr);
|
||||
engine.start();`, resolveDir: fileURLToPath(new URL("../src/", import.meta.url)), loader: "tsx" },
|
||||
bundle: true, write: false, format: "iife", platform: "browser", logLevel: "silent" });
|
||||
const css = readFileSync(new URL("../src/player/player.css", import.meta.url), "utf8");
|
||||
const server = createServer((req, res) => {
|
||||
if (req.url === "/bundle.js") { res.setHeader("Content-Type", "text/javascript"); res.end(compiled.outputFiles[0].text); return; }
|
||||
if (req.url === "/style.css") { res.setHeader("Content-Type", "text/css"); res.end(css); return; }
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.end('<!doctype html><meta charset="utf-8"><link rel="stylesheet" href="/style.css"><style>main{width:800px;height:600px}</style><main></main><script src="/bundle.js"></script>');
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true, channel: process.env.PGAMES_BROWSER_CHANNEL });
|
||||
const page = await browser.newPage({ viewport: { width: 1000, height: 800 } });
|
||||
page.setDefaultTimeout(5000);
|
||||
await page.goto(`http://127.0.0.1:${server.address().port}`);
|
||||
await page.getByRole("button", { name: "Галерея открыта", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Art", exact: true }).click();
|
||||
await page.locator(".pg-gallery").waitFor();
|
||||
const top = await page.locator(".pg-gallery").evaluate((overlay) => {
|
||||
const box = overlay.getBoundingClientRect();
|
||||
return !!document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2)?.closest(".pg-gallery");
|
||||
});
|
||||
assert.equal(top, true, "the gallery image is above the index and receives input");
|
||||
await page.locator(".pg-gallery").getByRole("button", { name: "Закрыть", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Art", exact: true }).waitFor();
|
||||
await page.getByRole("button", { name: "Закрыть", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Начать", exact: true }).waitFor();
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user