diff --git a/.claude/architecture.md b/.claude/architecture.md index 2a646c62..2b66fe4f 100644 --- a/.claude/architecture.md +++ b/.claude/architecture.md @@ -2,16 +2,14 @@ ## Packages -- **originGUI/** — In-game mod menu (TypeScript, bundled with esbuild). Injected into the Prodigy game page. -- **extension/** — Plasmo-based MV3 Chrome extension (Play Origin): - - Blocks original `game.min.js` via declarativeNetRequest - - Removes CSP/X-Frame-Options headers - - Injects patched game code + mod menu bundle via the `onreset` attribute trick - - Custom login background and logo redirects - - Fetches `manifest.json` from the sibling `P-NP` repo's `dist/` - - Fetches the original `game.min.js` directly from `code.prodigygame.com` - - Applies the manifest's regex rules + wraps with prefix/suffix in the background service worker - - Caches the patched result in `chrome.storage.local` keyed by `{gameClientVersion, manifestHash}` +- **originGUI/** — In-game mod menu (TypeScript, bundled with esbuild). `build.mjs` also emits `dist/menu.js` (gitignored): the bundle wrapped as `export default function startOriginMenu()` so the extension can package it. +- **extension/** — Plasmo-based MV3 Chrome extension (Play Origin). On branch `chrome/packaged-runtime` it runs no remote code: + - Prodigy loads its own, unmodified `game.min.js` + - `contents/runtime.ts` (MAIN world, `document_start`) wraps `Object.defineProperty` to capture webpack export objects, resolves hook targets by shape/name, installs the education bypasses and builds `window._` + - `contents/menu.ts` (MAIN world, `document_idle`) runs the packaged originGUI on `origin:ready` + - `background.ts` only registers two DNR redirects that swap the login background and logo for packaged, web-accessible art + - Prodigy's CSP is left intact + - Master still ships the old P-NP `manifest.json` + `onreset` pipeline for Edge/Firefox - **typings/** — Reverse-engineered TypeScript type definitions for Prodigy's game objects - **meta/** — Documentation, guides @@ -19,13 +17,21 @@ - **Package manager:** pnpm. Workspace defined in `pnpm-workspace.yaml`. - **Build tools:** esbuild for originGUI, Plasmo for extension -- **Injection method:** `document.documentElement.setAttribute("onreset", code)` + `dispatchEvent(new CustomEvent("reset"))` + `removeAttribute("onreset")` — bypasses MV3 CSP restrictions -- **Content script:** runs in `world: "MAIN"` via `chrome.scripting.registerContentScripts()` from the background service worker -- **DNR rules:** registered dynamically in `extension/background.ts` via `chrome.declarativeNetRequest.updateDynamicRules()` +- **Content scripts:** Plasmo registers the MAIN-world scripts with `chrome.scripting.registerContentScripts()` from the service worker +- **DNR rules:** registered dynamically in `extension/background.ts` via `chrome.declarativeNetRequest.updateDynamicRules()` (ids 3, 4: `extensionPath` redirects) - **Log prefix:** `[Origin]` in all console output -- **Window globals:** `__ORIGIN_INJECTED__`, `__ORIGIN_REWRITTEN__`, `__ORIGIN_GAME_URL__`, `__ORIGIN_GUI_URL__` -- **DOM attributes:** `data-origin-game-url`, `data-origin-gui-url`, `data-origin-ready`, `data-origin` -- **Storage keys:** `originGameUrl`, `originGuiUrl` +- **Window globals:** `_` (lodash, plus Play Origin's `instance`, `constants`, `player`, `network`, `gameData`, `membership`, `functions.setMembership`), `__ORIGIN_READY__` +- **Events:** `origin:ready` on `window` + +### Hook targets (`extension/lib/runtime/resolve.ts`) + +| Target | Found by | Used for | +|---|---|---| +| `singletonClass` | class with static `instance` getter and `prodigy` prototype getter | `_.instance`, service discovery | +| `constants` | `.constants` object owning `"GameConstants.Build.VERSION"` | `_.constants`, bypass gate | +| `componentRegistry` | object whose `OpenQuestionInterface` is a class (`EV()` decorator registry) | battle question bypass | +| `baseActionClass` | class whose prototype owns `init`, `execute`, `finish`, `findParameter`, `validateParameters` | `AnswerQuestion` action bypass | +| `towerTownClass` | class owning `openQuestionInterfaceThenEmitNotifications` | Tower Town bypass | ## Related Repos @@ -35,17 +41,19 @@ ``` extension/ - background.ts — Service worker: DNR rules registration - contents/prodigy.ts — Content script (MAIN world): game + mod menu injection - contents/origin-bridge.ts — Bridge script (ISOLATED world): URL communication + background.ts — Service worker: DNR login-art redirects only + contents/runtime.ts — MAIN world, document_start: hook + resolve + bypasses + window._ + contents/menu.ts — MAIN world, document_idle: starts packaged originGUI on origin:ready popup.tsx — Extension popup UI package.json — Plasmo config + manifest overrides - lib/ - patch-urls.ts — MANIFEST_URL + game-URL regex helpers - patches.ts — applyPatchRules + wrapWithPrefixSuffix - manifest.ts — Manifest type + validateManifest - bundle-cache.ts — chrome.storage.local cache wrapper - tests/ — unit + integration tests (node --test) + assets/origin-*.png — Packaged login background and logo (web-accessible) + lib/runtime/ + modules.ts — defineProperty hook capturing webpack export objects + resolve.ts — hook target resolvers + bypasses.ts — education bypass prototype wraps + api.ts — window._ API (ported from P-NP wrappers.ts) + ready.ts — origin:ready signal + tests/ — unit tests (node --test) originGUI/ build.mjs — esbuild build script src/index.ts — Entry point diff --git a/.claude/workflow.md b/.claude/workflow.md index 315867b2..db8ae4cc 100644 --- a/.claude/workflow.md +++ b/.claude/workflow.md @@ -21,4 +21,6 @@ cd extension && pnpm dev - Load unpacked extension from `extension/build/chrome-mv3-dev/` (dev) or `extension/build/chrome-mv3-prod/` (prod) - Navigate to https://math.prodigygame.com/ to test - Check browser console for `[Origin]` log messages -- Verify DNR rules are active: chrome://extensions → service worker → inspect +- Network tab: `code.prodigygame.com/code/*/game.min.js` is 200 (loaded by Prodigy, not blocked) +- Console: `[Origin] packaged runtime active`, three `[Origin] hook installed:` lines, no `hook target missing`, then `[Origin] menu started` +- Console check: `[!!_.instance?.prodigy, _.constants.get("GameConstants.Build.VERSION"), !!_.player?.data, window.__ORIGIN_READY__]` diff --git a/.gitignore b/.gitignore index 3b353d1f..0674e477 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,8 @@ extension/firefox/.plasmo/ # MCP debug config .mcp.json .worktrees/ +.superpowers/ + +# Generated menu module for the Chrome extension +originGUI/dist/menu.js +originGUI/dist/menu.d.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1313bcc0..33b1c394 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,13 +10,16 @@ Monorepo for modding online math games (formerly modding Prodigy Math Game), mai ## Critical Rules +> **Branch `chrome/packaged-runtime` is the Chrome Web Store exclusive line (v4.5.x).** It is never merged into `master`. `master` stays on the v4.4.x P-NP pipeline for Edge and Firefox. CWS listing and privacy answers for this line: `meta/webstores/CHROME.md`, `meta/webstores/JUSTIFICATIONS.md`. + 1. Use **pnpm**, never npm 2. **No force pushes**, no history rewrites 3. **esbuild** for originGUI, **Plasmo** for extension — no webpack 4. **MV3 only** — use declarativeNetRequest, not webRequest -5. The **`onreset` injection trick** is intentional and critical — do not replace it with script tags or other methods -6. **Graceful degradation** — if patches fail, set `patchDegraded: true` and open a GitHub issue -7. **Dev bundle guard** — before committing `originGUI/dist/bundle.js`, confirm it does NOT end with `/* DEV BUNDLE */`; if it does, run `cd originGUI && pnpm build` first +5. **No remote code on `chrome/packaged-runtime`** — the Chrome build must never fetch-and-run JavaScript, use `onreset`, `eval`, or `new Function`. Hooks live in `extension/lib/runtime/`; the menu is packaged via `extension/contents/menu.ts`. Master keeps the P-NP `onreset` pipeline for Edge/Firefox. +6. **Graceful degradation** — a hook target that stops resolving logs `[Origin] hook target missing: `; the other hooks keep working. A missing target means a Chrome Web Store resubmission. +7. **Prodigy's CSP stays intact** — the Chrome build does not strip it, so anything the menu or DNR loads (images, fonts, fetches) must come from `*.prodigygame.com` or be packaged and web-accessible. +8. **Dev bundle guard** — before committing `originGUI/dist/bundle.js`, confirm it does NOT end with `/* DEV BUNDLE */`; if it does, run `cd originGUI && pnpm build` first ## Branding (Play Origin / Prodigy Origin) diff --git a/docs/superpowers/plans/2026-09-24-chrome-packaged-runtime.md b/docs/superpowers/plans/2026-09-24-chrome-packaged-runtime.md new file mode 100644 index 00000000..fce48500 --- /dev/null +++ b/docs/superpowers/plans/2026-09-24-chrome-packaged-runtime.md @@ -0,0 +1,1366 @@ +# Chrome Packaged Runtime Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the Chrome extension's fetch-patch-`onreset` pipeline with packaged MAIN-world hooks and a packaged menu, so no JavaScript runs unless it was in the extension zip. + +**Architecture:** A `document_start` MAIN-world content script wraps `Object.defineProperty` to collect webpack export objects while Prodigy's unmodified `game.min.js` loads. After boot it resolves five targets by shape or name (singleton class, constants object, component registry, base action class, Tower Town class). It then wraps three prototype methods for the education bypasses and builds the `window._` API the menu reads. A second MAIN-world content script packages originGUI and starts it on `origin:ready`. + +**Tech Stack:** TypeScript, Plasmo 0.90 (Parcel), node:test with `--experimental-strip-types`, esbuild (originGUI), pnpm. + +**Spec:** `docs/superpowers/specs/2026-09-24-chrome-packaged-runtime-design.md` + +## Global Constraints + +- Branch `chrome/packaged-runtime` only. Never push or merge to master; master stays on the P-NP pipeline for Edge/Firefox. +- Only `extension/` (not `extension/firefox/`) and `originGUI/` change. +- No `eval`, `new Function`, `onreset`, or fetch-then-execute anywhere in code this branch ships. +- pnpm, never npm. No force pushes. +- Log prefix `[Origin]`. +- `lib/runtime/*.ts` modules import only *types* from each other (node's strip-types runner can't resolve extensionless value imports). Value wiring happens in `contents/*.ts`. +- Content script matches: `https://math.prodigygame.com/*`. +- Commits end with `Co-Authored-By: Claude Opus 5.5 `. + +## Review Focus + +1. **Game code reads a webpack getter before its module finishes (TDZ `ReferenceError`)** → the scan skips that value and keeps going (Task 2 test `skips getters that throw`). +2. **Prodigy removes or renames one target** → the other hooks still install, and one `[Origin] hook target missing:` line is logged after the give-up window (Task 2 test `resolves the targets that exist and reports the rest`, Task 5 glue). +3. **The user flips education back on mid-session** → the original method runs again, with no stale bypass (Task 3 tests `runs the original when education is enabled`). +4. **Lodash replaces `window._` after the API is applied** → the API re-applies to the new object without clobbering lodash's own `functions`, and a membership snapshot taken before the swap survives it (Task 4 tests `re-applies after window._ is replaced` and `keeps the original membership snapshot across re-apply`). +5. **Content script evaluated twice, or bypass installer called on every tick** → each method is wrapped exactly once (Task 3 test `wraps once`). + +--- + +### Task 1: Webpack export capture hook + +**Files:** +- Create: `extension/lib/runtime/modules.ts` +- Test: `extension/tests/runtime-modules.test.ts` + +**Interfaces:** +- Produces: `isWebpackExportDescriptor(key: PropertyKey, desc: unknown): boolean`, `installModuleHook(objectCtor: { defineProperty: ObjectConstructor["defineProperty"] }, sink: Set): () => void` (returns uninstall). + +- [ ] **Step 1: Write the failing test** + +```ts +// extension/tests/runtime-modules.test.ts +import { describe, it } from "node:test" +import assert from "node:assert/strict" +import { installModuleHook, isWebpackExportDescriptor } from "../lib/runtime/modules.ts" + +const makeCtor = () => ({ defineProperty: Object.defineProperty }) as { defineProperty: ObjectConstructor["defineProperty"] } + +// Mirrors game.min.js: __webpack_require__.d=(E,w)=>{for(var T in w)o(w,T)&&!o(E,T)&&Object.defineProperty(E,T,{enumerable:!0,get:w[T]})} +const webpackD = (ctor: ReturnType) => (exports: object, definition: Record unknown>) => { + for (const key in definition) { + if (Object.prototype.hasOwnProperty.call(definition, key) && !Object.prototype.hasOwnProperty.call(exports, key)) { + ctor.defineProperty(exports, key, { enumerable: true, get: definition[key] }) + } + } +} + +describe("isWebpackExportDescriptor", () => { + it("accepts webpack's {enumerable, get} export descriptor", () => { + assert.equal(isWebpackExportDescriptor("q", { enumerable: true, get: () => 1 }), true) + }) + it("accepts the Module toStringTag", () => { + assert.equal(isWebpackExportDescriptor(Symbol.toStringTag, { value: "Module" }), true) + }) + it("rejects other descriptors", () => { + assert.equal(isWebpackExportDescriptor("q", { value: 1 }), false) + assert.equal(isWebpackExportDescriptor("q", { enumerable: true, configurable: true, get: () => 1 }), false) + assert.equal(isWebpackExportDescriptor("q", undefined), false) + }) +}) + +describe("installModuleHook", () => { + it("captures export objects defined through the hooked defineProperty", () => { + const ctor = makeCtor() + const sink = new Set() + installModuleHook(ctor, sink) + const exports = {} + webpackD(ctor)(exports, { q: () => 42 }) + assert.ok(sink.has(exports)) + assert.equal((exports as { q: number }).q, 42) + }) + it("passes non-export defines through without capturing", () => { + const ctor = makeCtor() + const sink = new Set() + installModuleHook(ctor, sink) + const obj = {} + ctor.defineProperty(obj, "x", { value: 1, writable: true }) + assert.equal(sink.size, 0) + assert.equal((obj as { x: number }).x, 1) + }) + it("uninstall restores the original", () => { + const ctor = makeCtor() + const uninstall = installModuleHook(ctor, new Set()) + assert.notEqual(ctor.defineProperty, Object.defineProperty) + uninstall() + assert.equal(ctor.defineProperty, Object.defineProperty) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd extension && node --experimental-strip-types --test tests/runtime-modules.test.ts` +Expected: FAIL, cannot find module `../lib/runtime/modules.ts`. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// extension/lib/runtime/modules.ts +// Captures webpack export objects as Prodigy's unmodified game.min.js creates +// them. webpack's __webpack_require__.d defines every export as +// Object.defineProperty(exports, key, { enumerable: true, get }), and +// __webpack_require__.r tags a few namespaces with Symbol.toStringTag "Module". +// Wrapping Object.defineProperty lets packaged code see those objects without +// rewriting the game's source. + +type DefineProperty = ObjectConstructor["defineProperty"] + +export const isWebpackExportDescriptor = (key: PropertyKey, desc: unknown): boolean => { + if (!desc || typeof desc !== "object") return false + const d = desc as PropertyDescriptor + if (key === Symbol.toStringTag) return d.value === "Module" + return d.enumerable === true && typeof d.get === "function" && Object.keys(d).length === 2 +} + +export const installModuleHook = ( + objectCtor: { defineProperty: DefineProperty }, + sink: Set +): (() => void) => { + const original = objectCtor.defineProperty + const hooked = function defineProperty(target: object, key: PropertyKey, desc: PropertyDescriptor) { + if (isWebpackExportDescriptor(key, desc)) sink.add(target) + return original(target, key, desc) + } as DefineProperty + objectCtor.defineProperty = hooked + return () => { + if (objectCtor.defineProperty === hooked) objectCtor.defineProperty = original + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd extension && node --experimental-strip-types --test tests/runtime-modules.test.ts` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add extension/lib/runtime/modules.ts extension/tests/runtime-modules.test.ts +git commit -m "feat(extension): capture webpack export objects via defineProperty hook" +``` + +--- + +### Task 2: Hook target resolvers + +**Files:** +- Create: `extension/lib/runtime/resolve.ts` +- Test: `extension/tests/runtime-resolve.test.ts` + +**Interfaces:** +- Consumes: nothing at runtime (operates on `Iterable` from Task 1's sink). +- Produces: + - `type AnyClass = Function & { prototype: any }` + - `type Targets = { singletonClass?: AnyClass & { readonly instance?: any }; constants?: Record; componentRegistry?: Record; baseActionClass?: AnyClass; towerTownClass?: AnyClass }` + - `type TargetName = keyof Targets`, `const TARGET_NAMES: readonly TargetName[]` + - `resolveTargets(exportsObjects: Iterable, found: Targets): Targets` (fills only missing targets; returns a new object) + - `missingTargets(t: Targets): TargetName[]` + +- [ ] **Step 1: Write the failing test** + +```ts +// extension/tests/runtime-resolve.test.ts +import { describe, it } from "node:test" +import assert from "node:assert/strict" +import { missingTargets, resolveTargets, TARGET_NAMES } from "../lib/runtime/resolve.ts" + +const exportsOf = (values: Record): object => { + const e = {} + for (const k of Object.keys(values)) Object.defineProperty(e, k, { enumerable: true, get: () => values[k] }) + return e +} + +// Shapes copied from game.min.js (module ids as of 2026.18.1 in comments). +class Singleton { // 35120 q + static _instance: unknown = null + static get instance() { return this._instance } + get game() { return null } + get prodigy() { return null } +} +class GameOnlySingleton { // 61806 W: decoy, has instance+game but no prodigy + static get instance() { return null } + get game() { return null } +} +class ConstantsHolder { // 34829 s + static constants = { "GameConstants.Build.VERSION": "2026.18.1", "GameConstants.Debug.EDUCATION_ENABLED": true } +} +class OpenQuestionInterface { answerQuestion() {} } +const componentRegistry = { OpenQuestionInterface } // 40794 X +class BaseAction { // 62459 rcl + init() {} execute() {} finish() {} findParameter() {} validateParameters() {} +} +class AnswerQuestionAction extends BaseAction { execute() {} } // decoy: owns execute only +class TowerTown { openQuestionInterfaceThenEmitNotifications() {} } // 82142 gT + +const allExports = () => [ + exportsOf({ W: GameOnlySingleton }), + exportsOf({ q: Singleton }), + exportsOf({ A: {}, s: ConstantsHolder }), + exportsOf({ X: componentRegistry, u: class {} }), + exportsOf({ rcl: BaseAction, sub: AnswerQuestionAction }), + exportsOf({ gT: TowerTown }) +] + +describe("resolveTargets", () => { + it("finds every target by shape", () => { + const t = resolveTargets(allExports(), {}) + assert.equal(t.singletonClass, Singleton) + assert.equal(t.constants, ConstantsHolder.constants) + assert.equal(t.componentRegistry, componentRegistry) + assert.equal(t.baseActionClass, BaseAction) + assert.equal(t.towerTownClass, TowerTown) + assert.deepEqual(missingTargets(t), []) + }) + + it("skips getters that throw", () => { + const tdz = {} + Object.defineProperty(tdz, "S", { enumerable: true, get() { throw new ReferenceError("Cannot access 'S' before initialization") } }) + const t = resolveTargets([tdz, ...allExports()], {}) + assert.equal(t.singletonClass, Singleton) + }) + + it("resolves the targets that exist and reports the rest", () => { + const t = resolveTargets([exportsOf({ q: Singleton })], {}) + assert.equal(t.singletonClass, Singleton) + assert.deepEqual(missingTargets(t), TARGET_NAMES.filter((n) => n !== "singletonClass")) + }) + + it("keeps already-found targets and fills the rest later", () => { + const first = resolveTargets([exportsOf({ q: Singleton })], {}) + const second = resolveTargets(allExports(), first) + assert.equal(second.singletonClass, Singleton) + assert.equal(second.towerTownClass, TowerTown) + }) + + it("does not treat a registry that lacks OpenQuestionInterface yet as found", () => { + const t = resolveTargets([exportsOf({ X: {} })], {}) + assert.equal(t.componentRegistry, undefined) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd extension && node --experimental-strip-types --test tests/runtime-resolve.test.ts` +Expected: FAIL, cannot find module. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// extension/lib/runtime/resolve.ts +// Finds the game objects Play Origin hooks, by shape or by names minification +// can't touch. Uses property descriptors only, so no game getters or methods run +// apart from webpack's own export getters. + +export type AnyClass = Function & { prototype: any } + +export type Targets = { + singletonClass?: AnyClass & { readonly instance?: any } + constants?: Record + componentRegistry?: Record + baseActionClass?: AnyClass + towerTownClass?: AnyClass +} + +export type TargetName = keyof Targets + +export const TARGET_NAMES: readonly TargetName[] = [ + "singletonClass", + "constants", + "componentRegistry", + "baseActionClass", + "towerTownClass" +] + +const BASE_ACTION_METHODS = ["init", "execute", "finish", "findParameter", "validateParameters"] + +const descriptor = (obj: object, key: PropertyKey) => Object.getOwnPropertyDescriptor(obj, key) + +const isClass = (v: unknown): v is AnyClass => + typeof v === "function" && typeof (v as AnyClass).prototype === "object" && (v as AnyClass).prototype !== null + +const ownsMethod = (cls: AnyClass, name: string) => typeof descriptor(cls.prototype, name)?.value === "function" + +// module 35120: class with static `instance` getter whose instances expose `prodigy` +const isSingletonClass = (v: unknown): v is NonNullable => + isClass(v) && typeof descriptor(v, "instance")?.get === "function" && typeof descriptor(v.prototype, "prodigy")?.get === "function" + +// module 34829: `X.constants = {"GameConstants.Build.VERSION": ...}` +const constantsOf = (v: unknown): Record | undefined => { + if (!v || (typeof v !== "object" && typeof v !== "function")) return undefined + const c = descriptor(v, "constants")?.value + return c && typeof c === "object" && Object.prototype.hasOwnProperty.call(c, "GameConstants.Build.VERSION") ? c : undefined +} + +// module 40794 export X, filled by the EV("OpenQuestionInterface", …) decorator +const isComponentRegistry = (v: unknown): v is Record => + !!v && typeof v === "object" && isClass(descriptor(v, "OpenQuestionInterface")?.value) + +// module 62459 export rcl: subclasses only own `validate`/`execute` +const isBaseActionClass = (v: unknown): v is AnyClass => isClass(v) && BASE_ACTION_METHODS.every((m) => ownsMethod(v, m)) + +// module 82142 export gT (Tower Town) +const isTowerTownClass = (v: unknown): v is AnyClass => isClass(v) && ownsMethod(v, "openQuestionInterfaceThenEmitNotifications") + +function* exportValues(exportsObjects: Iterable): Generator { + for (const obj of exportsObjects) { + let keys: string[] + try { + keys = Object.keys(obj) + } catch { + continue + } + for (const key of keys) { + let value: unknown + try { + value = (obj as Record)[key] + } catch { + continue // module still evaluating (TDZ) + } + yield value + } + } +} + +export const missingTargets = (t: Targets): TargetName[] => TARGET_NAMES.filter((n) => !t[n]) + +export const resolveTargets = (exportsObjects: Iterable, found: Targets): Targets => { + const t: Targets = { ...found } + if (missingTargets(t).length === 0) return t + for (const v of exportValues(exportsObjects)) { + if (!t.singletonClass && isSingletonClass(v)) t.singletonClass = v + if (!t.constants) t.constants = constantsOf(v) + if (!t.componentRegistry && isComponentRegistry(v)) t.componentRegistry = v + if (!t.baseActionClass && isBaseActionClass(v)) t.baseActionClass = v + if (!t.towerTownClass && isTowerTownClass(v)) t.towerTownClass = v + if (missingTargets(t).length === 0) break + } + return t +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd extension && node --experimental-strip-types --test tests/runtime-resolve.test.ts` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add extension/lib/runtime/resolve.ts extension/tests/runtime-resolve.test.ts +git commit -m "feat(extension): resolve hook targets from captured webpack exports" +``` + +--- + +### Task 3: Education bypass wraps + +**Files:** +- Create: `extension/lib/runtime/bypasses.ts` +- Test: `extension/tests/runtime-bypasses.test.ts` + +**Interfaces:** +- Consumes: `AnyClass` type from `resolve.ts` (type-only import). +- Produces: + - `type GetConstants = () => Record | undefined` + - `installOpenQuestionBypass(cls: AnyClass, getConstants: GetConstants, random?: () => number): boolean` + - `installAnswerQuestionActionBypass(baseActionClass: AnyClass, getConstants: GetConstants, random?: () => number): boolean` + - `installTowerTownBypass(cls: AnyClass, getConstants: GetConstants): boolean` + - All return `true` if the wrap is (or already was) installed, `false` if the method is missing. + +Gate semantics (copied from the P-NP regex replacements): bypass active iff constants exist and `constants["GameConstants.Debug.EDUCATION_ENABLED"]` is falsy. Correct iff `random() < (constants["GameConstants.Debug.AUTO_ANSWER_CORRECT_PERCENT"] || 1)`. + +- [ ] **Step 1: Write the failing test** + +```ts +// extension/tests/runtime-bypasses.test.ts +import { describe, it } from "node:test" +import assert from "node:assert/strict" +import { + installAnswerQuestionActionBypass, + installOpenQuestionBypass, + installTowerTownBypass +} from "../lib/runtime/bypasses.ts" + +const EDU = "GameConstants.Debug.EDUCATION_ENABLED" +const PCT = "GameConstants.Debug.AUTO_ANSWER_CORRECT_PERCENT" + +const signal = () => { + const calls: unknown[][] = [] + return { calls, dispatch: (...args: unknown[]) => { calls.push(args) } } +} + +const makeOpenQuestionClass = () => + class OpenQuestionInterface { + onQuestionAnswered = signal() + onQuestionAnsweredCorrectly = signal() + onQuestionAnsweredIncorrectly = signal() + opened = 0 + answerQuestion() { this.opened++ } + } + +describe("installOpenQuestionBypass", () => { + it("runs the original when education is enabled", () => { + const C = makeOpenQuestionClass() + const constants: Record = { [EDU]: true, [PCT]: 1 } + installOpenQuestionBypass(C, () => constants) + const q = new C() + q.answerQuestion() + assert.equal(q.opened, 1) + assert.equal(q.onQuestionAnswered.calls.length, 0) + }) + + it("answers correctly without opening when education is disabled", () => { + const C = makeOpenQuestionClass() + installOpenQuestionBypass(C, () => ({ [EDU]: false, [PCT]: 1 }), () => 0) + const q = new C() + q.answerQuestion() + assert.equal(q.opened, 0) + assert.deepEqual(q.onQuestionAnswered.calls, [[true, 0, null]]) + assert.deepEqual(q.onQuestionAnsweredCorrectly.calls, [[0, null]]) + assert.equal(q.onQuestionAnsweredIncorrectly.calls.length, 0) + }) + + it("answers incorrectly when the roll misses the percentage", () => { + const C = makeOpenQuestionClass() + installOpenQuestionBypass(C, () => ({ [EDU]: false, [PCT]: 0.5 }), () => 0.9) + const q = new C() + q.answerQuestion() + assert.deepEqual(q.onQuestionAnswered.calls, [[false, 0, null]]) + assert.deepEqual(q.onQuestionAnsweredIncorrectly.calls, [[0, null]]) + }) + + it("follows the flag live: bypass off again once education is re-enabled", () => { + const C = makeOpenQuestionClass() + const constants: Record = { [EDU]: false } + installOpenQuestionBypass(C, () => constants, () => 0) + const q = new C() + q.answerQuestion() + constants[EDU] = true + q.answerQuestion() + assert.equal(q.opened, 1) + }) + + it("runs the original while constants are unresolved", () => { + const C = makeOpenQuestionClass() + installOpenQuestionBypass(C, () => undefined) + const q = new C() + q.answerQuestion() + assert.equal(q.opened, 1) + }) + + it("wraps once", () => { + const C = makeOpenQuestionClass() + installOpenQuestionBypass(C, () => undefined) + const first = C.prototype.answerQuestion + assert.equal(installOpenQuestionBypass(C, () => undefined), true) + assert.equal(C.prototype.answerQuestion, first) + }) + + it("returns false when the method is missing", () => { + assert.equal(installOpenQuestionBypass(class {}, () => undefined), false) + }) +}) + +describe("installAnswerQuestionActionBypass", () => { + const make = () => { + class BaseAction { + data: any = null + finished: unknown = undefined + init(data: any) { this.data = data } + execute() {} + finish(result: unknown) { this.finished = result } + findParameter() { return null } + validateParameters() { return true } + } + class AnswerQuestion extends BaseAction { executed = false; execute() { this.executed = true } } + class Other extends BaseAction { executed = false; execute() { this.executed = true } } + return { BaseAction, AnswerQuestion, Other } + } + + it("finishes AnswerQuestion actions without asking when education is disabled", () => { + const { BaseAction, AnswerQuestion } = make() + installAnswerQuestionActionBypass(BaseAction, () => ({ [EDU]: false }), () => 0) + const a = new AnswerQuestion() + a.init({ Type: "AnswerQuestion" }) + a.execute() + assert.equal(a.executed, false) + assert.deepEqual(a.finished, { answerCorrect: true, responseTime: 0 }) + }) + + it("runs the original AnswerQuestion when education is enabled", () => { + const { BaseAction, AnswerQuestion } = make() + installAnswerQuestionActionBypass(BaseAction, () => ({ [EDU]: true })) + const a = new AnswerQuestion() + a.init({ Type: "AnswerQuestion" }) + a.execute() + assert.equal(a.executed, true) + }) + + it("leaves other actions and the base execute alone", () => { + const { BaseAction, Other } = make() + const baseExecute = BaseAction.prototype.execute + installAnswerQuestionActionBypass(BaseAction, () => ({ [EDU]: false })) + const o = new Other() + o.init({ Type: "Other" }) + o.execute() + assert.equal(o.executed, true) + assert.equal(BaseAction.prototype.execute, baseExecute) + }) +}) + +describe("installTowerTownBypass", () => { + class TowerTown { + opened = 0 + openQuestionInterfaceThenEmitNotifications(_e: unknown, _w: unknown, _t: unknown, _s: unknown, _cb?: Function) { this.opened++ } + } + + it("calls the completion callback as a correct answer", () => { + installTowerTownBypass(TowerTown, () => ({ [EDU]: false })) + const t = new TowerTown() + const calls: unknown[][] = [] + t.openQuestionInterfaceThenEmitNotifications(1, true, null, false, (...a: unknown[]) => calls.push(a)) + assert.equal(t.opened, 0) + assert.deepEqual(calls, [[true, 10, 1, false, false, {}]]) + }) + + it("tolerates a missing callback", () => { + installTowerTownBypass(TowerTown, () => ({ [EDU]: false })) + assert.doesNotThrow(() => new TowerTown().openQuestionInterfaceThenEmitNotifications(1, true, null, false)) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd extension && node --experimental-strip-types --test tests/runtime-bypasses.test.ts` +Expected: FAIL, cannot find module. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// extension/lib/runtime/bypasses.ts +// Education bypasses, as prototype wraps. Replaces the P-NP regex rules +// answer-question-bypass, external-factory-bypass and open-question-bypass, +// which injected the same early returns into game.min.js source. + +import type { AnyClass } from "./resolve" + +export type GetConstants = () => Record | undefined + +const EDUCATION_ENABLED = "GameConstants.Debug.EDUCATION_ENABLED" +const CORRECT_PERCENT = "GameConstants.Debug.AUTO_ANSWER_CORRECT_PERCENT" +const WRAPPED = Symbol.for("playOrigin.wrapped") + +type AnyFn = (...args: any[]) => any + +const bypassed = (c: Record | undefined): c is Record => !!c && !c[EDUCATION_ENABLED] + +const rollCorrect = (c: Record, random: () => number) => + random() < ((c[CORRECT_PERCENT] as number) || 1) + +// Wraps an *own* prototype method once. Returns false if there is none. +const wrapOwnMethod = (proto: any, name: string, make: (original: AnyFn) => AnyFn): boolean => { + const original = Object.getOwnPropertyDescriptor(proto, name)?.value + if (typeof original !== "function") return false + if ((original as any)[WRAPPED]) return true + const wrapped = make(original) + ;(wrapped as any)[WRAPPED] = true + proto[name] = wrapped + return true +} + +export const installOpenQuestionBypass = ( + cls: AnyClass, + getConstants: GetConstants, + random: () => number = Math.random +): boolean => + wrapOwnMethod(cls.prototype, "answerQuestion", (original) => + function (this: any, ...args: unknown[]) { + const c = getConstants() + if (!bypassed(c)) return original.apply(this, args) + const correct = rollCorrect(c, random) + this.onQuestionAnswered.dispatch(correct, 0, null) + if (correct) this.onQuestionAnsweredCorrectly.dispatch(0, null) + else this.onQuestionAnsweredIncorrectly.dispatch(0, null) + } + ) + +// The action registry is module-private, so catch AnswerQuestion when the +// action factory calls init(data) with data.Type === "AnswerQuestion". +export const installAnswerQuestionActionBypass = ( + baseActionClass: AnyClass, + getConstants: GetConstants, + random: () => number = Math.random +): boolean => + wrapOwnMethod(baseActionClass.prototype, "init", (originalInit) => + function (this: any, ...args: unknown[]) { + const result = originalInit.apply(this, args) + const data = args[0] as { Type?: unknown } | null | undefined + const proto = Object.getPrototypeOf(this) + if (data?.Type === "AnswerQuestion" && proto !== baseActionClass.prototype) { + wrapOwnMethod(proto, "execute", (originalExecute) => + function (this: any, ...executeArgs: unknown[]) { + const c = getConstants() + if (!bypassed(c)) return originalExecute.apply(this, executeArgs) + this.finish({ answerCorrect: rollCorrect(c, random), responseTime: 0 }) + } + ) + } + return result + } + ) + +export const installTowerTownBypass = (cls: AnyClass, getConstants: GetConstants): boolean => + wrapOwnMethod(cls.prototype, "openQuestionInterfaceThenEmitNotifications", (original) => + function (this: any, ...args: unknown[]) { + if (!bypassed(getConstants())) return original.apply(this, args) + const done = args[4] + if (typeof done === "function") done(true, 10, 1, false, false, {}) + } + ) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd extension && node --experimental-strip-types --test tests/runtime-bypasses.test.ts` +Expected: PASS (12 tests). + +- [ ] **Step 5: Commit** + +```bash +git add extension/lib/runtime/bypasses.ts extension/tests/runtime-bypasses.test.ts +git commit -m "feat(extension): packaged education bypasses as prototype wraps" +``` + +--- + +### Task 4: `window._` API and ready signal + +**Files:** +- Create: `extension/lib/runtime/api.ts`, `extension/lib/runtime/ready.ts` +- Test: `extension/tests/runtime-api.test.ts` + +**Interfaces:** +- Consumes: `Targets` type from `resolve.ts` (type-only). +- Produces: + - `api.ts`: `discoverService(gc: any, matches: (inst: any) => boolean): any`, `addConstantsApi(raw: Record): Record`, `createOriginApi(deps: { singletonClass: NonNullable; constants: Record }): { ensure(win: any): void; isReady(win: any): boolean }` + - `ready.ts`: `READY_EVENT = "origin:ready"`, `markOriginReady(win: any): void`, `whenOriginReady(win: any): Promise` + +- [ ] **Step 1: Write the failing test** + +```ts +// extension/tests/runtime-api.test.ts +import { describe, it } from "node:test" +import assert from "node:assert/strict" +import { addConstantsApi, createOriginApi, discoverService } from "../lib/runtime/api.ts" +import { markOriginReady, READY_EVENT, whenOriginReady } from "../lib/runtime/ready.ts" + +const makeGame = () => { + const player = { data: { name: "x" }, appearanceChanged: false, unlockMemberItems() { this.unlocked = true }, unlocked: false } + const membership = { _data: { active: false }, isMember: false, hasFeatureAccess() { return false }, updateMembershipDates() {} } + const network = { getCharData() {}, processPlayer: 1, sendZoneEvent() {} } + const services = new Map([ + ["NotAnId", { player: { data: {} } }], + ["1a2-3b4c", { player }], + ["83f-419b", membership], + ["ab-cd", network] + ]) + const gameContainer = { _inversifyContainer: { _bindingDictionary: { _map: services } }, get: (id: unknown) => services.get(id) } + const singleton = { prodigy: { gameContainer } as any, game: { state: { states: new Map([["Boot", { _gameData: { pet: [] } }]]) } } } + class Singleton { static get instance() { return singleton } get prodigy() { return null } } + const constants: Record = { "GameConstants.Build.VERSION": "2026.18.1" } + return { player, membership, network, gameContainer, singleton, Singleton, constants } +} + +describe("discoverService", () => { + it("returns the first hex-id binding matching the shape", () => { + const g = makeGame() + // "NotAnId" also has a player but is not a hex service id, so it is skipped. + assert.equal(discoverService(g.gameContainer, (i) => "player" in i), g.gameContainer.get("1a2-3b4c")) + }) + it("returns null without a binding map", () => { + assert.equal(discoverService({}, () => true), null) + }) +}) + +describe("addConstantsApi", () => { + it("adds hidden Map-like accessors over the same object", () => { + const raw: Record = { "GameConstants.Debug.EDUCATION_ENABLED": true } + addConstantsApi(raw) + raw.set("GameConstants.Debug.EDUCATION_ENABLED", false) + assert.equal(raw["GameConstants.Debug.EDUCATION_ENABLED"], false) + assert.equal(raw.get("GameConstants.Debug.EDUCATION_ENABLED"), false) + assert.equal(raw.has("GameConstants.Debug.EDUCATION_ENABLED"), true) + assert.equal(raw.constants, raw) + assert.deepEqual(Object.keys(raw), ["GameConstants.Debug.EDUCATION_ENABLED"]) + }) +}) + +describe("createOriginApi", () => { + it("creates window._ and exposes instance, constants, player, network, gameData, membership", () => { + const g = makeGame() + const win: any = {} + createOriginApi({ singletonClass: g.Singleton, constants: g.constants }).ensure(win) + assert.equal(win._.instance, g.singleton) + assert.equal(win._.constants, g.constants) + assert.equal(win._.player, g.player) + assert.equal(win._.network, g.network) + assert.equal(win._.network.game, g.singleton.game) + assert.deepEqual(win._.gameData, { pet: [] }) + assert.equal(win._.membership, g.membership) + }) + + it("re-applies after window._ is replaced and keeps lodash's functions", () => { + const g = makeGame() + const win: any = {} + const api = createOriginApi({ singletonClass: g.Singleton, constants: g.constants }) + api.ensure(win) + const lodashFunctions = () => [] + const lodash: any = Object.assign(function lodash() {}, { functions: lodashFunctions, cloneDeep: (x: unknown) => x }) + win._ = lodash + api.ensure(win) + assert.equal(win._, lodash) + assert.equal(win._.instance, g.singleton) + assert.equal(win._.functions, lodashFunctions) + assert.equal(typeof win._.functions.setMembership, "function") + }) + + it("setMembership toggles and restores the original membership data", () => { + const g = makeGame() + const win: any = {} + createOriginApi({ singletonClass: g.Singleton, constants: g.constants }).ensure(win) + const original = g.membership._data + assert.equal(win._.functions.setMembership(true), true) + assert.equal((g.membership._data as any).active, true) + assert.equal(g.membership.hasFeatureAccess(), true) + assert.equal(g.player.unlocked, true) + assert.equal(win._.functions.setMembership(false), true) + assert.equal(g.membership._data, original) + assert.equal(g.membership.hasFeatureAccess(), false) + }) + + it("keeps the original membership snapshot across re-apply", () => { + const g = makeGame() + const win: any = {} + const api = createOriginApi({ singletonClass: g.Singleton, constants: g.constants }) + api.ensure(win) + const original = g.membership._data + win._.functions.setMembership(true) + win._ = Object.assign(function lodash() {}, { functions: () => [] }) + api.ensure(win) + win._.functions.setMembership(false) + assert.equal(g.membership._data, original) + }) + + it("isReady waits for prodigy and player", () => { + const g = makeGame() + const win: any = {} + const api = createOriginApi({ singletonClass: g.Singleton, constants: g.constants }) + const prodigy = g.singleton.prodigy + g.singleton.prodigy = null + api.ensure(win) + assert.equal(api.isReady(win), false) + g.singleton.prodigy = prodigy + assert.equal(api.isReady(win), true) + }) +}) + +describe("ready signal", () => { + it("resolves listeners registered before and after the signal", async () => { + const win: any = new EventTarget() + const early = whenOriginReady(win) + let events = 0 + win.addEventListener(READY_EVENT, () => events++) + markOriginReady(win) + markOriginReady(win) + await early + await whenOriginReady(win) + assert.equal(events, 1) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd extension && node --experimental-strip-types --test tests/runtime-api.test.ts` +Expected: FAIL, cannot find module. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// extension/lib/runtime/ready.ts +// Tells the packaged menu the game is ready to be modded. + +export const READY_EVENT = "origin:ready" +const READY_FLAG = "__ORIGIN_READY__" + +export const markOriginReady = (win: any): void => { + if (win[READY_FLAG]) return + win[READY_FLAG] = true + win.dispatchEvent(new Event(READY_EVENT)) +} + +export const whenOriginReady = (win: any): Promise => + win[READY_FLAG] + ? Promise.resolve() + : new Promise((resolve) => win.addEventListener(READY_EVENT, () => resolve(), { once: true })) +``` + +```ts +// extension/lib/runtime/api.ts +// The `window._` surface originGUI reads. Ported from the old P-NP suffix +// (P-NP/src/wrappers.ts), minus the parts that only existed because the +// patched bundle ran twice. + +import type { Targets } from "./resolve" + +type OriginApiDeps = { + singletonClass: NonNullable + constants: Record +} + +const API_MARK = "__originApi" +const SERVICE_ID = /^[0-9a-f]{2,4}-[0-9a-f]{2,6}$/ + +const defineHidden = (target: object, key: PropertyKey, value: unknown) => + Object.defineProperty(target, key, { value, configurable: true, writable: true }) + +const defineGetter = (target: object, key: PropertyKey, get: () => unknown) => + Object.defineProperty(target, key, { get, configurable: true, enumerable: true }) + +// Service ids drift every build; enumerate Inversify bindings and match by shape. +export const discoverService = (gc: any, matches: (inst: any) => boolean): any => { + let map: Map | undefined + try { + map = gc._inversifyContainer._bindingDictionary._map + } catch { + return null + } + if (!map || typeof map.keys !== "function") return null + for (const id of map.keys()) { + if (typeof id !== "string" || !SERVICE_ID.test(id)) continue + try { + const inst = gc.get(id) + if (inst && matches(inst)) return inst + } catch {} + } + return null +} + +// The menu writes `_.constants.constants[key]` and reads `_.constants.get(key)`; +// both must hit the object the game itself reads. +export const addConstantsApi = (raw: Record): Record => { + if (typeof raw.get !== "function") { + defineHidden(raw, "get", (key: string) => raw[key]) + defineHidden(raw, "set", (key: string, value: unknown) => { + raw[key] = value + return raw + }) + defineHidden(raw, "has", (key: string) => key in raw) + defineHidden(raw, "constants", raw) + } + return raw +} + +const isPlayerService = (inst: any) => { + try { + const p = inst.player + return !!p && typeof p === "object" && "data" in p + } catch { + return false + } +} +const isNetworkManager = (inst: any) => + typeof inst.getCharData === "function" && "processPlayer" in inst && typeof inst.sendZoneEvent === "function" +const isMembershipService = (inst: any) => typeof inst.hasFeatureAccess === "function" && "isMember" in inst + +export const createOriginApi = (deps: OriginApiDeps) => { + const services: { player?: any; network?: any; membership?: any } = {} + let membershipOriginal: { data: unknown } | undefined + + const instance = () => deps.singletonClass.instance ?? null + const find = (key: keyof typeof services, matches: (inst: any) => boolean) => { + if (services[key]) return services[key] + const gc = instance()?.prodigy?.gameContainer + if (!gc) return null + const svc = discoverService(gc, matches) + if (svc) services[key] = svc + return svc + } + + const apply = (target: any) => { + defineGetter(target, "instance", instance) + Object.defineProperty(target, "constants", { + value: addConstantsApi(deps.constants), + configurable: true, + enumerable: true, + writable: true + }) + // The service caches the player; `.player` is a getter returning a fresh object. + defineGetter(target, "player", () => find("player", isPlayerService)?.player ?? null) + defineGetter(target, "gameData", () => { + try { + return instance()?.game?.state?.states?.get?.("Boot")?._gameData ?? null + } catch { + return null + } + }) + defineGetter(target, "network", () => { + const nm = find("network", isNetworkManager) + if (nm && !nm.game) { + try { + defineGetter(nm, "game", () => instance()?.game ?? null) + } catch {} + } + return nm + }) + defineGetter(target, "membership", () => find("membership", isMembershipService)) + + const setMembership = (active: boolean): boolean => { + const ms = find("membership", isMembershipService) + if (!ms) return false + if (!membershipOriginal) membershipOriginal = { data: ms._data } + if (active) { + const day = 86_400_000 + ms._data = { + active: true, + features: [], + membershipStartTs: new Date(Date.now() - day).toISOString(), + membershipEndTs: new Date(Date.now() + 10 * 365 * day).toISOString() + } + // memberTier derives from hasFeatureAccess, so this promotes to Ultra. + defineHidden(ms, "hasFeatureAccess", () => true) + try { ms.updateMembershipDates?.() } catch {} + try { target.player?.unlockMemberItems?.() } catch {} + } else { + ms._data = membershipOriginal.data + if (Object.getOwnPropertyDescriptor(ms, "hasFeatureAccess")) delete ms.hasFeatureAccess + try { ms.updateMembershipDates?.() } catch {} + } + try { + const p = target.player + if (p) p.appearanceChanged = true + } catch {} + return true + } + // lodash owns `_.functions`; hang ours off it instead of replacing it. + if (target.functions == null) target.functions = Object.create(null) + defineHidden(target.functions, "setMembership", setMembership) + + defineHidden(target, API_MARK, true) + } + + return { + ensure(win: any): void { + if (win._ == null) win._ = {} + if (!win._[API_MARK]) apply(win._) + }, + isReady(win: any): boolean { + try { + return !!(instance()?.prodigy && win._?.player) + } catch { + return false + } + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd extension && node --experimental-strip-types --test tests/runtime-api.test.ts` +Expected: PASS (9 tests). + +- [ ] **Step 5: Commit** + +```bash +git add extension/lib/runtime/api.ts extension/lib/runtime/ready.ts extension/tests/runtime-api.test.ts +git commit -m "feat(extension): packaged window._ API and origin:ready signal" +``` + +--- + +### Task 5: Runtime content script; retire the onreset injection (LIVE CHECKPOINT A) + +**Files:** +- Create: `extension/contents/runtime.ts` +- Delete: `extension/contents/prodigy.ts`, `extension/contents/origin-bridge.ts` +- Replace: `extension/background.ts` (image redirects only) + +**Interfaces:** +- Consumes: everything from Tasks 1–4. +- Produces: page globals `window._` (API) and `window.__ORIGIN_READY__`, and the `origin:ready` event. + +- [ ] **Step 1: Write `extension/contents/runtime.ts`** + +```ts +import type { PlasmoCSConfig } from "plasmo" +import { createOriginApi } from "../lib/runtime/api" +import { + installAnswerQuestionActionBypass, + installOpenQuestionBypass, + installTowerTownBypass +} from "../lib/runtime/bypasses" +import { installModuleHook } from "../lib/runtime/modules" +import { markOriginReady } from "../lib/runtime/ready" +import { missingTargets, resolveTargets, type Targets } from "../lib/runtime/resolve" +import { VERSION } from "../lib/version" + +export const config: PlasmoCSConfig = { + matches: ["https://math.prodigygame.com/*"], + run_at: "document_start", + world: "MAIN" +} + +// Must run before game.min.js so every webpack export object is seen. +const exportsObjects = new Set() +installModuleHook(Object, exportsObjects) + +const TICK_MS = 500 +const GIVE_UP_MS = 120_000 +const startedAt = Date.now() + +let targets: Targets = {} +let api: ReturnType | undefined +let reportedMissing = false +const installed = new Set() +const getConstants = () => targets.constants + +const install = (name: string, run: () => boolean) => { + if (installed.has(name)) return + if (run()) { + installed.add(name) + console.log(`[Origin] hook installed: ${name}`) + } +} + +const tick = () => { + const searching = missingTargets(targets).length > 0 && Date.now() - startedAt < GIVE_UP_MS + if (searching) targets = resolveTargets(exportsObjects, targets) + + const { componentRegistry, baseActionClass, towerTownClass, singletonClass, constants } = targets + if (componentRegistry?.OpenQuestionInterface) { + install("OpenQuestionInterface.answerQuestion", () => + installOpenQuestionBypass(componentRegistry.OpenQuestionInterface, getConstants)) + } + if (baseActionClass) install("AnswerQuestion action", () => installAnswerQuestionActionBypass(baseActionClass, getConstants)) + if (towerTownClass) install("Tower Town questions", () => installTowerTownBypass(towerTownClass, getConstants)) + + if (singletonClass && constants) { + api ??= createOriginApi({ singletonClass, constants }) + api.ensure(window) + if (api.isReady(window)) markOriginReady(window) + } + + if (!searching && !reportedMissing) { + const missing = missingTargets(targets) + if (missing.length > 0) { + reportedMissing = true + console.warn(`[Origin] hook target missing: ${missing.join(", ")}`) + } + } +} + +setInterval(() => { + try { + tick() + } catch (e) { + console.error("[Origin] runtime tick failed:", e) + } +}, TICK_MS) + +console.log(`[Origin] packaged runtime active v${VERSION}`) +``` + +- [ ] **Step 2: Delete the old injection scripts** + +```bash +git rm extension/contents/prodigy.ts extension/contents/origin-bridge.ts +``` + +- [ ] **Step 3: Replace `extension/background.ts`** + +```ts +// Background service worker for Play Origin (Chrome Web Store build). +// +// Only job: register the DNR rules that swap Prodigy's login background and +// logo for Play Origin art. No script blocking, no header stripping, and no +// code fetching; the game runs its own game.min.js. + +const RULES: chrome.declarativeNetRequest.Rule[] = [ + { + id: 3, + priority: 2, + action: { + type: chrome.declarativeNetRequest.RuleActionType.REDIRECT, + redirect: { url: "https://raw.githubusercontent.com/ProdigyPXP/ProdigyOrigin/master/.github/origin-bg.png" } + }, + condition: { + urlFilter: "*://cdn.prodigygame.com/game/assets/v1_cache/single-images/login-bg-13/1/login-bg-13.png", + resourceTypes: [ + chrome.declarativeNetRequest.ResourceType.MAIN_FRAME, + chrome.declarativeNetRequest.ResourceType.SUB_FRAME, + chrome.declarativeNetRequest.ResourceType.IMAGE, + chrome.declarativeNetRequest.ResourceType.OTHER + ] + } + }, + { + id: 4, + priority: 2, + action: { + type: chrome.declarativeNetRequest.RuleActionType.REDIRECT, + redirect: { url: "https://raw.githubusercontent.com/ProdigyPXP/ProdigyOrigin/master/.github/origin-logo.png" } + }, + condition: { + urlFilter: "*://code.prodigygame.com/assets/svg/*logo*-*.svg", + resourceTypes: [ + chrome.declarativeNetRequest.ResourceType.MAIN_FRAME, + chrome.declarativeNetRequest.ResourceType.SUB_FRAME, + chrome.declarativeNetRequest.ResourceType.IMAGE, + chrome.declarativeNetRequest.ResourceType.OTHER + ] + } + } +] + +chrome.runtime.onInstalled.addListener(async () => { + const existing = await chrome.declarativeNetRequest.getDynamicRules() + await chrome.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: existing.map((r) => r.id), + addRules: RULES + }) + console.log("[Origin] DeclarativeNetRequest rules registered successfully") +}) +``` + +`onInstalled` clears the old block and CSP-strip rules (ids 1, 2) that a previous install left behind. + +- [ ] **Step 4: Unit tests still pass, typecheck, dev build** + +Run: `cd extension && pnpm test && pnpm typecheck` +Expected: every `runtime-*` test passes. Old pipeline tests (`patches`, `manifest`, `bundle-cache`, `patch-urls`, `integration`) still pass because their lib files still exist; Task 7 deletes them. + +Run: `cd extension && pnpm dev` (background). Expected: Plasmo prints the `build/chrome-mv3-dev` output with no errors. + +- [ ] **Step 5: LIVE CHECKPOINT A (user)** + +The user loads `extension/build/chrome-mv3-dev` unpacked. If the production Play Origin extension is installed, the user disables it first. Then they open `https://math.prodigygame.com/` and log in. Verify: +1. Network: `code.prodigygame.com/code/*/game.min.js` returns 200, not blocked. +2. Console shows `[Origin] packaged runtime active`, three `[Origin] hook installed:` lines, and no `hook target missing`. +3. In the console: `_.instance.prodigy`, `_.constants.get("GameConstants.Build.VERSION")`, `_.player.data`, `_.network` and `_.membership` are all non-null. `__ORIGIN_READY__ === true`. +4. `_.constants.set("GameConstants.Debug.EDUCATION_ENABLED", false)`, then enter a battle and attack: no question appears and the attack goes through. + +If any check fails, debug with systematic-debugging before Task 6. + +- [ ] **Step 6: Commit** + +```bash +git add extension/contents/runtime.ts extension/background.ts +git commit -m "feat(extension): load Prodigy's own game.min.js with packaged runtime hooks + +Removes the onreset injection, bridge, game.min.js block and CSP strip." +``` + +--- + +### Task 6: Package the menu (LIVE CHECKPOINT B) + +**Files:** +- Modify: `originGUI/build.mjs` (emit `dist/menu.js` + `dist/menu.d.ts`) +- Modify: `originGUI/src/index.ts` (drop dev socket eval and socket.io import) +- Modify: `originGUI/src/hacks/utility.ts` (drop "Update menu" and "Eval Console") +- Modify: `originGUI/src/hacks/beta.ts` (drop "Switch Branch") +- Modify: `originGUI/src/hacks/player.ts:422`, `originGUI/src/hacks/pets.ts:170` (closures instead of eval) +- Modify: `.gitignore`, `extension/package.json` scripts +- Create: `extension/contents/menu.ts` +- Rebuild + commit: `originGUI/dist/bundle.js` + +**Interfaces:** +- Consumes: `whenOriginReady` from `lib/runtime/ready.ts`. +- Produces: `originGUI/dist/menu.js` with `export default function startOriginMenu(): void`. + +- [ ] **Step 1: Remove remote/dynamic code paths from originGUI** + +In `originGUI/src/index.ts`: delete the line `import { io } from "socket.io-client"; // Import socket.io-client` and the whole `if (process.env.NODE_ENV === "development") { const socket = io(...) ... eval(data); ... }` block. + +In `originGUI/src/hacks/utility.ts`: delete everything from `// Begin Update menu` through `// End Update menu`, and from `// Begin Eval Console` through `// End Eval Console`. + +In `originGUI/src/hacks/beta.ts`: delete from `// Begin Switch Branch` through the closing `});` of that `new Hack(category.beta, "Switch Branch", ...)` call (it ends with `return await eval(await (await fetch(...)).text());\n});`), including an `// End Switch Branch` marker if present. + +In `originGUI/src/hacks/player.ts` replace +```ts + eval(`player.getLevel = () => {return ${level.value}}`); +``` +with +```ts + player.getLevel = () => Number(level.value); +``` + +In `originGUI/src/hacks/pets.ts` replace +```ts + // sorry in advance + eval(`player.kennel.petTeam[parseInt(${pet.value})+1].getLevel = () => {return ${num}}`); +``` +with +```ts + player.kennel.petTeam[parseInt(pet.value) + 1].getLevel = () => Number(num); +``` + +Verify: `grep -rnE "eval\(|new Function|socket\.io|raw\.githubusercontent.*bundle" originGUI/src` prints nothing. + +- [ ] **Step 2: Emit the menu module from `originGUI/build.mjs`** + +Below `const bundlePath = ...` add: +```js +const menuModulePath = path.join(distDir, "menu.js"); +const menuTypesPath = path.join(distDir, "menu.d.ts"); +``` +Below `createScssPlugin` add: +```js +// Wraps the IIFE bundle in a function so the Chrome extension can package the +// menu inside a content script and start it once the game is ready. +async function writeMenuModule () { + const bundle = await fs.promises.readFile(bundlePath, "utf8"); + await fs.promises.writeFile(menuModulePath, `// Generated by originGUI/build.mjs. Do not edit.\nexport default function startOriginMenu () {\n${bundle}\n}\n`); + await fs.promises.writeFile(menuTypesPath, "export default function startOriginMenu (): void;\n"); +} +``` +At the end of `buildBundle`, after `await esbuild.build({...});`, add `await writeMenuModule();`. + +Append to `.gitignore`: +``` +# Generated menu module for the Chrome extension +originGUI/dist/menu.js +originGUI/dist/menu.d.ts +``` + +- [ ] **Step 3: Menu content script** + +```ts +// extension/contents/menu.ts +import type { PlasmoCSConfig } from "plasmo" +import startOriginMenu from "../../originGUI/dist/menu.js" +import { whenOriginReady } from "../lib/runtime/ready" + +export const config: PlasmoCSConfig = { + matches: ["https://math.prodigygame.com/*"], + run_at: "document_idle", + world: "MAIN" +} + +// originGUI reads window._.player at load, so start only once the game is ready. +whenOriginReady(window).then(() => { + try { + startOriginMenu() + console.log("[Origin] menu started") + } catch (e) { + console.error("[Origin] menu failed to start:", e) + } +}) +``` + +- [ ] **Step 4: Build originGUI before the extension** + +In `extension/package.json` `scripts`: +```json +"menu": "pnpm --dir ../originGUI build", +"dev": "pnpm run menu && plasmo dev", +"build": "pnpm run menu && plasmo build", +"package": "pnpm run menu && plasmo package", +"typecheck": "pnpm run menu && tsc --noEmit", +``` +(leave `dev:firefox`, `build:firefox`, `package:firefox`, `test` unchanged). + +- [ ] **Step 5: Build and verify** + +Run: `cd extension && pnpm typecheck && pnpm build` +Expected: success. Then `grep -c "startOriginMenu\|origin-menu" build/chrome-mv3-prod/menu.*.js` is ≥ 1. The menu code is inside the content script. + +Run: `head -c 200 ../originGUI/dist/bundle.js; tail -c 60 ../originGUI/dist/bundle.js`. The bundle must not end with `/* DEV BUNDLE */`. + +- [ ] **Step 6: LIVE CHECKPOINT B (user)** + +Restart `pnpm dev`, reload the unpacked extension, and hard-reload Prodigy. Verify that `[Origin] menu started` is logged, the menu renders with ▲/▼, Shift toggles it, the "Disable math" hack (battle.ts `setConstant(EDUCATION_ENABLED,false)`) works in a battle, and membership on/off works. + +- [ ] **Step 7: Commit** + +```bash +git add .gitignore originGUI/build.mjs originGUI/src originGUI/dist/bundle.js extension/contents/menu.ts extension/package.json +git commit -m "feat: package originGUI into the Chrome extension, drop remote menu loading and evals" +``` + +--- + +### Task 7: Remove the dead pipeline, tighten the manifest, update docs + +**Files:** +- Delete: `extension/lib/{bundle-cache,manifest,patches,patch-urls}.ts`, `extension/tests/{bundle-cache,manifest,patches,patch-urls,integration}.test.ts` +- Modify: `extension/popup.tsx` (drop Developer Options / URL overrides), `extension/package.json` (manifest permissions) +- Modify: `CLAUDE.md`, `.claude/architecture.md`, `.claude/workflow.md` + +- [ ] **Step 1: Delete pipeline code and its tests** + +```bash +git rm extension/lib/bundle-cache.ts extension/lib/manifest.ts extension/lib/patches.ts extension/lib/patch-urls.ts \ + extension/tests/bundle-cache.test.ts extension/tests/manifest.test.ts extension/tests/patches.test.ts \ + extension/tests/patch-urls.test.ts extension/tests/integration.test.ts +grep -rn "lib/patches\|lib/manifest\|lib/bundle-cache\|lib/patch-urls" extension --include=*.ts --include=*.tsx | grep -v node_modules | grep -v "^extension/firefox" +``` +Expected: grep prints nothing. + +- [ ] **Step 2: Popup: remove the manifest/menu URL overrides** + +In `extension/popup.tsx`: delete the `manifestUrl`, `guiUrl`, `saved`, `devOpen`, `devUnlocked` and `challengeInput` state, the `useEffect` that reads `chrome.storage.local`, and `handleSave`/`handleReset`. Delete the JSX from `{/* Developer Options accordion */}` through the end of its `` block. Remove imports that become unused (check `Save`, `RotateCcw`, `ChevronDown`, `motion`, `AnimatePresence`, `useEffect`, `useState`). Then run `pnpm typecheck`. It must pass with no unused-symbol errors, and `grep -n "chrome.storage" popup.tsx` must print nothing. + +- [ ] **Step 3: Manifest permissions** + +In `extension/package.json` → `manifest`: +```json +"permissions": ["declarativeNetRequest"], +"host_permissions": ["*://*.prodigygame.com/*"], +``` +Check: `grep -rn "chrome.storage" extension --include=*.ts --include=*.tsx | grep -v node_modules | grep -v firefox` prints nothing. + +- [ ] **Step 4: Docs** + +`CLAUDE.md`: replace rules 5 and 6 with: +``` +5. **No remote code on `chrome/packaged-runtime`** — the Chrome build must never fetch-and-run JavaScript, use `onreset`, `eval`, or `new Function`. Hooks live in `extension/lib/runtime/`; the menu is packaged via `extension/contents/menu.ts`. Master keeps the P-NP `onreset` pipeline for Edge/Firefox. +6. **Graceful degradation** — a hook target that stops resolving logs `[Origin] hook target missing: `; the other hooks keep working. A missing target means a Chrome Web Store resubmission. +``` +`.claude/architecture.md`: replace the `extension/` bullet list, the "Injection method" / "Content script" / "Window globals" / "DOM attributes" / "Storage keys" lines, and the `extension/` part of Key Files, with a description of: `contents/runtime.ts` (defineProperty hook → resolve → bypasses + `window._`), `contents/menu.ts`, `lib/runtime/{modules,resolve,bypasses,api,ready}.ts`, `background.ts` (image redirects only), window globals `_` and `__ORIGIN_READY__`, event `origin:ready`, and the table of hook targets from the spec. +`.claude/workflow.md`: under Testing, replace "Verify DNR rules are active" with the Task 5 checkpoint A list. + +- [ ] **Step 5: Full verification** + +```bash +cd extension && pnpm test && pnpm typecheck && pnpm build +grep -rlE "onreset|raw\.githubusercontent\.com/ProdigyPXP/(P-NP|ProdigyOrigin/[^\"]*/originGUI/dist)" build/chrome-mv3-prod || echo "clean: no remote-code references" +grep -rnoE "\beval\(|new Function\(" build/chrome-mv3-prod | head +``` +Expected: tests, typecheck and build pass, and the first grep prints `clean: ...`. Any hit from the second grep must be inside third-party library code (e.g., sweetalert2, React). Record it in the final report. Play Origin code must have none. + +- [ ] **Step 6: Commit** + +```bash +git add -A extension/popup.tsx extension/package.json CLAUDE.md .claude/architecture.md .claude/workflow.md +git commit -m "chore(extension): drop P-NP pipeline, URL overrides and unused permissions from Chrome build" +``` diff --git a/docs/superpowers/specs/2026-09-24-chrome-packaged-runtime-design.md b/docs/superpowers/specs/2026-09-24-chrome-packaged-runtime-design.md new file mode 100644 index 00000000..0ed13574 --- /dev/null +++ b/docs/superpowers/specs/2026-09-24-chrome-packaged-runtime-design.md @@ -0,0 +1,67 @@ +# Chrome packaged runtime (no remote code) + +Branch: `chrome/packaged-runtime`. Master stays on the P-NP `manifest.json` + `onreset` pipeline so the Edge and Firefox listings keep fast-patching. This branch is the Chrome Web Store build only. + +Background: `../GIZMO_MIGRATION.md` (workspace root). Blue Argon = executing JavaScript that was not in the reviewed package. + +## Goal + +Prodigy loads its own, unmodified `game.min.js`. Every line of JavaScript Play Origin runs ships inside the extension zip. No `fetch` + `eval`, no `onreset`, no prefix/suffix from GitHub. + +Success criteria: + +1. With the dev extension loaded, `math.prodigygame.com` loads the original `game.min.js` from `code.prodigygame.com` (network tab: 200, not blocked). +2. `window._.instance.prodigy`, `_.constants.get("GameConstants.Build.VERSION")`, `_.player`, `_.network` and `_.membership` resolve. +3. With `GameConstants.Debug.EDUCATION_ENABLED` false, a battle question auto-answers through each of the three bypasses (battle `OpenQuestionInterface`, `AnswerQuestion` action, Tower Town). +4. The menu opens, and Shift still toggles it. +5. `grep -rE "eval\(|new Function|onreset|raw\.githubusercontent" extension/build/chrome-mv3-prod` finds nothing written by Play Origin. + +## Why hooks work here + +`game.min.js` is one webpack bundle. Its runtime helper `__webpack_require__.d` defines every export as `Object.defineProperty(exports, key, {enumerable: true, get})`. That's 1238 modules; only 5 also get the `Symbol.toStringTag` "Module" tag. A MAIN-world content script at `document_start` wraps `Object.defineProperty`, keeps a set of every object that receives a descriptor of exactly that shape (or the "Module" tag), and passes each call straight through. Targets are resolved lazily, after the game boots, by name or shape. Module ids are never used. + +| Old P-NP rule | Packaged replacement | +|---|---| +| `singleton-exposure` | Export that is a class with a static `instance` getter and a `prodigy` prototype getter (module 35120 `q` today). `_.instance` = `q.instance`. | +| `expose-constants` | Export whose `.constants` object has key `"GameConstants.Build.VERSION"` (module 34829 today). Same Map-like `get`/`set`/`has` + `.constants` self-alias as the old suffix. | +| `answer-question-bypass` | Component registry (export object with key `OpenQuestionInterface`, filled by the `EV(name, path)` decorator). Wrap `OpenQuestionInterface.prototype.answerQuestion`. | +| `external-factory-bypass` | The action registry (`Ie` in module 62459) is not exported. Instead, find the exported base action class (the only class whose prototype *owns* `init`, `execute`, `finish`, `findParameter`, `validateParameters`) and wrap its `init`. The action factory calls `init(data)` with `data.Type === "AnswerQuestion"`; on the first such call, wrap that subclass's own `execute`. The bypass calls `this.finish({answerCorrect, responseTime: 0})`. | +| `open-question-bypass` | Exported class whose prototype has `openQuestionInterfaceThenEmitNotifications` (module 82142 `gT` today). Wrap it; bypass calls the callback `(true, 10, 1, false, false, {})`. | +| `safe-bind`, semaphore guard | Dropped. They only guarded against `onreset` running the game twice. | + +Each bypass has the same gate as the old regex: active only when `_.constants.get("GameConstants.Debug.EDUCATION_ENABLED")` is falsy, correct with probability `AUTO_ANSWER_CORRECT_PERCENT` (default 1). Otherwise the original method runs. + +Registry keys are prefab serialization names, so minification can't rename them. Method names were already what the regex rules depended on. + +## Components + +- **`extension/lib/runtime/`**: pure, testable modules. + - `modules.ts`: `defineProperty` hook + the captured-exports list. + - `resolve.ts`: `findSingletonClass`, `findConstants`, `findRegistry(key)`, `findClassWithMethod(name)`, each taking the exports list. + - `bypasses.ts`: the three prototype wraps. Idempotent, marked with a symbol so they never double-wrap. + - `api.ts`: builds the `window._` surface the menu actually reads, ported from P-NP `wrappers.ts`: `instance`, `constants`, `player`, `network`, `gameData`, `membership`, `functions.setMembership`. `localizer`, `hack`, `variables` and `escapeBattle` are dropped because nothing in originGUI reads them. `setMembership` is attached to whatever `_.functions` already is (lodash's `functions`), not replacing it. The API re-applies itself when lodash replaces `window._`, same as the old 500 ms poll. + - `ready.ts`: `origin:ready` event + flag. Fired once the singleton has `prodigy` and `_.player` resolves (the menu captures `_.player` at load). + - Every lib/runtime module imports only *types* from its siblings. The node test runner can't resolve extensionless value imports, so the wiring lives in `contents/runtime.ts`. +- **`extension/contents/runtime.ts`**: MAIN world, `document_start`, `https://math.prodigygame.com/*`. Installs the hook, then the API and bypasses once resolution succeeds. If a target is still missing after the game boots, it logs `[Origin] hook target missing: ` and continues with what it has (graceful degradation). +- **`extension/contents/menu.ts`**: MAIN world, `document_idle`. `originGUI/build.mjs` also emits `dist/menu.js` (`export default function startOriginMenu () { }`, gitignored), and Parcel bundles it into this content script. The script waits for `origin:ready`, then calls `startOriginMenu()`. The extension's `dev`/`build`/`package`/`typecheck` scripts build originGUI first. +- **originGUI changes** (on this branch only): remove "Update menu", the beta branch loader, the eval console, and the dev socket `eval`. Replace the template-string `eval`s in `player.ts` and `pets.ts` with closures. Data `fetch`es (status message JSON, Prodigy API, asset URLs) stay; they load data, not code. + +## Removed from the Chrome extension + +`background.ts` patch pipeline (the service worker keeps only the DNR image redirects), `contents/prodigy.ts`, `contents/origin-bridge.ts`, `lib/{bundle-cache,manifest,patches,patch-urls}.ts` and their tests, DNR rules 1 (block `game.min.js`) and 2 (strip CSP/XFO), the `raw.githubusercontent.com` host permission, the popup's manifest/menu URL overrides, and the `unlimitedStorage` permission. + +The image redirects stay, but point at packaged PNGs (`extensionPath`, web-accessible). They're images, not code. + +**Amendment (live checkpoint B):** MAIN-world scripts *execute* regardless of the page CSP, but their network loads don't get that exemption. With the CSP strip gone, Prodigy's CSP blocked the GitHub-hosted logo redirect (`img-src`), the menu's `statusmessage.json` (`connect-src`), its Google Fonts, and the Teleport zone art. Resolution: keep Prodigy's CSP intact. Serve the login art from the package via `extensionPath` (verified live), drop the status message, and label zone tiles by name. Fonts fall back to system sans-serif. + +## Testing + +- Unit (node test runner, like the existing tests): run `resolve.ts` and `bypasses.ts` against fixtures that copy the real shapes (a fake exports list with a registry object, a class with a static `instance` getter, and so on). Cover idempotent wrapping, the gate on/off, and a missing-target log. +- No offline run of the real `game.min.js`: it needs a real DOM and a Phaser boot. Live verification covers it. +- Live: `pnpm dev`, load `extension/build/chrome-mv3-dev` unpacked, then walk through success criteria 1–5. + +## Out of scope + +- Firefox and Edge (stay on master). +- P-NP becoming a CI check that loads the latest `game.min.js` and asserts every hook target resolves. That's the next spec once the hooks are proven live. +- Store listing text and version bump. diff --git a/extension/assets/origin-bg.png b/extension/assets/origin-bg.png new file mode 100644 index 00000000..15b99290 Binary files /dev/null and b/extension/assets/origin-bg.png differ diff --git a/extension/assets/origin-logo.png b/extension/assets/origin-logo.png new file mode 100644 index 00000000..586c00af Binary files /dev/null and b/extension/assets/origin-logo.png differ diff --git a/extension/background.ts b/extension/background.ts index dc4f3b7a..8537360e 100644 --- a/extension/background.ts +++ b/extension/background.ts @@ -1,63 +1,17 @@ -// Background service worker for Play Origin (Chromium MV3). +// Background service worker for Play Origin (Chrome Web Store build). // -// Responsibilities: -// 1. DNR rules: BLOCK original game.min.js + strip CSP/X-Frame-Options + -// redirect login background and logo. -// 2. Handle GET_PATCHED_BUNDLE messages from the ISOLATED-world bridge: -// fetch manifest.json (popup may override URL), check the cache, -// fetch + patch the original on miss, prepend the resolved menu-URL -// global, return the patched JS source. - -import { - MANIFEST_URL, - gameVersionFromUrl -} from "./lib/patch-urls" -import { - applyPatchRules, - wrapWithPrefixSuffix -} from "./lib/patches" -import { validateManifest, type Manifest } from "./lib/manifest" -import { - getCachedBundle, - setCachedBundle, - type CacheEntry, - type StorageLocalApi -} from "./lib/bundle-cache" +// Only job: register the DNR rules that swap Prodigy's login background and +// logo for Play Origin art. No script blocking, no header stripping, and no +// code fetching; the game runs its own game.min.js. The art is packaged and +// web-accessible, because Prodigy's img-src CSP blocks a GitHub-hosted copy. const RULES: chrome.declarativeNetRequest.Rule[] = [ - { - id: 1, - priority: 1, - action: { type: chrome.declarativeNetRequest.RuleActionType.BLOCK }, - condition: { - urlFilter: "*://code.prodigygame.com/code/*/game.min.js*", - resourceTypes: [chrome.declarativeNetRequest.ResourceType.SCRIPT] - } - }, - { - id: 2, - priority: 1, - action: { - type: chrome.declarativeNetRequest.RuleActionType.MODIFY_HEADERS, - responseHeaders: [ - { header: "Content-Security-Policy", operation: chrome.declarativeNetRequest.HeaderOperation.REMOVE }, - { header: "X-Frame-Options", operation: chrome.declarativeNetRequest.HeaderOperation.REMOVE } - ] - }, - condition: { - urlFilter: "*://*.prodigygame.com/*", - resourceTypes: [ - chrome.declarativeNetRequest.ResourceType.MAIN_FRAME, - chrome.declarativeNetRequest.ResourceType.SUB_FRAME - ] - } - }, { id: 3, priority: 2, action: { type: chrome.declarativeNetRequest.RuleActionType.REDIRECT, - redirect: { url: "https://raw.githubusercontent.com/ProdigyPXP/ProdigyOrigin/master/.github/origin-bg.png" } + redirect: { extensionPath: "/assets/origin-bg.png" } }, condition: { urlFilter: "*://cdn.prodigygame.com/game/assets/v1_cache/single-images/login-bg-13/1/login-bg-13.png", @@ -74,7 +28,7 @@ const RULES: chrome.declarativeNetRequest.Rule[] = [ priority: 2, action: { type: chrome.declarativeNetRequest.RuleActionType.REDIRECT, - redirect: { url: "https://raw.githubusercontent.com/ProdigyPXP/ProdigyOrigin/master/.github/origin-logo.png" } + redirect: { extensionPath: "/assets/origin-logo.png" } }, condition: { urlFilter: "*://code.prodigygame.com/assets/svg/*logo*-*.svg", @@ -90,129 +44,9 @@ const RULES: chrome.declarativeNetRequest.Rule[] = [ chrome.runtime.onInstalled.addListener(async () => { const existing = await chrome.declarativeNetRequest.getDynamicRules() - const existingIds = existing.map((r) => r.id) await chrome.declarativeNetRequest.updateDynamicRules({ - removeRuleIds: existingIds, + removeRuleIds: existing.map((r) => r.id), addRules: RULES }) console.log("[Origin] DeclarativeNetRequest rules registered successfully") }) - -const readOverrides = async (): Promise<{ - manifestUrlOverride: string - guiUrlOverride: string -}> => { - const storage = chrome.storage.local as unknown as StorageLocalApi - const res = await storage.get(["originManifestUrl", "originGuiUrl"]) - const manifestUrlOverride = - typeof res.originManifestUrl === "string" ? res.originManifestUrl : "" - const guiUrlOverride = - typeof res.originGuiUrl === "string" ? res.originGuiUrl : "" - return { manifestUrlOverride, guiUrlOverride } -} - -const fetchManifest = async (url: string): Promise => { - const r = await fetch(url, { cache: "default" }) - if (!r.ok) throw new Error(`manifest.json HTTP ${r.status}`) - const json = (await r.json()) as unknown - return validateManifest(json) -} - -const fetchOriginal = async (url: string): Promise => { - const r = await fetch(url, { cache: "default" }) - if (!r.ok) throw new Error(`original game.min.js HTTP ${r.status} for ${url}`) - const body = await r.text() - if (body.length < 1000) { - throw new Error(`original game.min.js suspiciously small (${body.length} bytes)`) - } - return body -} - -// Returns the wrapped patched bundle (prefix + patched + suffix) WITHOUT the -// menu-URL global prepended. The menu URL is resolved per-request and -// prepended at the call site so popup changes don't invalidate the cache. -const getOrBuildWrappedBundle = async ( - originalUrl: string, - manifest: Manifest -): Promise => { - const storage = chrome.storage.local as unknown as StorageLocalApi - const gameClientVersion = gameVersionFromUrl(originalUrl) - if (!gameClientVersion) throw new Error(`unparseable originalUrl: ${originalUrl}`) - - const cached = await getCachedBundle(storage) - if ( - cached && - cached.gameClientVersion === gameClientVersion && - cached.manifestHash === manifest.hash - ) { - console.log( - `[Origin] cache HIT for ${gameClientVersion} (hash=${manifest.hash})` - ) - return cached.patchedBundle - } - - console.log( - `[Origin] cache MISS for ${gameClientVersion} (hash=${manifest.hash}) — building` - ) - const original = await fetchOriginal(originalUrl) - const { output: patchedCore, perRuleCounts } = applyPatchRules(original, manifest.rules) - for (const rule of manifest.rules) { - console.log(`[Origin] rule "${rule.id}" matched ${perRuleCounts[rule.id] ?? 0}x`) - } - const wrapped = wrapWithPrefixSuffix(patchedCore, manifest.prefix, manifest.suffix) - - const degradedRules = manifest.rules.filter(r => (perRuleCounts[r.id] ?? 0) < r.minMatches) - if (degradedRules.length > 0) { - for (const r of degradedRules) { - console.warn(`[Origin] DEGRADED: rule "${r.id}" matched ${perRuleCounts[r.id] ?? 0}x (expected ≥${r.minMatches}) — skipping cache`) - } - return wrapped - } - - const entry: CacheEntry = { - gameClientVersion, - manifestHash: manifest.hash, - patchedBundle: wrapped, - storedAt: Date.now() - } - await setCachedBundle(storage, entry) - console.log(`[Origin] cache STORED for ${gameClientVersion}`) - return wrapped -} - -const getOrBuildPatchedBundle = async (originalUrl: string): Promise => { - const { manifestUrlOverride, guiUrlOverride } = await readOverrides() - const manifestUrl = manifestUrlOverride || MANIFEST_URL - if (manifestUrlOverride) { - console.log(`[Origin] using manifest URL override: ${manifestUrl}`) - } - - const manifest = await fetchManifest(manifestUrl) - const wrapped = await getOrBuildWrappedBundle(originalUrl, manifest) - - const resolvedMenuUrl = guiUrlOverride || manifest.defaultMenuUrl - if (guiUrlOverride) { - console.log(`[Origin] using menu URL override: ${resolvedMenuUrl}`) - } - const menuUrlPrefix = - `globalThis.__ORIGIN_MENU_URL__=${JSON.stringify(resolvedMenuUrl)};\n` - return menuUrlPrefix + wrapped -} - -chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { - if (msg?.type !== "GET_PATCHED_BUNDLE" || typeof msg.originalUrl !== "string") { - return false - } - getOrBuildPatchedBundle(msg.originalUrl) - .then((patchedBundle) => sendResponse({ ok: true, patchedBundle })) - .catch((err: unknown) => { - console.error("[Origin] GET_PATCHED_BUNDLE failed:", err) - sendResponse({ - ok: false, - error: err instanceof Error ? err.message : String(err) - }) - }) - return true -}) - -export {} diff --git a/extension/contents/menu.ts b/extension/contents/menu.ts new file mode 100644 index 00000000..dc0d2cd2 --- /dev/null +++ b/extension/contents/menu.ts @@ -0,0 +1,19 @@ +import type { PlasmoCSConfig } from "plasmo" +import startOriginMenu from "../../originGUI/dist/menu.js" +import { whenOriginReady } from "../lib/runtime/ready" + +export const config: PlasmoCSConfig = { + matches: ["https://math.prodigygame.com/*"], + run_at: "document_idle", + world: "MAIN" +} + +// originGUI reads window._.player at load, so start only once the game is ready. +whenOriginReady(window).then(() => { + try { + startOriginMenu() + console.log("[Origin] menu started") + } catch (e) { + console.error("[Origin] menu failed to start:", e) + } +}) diff --git a/extension/contents/origin-bridge.ts b/extension/contents/origin-bridge.ts deleted file mode 100644 index 0889db0e..00000000 --- a/extension/contents/origin-bridge.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { PlasmoCSConfig } from "plasmo" - -/** - * Play Origin Bridge — ISOLATED-world content script. - * - * The MAIN-world script (contents/prodigy.ts) cannot call chrome.* APIs. - * It dispatches `__origin_patch_request__` on `document` with the captured - * game URL; this bridge forwards the request to the background service worker - * via chrome.runtime.sendMessage, then dispatches `__origin_patch_response__` - * back with the result. - * - * Default world is ISOLATED — leaving the `world:` field unset avoids the - * Parcel transformer rejecting it. - */ -export const config: PlasmoCSConfig = { - matches: ["https://math.prodigygame.com/*"], - run_at: "document_start" -} - -type RequestDetail = { originalUrl: string } -type ResponseDetail = - | { ok: true; patchedBundle: string } - | { ok: false; error: string } - -document.addEventListener("__origin_patch_request__", (e: Event) => { - const detail = (e as CustomEvent).detail - if (!detail || typeof detail.originalUrl !== "string") return - - chrome.runtime - .sendMessage({ type: "GET_PATCHED_BUNDLE", originalUrl: detail.originalUrl }) - .then((response: ResponseDetail) => { - document.dispatchEvent( - new CustomEvent("__origin_patch_response__", { detail: response }) - ) - }) - .catch((err: unknown) => { - const message = err instanceof Error ? err.message : String(err) - document.dispatchEvent( - new CustomEvent("__origin_patch_response__", { - detail: { ok: false, error: `bridge: ${message}` } - }) - ) - }) -}) - -console.log("[Origin] bridge active") diff --git a/extension/contents/prodigy.ts b/extension/contents/prodigy.ts deleted file mode 100644 index 8344d7d5..00000000 --- a/extension/contents/prodigy.ts +++ /dev/null @@ -1,237 +0,0 @@ -import type { PlasmoCSConfig } from "plasmo" -import { isGameScriptSrc } from "../lib/patch-urls" -import { VERSION } from "../lib/version" - -export const config: PlasmoCSConfig = { - matches: ["https://math.prodigygame.com/*"], - run_at: "document_start", - world: "MAIN" -} - -declare global { - interface Window { - __ORIGIN_INJECTED__?: boolean - __ORIGIN_HANDLED__?: boolean - } -} - -// ─── Save originals ─── -const origAppendChild = Node.prototype.appendChild -const origInsertBefore = Node.prototype.insertBefore -const origAppend = Element.prototype.append -const origRemoveAttribute = Element.prototype.removeAttribute -const origSetAttribute = Element.prototype.setAttribute -const origGetAttribute = Element.prototype.getAttribute -const origCreateElement = Document.prototype.createElement - -// ─── Integrity (SRI) bypass ─── -function lockIntegrity(el: HTMLElement): void { - Object.defineProperty(el, "integrity", { - set() {}, - get() { return "" }, - configurable: true - }) -} - -Document.prototype.createElement = function (tagName: string, options?: ElementCreationOptions): HTMLElement { - const el = origCreateElement.call(this, tagName, options) as HTMLElement - const tag = tagName.toLowerCase() - if (tag === "script" || tag === "link") { - lockIntegrity(el) - } - return el -} - -Element.prototype.setAttribute = function (name: string, value: string) { - if (name === "integrity") return - return origSetAttribute.call(this, name, value) -} - -for (const Proto of [HTMLScriptElement.prototype, HTMLLinkElement.prototype]) { - Object.defineProperty(Proto, "integrity", { - set() {}, - get() { return "" }, - configurable: true - }) -} - -function stripIntegrity(node: Node): void { - if (node instanceof HTMLScriptElement || node instanceof HTMLLinkElement) { - if (origGetAttribute.call(node, "integrity")) { - origRemoveAttribute.call(node, "integrity") - } - } -} - -// ─── Bridge round-trip ─── - -type ResponseDetail = - | { ok: true; patchedBundle: string } - | { ok: false; error: string } - -const requestPatchedBundle = (originalUrl: string): Promise => { - return new Promise((resolve, reject) => { - const onResponse = (e: Event) => { - document.removeEventListener("__origin_patch_response__", onResponse) - const detail = (e as CustomEvent).detail - if (detail.ok) resolve(detail.patchedBundle) - else reject(new Error(detail.error || "patch failed")) - } - document.addEventListener("__origin_patch_response__", onResponse) - document.dispatchEvent( - new CustomEvent("__origin_patch_request__", { detail: { originalUrl } }) - ) - }) -} - -// ─── Game-script interception ─── - -function isInterceptableGameScript(node: Node): node is HTMLScriptElement { - if (!(node instanceof HTMLScriptElement)) return false - if (origGetAttribute.call(node, "data-origin") === "1") return false - const src = node.src || origGetAttribute.call(node, "src") || "" - return isGameScriptSrc(src) -} - -function captureSrc(node: HTMLScriptElement): string { - return node.src || origGetAttribute.call(node, "src") || "" -} - -function createNopScript(): HTMLScriptElement { - const nop = origCreateElement.call(document, "script") as HTMLScriptElement - lockIntegrity(nop) - origSetAttribute.call(nop, "data-origin", "1") - return nop -} - -function injectPatchedBundle(originalUrl: string): void { - if (window.__ORIGIN_HANDLED__) return - window.__ORIGIN_HANDLED__ = true - - console.log(`[Origin] requesting patched bundle (origin=${originalUrl})`) - requestPatchedBundle(originalUrl) - .then((js) => { - // Prepend `var URL=window.URL;` to shadow document.URL (a string) so - // `new URL(...)` calls inside the patched bundle resolve to the URL class. - const payload = "var URL=window.URL;\n" + js - document.documentElement.setAttribute("onreset", payload) - document.documentElement.dispatchEvent(new CustomEvent("reset")) - document.documentElement.removeAttribute("onreset") - console.log("[Origin] patched bundle injected via onreset") - }) - .catch((e) => { - console.error("[Origin] failed to obtain patched bundle:", e) - }) -} - -// ─── Override script-insertion APIs ─── - -Node.prototype.appendChild = function (child: T): T { - if (isInterceptableGameScript(child)) { - const originalUrl = captureSrc(child as unknown as HTMLScriptElement) - injectPatchedBundle(originalUrl) - return origAppendChild.call(this, createNopScript()) as unknown as T - } - stripIntegrity(child) - return origAppendChild.call(this, child) as T -} - -Node.prototype.insertBefore = function (newNode: T, refNode: Node | null): T { - if (isInterceptableGameScript(newNode)) { - const originalUrl = captureSrc(newNode as unknown as HTMLScriptElement) - injectPatchedBundle(originalUrl) - return origInsertBefore.call(this, createNopScript(), refNode) as unknown as T - } - stripIntegrity(newNode) - return origInsertBefore.call(this, newNode, refNode) as T -} - -Element.prototype.append = function (...nodes: (Node | string)[]) { - const processed = nodes.map((node) => { - if (node instanceof Node && isInterceptableGameScript(node)) { - const originalUrl = captureSrc(node as HTMLScriptElement) - injectPatchedBundle(originalUrl) - return createNopScript() - } - if (node instanceof Node) stripIntegrity(node) - return node - }) - return origAppend.apply(this, processed) -} - -// ─── MutationObserver: catch parser-added script tags + preloads ─── - -function handleParserAddedGameScript(script: HTMLScriptElement): void { - const originalUrl = captureSrc(script) - origSetAttribute.call(script, "data-origin", "1") - script.removeAttribute("src") - script.textContent = "" - injectPatchedBundle(originalUrl) -} - -const observer = new MutationObserver((mutations) => { - for (const mutation of mutations) { - for (const node of mutation.addedNodes) { - if (node instanceof HTMLScriptElement) { - if (origGetAttribute.call(node, "integrity")) { - origRemoveAttribute.call(node, "integrity") - } - if ( - origGetAttribute.call(node, "data-origin") !== "1" && - isGameScriptSrc(node.src || origGetAttribute.call(node, "src") || "") - ) { - handleParserAddedGameScript(node) - } - } else if (node instanceof HTMLLinkElement) { - if (origGetAttribute.call(node, "integrity")) { - origRemoveAttribute.call(node, "integrity") - } - const rel = (node.rel || "").toLowerCase() - if (rel === "preload" && isGameScriptSrc(node.href || "")) { - node.remove() - } - } - } - } -}) - -function scanExistingDom(): void { - const existing = document.querySelector( - 'script[src*="/code/"]:not([data-origin])' - ) - if (existing && isGameScriptSrc(existing.src)) { - handleParserAddedGameScript(existing) - } - - document - .querySelectorAll('link[rel="preload"]') - .forEach((link) => { - if (isGameScriptSrc(link.href)) link.remove() - }) - - document - .querySelectorAll("script[integrity], link[integrity]") - .forEach((el) => origRemoveAttribute.call(el, "integrity")) -} - -// ─── Init ─── -;(function main() { - if (window.__ORIGIN_INJECTED__) return - window.__ORIGIN_INJECTED__ = true - - const root = document.documentElement || document - observer.observe(root, { childList: true, subtree: true }) - - if (!document.documentElement) { - const earlyObserver = new MutationObserver(() => { - if (document.documentElement) { - earlyObserver.disconnect() - observer.observe(document.documentElement, { childList: true, subtree: true }) - } - }) - earlyObserver.observe(document, { childList: true }) - } - - scanExistingDom() - console.log(`[Origin] content script active (client-side patching v${VERSION})`) -})() diff --git a/extension/contents/runtime.ts b/extension/contents/runtime.ts new file mode 100644 index 00000000..9035f03c --- /dev/null +++ b/extension/contents/runtime.ts @@ -0,0 +1,76 @@ +import type { PlasmoCSConfig } from "plasmo" +import { createOriginApi } from "../lib/runtime/api" +import { + installAnswerQuestionActionBypass, + installOpenQuestionBypass, + installTowerTownBypass +} from "../lib/runtime/bypasses" +import { installModuleHook } from "../lib/runtime/modules" +import { markOriginReady } from "../lib/runtime/ready" +import { missingTargets, resolveTargets, type Targets } from "../lib/runtime/resolve" +import { VERSION } from "../lib/version" + +export const config: PlasmoCSConfig = { + matches: ["https://math.prodigygame.com/*"], + run_at: "document_start", + world: "MAIN" +} + +// Must run before game.min.js so every webpack export object is seen. +const exportsObjects = new Set() +installModuleHook(Object, exportsObjects) + +const TICK_MS = 500 +const GIVE_UP_MS = 120_000 +const startedAt = Date.now() + +let targets: Targets = {} +let api: ReturnType | undefined +let reportedMissing = false +const installed = new Set() +const getConstants = () => targets.constants + +const install = (name: string, run: () => boolean) => { + if (installed.has(name)) return + if (run()) { + installed.add(name) + console.log(`[Origin] hook installed: ${name}`) + } +} + +const tick = () => { + const searching = missingTargets(targets).length > 0 && Date.now() - startedAt < GIVE_UP_MS + if (searching) targets = resolveTargets(exportsObjects, targets) + + const { componentRegistry, baseActionClass, towerTownClass, singletonClass, constants } = targets + if (componentRegistry?.OpenQuestionInterface) { + install("OpenQuestionInterface.answerQuestion", () => + installOpenQuestionBypass(componentRegistry.OpenQuestionInterface, getConstants)) + } + if (baseActionClass) install("AnswerQuestion action", () => installAnswerQuestionActionBypass(baseActionClass, getConstants)) + if (towerTownClass) install("Tower Town questions", () => installTowerTownBypass(towerTownClass, getConstants)) + + if (singletonClass && constants) { + api ??= createOriginApi({ singletonClass, constants }) + api.ensure(window) + if (api.isReady(window)) markOriginReady(window) + } + + if (!searching && !reportedMissing) { + const missing = missingTargets(targets) + if (missing.length > 0) { + reportedMissing = true + console.warn(`[Origin] hook target missing: ${missing.join(", ")}`) + } + } +} + +setInterval(() => { + try { + tick() + } catch (e) { + console.error("[Origin] runtime tick failed:", e) + } +}, TICK_MS) + +console.log(`[Origin] packaged runtime active v${VERSION}`) diff --git a/extension/lib/bundle-cache.ts b/extension/lib/bundle-cache.ts deleted file mode 100644 index 2882c584..00000000 --- a/extension/lib/bundle-cache.ts +++ /dev/null @@ -1,45 +0,0 @@ -export const CACHE_KEY = "patchedBundleCache" - -export type CacheEntry = { - gameClientVersion: string - manifestHash: string - patchedBundle: string - storedAt: number -} - -export type StorageLocalApi = { - get: (keys: string | string[]) => Promise> - set: (items: Record) => Promise - remove: (keys: string | string[]) => Promise -} - -export const getCachedBundle = async ( - storage: StorageLocalApi -): Promise => { - const result = await storage.get(CACHE_KEY) - const v = result[CACHE_KEY] - if (!v || typeof v !== "object") return undefined - const e = v as Partial - if ( - typeof e.gameClientVersion !== "string" || - typeof e.manifestHash !== "string" || - typeof e.patchedBundle !== "string" || - typeof e.storedAt !== "number" - ) { - return undefined - } - return e as CacheEntry -} - -export const setCachedBundle = async ( - storage: StorageLocalApi, - entry: CacheEntry -): Promise => { - await storage.set({ [CACHE_KEY]: entry }) -} - -export const clearCachedBundle = async ( - storage: StorageLocalApi -): Promise => { - await storage.remove(CACHE_KEY) -} diff --git a/extension/lib/manifest.ts b/extension/lib/manifest.ts deleted file mode 100644 index 973a3efe..00000000 --- a/extension/lib/manifest.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { PatchRule } from "./patches" - -export type Manifest = { - schemaVersion: 1 - patcherVersion: string - hash: string - rules: PatchRule[] - prefix: string - suffix: string - defaultMenuUrl: string -} - -const isPatchRule = (v: unknown): v is PatchRule => { - if (!v || typeof v !== "object") return false - const r = v as Record - return ( - typeof r.id === "string" && - typeof r.description === "string" && - typeof r.find === "string" && - typeof r.flags === "string" && - typeof r.replace === "string" && - typeof r.minMatches === "number" - ) -} - -export const validateManifest = (value: unknown): Manifest => { - if (!value || typeof value !== "object") { - throw new Error("manifest.json must be a JSON object") - } - const m = value as Record - if (m.schemaVersion !== 1) { - throw new Error(`manifest.schemaVersion must be 1 (got ${m.schemaVersion as unknown as string})`) - } - if (typeof m.patcherVersion !== "string") { - throw new Error("manifest.patcherVersion must be a string") - } - if (typeof m.hash !== "string") { - throw new Error("manifest.hash must be a string") - } - if (!Array.isArray(m.rules) || !m.rules.every(isPatchRule)) { - throw new Error("manifest.rules must be an array of PatchRule") - } - if (typeof m.prefix !== "string") { - throw new Error("manifest.prefix must be a string") - } - if (typeof m.suffix !== "string") { - throw new Error("manifest.suffix must be a string") - } - if (typeof m.defaultMenuUrl !== "string") { - throw new Error("manifest.defaultMenuUrl must be a string") - } - return m as unknown as Manifest -} diff --git a/extension/lib/patch-urls.ts b/extension/lib/patch-urls.ts deleted file mode 100644 index e5f2c540..00000000 --- a/extension/lib/patch-urls.ts +++ /dev/null @@ -1,18 +0,0 @@ -export const MANIFEST_URL = - "https://raw.githubusercontent.com/ProdigyPXP/P-NP/master/dist/manifest.json" - -// Matches canonical Prodigy game URL: //code.prodigygame.com/code//game.min.js -// Optional query string follows. -export const GAME_SCRIPT_RE = - /\/\/code\.prodigygame\.com\/code\/([^/]+)\/game\.min\.js(?:\?|#|$)/i - -export const isGameScriptSrc = (src: string | null | undefined): boolean => { - if (!src) return false - return GAME_SCRIPT_RE.test(src) -} - -export const gameVersionFromUrl = (url: string): string => { - if (!url) return "" - const m = url.match(GAME_SCRIPT_RE) - return m?.[1] ?? "" -} diff --git a/extension/lib/patches.ts b/extension/lib/patches.ts deleted file mode 100644 index 4e9743a9..00000000 --- a/extension/lib/patches.ts +++ /dev/null @@ -1,42 +0,0 @@ -export type PatchRule = { - id: string - description: string - find: string - flags: string - replace: string - minMatches: number -} - -export type ApplyPatchRulesOutcome = { - output: string - perRuleCounts: Record -} - -export const applyPatchRules = ( - source: string, - rules: ReadonlyArray -): ApplyPatchRulesOutcome => { - const perRuleCounts: Record = {} - let output = source - for (const rule of rules) { - const re = new RegExp(rule.find, rule.flags) - let count = 0 - output = output.replace(re, (...args) => { - count += 1 - const groups = args.slice(1, -2) - let result = rule.replace - groups.forEach((g, i) => { - result = result.replace(new RegExp("\\$" + (i + 1), "g"), g ?? "") - }) - return result - }) - perRuleCounts[rule.id] = count - } - return { output, perRuleCounts } -} - -export const wrapWithPrefixSuffix = ( - body: string, - prefix: string, - suffix: string -): string => `${prefix}\n${body}\n${suffix}` diff --git a/extension/lib/runtime/api.ts b/extension/lib/runtime/api.ts new file mode 100644 index 00000000..048f4f58 --- /dev/null +++ b/extension/lib/runtime/api.ts @@ -0,0 +1,161 @@ +// extension/lib/runtime/api.ts +// The `window._` surface originGUI reads. Ported from the old P-NP suffix +// (P-NP/src/wrappers.ts), minus the parts that only existed because the +// patched bundle ran twice. + +import type { Targets } from "./resolve" + +type OriginApiDeps = { + singletonClass: NonNullable + constants: Record +} + +const API_MARK = "__originApi" +const SERVICE_ID = /^[0-9a-f]{2,4}-[0-9a-f]{2,6}$/ + +const defineHidden = (target: object, key: PropertyKey, value: unknown) => + Object.defineProperty(target, key, { value, configurable: true, writable: true }) + +const defineGetter = (target: object, key: PropertyKey, get: () => unknown) => + Object.defineProperty(target, key, { get, configurable: true, enumerable: true }) + +// Service ids drift every build; enumerate Inversify bindings and match by shape. +export const discoverService = (gc: any, matches: (inst: any) => boolean): any => { + let map: Map | undefined + try { + map = gc._inversifyContainer._bindingDictionary._map + } catch { + return null + } + if (!map || typeof map.keys !== "function") return null + for (const id of map.keys()) { + if (typeof id !== "string" || !SERVICE_ID.test(id)) continue + try { + const inst = gc.get(id) + if (inst && matches(inst)) return inst + } catch {} + } + return null +} + +// The menu writes `_.constants.constants[key]` and reads `_.constants.get(key)`; +// both must hit the object the game itself reads. +export const addConstantsApi = (raw: Record): Record => { + if (typeof raw.get !== "function") { + defineHidden(raw, "get", (key: string) => raw[key]) + defineHidden(raw, "set", (key: string, value: unknown) => { + raw[key] = value + return raw + }) + defineHidden(raw, "has", (key: string) => key in raw) + defineHidden(raw, "constants", raw) + } + return raw +} + +const isPlayerService = (inst: any) => { + try { + const p = inst.player + return !!p && typeof p === "object" && "data" in p + } catch { + return false + } +} +const isNetworkManager = (inst: any) => + typeof inst.getCharData === "function" && "processPlayer" in inst && typeof inst.sendZoneEvent === "function" +const isMembershipService = (inst: any) => typeof inst.hasFeatureAccess === "function" && "isMember" in inst + +export const createOriginApi = (deps: OriginApiDeps) => { + const services: { player?: any; network?: any; membership?: any } = {} + // Own hasFeatureAccess descriptor, if the service had one before we overrode it. + let membershipOriginal: { data: unknown; hasFeatureAccess?: PropertyDescriptor } | undefined + + const instance = () => deps.singletonClass.instance ?? null + const find = (key: keyof typeof services, matches: (inst: any) => boolean) => { + if (services[key]) return services[key] + const gc = instance()?.prodigy?.gameContainer + if (!gc) return null + const svc = discoverService(gc, matches) + if (svc) services[key] = svc + return svc + } + + const apply = (target: any) => { + defineGetter(target, "instance", instance) + Object.defineProperty(target, "constants", { + value: addConstantsApi(deps.constants), + configurable: true, + enumerable: true, + writable: true + }) + // The service caches the player; `.player` is a getter returning a fresh object. + defineGetter(target, "player", () => find("player", isPlayerService)?.player ?? null) + defineGetter(target, "gameData", () => { + try { + return instance()?.game?.state?.states?.get?.("Boot")?._gameData ?? null + } catch { + return null + } + }) + defineGetter(target, "network", () => { + const nm = find("network", isNetworkManager) + if (nm && !nm.game) { + try { + defineGetter(nm, "game", () => instance()?.game ?? null) + } catch {} + } + return nm + }) + defineGetter(target, "membership", () => find("membership", isMembershipService)) + + const setMembership = (active: boolean): boolean => { + const ms = find("membership", isMembershipService) + if (!ms) return false + if (!membershipOriginal) { + membershipOriginal = { data: ms._data, hasFeatureAccess: Object.getOwnPropertyDescriptor(ms, "hasFeatureAccess") } + } + if (active) { + const day = 86_400_000 + ms._data = { + active: true, + features: [], + membershipStartTs: new Date(Date.now() - day).toISOString(), + membershipEndTs: new Date(Date.now() + 10 * 365 * day).toISOString() + } + // memberTier derives from hasFeatureAccess, so this promotes to Ultra. + defineHidden(ms, "hasFeatureAccess", () => true) + try { ms.updateMembershipDates?.() } catch {} + try { target.player?.unlockMemberItems?.() } catch {} + } else { + ms._data = membershipOriginal.data + if (membershipOriginal.hasFeatureAccess) Object.defineProperty(ms, "hasFeatureAccess", membershipOriginal.hasFeatureAccess) + else delete ms.hasFeatureAccess + try { ms.updateMembershipDates?.() } catch {} + } + try { + const p = target.player + if (p) p.appearanceChanged = true + } catch {} + return true + } + // lodash owns `_.functions`; hang ours off it instead of replacing it. + if (target.functions == null) target.functions = Object.create(null) + defineHidden(target.functions, "setMembership", setMembership) + + defineHidden(target, API_MARK, true) + } + + return { + ensure(win: any): void { + if (win._ == null) win._ = {} + if (!win._[API_MARK]) apply(win._) + }, + isReady(win: any): boolean { + try { + return !!(instance()?.prodigy && win._?.player) + } catch { + return false + } + } + } +} diff --git a/extension/lib/runtime/bypasses.ts b/extension/lib/runtime/bypasses.ts new file mode 100644 index 00000000..bf8b1240 --- /dev/null +++ b/extension/lib/runtime/bypasses.ts @@ -0,0 +1,80 @@ +// extension/lib/runtime/bypasses.ts +// Education bypasses, as prototype wraps. Replaces the P-NP regex rules +// answer-question-bypass, external-factory-bypass and open-question-bypass, +// which injected the same early returns into game.min.js source. + +import type { AnyClass } from "./resolve" + +export type GetConstants = () => Record | undefined + +const EDUCATION_ENABLED = "GameConstants.Debug.EDUCATION_ENABLED" +const CORRECT_PERCENT = "GameConstants.Debug.AUTO_ANSWER_CORRECT_PERCENT" +const WRAPPED = Symbol.for("playOrigin.wrapped") + +type AnyFn = (...args: any[]) => any + +const bypassed = (c: Record | undefined): c is Record => !!c && !c[EDUCATION_ENABLED] + +const rollCorrect = (c: Record, random: () => number) => + random() < ((c[CORRECT_PERCENT] as number) || 1) + +// Wraps an *own* prototype method once. Returns false if there is none. +const wrapOwnMethod = (proto: any, name: string, make: (original: AnyFn) => AnyFn): boolean => { + const original = Object.getOwnPropertyDescriptor(proto, name)?.value + if (typeof original !== "function") return false + if ((original as any)[WRAPPED]) return true + const wrapped = make(original) + ;(wrapped as any)[WRAPPED] = true + proto[name] = wrapped + return true +} + +export const installOpenQuestionBypass = ( + cls: AnyClass, + getConstants: GetConstants, + random: () => number = Math.random +): boolean => + wrapOwnMethod(cls.prototype, "answerQuestion", (original) => + function (this: any, ...args: unknown[]) { + const c = getConstants() + if (!bypassed(c)) return original.apply(this, args) + const correct = rollCorrect(c, random) + this.onQuestionAnswered.dispatch(correct, 0, null) + if (correct) this.onQuestionAnsweredCorrectly.dispatch(0, null) + else this.onQuestionAnsweredIncorrectly.dispatch(0, null) + } + ) + +// The action registry is module-private, so catch AnswerQuestion when the +// action factory calls init(data) with data.Type === "AnswerQuestion". +export const installAnswerQuestionActionBypass = ( + baseActionClass: AnyClass, + getConstants: GetConstants, + random: () => number = Math.random +): boolean => + wrapOwnMethod(baseActionClass.prototype, "init", (originalInit) => + function (this: any, ...args: unknown[]) { + const result = originalInit.apply(this, args) + const data = args[0] as { Type?: unknown } | null | undefined + const proto = Object.getPrototypeOf(this) + if (data?.Type === "AnswerQuestion" && proto !== baseActionClass.prototype) { + wrapOwnMethod(proto, "execute", (originalExecute) => + function (this: any, ...executeArgs: unknown[]) { + const c = getConstants() + if (!bypassed(c)) return originalExecute.apply(this, executeArgs) + this.finish({ answerCorrect: rollCorrect(c, random), responseTime: 0 }) + } + ) + } + return result + } + ) + +export const installTowerTownBypass = (cls: AnyClass, getConstants: GetConstants): boolean => + wrapOwnMethod(cls.prototype, "openQuestionInterfaceThenEmitNotifications", (original) => + function (this: any, ...args: unknown[]) { + if (!bypassed(getConstants())) return original.apply(this, args) + const done = args[4] + if (typeof done === "function") done(true, 10, 1, false, false, {}) + } + ) diff --git a/extension/lib/runtime/modules.ts b/extension/lib/runtime/modules.ts new file mode 100644 index 00000000..720742b1 --- /dev/null +++ b/extension/lib/runtime/modules.ts @@ -0,0 +1,31 @@ +// extension/lib/runtime/modules.ts +// Captures webpack export objects as Prodigy's unmodified game.min.js creates +// them. webpack's __webpack_require__.d defines every export as +// Object.defineProperty(exports, key, { enumerable: true, get }), and +// __webpack_require__.r tags a few namespaces with Symbol.toStringTag "Module". +// Wrapping Object.defineProperty lets packaged code see those objects without +// rewriting the game's source. + +type DefineProperty = ObjectConstructor["defineProperty"] + +export const isWebpackExportDescriptor = (key: PropertyKey, desc: unknown): boolean => { + if (!desc || typeof desc !== "object") return false + const d = desc as PropertyDescriptor + if (key === Symbol.toStringTag) return d.value === "Module" + return d.enumerable === true && typeof d.get === "function" && Object.keys(d).length === 2 +} + +export const installModuleHook = ( + objectCtor: { defineProperty: DefineProperty }, + sink: Set +): (() => void) => { + const original = objectCtor.defineProperty + const hooked = function defineProperty(target: object, key: PropertyKey, desc: PropertyDescriptor) { + if (isWebpackExportDescriptor(key, desc)) sink.add(target) + return original(target, key, desc) + } as DefineProperty + objectCtor.defineProperty = hooked + return () => { + if (objectCtor.defineProperty === hooked) objectCtor.defineProperty = original + } +} diff --git a/extension/lib/runtime/ready.ts b/extension/lib/runtime/ready.ts new file mode 100644 index 00000000..fc825768 --- /dev/null +++ b/extension/lib/runtime/ready.ts @@ -0,0 +1,16 @@ +// extension/lib/runtime/ready.ts +// Tells the packaged menu the game is ready to be modded. + +export const READY_EVENT = "origin:ready" +const READY_FLAG = "__ORIGIN_READY__" + +export const markOriginReady = (win: any): void => { + if (win[READY_FLAG]) return + win[READY_FLAG] = true + win.dispatchEvent(new Event(READY_EVENT)) +} + +export const whenOriginReady = (win: any): Promise => + win[READY_FLAG] + ? Promise.resolve() + : new Promise((resolve) => win.addEventListener(READY_EVENT, () => resolve(), { once: true })) diff --git a/extension/lib/runtime/resolve.ts b/extension/lib/runtime/resolve.ts new file mode 100644 index 00000000..df08ec44 --- /dev/null +++ b/extension/lib/runtime/resolve.ts @@ -0,0 +1,90 @@ +// extension/lib/runtime/resolve.ts +// Finds the game objects Play Origin hooks, by shape or by names minification +// can't touch. Uses property descriptors only, so no game getters or methods run +// apart from webpack's own export getters. + +export type AnyClass = Function & { prototype: any } + +export type Targets = { + singletonClass?: AnyClass & { readonly instance?: any } + constants?: Record + componentRegistry?: Record + baseActionClass?: AnyClass + towerTownClass?: AnyClass +} + +export type TargetName = keyof Targets + +export const TARGET_NAMES: readonly TargetName[] = [ + "singletonClass", + "constants", + "componentRegistry", + "baseActionClass", + "towerTownClass" +] + +const BASE_ACTION_METHODS = ["init", "execute", "finish", "findParameter", "validateParameters"] + +const descriptor = (obj: object, key: PropertyKey) => Object.getOwnPropertyDescriptor(obj, key) + +const isClass = (v: unknown): v is AnyClass => + typeof v === "function" && typeof (v as AnyClass).prototype === "object" && (v as AnyClass).prototype !== null + +const ownsMethod = (cls: AnyClass, name: string) => typeof descriptor(cls.prototype, name)?.value === "function" + +// module 35120: class with static `instance` getter whose instances expose `prodigy` +const isSingletonClass = (v: unknown): v is NonNullable => + isClass(v) && typeof descriptor(v, "instance")?.get === "function" && typeof descriptor(v.prototype, "prodigy")?.get === "function" + +// module 34829: `X.constants = {"GameConstants.Build.VERSION": ...}` +const constantsOf = (v: unknown): Record | undefined => { + if (!v || (typeof v !== "object" && typeof v !== "function")) return undefined + const c = descriptor(v, "constants")?.value + return c && typeof c === "object" && Object.prototype.hasOwnProperty.call(c, "GameConstants.Build.VERSION") ? c : undefined +} + +// module 40794 export X, filled by the EV("OpenQuestionInterface", …) decorator +const isComponentRegistry = (v: unknown): v is Record => + !!v && typeof v === "object" && isClass(descriptor(v, "OpenQuestionInterface")?.value) + +// module 62459 export rcl: subclasses only own `validate`/`execute` +const isBaseActionClass = (v: unknown): v is AnyClass => isClass(v) && BASE_ACTION_METHODS.every((m) => ownsMethod(v, m)) + +// module 82142 export gT (Tower Town) +const isTowerTownClass = (v: unknown): v is AnyClass => isClass(v) && ownsMethod(v, "openQuestionInterfaceThenEmitNotifications") + +function* exportValues(exportsObjects: Iterable): Generator { + for (const obj of exportsObjects) { + let keys: string[] + try { + keys = Object.keys(obj) + } catch { + continue + } + for (const key of keys) { + let value: unknown + try { + value = (obj as Record)[key] + } catch { + continue // module still evaluating (TDZ) + } + yield value + } + } +} + +export const missingTargets = (t: Targets): TargetName[] => TARGET_NAMES.filter((n) => !t[n]) + +export const resolveTargets = (exportsObjects: Iterable, found: Targets): Targets => { + const t: Targets = { ...found } + if (missingTargets(t).length === 0) return t + for (const v of exportValues(exportsObjects)) { + if (!t.singletonClass && isSingletonClass(v)) t.singletonClass = v + if (!t.constants) t.constants = constantsOf(v) + if (!t.componentRegistry && isComponentRegistry(v)) t.componentRegistry = v + if (!t.baseActionClass && isBaseActionClass(v)) t.baseActionClass = v + if (!t.towerTownClass && isTowerTownClass(v)) t.towerTownClass = v + if (missingTargets(t).length === 0) break + } + return t +} diff --git a/extension/lib/version.ts b/extension/lib/version.ts index e8618d88..cfcbaae7 100644 --- a/extension/lib/version.ts +++ b/extension/lib/version.ts @@ -1 +1 @@ -export const VERSION = "4.4.1" +export const VERSION = "4.5.0" diff --git a/extension/package.json b/extension/package.json index 9bab91f5..46524487 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,16 +1,17 @@ { "name": "prodigy-origin-extension", - "version": "4.4.1", + "version": "4.5.0", "private": true, "description": "Plasmo-based Play Origin browser extension", "scripts": { - "dev": "plasmo dev", + "menu": "pnpm --dir ../originGUI build", + "dev": "pnpm run menu && plasmo dev", "dev:firefox": "plasmo dev --target=firefox-mv3", - "build": "plasmo build", + "build": "pnpm run menu && plasmo build", "build:firefox": "plasmo build --target=firefox-mv3", - "package": "plasmo package", + "package": "pnpm run menu && plasmo package", "package:firefox": "plasmo package --target=firefox-mv3", - "typecheck": "tsc --noEmit", + "typecheck": "pnpm run menu && tsc --noEmit", "test": "node --experimental-strip-types --test \"tests/**/*.test.ts\"" }, "dependencies": { @@ -33,13 +34,16 @@ "description": "The ultimate open-source mod loader for online math games.", "author": "ProdigyPXP", "permissions": [ - "declarativeNetRequest", - "storage", - "unlimitedStorage" + "declarativeNetRequest" + ], + "web_accessible_resources": [ + { + "resources": ["assets/origin-bg.png", "assets/origin-logo.png"], + "matches": ["*://*.prodigygame.com/*"] + } ], "host_permissions": [ - "*://*.prodigygame.com/*", - "https://raw.githubusercontent.com/*" + "*://*.prodigygame.com/*" ], "browser_specific_settings": { "gecko": { diff --git a/extension/popup.tsx b/extension/popup.tsx index 32c24ef0..ab3df45c 100644 --- a/extension/popup.tsx +++ b/extension/popup.tsx @@ -1,43 +1,10 @@ -import { useEffect, useState } from "react"; import { VERSION } from "./lib/version"; -import { motion, AnimatePresence } from "framer-motion"; -import { ExternalLink, Save, RotateCcw, Play, MessageCircle, ChevronDown } from "lucide-react"; +import { ExternalLink, Play, MessageCircle } from "lucide-react"; import { Button } from "./components/Button"; import logoUrl from "data-base64:./assets/icon.png"; import "./style.css"; function Popup() { - const [manifestUrl, setManifestUrl] = useState(""); - const [guiUrl, setGuiUrl] = useState(""); - const [saved, setSaved] = useState(false); - const [devOpen, setDevOpen] = useState(false); - const [devUnlocked, setDevUnlocked] = useState(false); - const [challengeInput, setChallengeInput] = useState(""); - - useEffect(() => { - chrome.storage.local.get(["originManifestUrl", "originGuiUrl"], (res) => { - setManifestUrl(res.originManifestUrl ?? ""); - setGuiUrl(res.originGuiUrl ?? ""); - }); - }, []); - - const handleSave = () => { - chrome.storage.local.set( - { originManifestUrl: manifestUrl, originGuiUrl: guiUrl }, - () => { - setSaved(true); - setTimeout(() => setSaved(false), 2000); - } - ); - }; - - const handleReset = () => { - chrome.storage.local.remove(["originManifestUrl", "originGuiUrl"], () => { - setManifestUrl(""); - setGuiUrl(""); - }); - }; - return (
@@ -73,138 +40,6 @@ function Popup() {
- -
- - {/* Developer Options accordion */} - - - - {devOpen && ( - -
-

- ⚠️ Do not change these unless you know what you're doing. -

- - {devUnlocked ? ( - <> - - - - -
- - -
- -

- Leave blank to use defaults. Reload the Prodigy tab after saving. -

- - ) : ( -
- {(() => { - const checkAnswer = () => { - const answer = challengeInput.trim().toLowerCase(); - if (["i++", "i++;", "i += 1", "i += 1;"].includes(answer)) { - setDevUnlocked(true); - } - }; - return ( - <> - - setChallengeInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - checkAnswer(); - } - }} - className="field-input" - /> - - - ); - })()} -
- )} -
-
- )} -
); } diff --git a/extension/tests/bundle-cache.test.ts b/extension/tests/bundle-cache.test.ts deleted file mode 100644 index 316a56d4..00000000 --- a/extension/tests/bundle-cache.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { - CACHE_KEY, - getCachedBundle, - setCachedBundle, - clearCachedBundle, - type StorageLocalApi, - type CacheEntry -} from "../lib/bundle-cache.ts"; - -const makeFakeStorage = (initial: Record = {}): StorageLocalApi => { - let store: Record = { ...initial }; - return { - get: async (keys) => { - if (typeof keys === "string") return { [keys]: store[keys] }; - if (Array.isArray(keys)) { - const out: Record = {}; - for (const k of keys) out[k] = store[k]; - return out; - } - return { ...store }; - }, - set: async (items) => { store = { ...store, ...items }; }, - remove: async (keys) => { - const ks = Array.isArray(keys) ? keys : [keys]; - for (const k of ks) delete store[k]; - } - }; -}; - -const entry: CacheEntry = { - gameClientVersion: "2026.18.1", - manifestHash: "deadbeef00000000", - patchedBundle: "console.log(1)", - storedAt: 1000 -}; - -describe("getCachedBundle", () => { - it("returns undefined when nothing stored", async () => { - const r = await getCachedBundle(makeFakeStorage()); - assert.equal(r, undefined); - }); - - it("returns the stored entry", async () => { - const r = await getCachedBundle(makeFakeStorage({ [CACHE_KEY]: entry })); - assert.deepEqual(r, entry); - }); - - it("returns undefined when stored shape is invalid", async () => { - const r = await getCachedBundle(makeFakeStorage({ [CACHE_KEY]: { wrong: true } })); - assert.equal(r, undefined); - }); -}); - -describe("setCachedBundle", () => { - it("writes the entry", async () => { - const s = makeFakeStorage(); - await setCachedBundle(s, entry); - assert.deepEqual(await getCachedBundle(s), entry); - }); - - it("overwrites prior entries", async () => { - const s = makeFakeStorage(); - await setCachedBundle(s, entry); - await setCachedBundle(s, { ...entry, gameClientVersion: "2026.19.0" }); - const got = await getCachedBundle(s); - assert.equal(got?.gameClientVersion, "2026.19.0"); - }); -}); - -describe("clearCachedBundle", () => { - it("removes the entry", async () => { - const s = makeFakeStorage({ [CACHE_KEY]: entry }); - await clearCachedBundle(s); - assert.equal(await getCachedBundle(s), undefined); - }); - - it("no-ops when nothing is stored", async () => { - await clearCachedBundle(makeFakeStorage()); - }); -}); diff --git a/extension/tests/integration.test.ts b/extension/tests/integration.test.ts deleted file mode 100644 index 8b1f78c3..00000000 --- a/extension/tests/integration.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, it, test } from "node:test" -import assert from "node:assert/strict" -import { applyPatchRules, wrapWithPrefixSuffix, type PatchRule } from "../lib/patches.ts" -import { validateManifest } from "../lib/manifest.ts" - -const SYNTHETIC_MANIFEST = { - schemaVersion: 1, - patcherVersion: "4.4.0", - hash: "abc123def4567890", - prefix: "/* PRE */", - suffix: "/* POST */", - defaultMenuUrl: "https://example.com/bundle.js", - rules: [ - { - id: "singleton-exposure", - description: "", - find: "(_instance=this\\),this\\._game=([A-Za-z_$][\\w$]*)\\}destroy\\(\\)\\{)", - flags: "", - replace: "_instance=this),window.__PNP__=this,this._game=$2}destroy(){", - minMatches: 1 - }, - { - id: "safe-bind", - description: "", - find: "([A-Za-z_$][\\w$]*\\.q\\.instance\\.prodigy\\.gameContainer)\\.bind\\(", - flags: "g", - replace: "window.__pnp_safeBind($1,", - minMatches: 1 - } - ] as PatchRule[] -} - -const ORIGINAL = ` -class Prodigy{constructor(E){_instance=this),this._game=E}destroy(){}} -class X{thing(){return A.q.instance.prodigy.gameContainer.bind("MathTower");}} -class Y{other(){return B.q.instance.prodigy.gameContainer.bind("Other");}} -` - -describe("end-to-end: validateManifest → applyPatchRules → wrapWithPrefixSuffix", () => { - it("produces a wrapped bundle with prefix, patched core, and suffix", () => { - const manifest = validateManifest(SYNTHETIC_MANIFEST) - const { output: patched, perRuleCounts } = applyPatchRules(ORIGINAL, manifest.rules) - - assert.equal(perRuleCounts["singleton-exposure"], 1) - assert.equal(perRuleCounts["safe-bind"], 2) - assert.ok(patched.includes("window.__PNP__=this")) - assert.ok(patched.includes("window.__pnp_safeBind(A.q.instance.prodigy.gameContainer,")) - assert.ok(patched.includes("window.__pnp_safeBind(B.q.instance.prodigy.gameContainer,")) - - const wrapped = wrapWithPrefixSuffix(patched, manifest.prefix, manifest.suffix) - assert.ok(wrapped.startsWith("/* PRE */")) - assert.ok(wrapped.endsWith("/* POST */\n") || wrapped.endsWith("/* POST */")) - assert.ok(wrapped.includes("window.__PNP__=this")) - }) - - it("validateManifest rejects a malformed payload", () => { - assert.throws(() => validateManifest({ ...SYNTHETIC_MANIFEST, schemaVersion: 99 })) - }) -}) - -test("menu-URL prefix is prepended to wrapped bundle", () => { - const wrapped = wrapWithPrefixSuffix("body", "prefix", "suffix"); - const menuUrl = "https://example.com/m.js"; - const finalBundle = - `globalThis.__ORIGIN_MENU_URL__=${JSON.stringify(menuUrl)};\n${wrapped}`; - assert.ok(finalBundle.startsWith( - `globalThis.__ORIGIN_MENU_URL__="https://example.com/m.js";\n` - )); - assert.ok(finalBundle.includes("prefix\nbody\nsuffix")); -}); - -test("override URL takes priority over manifest.defaultMenuUrl", () => { - const manifest = { defaultMenuUrl: "https://default.example/m.js" }; - const override = "https://custom.example/m.js"; - const resolved = override || manifest.defaultMenuUrl; - assert.equal(resolved, "https://custom.example/m.js"); - - const emptyOverride: string = ""; - const resolved2 = emptyOverride || manifest.defaultMenuUrl; - assert.equal(resolved2, "https://default.example/m.js"); -}); diff --git a/extension/tests/manifest.test.ts b/extension/tests/manifest.test.ts deleted file mode 100644 index 1d4c9d66..00000000 --- a/extension/tests/manifest.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, it, test } from "node:test"; -import assert from "node:assert/strict"; -import { validateManifest, type Manifest } from "../lib/manifest.ts"; - -const VALID: Manifest = { - schemaVersion: 1, - patcherVersion: "4.4.0", - hash: "deadbeef00000000", - rules: [ - { id: "r1", description: "", find: "a", flags: "g", replace: "b", minMatches: 1 } - ], - prefix: "PRE", - suffix: "POST", - defaultMenuUrl: "https://example.com/bundle.js" -}; - -describe("validateManifest", () => { - it("returns the manifest unchanged on a valid object", () => { - assert.deepEqual(validateManifest(VALID), VALID); - }); - - it("throws on null / non-object input", () => { - assert.throws(() => validateManifest(null)); - assert.throws(() => validateManifest("not an object")); - assert.throws(() => validateManifest(123)); - }); - - it("throws when schemaVersion is missing or wrong", () => { - assert.throws(() => validateManifest({ ...VALID, schemaVersion: 2 })); - assert.throws(() => validateManifest({ ...VALID, schemaVersion: undefined })); - }); - - it("throws when rules is not an array", () => { - assert.throws(() => validateManifest({ ...VALID, rules: "nope" })); - }); - - it("throws when a rule is missing required fields", () => { - assert.throws(() => validateManifest({ - ...VALID, - rules: [{ id: "r1", find: "a" }] - })); - }); - - it("throws when hash is not a string", () => { - assert.throws(() => validateManifest({ ...VALID, hash: 123 })); - }); - - it("throws when prefix/suffix are not strings", () => { - assert.throws(() => validateManifest({ ...VALID, prefix: null })); - assert.throws(() => validateManifest({ ...VALID, suffix: null })); - }); -}); - -test("validateManifest accepts defaultMenuUrl", () => { - const ok = validateManifest({ - schemaVersion: 1, - patcherVersion: "4.4.1", - hash: "deadbeefcafebabe", - rules: [], - prefix: "P", - suffix: "S", - defaultMenuUrl: "https://example.com/bundle.js" - }); - assert.equal(ok.defaultMenuUrl, "https://example.com/bundle.js"); -}); - -test("validateManifest rejects manifest missing defaultMenuUrl", () => { - assert.throws(() => validateManifest({ - schemaVersion: 1, - patcherVersion: "4.4.1", - hash: "deadbeefcafebabe", - rules: [], - prefix: "P", - suffix: "S" - }), /defaultMenuUrl/); -}); - -test("validateManifest rejects non-string defaultMenuUrl", () => { - assert.throws(() => validateManifest({ - schemaVersion: 1, - patcherVersion: "4.4.1", - hash: "deadbeefcafebabe", - rules: [], - prefix: "P", - suffix: "S", - defaultMenuUrl: 42 - }), /defaultMenuUrl/); -}); diff --git a/extension/tests/patch-urls.test.ts b/extension/tests/patch-urls.test.ts deleted file mode 100644 index d0d489ea..00000000 --- a/extension/tests/patch-urls.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { - MANIFEST_URL, - GAME_SCRIPT_RE, - isGameScriptSrc, - gameVersionFromUrl -} from "../lib/patch-urls.ts"; - -describe("MANIFEST_URL", () => { - it("points at raw.githubusercontent.com on the master branch", () => { - assert.equal( - MANIFEST_URL, - "https://raw.githubusercontent.com/ProdigyPXP/P-NP/master/dist/manifest.json" - ); - }); - - it("uses https", () => { - assert.ok(MANIFEST_URL.startsWith("https://")); - }); -}); - -describe("GAME_SCRIPT_RE", () => { - it("matches the canonical code.prodigygame.com game URL with version path", () => { - assert.ok(GAME_SCRIPT_RE.test("https://code.prodigygame.com/code/2026.18.1/game.min.js")); - }); - - it("matches with a ?v= query string", () => { - assert.ok(GAME_SCRIPT_RE.test("https://code.prodigygame.com/code/2026.18.1/game.min.js?v=2026.18.1")); - }); - - it("does not match unrelated scripts", () => { - assert.ok(!GAME_SCRIPT_RE.test("https://code.prodigygame.com/js/load-game-abc.min.js")); - assert.ok(!GAME_SCRIPT_RE.test("https://example.com/game.min.js")); - }); -}); - -describe("isGameScriptSrc", () => { - it("returns true for the canonical URL", () => { - assert.equal( - isGameScriptSrc("https://code.prodigygame.com/code/2026.18.1/game.min.js"), - true - ); - }); - - it("returns false for empty / null / undefined", () => { - assert.equal(isGameScriptSrc(""), false); - assert.equal(isGameScriptSrc(null as unknown as string), false); - assert.equal(isGameScriptSrc(undefined), false); - }); -}); - -describe("gameVersionFromUrl", () => { - it("extracts the version segment from the URL path", () => { - assert.equal( - gameVersionFromUrl("https://code.prodigygame.com/code/2026.18.1/game.min.js?v=2026.18.1"), - "2026.18.1" - ); - }); - - it("extracts even when no query string", () => { - assert.equal( - gameVersionFromUrl("https://code.prodigygame.com/code/2026.25.0/game.min.js"), - "2026.25.0" - ); - }); - - it("returns empty string when path doesn't match", () => { - assert.equal(gameVersionFromUrl("https://example.com/foo.js"), ""); - assert.equal(gameVersionFromUrl(""), ""); - }); -}); diff --git a/extension/tests/patches.test.ts b/extension/tests/patches.test.ts deleted file mode 100644 index fd8fac92..00000000 --- a/extension/tests/patches.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { applyPatchRules, wrapWithPrefixSuffix, type PatchRule } from "../lib/patches.ts"; - -const singletonRule: PatchRule = { - id: "singleton-exposure", - description: "", - find: "(_instance=this\\),this\\._game=([A-Za-z_$][\\w$]*)\\}destroy\\(\\)\\{)", - flags: "", - replace: "_instance=this),window.__PNP__=this,this._game=$2}destroy(){", - minMatches: 1 -}; - -const safeBindRule: PatchRule = { - id: "safe-bind", - description: "", - find: "([A-Za-z_$][\\w$]*\\.q\\.instance\\.prodigy\\.gameContainer)\\.bind\\(", - flags: "g", - replace: "window.__pnp_safeBind($1,", - minMatches: 1 -}; - -describe("applyPatchRules", () => { - it("applies a rule with first-match semantics (no g flag)", () => { - const input = `class X{constructor(){_instance=this),this._game=E}destroy(){}}`; - const { output, perRuleCounts } = applyPatchRules(input, [singletonRule]); - assert.equal(perRuleCounts["singleton-exposure"], 1); - assert.ok(output.includes("window.__PNP__=this")); - }); - - it("applies a rule with all-match semantics (g flag)", () => { - const input = `A.q.instance.prodigy.gameContainer.bind("a"); B.q.instance.prodigy.gameContainer.bind("b");`; - const { output, perRuleCounts } = applyPatchRules(input, [safeBindRule]); - assert.equal(perRuleCounts["safe-bind"], 2); - assert.ok(output.includes("window.__pnp_safeBind(A.q.instance.prodigy.gameContainer,")); - assert.ok(output.includes("window.__pnp_safeBind(B.q.instance.prodigy.gameContainer,")); - }); - - it("applies rules in order", () => { - const a: PatchRule = { id: "a", description: "", find: "foo", flags: "g", replace: "FOO", minMatches: 1 }; - const b: PatchRule = { id: "b", description: "", find: "FOO", flags: "g", replace: "BAR", minMatches: 1 }; - const { output } = applyPatchRules("foo", [a, b]); - assert.equal(output, "BAR"); - }); - - it("reports zero matches without throwing", () => { - const { output, perRuleCounts } = applyPatchRules("nothing", [singletonRule]); - assert.equal(perRuleCounts["singleton-exposure"], 0); - assert.equal(output, "nothing"); - }); -}); - -describe("wrapWithPrefixSuffix", () => { - it("prepends prefix and appends suffix", () => { - const out = wrapWithPrefixSuffix("BODY", "PRE", "POST"); - assert.equal(out, "PRE\nBODY\nPOST"); - }); - - it("preserves the body verbatim", () => { - const body = "(function(){var x=1;})();"; - const out = wrapWithPrefixSuffix(body, "P", "S"); - assert.ok(out.includes(body)); - }); -}); diff --git a/extension/tests/runtime-api.test.ts b/extension/tests/runtime-api.test.ts new file mode 100644 index 00000000..53e21b74 --- /dev/null +++ b/extension/tests/runtime-api.test.ts @@ -0,0 +1,129 @@ +// extension/tests/runtime-api.test.ts +import { describe, it } from "node:test" +import assert from "node:assert/strict" +import { addConstantsApi, createOriginApi, discoverService } from "../lib/runtime/api.ts" +import { markOriginReady, READY_EVENT, whenOriginReady } from "../lib/runtime/ready.ts" + +const makeGame = () => { + const player = { data: { name: "x" }, appearanceChanged: false, unlockMemberItems() { this.unlocked = true }, unlocked: false } + const membership = { _data: { active: false }, isMember: false, hasFeatureAccess() { return false }, updateMembershipDates() {} } + const network = { getCharData() {}, processPlayer: 1, sendZoneEvent() {} } + const services = new Map([ + ["NotAnId", { player: { data: {} } }], + ["1a2-3b4c", { player }], + ["83f-419b", membership], + ["ab-cd", network] + ]) + const gameContainer = { _inversifyContainer: { _bindingDictionary: { _map: services } }, get: (id: unknown) => services.get(id) } + const singleton = { prodigy: { gameContainer } as any, game: { state: { states: new Map([["Boot", { _gameData: { pet: [] } }]]) } } } + class Singleton { static get instance() { return singleton } get prodigy() { return null } } + const constants: Record = { "GameConstants.Build.VERSION": "2026.18.1" } + return { player, membership, network, gameContainer, singleton, Singleton, constants } +} + +describe("discoverService", () => { + it("returns the first hex-id binding matching the shape", () => { + const g = makeGame() + // "NotAnId" also has a player but is not a hex service id, so it is skipped. + assert.equal(discoverService(g.gameContainer, (i) => "player" in i), g.gameContainer.get("1a2-3b4c")) + }) + it("returns null without a binding map", () => { + assert.equal(discoverService({}, () => true), null) + }) +}) + +describe("addConstantsApi", () => { + it("adds hidden Map-like accessors over the same object", () => { + const raw: Record = { "GameConstants.Debug.EDUCATION_ENABLED": true } + addConstantsApi(raw) + raw.set("GameConstants.Debug.EDUCATION_ENABLED", false) + assert.equal(raw["GameConstants.Debug.EDUCATION_ENABLED"], false) + assert.equal(raw.get("GameConstants.Debug.EDUCATION_ENABLED"), false) + assert.equal(raw.has("GameConstants.Debug.EDUCATION_ENABLED"), true) + assert.equal(raw.constants, raw) + assert.deepEqual(Object.keys(raw), ["GameConstants.Debug.EDUCATION_ENABLED"]) + }) +}) + +describe("createOriginApi", () => { + it("creates window._ and exposes instance, constants, player, network, gameData, membership", () => { + const g = makeGame() + const win: any = {} + createOriginApi({ singletonClass: g.Singleton, constants: g.constants }).ensure(win) + assert.equal(win._.instance, g.singleton) + assert.equal(win._.constants, g.constants) + assert.equal(win._.player, g.player) + assert.equal(win._.network, g.network) + assert.equal(win._.network.game, g.singleton.game) + assert.deepEqual(win._.gameData, { pet: [] }) + assert.equal(win._.membership, g.membership) + }) + + it("re-applies after window._ is replaced and keeps lodash's functions", () => { + const g = makeGame() + const win: any = {} + const api = createOriginApi({ singletonClass: g.Singleton, constants: g.constants }) + api.ensure(win) + const lodashFunctions = () => [] + const lodash: any = Object.assign(function lodash() {}, { functions: lodashFunctions, cloneDeep: (x: unknown) => x }) + win._ = lodash + api.ensure(win) + assert.equal(win._, lodash) + assert.equal(win._.instance, g.singleton) + assert.equal(win._.functions, lodashFunctions) + assert.equal(typeof win._.functions.setMembership, "function") + }) + + it("setMembership toggles and restores the original membership data", () => { + const g = makeGame() + const win: any = {} + createOriginApi({ singletonClass: g.Singleton, constants: g.constants }).ensure(win) + const original = g.membership._data + assert.equal(win._.functions.setMembership(true), true) + assert.equal((g.membership._data as any).active, true) + assert.equal(g.membership.hasFeatureAccess(), true) + assert.equal(g.player.unlocked, true) + assert.equal(win._.functions.setMembership(false), true) + assert.equal(g.membership._data, original) + assert.equal(g.membership.hasFeatureAccess(), false) + }) + + it("keeps the original membership snapshot across re-apply", () => { + const g = makeGame() + const win: any = {} + const api = createOriginApi({ singletonClass: g.Singleton, constants: g.constants }) + api.ensure(win) + const original = g.membership._data + win._.functions.setMembership(true) + win._ = Object.assign(function lodash() {}, { functions: () => [] }) + api.ensure(win) + win._.functions.setMembership(false) + assert.equal(g.membership._data, original) + }) + + it("isReady waits for prodigy and player", () => { + const g = makeGame() + const win: any = {} + const api = createOriginApi({ singletonClass: g.Singleton, constants: g.constants }) + const prodigy = g.singleton.prodigy + g.singleton.prodigy = null + api.ensure(win) + assert.equal(api.isReady(win), false) + g.singleton.prodigy = prodigy + assert.equal(api.isReady(win), true) + }) +}) + +describe("ready signal", () => { + it("resolves listeners registered before and after the signal", async () => { + const win: any = new EventTarget() + const early = whenOriginReady(win) + let events = 0 + win.addEventListener(READY_EVENT, () => events++) + markOriginReady(win) + markOriginReady(win) + await early + await whenOriginReady(win) + assert.equal(events, 1) + }) +}) diff --git a/extension/tests/runtime-bypasses.test.ts b/extension/tests/runtime-bypasses.test.ts new file mode 100644 index 00000000..d64d2ebc --- /dev/null +++ b/extension/tests/runtime-bypasses.test.ts @@ -0,0 +1,156 @@ +// extension/tests/runtime-bypasses.test.ts +import { describe, it } from "node:test" +import assert from "node:assert/strict" +import { + installAnswerQuestionActionBypass, + installOpenQuestionBypass, + installTowerTownBypass +} from "../lib/runtime/bypasses.ts" + +const EDU = "GameConstants.Debug.EDUCATION_ENABLED" +const PCT = "GameConstants.Debug.AUTO_ANSWER_CORRECT_PERCENT" + +const signal = () => { + const calls: unknown[][] = [] + return { calls, dispatch: (...args: unknown[]) => { calls.push(args) } } +} + +const makeOpenQuestionClass = () => + class OpenQuestionInterface { + onQuestionAnswered = signal() + onQuestionAnsweredCorrectly = signal() + onQuestionAnsweredIncorrectly = signal() + opened = 0 + answerQuestion() { this.opened++ } + } + +describe("installOpenQuestionBypass", () => { + it("runs the original when education is enabled", () => { + const C = makeOpenQuestionClass() + const constants: Record = { [EDU]: true, [PCT]: 1 } + installOpenQuestionBypass(C, () => constants) + const q = new C() + q.answerQuestion() + assert.equal(q.opened, 1) + assert.equal(q.onQuestionAnswered.calls.length, 0) + }) + + it("answers correctly without opening when education is disabled", () => { + const C = makeOpenQuestionClass() + installOpenQuestionBypass(C, () => ({ [EDU]: false, [PCT]: 1 }), () => 0) + const q = new C() + q.answerQuestion() + assert.equal(q.opened, 0) + assert.deepEqual(q.onQuestionAnswered.calls, [[true, 0, null]]) + assert.deepEqual(q.onQuestionAnsweredCorrectly.calls, [[0, null]]) + assert.equal(q.onQuestionAnsweredIncorrectly.calls.length, 0) + }) + + it("answers incorrectly when the roll misses the percentage", () => { + const C = makeOpenQuestionClass() + installOpenQuestionBypass(C, () => ({ [EDU]: false, [PCT]: 0.5 }), () => 0.9) + const q = new C() + q.answerQuestion() + assert.deepEqual(q.onQuestionAnswered.calls, [[false, 0, null]]) + assert.deepEqual(q.onQuestionAnsweredIncorrectly.calls, [[0, null]]) + }) + + it("follows the flag live: bypass off again once education is re-enabled", () => { + const C = makeOpenQuestionClass() + const constants: Record = { [EDU]: false } + installOpenQuestionBypass(C, () => constants, () => 0) + const q = new C() + q.answerQuestion() + constants[EDU] = true + q.answerQuestion() + assert.equal(q.opened, 1) + }) + + it("runs the original while constants are unresolved", () => { + const C = makeOpenQuestionClass() + installOpenQuestionBypass(C, () => undefined) + const q = new C() + q.answerQuestion() + assert.equal(q.opened, 1) + }) + + it("wraps once", () => { + const C = makeOpenQuestionClass() + installOpenQuestionBypass(C, () => undefined) + const first = C.prototype.answerQuestion + assert.equal(installOpenQuestionBypass(C, () => undefined), true) + assert.equal(C.prototype.answerQuestion, first) + }) + + it("returns false when the method is missing", () => { + assert.equal(installOpenQuestionBypass(class {}, () => undefined), false) + }) +}) + +describe("installAnswerQuestionActionBypass", () => { + const make = () => { + class BaseAction { + data: any = null + finished: unknown = undefined + init(data: any) { this.data = data } + execute() {} + finish(result: unknown) { this.finished = result } + findParameter() { return null } + validateParameters() { return true } + } + class AnswerQuestion extends BaseAction { executed = false; execute() { this.executed = true } } + class Other extends BaseAction { executed = false; execute() { this.executed = true } } + return { BaseAction, AnswerQuestion, Other } + } + + it("finishes AnswerQuestion actions without asking when education is disabled", () => { + const { BaseAction, AnswerQuestion } = make() + installAnswerQuestionActionBypass(BaseAction, () => ({ [EDU]: false }), () => 0) + const a = new AnswerQuestion() + a.init({ Type: "AnswerQuestion" }) + a.execute() + assert.equal(a.executed, false) + assert.deepEqual(a.finished, { answerCorrect: true, responseTime: 0 }) + }) + + it("runs the original AnswerQuestion when education is enabled", () => { + const { BaseAction, AnswerQuestion } = make() + installAnswerQuestionActionBypass(BaseAction, () => ({ [EDU]: true })) + const a = new AnswerQuestion() + a.init({ Type: "AnswerQuestion" }) + a.execute() + assert.equal(a.executed, true) + }) + + it("leaves other actions and the base execute alone", () => { + const { BaseAction, Other } = make() + const baseExecute = BaseAction.prototype.execute + installAnswerQuestionActionBypass(BaseAction, () => ({ [EDU]: false })) + const o = new Other() + o.init({ Type: "Other" }) + o.execute() + assert.equal(o.executed, true) + assert.equal(BaseAction.prototype.execute, baseExecute) + }) +}) + +describe("installTowerTownBypass", () => { + class TowerTown { + opened = 0 + openQuestionInterfaceThenEmitNotifications(_e: unknown, _w: unknown, _t: unknown, _s: unknown, _cb?: Function) { this.opened++ } + } + + it("calls the completion callback as a correct answer", () => { + installTowerTownBypass(TowerTown, () => ({ [EDU]: false })) + const t = new TowerTown() + const calls: unknown[][] = [] + t.openQuestionInterfaceThenEmitNotifications(1, true, null, false, (...a: unknown[]) => calls.push(a)) + assert.equal(t.opened, 0) + assert.deepEqual(calls, [[true, 10, 1, false, false, {}]]) + }) + + it("tolerates a missing callback", () => { + installTowerTownBypass(TowerTown, () => ({ [EDU]: false })) + assert.doesNotThrow(() => new TowerTown().openQuestionInterfaceThenEmitNotifications(1, true, null, false)) + }) +}) diff --git a/extension/tests/runtime-modules.test.ts b/extension/tests/runtime-modules.test.ts new file mode 100644 index 00000000..41dfeb32 --- /dev/null +++ b/extension/tests/runtime-modules.test.ts @@ -0,0 +1,57 @@ +// extension/tests/runtime-modules.test.ts +import { describe, it } from "node:test" +import assert from "node:assert/strict" +import { installModuleHook, isWebpackExportDescriptor } from "../lib/runtime/modules.ts" + +const makeCtor = () => ({ defineProperty: Object.defineProperty }) as { defineProperty: ObjectConstructor["defineProperty"] } + +// Mirrors game.min.js: __webpack_require__.d=(E,w)=>{for(var T in w)o(w,T)&&!o(E,T)&&Object.defineProperty(E,T,{enumerable:!0,get:w[T]})} +const webpackD = (ctor: ReturnType) => (exports: object, definition: Record unknown>) => { + for (const key in definition) { + if (Object.prototype.hasOwnProperty.call(definition, key) && !Object.prototype.hasOwnProperty.call(exports, key)) { + ctor.defineProperty(exports, key, { enumerable: true, get: definition[key] }) + } + } +} + +describe("isWebpackExportDescriptor", () => { + it("accepts webpack's {enumerable, get} export descriptor", () => { + assert.equal(isWebpackExportDescriptor("q", { enumerable: true, get: () => 1 }), true) + }) + it("accepts the Module toStringTag", () => { + assert.equal(isWebpackExportDescriptor(Symbol.toStringTag, { value: "Module" }), true) + }) + it("rejects other descriptors", () => { + assert.equal(isWebpackExportDescriptor("q", { value: 1 }), false) + assert.equal(isWebpackExportDescriptor("q", { enumerable: true, configurable: true, get: () => 1 }), false) + assert.equal(isWebpackExportDescriptor("q", undefined), false) + }) +}) + +describe("installModuleHook", () => { + it("captures export objects defined through the hooked defineProperty", () => { + const ctor = makeCtor() + const sink = new Set() + installModuleHook(ctor, sink) + const exports = {} + webpackD(ctor)(exports, { q: () => 42 }) + assert.ok(sink.has(exports)) + assert.equal((exports as { q: number }).q, 42) + }) + it("passes non-export defines through without capturing", () => { + const ctor = makeCtor() + const sink = new Set() + installModuleHook(ctor, sink) + const obj = {} + ctor.defineProperty(obj, "x", { value: 1, writable: true }) + assert.equal(sink.size, 0) + assert.equal((obj as { x: number }).x, 1) + }) + it("uninstall restores the original", () => { + const ctor = makeCtor() + const uninstall = installModuleHook(ctor, new Set()) + assert.notEqual(ctor.defineProperty, Object.defineProperty) + uninstall() + assert.equal(ctor.defineProperty, Object.defineProperty) + }) +}) diff --git a/extension/tests/runtime-resolve.test.ts b/extension/tests/runtime-resolve.test.ts new file mode 100644 index 00000000..ddfad445 --- /dev/null +++ b/extension/tests/runtime-resolve.test.ts @@ -0,0 +1,78 @@ +// extension/tests/runtime-resolve.test.ts +import { describe, it } from "node:test" +import assert from "node:assert/strict" +import { missingTargets, resolveTargets, TARGET_NAMES } from "../lib/runtime/resolve.ts" + +const exportsOf = (values: Record): object => { + const e = {} + for (const k of Object.keys(values)) Object.defineProperty(e, k, { enumerable: true, get: () => values[k] }) + return e +} + +// Shapes copied from game.min.js (module ids as of 2026.18.1 in comments). +class Singleton { // 35120 q + static _instance: unknown = null + static get instance() { return this._instance } + get game() { return null } + get prodigy() { return null } +} +class GameOnlySingleton { // 61806 W: decoy, has instance+game but no prodigy + static get instance() { return null } + get game() { return null } +} +class ConstantsHolder { // 34829 s + static constants = { "GameConstants.Build.VERSION": "2026.18.1", "GameConstants.Debug.EDUCATION_ENABLED": true } +} +class OpenQuestionInterface { answerQuestion() {} } +const componentRegistry = { OpenQuestionInterface } // 40794 X +class BaseAction { // 62459 rcl + init() {} execute() {} finish() {} findParameter() {} validateParameters() {} +} +class AnswerQuestionAction extends BaseAction { execute() {} } // decoy: owns execute only +class TowerTown { openQuestionInterfaceThenEmitNotifications() {} } // 82142 gT + +const allExports = () => [ + exportsOf({ W: GameOnlySingleton }), + exportsOf({ q: Singleton }), + exportsOf({ A: {}, s: ConstantsHolder }), + exportsOf({ X: componentRegistry, u: class {} }), + exportsOf({ rcl: BaseAction, sub: AnswerQuestionAction }), + exportsOf({ gT: TowerTown }) +] + +describe("resolveTargets", () => { + it("finds every target by shape", () => { + const t = resolveTargets(allExports(), {}) + assert.equal(t.singletonClass, Singleton) + assert.equal(t.constants, ConstantsHolder.constants) + assert.equal(t.componentRegistry, componentRegistry) + assert.equal(t.baseActionClass, BaseAction) + assert.equal(t.towerTownClass, TowerTown) + assert.deepEqual(missingTargets(t), []) + }) + + it("skips getters that throw", () => { + const tdz = {} + Object.defineProperty(tdz, "S", { enumerable: true, get() { throw new ReferenceError("Cannot access 'S' before initialization") } }) + const t = resolveTargets([tdz, ...allExports()], {}) + assert.equal(t.singletonClass, Singleton) + }) + + it("resolves the targets that exist and reports the rest", () => { + const t = resolveTargets([exportsOf({ q: Singleton })], {}) + assert.equal(t.singletonClass, Singleton) + assert.deepEqual(missingTargets(t), TARGET_NAMES.filter((n) => n !== "singletonClass")) + }) + + it("keeps already-found targets and fills the rest later", () => { + const first = resolveTargets([exportsOf({ q: Singleton })], {}) + const second = resolveTargets(allExports(), first) + assert.equal(second.singletonClass, Singleton) + assert.equal(second.towerTownClass, TowerTown) + }) + + it("does not treat a registry that lacks OpenQuestionInterface yet as found", () => { + const t = resolveTargets([exportsOf({ X: {} })], {}) + assert.equal(t.componentRegistry, undefined) + }) +}) diff --git a/extension/tsconfig.json b/extension/tsconfig.json index 8ed3b435..bc891cbb 100644 --- a/extension/tsconfig.json +++ b/extension/tsconfig.json @@ -5,11 +5,18 @@ "ignoreDeprecations": "6.0", "types": [ "chrome" - ] + ], + "moduleResolution": "bundler" }, "include": [ ".plasmo/index.d.ts", "**/*.ts", "**/*.tsx" + ], + "exclude": [ + "node_modules", + "build", + "firefox", + "tests" ] } diff --git a/meta/webstores/CHROME.md b/meta/webstores/CHROME.md index a94038ae..259a4bc7 100644 --- a/meta/webstores/CHROME.md +++ b/meta/webstores/CHROME.md @@ -42,7 +42,7 @@ WHAT YOU GET (THE QUICK VERSION) — Battle helpers including instant win, auto-answer, and damage modifiers — Full inventory unlocks — Custom login background and clean UI tweaks -— Automatic, hands-off updates as the game patches +— Everything ships inside the extension — no code downloaded at runtime — Zero account risk on your end — everything runs locally in your browser ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -53,7 +53,7 @@ You install the extension. You go to math.prod·igygame.com. You log in like nor That's it. There's no separate launcher. No fake game client. No sketchy download from a forum. No file you have to manually drop into a folder somewhere. Play Origin works directly inside the official game website using the same Chrome extension framework as your password manager or ad blocker. -Under the hood, Play Origin loads the official game script straight from the game's own servers and applies a small set of community-maintained patches to it right on your device — in your own browser, never on a server — then runs the result. Those patches expose the game's internal systems to a clean, friendly menu. The patch rules are maintained automatically by the ProdigyPXP project and refreshed every couple of hours, so when the game pushes an update, the mod is usually working again before you even notice anything changed. Because the game code that actually runs is the game's own — just patched locally — there's no opaque pre-built game file being shipped to you. +Under the hood, the game loads its own official script from its own servers, completely unmodified. Play Origin never downloads, rewrites, or replaces it. The extension's own scripts, which are included in the package you install from the Chrome Web Store, run alongside the game in your browser and connect the mod menu to the game's internal systems. Nothing is fetched from anywhere else and run, and nothing runs on a server. What you install is exactly what runs. ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -83,8 +83,8 @@ Teleport to any zone in the game. Skip Member-only gates. Replay quests you've a CUSTOM LOGIN PAGE Optional cosmetic touches like a custom background and a refreshed logo, so the experience feels distinct from the moment you load the page. -AUTOMATIC UPDATES -The game patches frequently. Most mod tools break the moment that happens and stay broken until someone manually fixes them, sometimes for days. Play Origin uses an automated pipeline that refreshes its patch rules every two hours — your browser picks up the new rules and re-patches the game's latest script locally, so downtime is measured in minutes, not days. +BUILT TO SURVIVE GAME UPDATES +The game patches frequently, and most mod tools break the moment that happens. Play Origin doesn't patch the game's source. It hooks into parts of the game that rarely change, so most game updates don't affect it at all. When one does, a fixed version ships through the Chrome Web Store and Chrome updates the extension for you. ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -145,7 +145,7 @@ Is it free? Completely. No pro tier, no upsell, no email signup, no ads. Ever. Does it still work after a game update? -Almost always within a couple of hours. An automated pipeline refreshes the patch rules every two hours, and your browser re-patches the latest game script locally. +Usually, yes. Play Origin hooks into parts of the game that rarely change. When an update does break something, the fix ships as a new version through the Chrome Web Store, and Chrome installs it automatically. ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -166,7 +166,7 @@ INSTALLATION 5. The mod menu will appear in the corner of the screen once the game finishes loading. 6. Click the menu, explore the tabs, and play the way you've always wanted to. -If anything looks off, open the Play Origin popup from your Chrome toolbar. It shows the current status and a one-click "reload" button that fixes 99 percent of problems instantly. +If anything looks off, reload the game tab. The Play Origin popup in your Chrome toolbar shows the installed version and has a one-click shortcut to open the game. ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -176,7 +176,7 @@ Bug reports, feature requests, and questions go to the GitHub issue tracker at g For real-time chat with other users — including help getting set up, troubleshooting weird behavior, and sharing tips — join the community Discord linked from the GitHub page. -If something breaks after a game update, sit tight. The auto-patcher usually catches it within a couple of hours. If it doesn't, post on GitHub and someone will look at it. +If something breaks after a game update, post on GitHub. Fixes ship as a new Chrome Web Store version, and Chrome installs it for you automatically. ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/meta/webstores/JUSTIFICATIONS.md b/meta/webstores/JUSTIFICATIONS.md index f4e29def..401ee21a 100644 --- a/meta/webstores/JUSTIFICATIONS.md +++ b/meta/webstores/JUSTIFICATIONS.md @@ -2,19 +2,21 @@ These are the answers for the Chrome Web Store "Privacy practices" tab. Each block is sized to fit its field's character limit and can be pasted in verbatim. -> **Covers v4.4.x (client-side patcher).** Starting in v4.4.0 the extension no longer -> downloads a fully pre-built patched bundle. Instead it fetches Prodigy's *own* game -> script at runtime and applies a small set of community-maintained regex patch rules -> to it **locally, in the user's browser**, caching the result. This supersedes the -> v4.3.x justifications. The new behavior adds the `unlimitedStorage` permission and -> broadens the host permission from `math.prodigygame.com` to `*://*.prodigygame.com/*`. +> **Covers v4.5.x (Chrome Web Store exclusive, branch `chrome/packaged-runtime`).** +> v4.5.0 runs no remote code. Prodigy loads its own, unmodified game script; all of +> Play Origin's JavaScript (the game hooks and the mod menu) ships inside the package. +> Nothing is fetched and executed, nothing is patched in source, and Prodigy's CSP is +> left intact. Compared with v4.4.x this drops the `storage` and `unlimitedStorage` +> permissions, the `raw.githubusercontent.com` host permission, the game-script BLOCK +> rule and the CSP-stripping rule, and changes "Are you using remote code?" to **No**. +> Edge and Firefox stay on the v4.4.x pipeline (master); their justifications live there. ## Single purpose description > Field limit: 1,000 characters ``` -Play Origin injects an in-game mod menu into Prodigy Math Game (math.prodigygame.com). Its single purpose is to enhance that one game: the menu unlocks content, lets players mod their own levels, pets, and inventory, and provides battle helpers. Everything the extension does serves this goal — giving players control over a game that otherwise locks most of its content. It does this by patching Prodigy's own game script locally in the browser, never by reading or modifying any other site. +Play Origin adds an in-game mod menu to Prodigy Math Game (math.prodigygame.com). Its single purpose is to enhance that one game: the menu unlocks content, lets players mod their own levels, pets, and inventory, and provides battle helpers. Everything the extension does serves this goal. It works by running packaged scripts alongside Prodigy's own, unmodified game code on the game page, and it never reads or modifies any other site. ``` ## Permission justification — `scripting` @@ -22,39 +24,23 @@ Play Origin injects an in-game mod menu into Prodigy Math Game (math.prodigygame > Field limit: 1,000 characters ``` -The scripting permission registers a MAIN-world content script on math.prodigygame.com via chrome.scripting.registerContentScripts. It needs the MAIN world — not an isolated content script — because it overrides native DOM methods (Document.createElement, Node.appendChild, Node.insertBefore, Element.append, Element.setAttribute) to catch the exact moment Prodigy's own game.min.js script tag is inserted, neutralize it, and inject the locally-patched bundle in its place. Those prototype overrides only take effect in the page's own JavaScript context; an isolated-world script cannot patch the page's prototype chain or reach the live game-engine objects the mod menu binds to. The extension framework (Plasmo) registers the MAIN-world script from the background service worker, which is what requires this permission. No other scripting API is used. -``` +The extension framework (Plasmo) registers two content scripts in the page's MAIN world on math.prodigygame.com via chrome.scripting.registerContentScripts, which is what requires this permission. Both scripts are part of the extension package; no code is downloaded. -## Permission justification — `declarativeNetRequest` +1. A runtime script at document_start. It observes the game's own modules as they load (by wrapping Object.defineProperty) and adds small wrappers around a few of the game's methods, such as answering a question automatically when the player turns that option on. -> Field limit: 1,000 characters +2. The mod menu UI at document_idle, started once the game is ready. +They must run in the MAIN world because they work with the live objects of Prodigy's JavaScript engine, which an isolated-world content script cannot reach. No other scripting API is used. ``` -The declarativeNetRequest permission drives a small set of static rules, all scoped to prodigygame.com, registered at install via updateDynamicRules: - -1. BLOCK the original game.min.js request from Prodigy's code.prodigygame.com CDN, so the original and the locally-patched build do not both execute and conflict. - -2. MODIFY_HEADERS to strip the Content-Security-Policy and X-Frame-Options response headers on prodigygame.com page frames, so the patched bundle and the mod menu UI can be injected without being blocked by Prodigy's CSP. -3 and 4. REDIRECT the login-screen background image and logo to the extension's own equivalents (a cosmetic re-skin) hosted in the public ProdigyPXP GitHub repository. - -The rules match only on URL patterns under prodigygame.com. No browsing history or request content is read, and no other site is affected. -``` - -## Permission justification — `storage` +## Permission justification — `declarativeNetRequest` > Field limit: 1,000 characters ``` -The storage permission persists data locally between sessions via chrome.storage.local. Its main job is caching the locally-patched game bundle: the background service worker fetches Prodigy's own game script, applies the patch rules, and stores the result — keyed by the game's client version and a hash of the patch manifest. On later page loads it serves the cached build instead of re-fetching and re-patching the whole script on every navigation, and rebuilds automatically when either the game version or the patch rules change. It also stores two optional developer-override URLs (originManifestUrl, originGuiUrl) so advanced users can point the extension at a custom build or a fork. Everything is stored locally — nothing is synced and nothing leaves the user's machine. No personal data is stored: the cached content is derived entirely from Prodigy's own publicly served script and the extension's public patch rules. -``` - -## Permission justification — `unlimitedStorage` - -> Field limit: 1,000 characters +The declarativeNetRequest permission drives two REDIRECT rules, registered at install via updateDynamicRules. They swap the game's login-screen background image and logo for Play Origin artwork, a purely cosmetic re-skin. Both rules redirect to images packaged inside the extension (extensionPath, declared in web_accessible_resources for prodigygame.com only). -``` -The unlimitedStorage permission is needed because the cached patched bundle in chrome.storage.local is a full, minified game script that can exceed the default storage quota. Prodigy's game.min.js is a large production JavaScript bundle; after the extension fetches it and applies the patch rules, the resulting build is stored so it does not have to be re-fetched and re-patched on every page load. Without unlimitedStorage the cache write would fail whenever the patched bundle is larger than the default quota, forcing a full rebuild on every single navigation and defeating the cache entirely. No personal data is stored under this quota — the only content written is the patched bundle derived from Prodigy's own publicly served script, plus two short configuration URLs. +The rules match only two image URL patterns under prodigygame.com. They do not block requests, do not modify headers, do not touch scripts, and do not read browsing history or request content. No other site is affected. ``` ## Host permission justification @@ -62,19 +48,16 @@ The unlimitedStorage permission is needed because the cached patched bundle in c > Field limit: 1,000 characters ``` -Host permission for prodigygame.com (*://*.prodigygame.com/*) is required because Prodigy Math Game runs across several prodigygame.com subdomains and the extension's whole purpose is to modify that one game. It is used to inject the mod menu content script on the game page (math.prodigygame.com), to scope the declarativeNetRequest rules to the game's own requests (blocking game.min.js on code.prodigygame.com, stripping CSP headers, re-skinning the login image), and for the background service worker to fetch Prodigy's own original game.min.js so it can be patched locally. Host permission for raw.githubusercontent.com is required so the extension can fetch its patch manifest (regex patch rules) and its mod menu UI bundle from the public ProdigyPXP GitHub repositories at runtime. No other hosts are accessed, and no user data is sent to either domain — both are used purely for read-only fetches and local script injection. +Host permission for prodigygame.com (*://*.prodigygame.com/*) is required because Prodigy Math Game is served across several prodigygame.com subdomains and the extension's whole purpose is to modify that one game. It is used to run the packaged content scripts on the game page (math.prodigygame.com) and to apply the two cosmetic image redirects to the game's own login background (cdn.prodigygame.com) and logo (code.prodigygame.com). No other hosts are requested. The extension sends no user data anywhere and does not fetch any code. ``` ## Remote code justification -> Are you using remote code? **Yes** — Field limit: 1,000 characters - -``` -Play Origin does not download a pre-built patched bundle. It fetches two things and applies patches locally: +> Are you using remote code? **No** -1. A patch manifest — a JSON file from the extension's own public GitHub repo (raw.githubusercontent.com, ProdigyPXP): regex find/replace rules plus a small loader prefix/suffix, not executable code. Example: unlock a feature getter so it returns true. +No justification is required when answering No. For reviewers' reference: every script the extension executes (`runtime.*.js`, `menu.*.js`, the popup and the background service worker) is in the submitted package. The extension contains no `eval`, no `new Function`, and no code loaded from a URL. The only network requests the mod menu makes are JSON calls to Prodigy's own API (`api.prodigygame.com`) to save and load the player's character. -2. Prodigy's own game.min.js — fetched from code.prodigygame.com by the background service worker, the same script its CDN serves every player. A DNR rule blocks it in the tab; the worker fetches it independently because service-worker fetches aren't subject to the extension's DNR rules. +## Data usage -The worker patches it locally and caches the result in chrome.storage.local; the MAIN-world script injects it via the DOM. The mod menu UI is a separate pre-built bundle from the same repo, run in the page. Prodigy updates often, so a pipeline refreshes the manifest every few hours. No user data is sent. -``` +- Collects no user data. No analytics, no telemetry, no remote logging. +- Stores nothing: the extension requests no storage permission. diff --git a/originGUI/build.mjs b/originGUI/build.mjs index ac8c85d3..49722648 100644 --- a/originGUI/build.mjs +++ b/originGUI/build.mjs @@ -10,6 +10,8 @@ const rootDir = path.dirname(fileURLToPath(import.meta.url)); const srcDir = path.join(rootDir, "src"); const distDir = path.join(rootDir, "dist"); const bundlePath = path.join(distDir, "bundle.js"); +const menuModulePath = path.join(distDir, "menu.js"); +const menuTypesPath = path.join(distDir, "menu.d.ts"); // Files intentionally excluded from auto-discovery (inert stubs / archived code) const EXCLUDED_FILES = new Set(["patched.ts", "patchedMods.ts"]); @@ -75,6 +77,14 @@ if (!style.parentNode) { }; } +// Wraps the IIFE bundle in a function so the Chrome extension can package the +// menu inside a content script and start it once the game is ready. +async function writeMenuModule () { + const bundle = await fs.promises.readFile(bundlePath, "utf8"); + await fs.promises.writeFile(menuModulePath, `// Generated by originGUI/build.mjs. Do not edit.\nexport default function startOriginMenu () {\n${bundle}\n}\n`); + await fs.promises.writeFile(menuTypesPath, "export default function startOriginMenu (): void;\n"); +} + function toImportPath (filePath) { return `./${path.relative(rootDir, filePath).split(path.sep).join("/")}`; } @@ -112,6 +122,8 @@ export async function buildBundle ({ mode = "production" } = {}) { plugins: [createScssPlugin()], logLevel: "info", }); + + await writeMenuModule(); } const isDirectRun = process.argv[1] === fileURLToPath(import.meta.url); diff --git a/originGUI/dist/bundle.js b/originGUI/dist/bundle.js index f02736b3..af8b6a47 100644 --- a/originGUI/dist/bundle.js +++ b/originGUI/dist/bundle.js @@ -1,4 +1,4 @@ -(()=>{var vs=Object.create;var en=Object.defineProperty;var ks=Object.getOwnPropertyDescriptor;var Cs=Object.getOwnPropertyNames;var Es=Object.getPrototypeOf,xs=Object.prototype.hasOwnProperty;var y=(t,e,n)=>()=>{if(n)throw n[0];try{return t&&(e=t(t=0)),e}catch(o){throw n=[o],o}};var mt=(t,e)=>()=>{try{return e||t((e={exports:{}}).exports,e),e.exports}catch(n){throw e=0,n}},Ss=(t,e)=>{for(var n in e)en(t,n,{get:e[n],enumerable:!0})},As=(t,e,n,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let r of Cs(e))!xs.call(t,r)&&r!==n&&en(t,r,{get:()=>e[r],enumerable:!(o=ks(e,r))||o.enumerable});return t};var wt=(t,e,n)=>(n=t!=null?vs(Es(t)):{},As(e||!t||!t.__esModule?en(n,"default",{value:t,enumerable:!0}):n,t));var G,Ke,Ge,gt=y(()=>{G=Object.create(null);G.open="0";G.close="1";G.ping="2";G.pong="3";G.message="4";G.upgrade="5";G.noop="6";Ke=Object.create(null);Object.keys(G).forEach(t=>{Ke[G[t]]=t});Ge={type:"error",data:"parser error"}});function oo(t){return t instanceof Uint8Array?t:t instanceof ArrayBuffer?new Uint8Array(t):new Uint8Array(t.buffer,t.byteOffset,t.byteLength)}function ao(t,e){if(ro&&t.data instanceof Blob)return t.data.arrayBuffer().then(oo).then(e);if(so&&(t.data instanceof ArrayBuffer||io(t.data)))return e(oo(t.data));Xe(t,!1,n=>{tn||(tn=new TextEncoder),e(tn.encode(n))})}var ro,so,io,Xe,no,tn,lo=y(()=>{gt();ro=typeof Blob=="function"||typeof Blob<"u"&&Object.prototype.toString.call(Blob)==="[object BlobConstructor]",so=typeof ArrayBuffer=="function",io=t=>typeof ArrayBuffer.isView=="function"?ArrayBuffer.isView(t):t&&t.buffer instanceof ArrayBuffer,Xe=({type:t,data:e},n,o)=>ro&&e instanceof Blob?n?o(e):no(e,o):so&&(e instanceof ArrayBuffer||io(e))?n?o(e):no(new Blob([e]),o):o(G[t]+(e||"")),no=(t,e)=>{let n=new FileReader;return n.onload=function(){let o=n.result.split(",")[1];e("b"+(o||""))},n.readAsDataURL(t)}});var co,Je,uo,po=y(()=>{co="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",Je=typeof Uint8Array>"u"?[]:new Uint8Array(256);for(let t=0;t{let e=t.length*.75,n=t.length,o,r=0,s,l,u,p;t[t.length-1]==="="&&(e--,t[t.length-2]==="="&&e--);let C=new ArrayBuffer(e),B=new Uint8Array(C);for(o=0;o>4,B[r++]=(l&15)<<4|u>>2,B[r++]=(u&3)<<6|p&63;return C}});var Ts,Ze,_s,ho,fo=y(()=>{gt();po();Ts=typeof ArrayBuffer=="function",Ze=(t,e)=>{if(typeof t!="string")return{type:"message",data:ho(t,e)};let n=t.charAt(0);return n==="b"?{type:"message",data:_s(t.substring(1),e)}:Ke[n]?t.length>1?{type:Ke[n],data:t.substring(1)}:{type:Ke[n]}:Ge},_s=(t,e)=>{if(Ts){let n=uo(t);return ho(n,e)}else return{base64:!0,data:t}},ho=(t,e)=>e==="blob"?t instanceof Blob?t:new Blob([t]):t instanceof ArrayBuffer?t:t.buffer});function yo(){return new TransformStream({transform(t,e){ao(t,n=>{let o=n.length,r;if(o<126)r=new Uint8Array(1),new DataView(r.buffer).setUint8(0,o);else if(o<65536){r=new Uint8Array(3);let s=new DataView(r.buffer);s.setUint8(0,126),s.setUint16(1,o)}else{r=new Uint8Array(9);let s=new DataView(r.buffer);s.setUint8(0,127),s.setBigUint64(1,BigInt(o))}t.data&&typeof t.data!="string"&&(r[0]|=128),e.enqueue(r),e.enqueue(n)})}})}function yt(t){return t.reduce((e,n)=>e+n.length,0)}function bt(t,e){if(t[0].length===e)return t.shift();let n=new Uint8Array(e),o=0;for(let r=0;rMath.pow(2,21)-1){u.enqueue(Ge);break}r=B*Math.pow(2,32)+C.getUint32(4),o=3}else{if(yt(n)t){u.enqueue(Ge);break}}}})}var mo,wo,go,nn,on,Te=y(()=>{lo();fo();gt();mo="",wo=(t,e)=>{let n=t.length,o=new Array(n),r=0;t.forEach((s,l)=>{Xe(s,!1,u=>{o[l]=u,++r===n&&e(o.join(mo))})})},go=(t,e)=>{let n=t.split(mo),o=[];for(let r=0;r{E.prototype.on=E.prototype.addEventListener=function(t,e){return this._callbacks=this._callbacks||{},(this._callbacks["$"+t]=this._callbacks["$"+t]||[]).push(e),this};E.prototype.once=function(t,e){function n(){this.off(t,n),e.apply(this,arguments)}return n.fn=e,this.on(t,n),this};E.prototype.off=E.prototype.removeListener=E.prototype.removeAllListeners=E.prototype.removeEventListener=function(t,e){if(this._callbacks=this._callbacks||{},arguments.length==0)return this._callbacks={},this;var n=this._callbacks["$"+t];if(!n)return this;if(arguments.length==1)return delete this._callbacks["$"+t],this;for(var o,r=0;r{te=typeof Promise=="function"&&typeof Promise.resolve=="function"?e=>Promise.resolve().then(e):(e,n)=>n(e,0),N=typeof self<"u"?self:typeof window<"u"?window:Function("return this")(),vo="arraybuffer"});function vt(t,...e){return e.reduce((n,o)=>(t.hasOwnProperty(o)&&(n[o]=t[o]),n),{})}function ne(t,e){e.useNativeTimers?(t.setTimeoutFn=Bs.bind(N),t.clearTimeoutFn=Is.bind(N)):(t.setTimeoutFn=N.setTimeout.bind(N),t.clearTimeoutFn=N.clearTimeout.bind(N))}function ko(t){return typeof t=="string"?Ls(t):Math.ceil((t.byteLength||t.size)*Ds)}function Ls(t){let e=0,n=0;for(let o=0,r=t.length;o=57344?n+=3:(o++,n+=4);return n}function kt(){return Date.now().toString(36).substring(3)+Math.random().toString(36).substring(2,5)}var Bs,Is,Ds,ge=y(()=>{we();Bs=N.setTimeout,Is=N.clearTimeout;Ds=1.33});function Co(t){let e="";for(let n in t)t.hasOwnProperty(n)&&(e.length&&(e+="&"),e+=encodeURIComponent(n)+"="+encodeURIComponent(t[n]));return e}function Eo(t){let e={},n=t.split("&");for(let o=0,r=n.length;o{});var Ct,oe,Qe=y(()=>{Te();me();ge();rn();Ct=class extends Error{constructor(e,n,o){super(e),this.description=n,this.context=o,this.type="TransportError"}},oe=class extends E{constructor(e){super(),this.writable=!1,ne(this,e),this.opts=e,this.query=e.query,this.socket=e.socket,this.supportsBinary=!e.forceBase64}onError(e,n,o){return super.emitReserved("error",new Ct(e,n,o)),this}open(){return this.readyState="opening",this.doOpen(),this}close(){return(this.readyState==="opening"||this.readyState==="open")&&(this.doClose(),this.onClose()),this}send(e){this.readyState==="open"&&this.write(e)}onOpen(){this.readyState="open",this.writable=!0,super.emitReserved("open")}onData(e){let n=Ze(e,this.socket.binaryType);this.onPacket(n)}onPacket(e){super.emitReserved("packet",e)}onClose(e){this.readyState="closed",super.emitReserved("close",e)}pause(e){}createUri(e,n={}){return e+"://"+this._hostname()+this._port()+this.opts.path+this._query(n)}_hostname(){let e=this.opts.hostname;return e.indexOf(":")===-1?e:"["+e+"]"}_port(){return this.opts.port&&(this.opts.secure&&Number(this.opts.port)!==443||!this.opts.secure&&Number(this.opts.port)!==80)?":"+this.opts.port:""}_query(e){let n=Co(e);return n.length?"?"+n:""}}});var et,sn=y(()=>{Qe();ge();Te();et=class extends oe{constructor(){super(...arguments),this._polling=!1}get name(){return"polling"}doOpen(){this._poll()}pause(e){this.readyState="pausing";let n=()=>{this.readyState="paused",e()};if(this._polling||!this.writable){let o=0;this._polling&&(o++,this.once("pollComplete",function(){--o||n()})),this.writable||(o++,this.once("drain",function(){--o||n()}))}else n()}_poll(){this._polling=!0,this.doPoll(),this.emitReserved("poll")}onData(e){let n=o=>{if(this.readyState==="opening"&&o.type==="open"&&this.onOpen(),o.type==="close")return this.onClose({description:"transport closed by the server"}),!1;this.onPacket(o)};go(e,this.socket.binaryType).forEach(n),this.readyState!=="closed"&&(this._polling=!1,this.emitReserved("pollComplete"),this.readyState==="open"&&this._poll())}doClose(){let e=()=>{this.write([{type:"close"}])};this.readyState==="open"?e():this.once("open",e)}write(e){this.writable=!1,wo(e,n=>{this.doWrite(n,()=>{this.writable=!0,this.emitReserved("drain")})})}uri(){let e=this.opts.secure?"https":"http",n=this.query||{};return this.opts.timestampRequests!==!1&&(n[this.opts.timestampParam]=kt()),!this.supportsBinary&&!n.sid&&(n.b64=1),this.createUri(e,n)}}});var xo,So,Ao=y(()=>{xo=!1;try{xo=typeof XMLHttpRequest<"u"&&"withCredentials"in new XMLHttpRequest}catch{}So=xo});function Os(){}function To(){for(let t in ce.requests)ce.requests.hasOwnProperty(t)&&ce.requests[t].abort()}function _o(t){let e=t.xdomain;try{if(typeof XMLHttpRequest<"u"&&(!e||So))return new XMLHttpRequest}catch{}if(!e)try{return new N[["Active"].concat("Object").join("X")]("Microsoft.XMLHTTP")}catch{}}var an,ce,Rs,ue,Et=y(()=>{sn();me();ge();we();Ao();an=class extends et{constructor(e){if(super(e),typeof location<"u"){let n=location.protocol==="https:",o=location.port;o||(o=n?"443":"80"),this.xd=typeof location<"u"&&e.hostname!==location.hostname||o!==e.port}}doWrite(e,n){let o=this.request({method:"POST",data:e});o.on("success",n),o.on("error",(r,s)=>{this.onError("xhr post error",r,s)})}doPoll(){let e=this.request();e.on("data",this.onData.bind(this)),e.on("error",(n,o)=>{this.onError("xhr poll error",n,o)}),this.pollXhr=e}},ce=class t extends E{constructor(e,n,o){super(),this.createRequest=e,ne(this,o),this._opts=o,this._method=o.method||"GET",this._uri=n,this._data=o.data!==void 0?o.data:null,this._create()}_create(){var e;let n=vt(this._opts,"agent","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","autoUnref");n.xdomain=!!this._opts.xd;let o=this._xhr=this.createRequest(n);try{o.open(this._method,this._uri,!0);try{if(this._opts.extraHeaders){o.setDisableHeaderCheck&&o.setDisableHeaderCheck(!0);for(let r in this._opts.extraHeaders)this._opts.extraHeaders.hasOwnProperty(r)&&o.setRequestHeader(r,this._opts.extraHeaders[r])}}catch{}if(this._method==="POST")try{o.setRequestHeader("Content-type","text/plain;charset=UTF-8")}catch{}try{o.setRequestHeader("Accept","*/*")}catch{}(e=this._opts.cookieJar)===null||e===void 0||e.addCookies(o),"withCredentials"in o&&(o.withCredentials=this._opts.withCredentials),this._opts.requestTimeout&&(o.timeout=this._opts.requestTimeout),o.onreadystatechange=()=>{var r;o.readyState===3&&((r=this._opts.cookieJar)===null||r===void 0||r.parseCookies(o.getResponseHeader("set-cookie"))),o.readyState===4&&(o.status===200||o.status===1223?this._onLoad():this.setTimeoutFn(()=>{this._onError(typeof o.status=="number"?o.status:0)},0))},o.send(this._data)}catch(r){this.setTimeoutFn(()=>{this._onError(r)},0);return}typeof document<"u"&&(this._index=t.requestsCount++,t.requests[this._index]=this)}_onError(e){this.emitReserved("error",e,this._xhr),this._cleanup(!0)}_cleanup(e){if(!(typeof this._xhr>"u"||this._xhr===null)){if(this._xhr.onreadystatechange=Os,e)try{this._xhr.abort()}catch{}typeof document<"u"&&delete t.requests[this._index],this._xhr=null}}_onLoad(){let e=this._xhr.responseText;e!==null&&(this.emitReserved("data",e),this.emitReserved("success"),this._cleanup())}abort(){this._cleanup()}};ce.requestsCount=0;ce.requests={};if(typeof document<"u"){if(typeof attachEvent=="function")attachEvent("onunload",To);else if(typeof addEventListener=="function"){let t="onpagehide"in N?"pagehide":"unload";addEventListener(t,To,!1)}}Rs=(function(){let t=_o({xdomain:!1});return t&&t.responseType!==null})(),ue=class extends an{constructor(e){super(e);let n=e&&e.forceBase64;this.supportsBinary=Rs&&!n}request(e={}){return Object.assign(e,{xd:this.xd},this.opts),new ce(_o,this.uri(),e)}}});var Po,cn,ln,de,xt=y(()=>{Qe();ge();Te();we();Po=typeof navigator<"u"&&typeof navigator.product=="string"&&navigator.product.toLowerCase()==="reactnative",cn=class extends oe{get name(){return"websocket"}doOpen(){let e=this.uri(),n=this.opts.protocols,o=Po?{}:vt(this.opts,"agent","perMessageDeflate","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","localAddress","protocolVersion","origin","maxPayload","family","checkServerIdentity");this.opts.extraHeaders&&(o.headers=this.opts.extraHeaders);try{this.ws=this.createSocket(e,n,o)}catch(r){return this.emitReserved("error",r)}this.ws.binaryType=this.socket.binaryType,this.addEventListeners()}addEventListeners(){this.ws.onopen=()=>{this.opts.autoUnref&&this.ws._socket.unref(),this.onOpen()},this.ws.onclose=e=>this.onClose({description:"websocket connection closed",context:e}),this.ws.onmessage=e=>this.onData(e.data),this.ws.onerror=e=>this.onError("websocket error",e)}write(e){this.writable=!1;for(let n=0;n{try{this.doWrite(o,s)}catch{}r&&te(()=>{this.writable=!0,this.emitReserved("drain")},this.setTimeoutFn)})}}doClose(){typeof this.ws<"u"&&(this.ws.onerror=()=>{},this.ws.close(),this.ws=null)}uri(){let e=this.opts.secure?"wss":"ws",n=this.query||{};return this.opts.timestampRequests&&(n[this.opts.timestampParam]=kt()),this.supportsBinary||(n.b64=1),this.createUri(e,n)}},ln=N.WebSocket||N.MozWebSocket,de=class extends cn{createSocket(e,n,o){return Po?new ln(e,n,o):n?new ln(e,n):new ln(e)}doWrite(e,n){this.ws.send(n)}}});var _e,un=y(()=>{Qe();we();Te();_e=class extends oe{get name(){return"webtransport"}doOpen(){try{this._transport=new WebTransport(this.createUri("https"),this.opts.transportOptions[this.name])}catch(e){return this.emitReserved("error",e)}this._transport.closed.then(()=>{this.onClose()}).catch(e=>{this.onError("webtransport error",e)}),this._transport.ready.then(()=>{this._transport.createBidirectionalStream().then(e=>{let n=bo(Number.MAX_SAFE_INTEGER,this.socket.binaryType),o=e.readable.pipeThrough(n).getReader(),r=yo();r.readable.pipeTo(e.writable),this._writer=r.writable.getWriter();let s=()=>{o.read().then(({done:u,value:p})=>{u||(this.onPacket(p),s())}).catch(u=>{})};s();let l={type:"open"};this.query.sid&&(l.data=`{"sid":"${this.query.sid}"}`),this._writer.write(l).then(()=>this.onOpen())})})}write(e){this.writable=!1;for(let n=0;n{r&&te(()=>{this.writable=!0,this.emitReserved("drain")},this.setTimeoutFn)})}}doClose(){var e;(e=this._transport)===null||e===void 0||e.close()}}});var dn,pn=y(()=>{Et();xt();un();dn={websocket:de,webtransport:_e,polling:ue}});function Pe(t){if(t.length>8e3)throw"URI too long";let e=t,n=t.indexOf("["),o=t.indexOf("]");n!=-1&&o!=-1&&(t=t.substring(0,n)+t.substring(n,o).replace(/:/g,";")+t.substring(o,t.length));let r=Ns.exec(t||""),s={},l=14;for(;l--;)s[Ms[l]]=r[l]||"";return n!=-1&&o!=-1&&(s.source=e,s.host=s.host.substring(1,s.host.length-1).replace(/;/g,":"),s.authority=s.authority.replace("[","").replace("]","").replace(/;/g,":"),s.ipv6uri=!0),s.pathNames=$s(s,s.path),s.queryKey=qs(s,s.query),s}function $s(t,e){let n=/\/{2,9}/g,o=e.replace(n,"/").split("/");return(e.slice(0,1)=="/"||e.length===0)&&o.splice(0,1),e.slice(-1)=="/"&&o.splice(o.length-1,1),o}function qs(t,e){let n={};return e.replace(/(?:^|&)([^&=]*)=?([^&]*)/g,function(o,r,s){r&&(n[r]=s)}),n}var Ns,Ms,hn=y(()=>{Ns=/^(?:(?![^:@\/?#]+:[^:@\/]*@)(http|https|ws|wss):\/\/)?((?:(([^:@\/?#]*)(?::([^:@\/?#]*))?)?@)?((?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}|[^:\/?#]*)(?::(\d*))?)(((\/(?:[^?#](?![^?#\/]*\.[^?#\/.]+(?:[?#]|$)))*\/?)?([^?#\/]*))(?:\?([^#]*))?(?:#(.*))?)/,Ms=["source","protocol","authority","userInfo","user","password","host","port","relative","path","directory","file","query","anchor"]});var fn,St,ye,At,Be,mn=y(()=>{pn();ge();rn();hn();me();Te();we();fn=typeof addEventListener=="function"&&typeof removeEventListener=="function",St=[];fn&&addEventListener("offline",()=>{St.forEach(t=>t())},!1);ye=class t extends E{constructor(e,n){if(super(),this.binaryType=vo,this.writeBuffer=[],this._prevBufferLen=0,this._pingInterval=-1,this._pingTimeout=-1,this._maxPayload=-1,this._pingTimeoutTime=1/0,e&&typeof e=="object"&&(n=e,e=null),e){let o=Pe(e);n.hostname=o.host,n.secure=o.protocol==="https"||o.protocol==="wss",n.port=o.port,o.query&&(n.query=o.query)}else n.host&&(n.hostname=Pe(n.host).host);ne(this,n),this.secure=n.secure!=null?n.secure:typeof location<"u"&&location.protocol==="https:",n.hostname&&!n.port&&(n.port=this.secure?"443":"80"),this.hostname=n.hostname||(typeof location<"u"?location.hostname:"localhost"),this.port=n.port||(typeof location<"u"&&location.port?location.port:this.secure?"443":"80"),this.transports=[],this._transportsByName={},n.transports.forEach(o=>{let r=o.prototype.name;this.transports.push(r),this._transportsByName[r]=o}),this.opts=Object.assign({path:"/engine.io",agent:!1,withCredentials:!1,upgrade:!0,timestampParam:"t",rememberUpgrade:!1,addTrailingSlash:!0,rejectUnauthorized:!0,perMessageDeflate:{threshold:1024},transportOptions:{},closeOnBeforeunload:!1},n),this.opts.path=this.opts.path.replace(/\/$/,"")+(this.opts.addTrailingSlash?"/":""),typeof this.opts.query=="string"&&(this.opts.query=Eo(this.opts.query)),fn&&(this.opts.closeOnBeforeunload&&(this._beforeunloadEventListener=()=>{this.transport&&(this.transport.removeAllListeners(),this.transport.close())},addEventListener("beforeunload",this._beforeunloadEventListener,!1)),this.hostname!=="localhost"&&(this._offlineEventListener=()=>{this._onClose("transport close",{description:"network connection lost"})},St.push(this._offlineEventListener))),this.opts.withCredentials&&(this._cookieJar=void 0),this._open()}createTransport(e){let n=Object.assign({},this.opts.query);n.EIO=on,n.transport=e,this.id&&(n.sid=this.id);let o=Object.assign({},this.opts,{query:n,socket:this,hostname:this.hostname,secure:this.secure,port:this.port},this.opts.transportOptions[e]);return new this._transportsByName[e](o)}_open(){if(this.transports.length===0){this.setTimeoutFn(()=>{this.emitReserved("error","No transports available")},0);return}let e=this.opts.rememberUpgrade&&t.priorWebsocketSuccess&&this.transports.indexOf("websocket")!==-1?"websocket":this.transports[0];this.readyState="opening";let n=this.createTransport(e);n.open(),this.setTransport(n)}setTransport(e){this.transport&&this.transport.removeAllListeners(),this.transport=e,e.on("drain",this._onDrain.bind(this)).on("packet",this._onPacket.bind(this)).on("error",this._onError.bind(this)).on("close",n=>this._onClose("transport close",n))}onOpen(){this.readyState="open",t.priorWebsocketSuccess=this.transport.name==="websocket",this.emitReserved("open"),this.flush()}_onPacket(e){if(this.readyState==="opening"||this.readyState==="open"||this.readyState==="closing")switch(this.emitReserved("packet",e),this.emitReserved("heartbeat"),e.type){case"open":this.onHandshake(JSON.parse(e.data));break;case"ping":this._sendPacket("pong"),this.emitReserved("ping"),this.emitReserved("pong"),this._resetPingTimeout();break;case"error":let n=new Error("server error");n.code=e.data,this._onError(n);break;case"message":this.emitReserved("data",e.data),this.emitReserved("message",e.data);break}}onHandshake(e){this.emitReserved("handshake",e),this.id=e.sid,this.transport.query.sid=e.sid,this._pingInterval=e.pingInterval,this._pingTimeout=e.pingTimeout,this._maxPayload=e.maxPayload,this.onOpen(),this.readyState!=="closed"&&this._resetPingTimeout()}_resetPingTimeout(){this.clearTimeoutFn(this._pingTimeoutTimer);let e=this._pingInterval+this._pingTimeout;this._pingTimeoutTime=Date.now()+e,this._pingTimeoutTimer=this.setTimeoutFn(()=>{this._onClose("ping timeout")},e),this.opts.autoUnref&&this._pingTimeoutTimer.unref()}_onDrain(){this.writeBuffer.splice(0,this._prevBufferLen),this._prevBufferLen=0,this.writeBuffer.length===0?this.emitReserved("drain"):this.flush()}flush(){if(this.readyState!=="closed"&&this.transport.writable&&!this.upgrading&&this.writeBuffer.length){let e=this._getWritablePackets();this.transport.send(e),this._prevBufferLen=e.length,this.emitReserved("flush")}}_getWritablePackets(){if(!(this._maxPayload&&this.transport.name==="polling"&&this.writeBuffer.length>1))return this.writeBuffer;let n=1;for(let o=0;o0&&n>this._maxPayload)return this.writeBuffer.slice(0,o);n+=2}return this.writeBuffer}_hasPingExpired(){if(!this._pingTimeoutTime)return!0;let e=Date.now()>this._pingTimeoutTime;return e&&(this._pingTimeoutTime=0,te(()=>{this._onClose("ping timeout")},this.setTimeoutFn)),e}write(e,n,o){return this._sendPacket("message",e,n,o),this}send(e,n,o){return this._sendPacket("message",e,n,o),this}_sendPacket(e,n,o,r){if(typeof n=="function"&&(r=n,n=void 0),typeof o=="function"&&(r=o,o=null),this.readyState==="closing"||this.readyState==="closed")return;o=o||{},o.compress=o.compress!==!1;let s={type:e,data:n,options:o};this.emitReserved("packetCreate",s),this.writeBuffer.push(s),r&&this.once("flush",r),this.flush()}close(){let e=()=>{this._onClose("forced close"),this.transport.close()},n=()=>{this.off("upgrade",n),this.off("upgradeError",n),e()},o=()=>{this.once("upgrade",n),this.once("upgradeError",n)};return(this.readyState==="opening"||this.readyState==="open")&&(this.readyState="closing",this.writeBuffer.length?this.once("drain",()=>{this.upgrading?o():e()}):this.upgrading?o():e()),this}_onError(e){if(t.priorWebsocketSuccess=!1,this.opts.tryAllTransports&&this.transports.length>1&&this.readyState==="opening")return this.transports.shift(),this._open();this.emitReserved("error",e),this._onClose("transport error",e)}_onClose(e,n){if(this.readyState==="opening"||this.readyState==="open"||this.readyState==="closing"){if(this.clearTimeoutFn(this._pingTimeoutTimer),this.transport.removeAllListeners("close"),this.transport.close(),this.transport.removeAllListeners(),fn&&(this._beforeunloadEventListener&&removeEventListener("beforeunload",this._beforeunloadEventListener,!1),this._offlineEventListener)){let o=St.indexOf(this._offlineEventListener);o!==-1&&St.splice(o,1)}this.readyState="closed",this.id=null,this.emitReserved("close",e,n),this.writeBuffer=[],this._prevBufferLen=0}}};ye.protocol=on;At=class extends ye{constructor(){super(...arguments),this._upgrades=[]}onOpen(){if(super.onOpen(),this.readyState==="open"&&this.opts.upgrade)for(let e=0;e{o||(n.send([{type:"ping",data:"probe"}]),n.once("packet",le=>{if(!o)if(le.type==="pong"&&le.data==="probe"){if(this.upgrading=!0,this.emitReserved("upgrading",n),!n)return;ye.priorWebsocketSuccess=n.name==="websocket",this.transport.pause(()=>{o||this.readyState!=="closed"&&(B(),this.setTransport(n),n.send([{type:"upgrade"}]),this.emitReserved("upgrade",n),n=null,this.upgrading=!1,this.flush())})}else{let je=new Error("probe error");je.transport=n.name,this.emitReserved("upgradeError",je)}}))};function s(){o||(o=!0,B(),n.close(),n=null)}let l=le=>{let je=new Error("probe error: "+le);je.transport=n.name,s(),this.emitReserved("upgradeError",je)};function u(){l("transport closed")}function p(){l("socket closed")}function C(le){n&&le.name!==n.name&&s()}let B=()=>{n.removeListener("open",r),n.removeListener("error",l),n.removeListener("close",u),this.off("close",p),this.off("upgrading",C)};n.once("open",r),n.once("error",l),n.once("close",u),this.once("close",p),this.once("upgrading",C),this._upgrades.indexOf("webtransport")!==-1&&e!=="webtransport"?this.setTimeoutFn(()=>{o||n.open()},200):n.open()}onHandshake(e){this._upgrades=this._filterUpgrades(e.upgrades),super.onHandshake(e)}_filterUpgrades(e){let n=[];for(let o=0;odn[s]).filter(s=>!!s)),super(o?r:e,r)}}});var Bo=y(()=>{sn()});var Wc,Tt=y(()=>{mn();mn();Qe();pn();ge();hn();we();Bo();Et();Et();xt();xt();un();Wc=Be.protocol});function Io(t,e="",n){let o=t;n=n||typeof location<"u"&&location,t==null&&(t=n.protocol+"//"+n.host),typeof t=="string"&&(t.charAt(0)==="/"&&(t.charAt(1)==="/"?t=n.protocol+t:t=n.host+t),/^(https?|wss?):\/\//.test(t)||(typeof n<"u"?t=n.protocol+"//"+t:t="https://"+t),o=Pe(t)),o.port||(/^(http|ws)$/.test(o.protocol)?o.port="80":/^(http|ws)s$/.test(o.protocol)&&(o.port="443")),o.path=o.path||"/";let s=o.host.indexOf(":")!==-1?"["+o.host+"]":o.host;return o.id=o.protocol+"://"+s+":"+o.port+e,o.href=o.protocol+"://"+s+(n&&n.port===o.port?"":":"+o.port),o}var Do=y(()=>{Tt()});function nt(t){return Fs&&(t instanceof ArrayBuffer||Vs(t))||Us&&t instanceof Blob||zs&&t instanceof File}function tt(t,e){if(!t||typeof t!="object")return!1;if(Array.isArray(t)){for(let n=0,o=t.length;n{Fs=typeof ArrayBuffer=="function",Vs=t=>typeof ArrayBuffer.isView=="function"?ArrayBuffer.isView(t):t.buffer instanceof ArrayBuffer,Lo=Object.prototype.toString,Us=typeof Blob=="function"||typeof Blob<"u"&&Lo.call(Blob)==="[object BlobConstructor]",zs=typeof File=="function"||typeof File<"u"&&Lo.call(File)==="[object FileConstructor]"});function Oo(t){let e=[],n=t.data,o=t;return o.data=_t(n,e),o.attachments=e.length,{packet:o,buffers:e}}function _t(t,e,n){if(!t)return t;if(nt(t)){let o={_placeholder:!0,num:e.length};return e.push(t),o}else if(Array.isArray(t)){let o=new Array(t.length);for(let r=0;r=0&&t.num{wn()});var kn={};Ss(kn,{Decoder:()=>bn,Encoder:()=>yn,PacketType:()=>g,isPacketValid:()=>Ks,protocol:()=>$o});function Ws(t){return typeof t=="string"}function Ys(t){return t===void 0||qo(t)}function Pt(t){return Object.prototype.toString.call(t)==="[object Object]"}function js(t,e){switch(t){case g.CONNECT:return e===void 0||Pt(e);case g.DISCONNECT:return e===void 0;case g.EVENT:return Array.isArray(e)&&(typeof e[0]=="number"||typeof e[0]=="string"&&Mo.indexOf(e[0])===-1);case g.ACK:return Array.isArray(e);case g.CONNECT_ERROR:return typeof e=="string"||Pt(e);default:return!1}}function Ks(t){return Ws(t.nsp)&&Ys(t.id)&&js(t.type,t.data)}var Mo,$o,g,yn,bn,vn,qo,Bt=y(()=>{me();No();wn();Mo=["connect","connect_error","disconnect","disconnecting","newListener","removeListener"],$o=5;(function(t){t[t.CONNECT=0]="CONNECT",t[t.DISCONNECT=1]="DISCONNECT",t[t.EVENT=2]="EVENT",t[t.ACK=3]="ACK",t[t.CONNECT_ERROR=4]="CONNECT_ERROR",t[t.BINARY_EVENT=5]="BINARY_EVENT",t[t.BINARY_ACK=6]="BINARY_ACK"})(g||(g={}));yn=class{constructor(e){this.replacer=e}encode(e){return(e.type===g.EVENT||e.type===g.ACK)&&tt(e)?this.encodeAsBinary({type:e.type===g.EVENT?g.BINARY_EVENT:g.BINARY_ACK,nsp:e.nsp,data:e.data,id:e.id}):[this.encodeAsString(e)]}encodeAsString(e){let n=""+e.type;return(e.type===g.BINARY_EVENT||e.type===g.BINARY_ACK)&&(n+=e.attachments+"-"),e.nsp&&e.nsp!=="/"&&(n+=e.nsp+","),e.id!=null&&(n+=e.id),e.data!=null&&(n+=JSON.stringify(e.data,this.replacer)),n}encodeAsBinary(e){let n=Oo(e),o=this.encodeAsString(n.packet),r=n.buffers;return r.unshift(o),r}},bn=class t extends E{constructor(e){super(),this.opts=Object.assign({reviver:void 0,maxAttachments:10},typeof e=="function"?{reviver:e}:e)}add(e){let n;if(typeof e=="string"){if(this.reconstructor)throw new Error("got plaintext data when reconstructing a packet");n=this.decodeString(e);let o=n.type===g.BINARY_EVENT;o||n.type===g.BINARY_ACK?(n.type=o?g.EVENT:g.ACK,this.reconstructor=new vn(n)):super.emitReserved("decoded",n)}else if(nt(e)||e.base64)if(this.reconstructor)n=this.reconstructor.takeBinaryData(e),n&&(this.reconstructor=null,super.emitReserved("decoded",n));else throw new Error("got binary data when not reconstructing a packet");else throw new Error("Unknown type: "+e)}decodeString(e){let n=0,o={type:Number(e.charAt(0))};if(g[o.type]===void 0)throw new Error("unknown packet type "+o.type);if(o.type===g.BINARY_EVENT||o.type===g.BINARY_ACK){let s=n+1;for(;e.charAt(++n)!=="-"&&n!=e.length;);let l=e.substring(s,n);if(l!=Number(l)||e.charAt(n)!=="-")throw new Error("Illegal attachments");let u=Number(l);if(!qo(u)||u<1)throw new Error("Illegal attachments");if(u>this.opts.maxAttachments)throw new Error("too many attachments");o.attachments=u}if(e.charAt(n+1)==="/"){let s=n+1;for(;++n&&!(e.charAt(n)===","||n===e.length););o.nsp=e.substring(s,n)}else o.nsp="/";let r=e.charAt(n+1);if(r!==""&&Number(r)==r){let s=n+1;for(;++n;){let l=e.charAt(n);if(l==null||Number(l)!=l){--n;break}if(n===e.length)break}o.id=Number(e.substring(s,n+1))}if(e.charAt(++n)){let s=this.tryParse(e.substr(n));if(t.isPayloadValid(o.type,s))o.data=s;else throw new Error("invalid payload")}return o}tryParse(e){try{return JSON.parse(e,this.opts.reviver)}catch{return!1}}static isPayloadValid(e,n){switch(e){case g.CONNECT:return Pt(n);case g.DISCONNECT:return n===void 0;case g.CONNECT_ERROR:return typeof n=="string"||Pt(n);case g.EVENT:case g.BINARY_EVENT:return Array.isArray(n)&&(typeof n[0]=="number"||typeof n[0]=="string"&&Mo.indexOf(n[0])===-1);case g.ACK:case g.BINARY_ACK:return Array.isArray(n)}}destroy(){this.reconstructor&&(this.reconstructor.finishedReconstruction(),this.reconstructor=null)}},vn=class{constructor(e){this.packet=e,this.buffers=[],this.reconPack=e}takeBinaryData(e){if(this.buffers.push(e),this.buffers.length===this.reconPack.attachments){let n=Ro(this.reconPack,this.buffers);return this.finishedReconstruction(),n}return null}finishedReconstruction(){this.reconPack=null,this.buffers=[]}};qo=Number.isInteger||function(t){return typeof t=="number"&&isFinite(t)&&Math.floor(t)===t}});function F(t,e,n){return t.on(e,n),function(){t.off(e,n)}}var Cn=y(()=>{});var Gs,Ie,En=y(()=>{Bt();Cn();me();Gs=Object.freeze({connect:1,connect_error:1,disconnect:1,disconnecting:1,newListener:1,removeListener:1}),Ie=class extends E{constructor(e,n,o){super(),this.connected=!1,this.recovered=!1,this.receiveBuffer=[],this.sendBuffer=[],this._queue=[],this._queueSeq=0,this.ids=0,this.acks={},this.flags={},this.io=e,this.nsp=n,o&&o.auth&&(this.auth=o.auth),this._opts=Object.assign({},o),this.io._autoConnect&&this.open()}get disconnected(){return!this.connected}subEvents(){if(this.subs)return;let e=this.io;this.subs=[F(e,"open",this.onopen.bind(this)),F(e,"packet",this.onpacket.bind(this)),F(e,"error",this.onerror.bind(this)),F(e,"close",this.onclose.bind(this))]}get active(){return!!this.subs}connect(){return this.connected?this:(this.subEvents(),this.io._reconnecting||this.io.open(),this.io._readyState==="open"&&this.onopen(),this)}open(){return this.connect()}send(...e){return e.unshift("message"),this.emit.apply(this,e),this}emit(e,...n){var o,r,s;if(Gs.hasOwnProperty(e))throw new Error('"'+e.toString()+'" is a reserved event name');if(n.unshift(e),this._opts.retries&&!this.flags.fromQueue&&!this.flags.volatile)return this._addToQueue(n),this;let l={type:g.EVENT,data:n};if(l.options={},l.options.compress=this.flags.compress!==!1,typeof n[n.length-1]=="function"){let B=this.ids++,le=n.pop();this._registerAckCallback(B,le),l.id=B}let u=(r=(o=this.io.engine)===null||o===void 0?void 0:o.transport)===null||r===void 0?void 0:r.writable,p=this.connected&&!(!((s=this.io.engine)===null||s===void 0)&&s._hasPingExpired());return this.flags.volatile&&!u||(p?(this.notifyOutgoingListeners(l),this.packet(l)):this.sendBuffer.push(l)),this.flags={},this}_registerAckCallback(e,n){var o;let r=(o=this.flags.timeout)!==null&&o!==void 0?o:this._opts.ackTimeout;if(r===void 0){this.acks[e]=n;return}let s=this.io.setTimeoutFn(()=>{delete this.acks[e];for(let u=0;u{this.io.clearTimeoutFn(s),n.apply(this,u)};l.withError=!0,this.acks[e]=l}emitWithAck(e,...n){return new Promise((o,r)=>{let s=(l,u)=>l?r(l):o(u);s.withError=!0,n.push(s),this.emit(e,...n)})}_addToQueue(e){let n;typeof e[e.length-1]=="function"&&(n=e.pop());let o={id:this._queueSeq++,tryCount:0,pending:!1,args:e,flags:Object.assign({fromQueue:!0},this.flags)};e.push((r,...s)=>(this._queue[0],r!==null?o.tryCount>this._opts.retries&&(this._queue.shift(),n&&n(r)):(this._queue.shift(),n&&n(null,...s)),o.pending=!1,this._drainQueue())),this._queue.push(o),this._drainQueue()}_drainQueue(e=!1){if(!this.connected||this._queue.length===0)return;let n=this._queue[0];n.pending&&!e||(n.pending=!0,n.tryCount++,this.flags=n.flags,this.emit.apply(this,n.args))}packet(e){e.nsp=this.nsp,this.io._packet(e)}onopen(){typeof this.auth=="function"?this.auth(e=>{this._sendConnectPacket(e)}):this._sendConnectPacket(this.auth)}_sendConnectPacket(e){this.packet({type:g.CONNECT,data:this._pid?Object.assign({pid:this._pid,offset:this._lastOffset},e):e})}onerror(e){this.connected||this.emitReserved("connect_error",e)}onclose(e,n){this.connected=!1,delete this.id,this.emitReserved("disconnect",e,n),this._clearAcks()}_clearAcks(){Object.keys(this.acks).forEach(e=>{if(!this.sendBuffer.some(o=>String(o.id)===e)){let o=this.acks[e];delete this.acks[e],o.withError&&o.call(this,new Error("socket has been disconnected"))}})}onpacket(e){if(e.nsp===this.nsp)switch(e.type){case g.CONNECT:e.data&&e.data.sid?this.onconnect(e.data.sid,e.data.pid):this.emitReserved("connect_error",new Error("It seems you are trying to reach a Socket.IO server in v2.x with a v3.x client, but they are not compatible (more information here: https://socket.io/docs/v3/migrating-from-2-x-to-3-0/)"));break;case g.EVENT:case g.BINARY_EVENT:this.onevent(e);break;case g.ACK:case g.BINARY_ACK:this.onack(e);break;case g.DISCONNECT:this.ondisconnect();break;case g.CONNECT_ERROR:this.destroy();let o=new Error(e.data.message);o.data=e.data.data,this.emitReserved("connect_error",o);break}}onevent(e){let n=e.data||[];e.id!=null&&n.push(this.ack(e.id)),this.connected?this.emitEvent(n):this.receiveBuffer.push(Object.freeze(n))}emitEvent(e){if(this._anyListeners&&this._anyListeners.length){let n=this._anyListeners.slice();for(let o of n)o.apply(this,e)}super.emit.apply(this,e),this._pid&&e.length&&typeof e[e.length-1]=="string"&&(this._lastOffset=e[e.length-1])}ack(e){let n=this,o=!1;return function(...r){o||(o=!0,n.packet({type:g.ACK,id:e,data:r}))}}onack(e){let n=this.acks[e.id];typeof n=="function"&&(delete this.acks[e.id],n.withError&&e.data.unshift(null),n.apply(this,e.data))}onconnect(e,n){this.id=e,this.recovered=n&&this._pid===n,this._pid=n,this.connected=!0,this.emitBuffered(),this._drainQueue(!0),this.emitReserved("connect")}emitBuffered(){this.receiveBuffer.forEach(e=>this.emitEvent(e)),this.receiveBuffer=[],this.sendBuffer.forEach(e=>{this.notifyOutgoingListeners(e),this.packet(e)}),this.sendBuffer=[]}ondisconnect(){this.destroy(),this.onclose("io server disconnect")}destroy(){this.subs&&(this.subs.forEach(e=>e()),this.subs=void 0),this.io._destroy(this)}disconnect(){return this.connected&&this.packet({type:g.DISCONNECT}),this.destroy(),this.connected&&this.onclose("io client disconnect"),this}close(){return this.disconnect()}compress(e){return this.flags.compress=e,this}get volatile(){return this.flags.volatile=!0,this}timeout(e){return this.flags.timeout=e,this}onAny(e){return this._anyListeners=this._anyListeners||[],this._anyListeners.push(e),this}prependAny(e){return this._anyListeners=this._anyListeners||[],this._anyListeners.unshift(e),this}offAny(e){if(!this._anyListeners)return this;if(e){let n=this._anyListeners;for(let o=0;o0&&t.jitter<=1?t.jitter:0,this.attempts=0}var Ho=y(()=>{be.prototype.duration=function(){var t=this.ms*Math.pow(this.factor,this.attempts++);if(this.jitter){var e=Math.random(),n=Math.floor(e*this.jitter*t);t=(Math.floor(e*10)&1)==0?t-n:t+n}return Math.min(t,this.max)|0};be.prototype.reset=function(){this.attempts=0};be.prototype.setMin=function(t){this.ms=t};be.prototype.setMax=function(t){this.max=t};be.prototype.setJitter=function(t){this.jitter=t}});var De,Fo=y(()=>{Tt();En();Bt();Cn();Ho();me();De=class extends E{constructor(e,n){var o;super(),this.nsps={},this.subs=[],e&&typeof e=="object"&&(n=e,e=void 0),n=n||{},n.path=n.path||"/socket.io",this.opts=n,ne(this,n),this.reconnection(n.reconnection!==!1),this.reconnectionAttempts(n.reconnectionAttempts||1/0),this.reconnectionDelay(n.reconnectionDelay||1e3),this.reconnectionDelayMax(n.reconnectionDelayMax||5e3),this.randomizationFactor((o=n.randomizationFactor)!==null&&o!==void 0?o:.5),this.backoff=new be({min:this.reconnectionDelay(),max:this.reconnectionDelayMax(),jitter:this.randomizationFactor()}),this.timeout(n.timeout==null?2e4:n.timeout),this._readyState="closed",this.uri=e;let r=n.parser||kn;this.encoder=new r.Encoder,this.decoder=new r.Decoder,this._autoConnect=n.autoConnect!==!1,this._autoConnect&&this.open()}reconnection(e){return arguments.length?(this._reconnection=!!e,e||(this.skipReconnect=!0),this):this._reconnection}reconnectionAttempts(e){return e===void 0?this._reconnectionAttempts:(this._reconnectionAttempts=e,this)}reconnectionDelay(e){var n;return e===void 0?this._reconnectionDelay:(this._reconnectionDelay=e,(n=this.backoff)===null||n===void 0||n.setMin(e),this)}randomizationFactor(e){var n;return e===void 0?this._randomizationFactor:(this._randomizationFactor=e,(n=this.backoff)===null||n===void 0||n.setJitter(e),this)}reconnectionDelayMax(e){var n;return e===void 0?this._reconnectionDelayMax:(this._reconnectionDelayMax=e,(n=this.backoff)===null||n===void 0||n.setMax(e),this)}timeout(e){return arguments.length?(this._timeout=e,this):this._timeout}maybeReconnectOnOpen(){!this._reconnecting&&this._reconnection&&this.backoff.attempts===0&&this.reconnect()}open(e){if(~this._readyState.indexOf("open"))return this;this.engine=new Be(this.uri,this.opts);let n=this.engine,o=this;this._readyState="opening",this.skipReconnect=!1;let r=F(n,"open",function(){o.onopen(),e&&e()}),s=u=>{this.cleanup(),this._readyState="closed",this.emitReserved("error",u),e?e(u):this.maybeReconnectOnOpen()},l=F(n,"error",s);if(this._timeout!==!1){let u=this._timeout,p=this.setTimeoutFn(()=>{r(),s(new Error("timeout")),n.close()},u);this.opts.autoUnref&&p.unref(),this.subs.push(()=>{this.clearTimeoutFn(p)})}return this.subs.push(r),this.subs.push(l),this}connect(e){return this.open(e)}onopen(){this.cleanup(),this._readyState="open",this.emitReserved("open");let e=this.engine;this.subs.push(F(e,"ping",this.onping.bind(this)),F(e,"data",this.ondata.bind(this)),F(e,"error",this.onerror.bind(this)),F(e,"close",this.onclose.bind(this)),F(this.decoder,"decoded",this.ondecoded.bind(this)))}onping(){this.emitReserved("ping")}ondata(e){try{this.decoder.add(e)}catch(n){this.onclose("parse error",n)}}ondecoded(e){te(()=>{this.emitReserved("packet",e)},this.setTimeoutFn)}onerror(e){this.emitReserved("error",e)}socket(e,n){let o=this.nsps[e];return o?this._autoConnect&&!o.active&&o.connect():(o=new Ie(this,e,n),this.nsps[e]=o),o}_destroy(e){let n=Object.keys(this.nsps);for(let o of n)if(this.nsps[o].active)return;this._close()}_packet(e){let n=this.encoder.encode(e);for(let o=0;oe()),this.subs.length=0,this.decoder.destroy()}_close(){this.skipReconnect=!0,this._reconnecting=!1,this.onclose("forced close")}disconnect(){return this._close()}onclose(e,n){var o;this.cleanup(),(o=this.engine)===null||o===void 0||o.close(),this.backoff.reset(),this._readyState="closed",this.emitReserved("close",e,n),this._reconnection&&!this.skipReconnect&&this.reconnect()}reconnect(){if(this._reconnecting||this.skipReconnect)return this;let e=this;if(this.backoff.attempts>=this._reconnectionAttempts)this.backoff.reset(),this.emitReserved("reconnect_failed"),this._reconnecting=!1;else{let n=this.backoff.duration();this._reconnecting=!0;let o=this.setTimeoutFn(()=>{e.skipReconnect||(this.emitReserved("reconnect_attempt",e.backoff.attempts),!e.skipReconnect&&e.open(r=>{r?(e._reconnecting=!1,e.reconnect(),this.emitReserved("reconnect_error",r)):e.onreconnect()}))},n);this.opts.autoUnref&&o.unref(),this.subs.push(()=>{this.clearTimeoutFn(o)})}}onreconnect(){let e=this.backoff.attempts;this._reconnecting=!1,this.backoff.reset(),this.emitReserved("reconnect",e)}}});function xn(t,e){typeof t=="object"&&(e=t,t=void 0),e=e||{};let n=Io(t,e.path||"/socket.io"),o=n.source,r=n.id,s=n.path,l=ot[r]&&s in ot[r].nsps,u=e.forceNew||e["force new connection"]||e.multiplex===!1||l,p;return u?p=new De(o,e):(ot[r]||(ot[r]=new De(o,e)),p=ot[r]),n.query&&!e.query&&(e.query=n.queryKey),p.socket(n.path,e)}var ot,Vo=y(()=>{Do();Fo();En();Bt();Tt();ot={};Object.assign(xn,{Manager:De,Socket:Ie,io:xn,connect:xn})});var Uo=y(()=>{"use strict";(()=>{let t=`@import url("https://fonts.googleapis.com/css?family=Arvo:400,700|Sen:400,700,800&display=swap&subset=latin-ext"); +(()=>{(()=>{let e=`@import url("https://fonts.googleapis.com/css?family=Arvo:400,700|Sen:400,700,800&display=swap&subset=latin-ext"); #game-wrapper { font-family: "Sen", sans-serif; } @@ -175,10 +175,7 @@ z-index: 2; background-color: rgba(0, 0, 0, 0.5); backdrop-filter: blur(5px); -}`,e="origingui-styles",n=document.getElementById(e)||document.createElement("style");n.id=e,n.textContent=t,n.parentNode||document.head.appendChild(n)})()});var d,i,zo,rt,Wo,It,pe,Iu,Dt,D,V,Xs,O,Yo,I=y(()=>{"use strict";d=window._,i=d.player,zo=()=>{try{return window._.instance?.game?.state?.getCurrentState()??null}catch{return null}},rt=new Proxy({},{get(t,e){let n=zo();return n?n[e]:void 0},set(t,e,n){let o=zo();return o&&(o[e]=n),!0}}),Wo=d.instance,It=Wo.game,pe=Wo.prodigy,Iu=d.instance.game.state.states.get("Boot").gameData,Dt=(t,e)=>d.gameData[t].find(n=>n.ID===e)??null,D=9e9,V=()=>{d.network.processPlayer=!0,d.player.forceSaveCharacter()},Xs="https://raw.githubusercontent.com/ProdigyPXP/ProdigyOrigin/master/originGUI/src/assets/",O=t=>`${Xs}${t}`,Yo={academy:O("academy.png"),bonfire_spire:O("bonfire_spire.png"),forest:O("forest.png"),shipwreck_shore:O("shipwreck_shore.png"),shiverchill:O("shiverchill.png"),skywatch:O("skywatch.png"),dyno:O("dyno.png"),elemental_guardian:O("elemental_guardian.png"),darktower:O("darktower.png"),earthtower:O("earthtower.png"),crystal_caverns:O("crystal_caverns.png"),archives:O("archives.png"),house:O("house.png"),toyzone:O("toyzone.png"),tower_town:O("tower_town.png"),lamplight:O("lamplight.png")}});function sr(t,e,n){if(typeof t=="function"?t===e:t.has(e))return arguments.length<3?e:n;throw new TypeError("Private element is not present on this object")}function Js(t,e){if(e.has(t))throw new TypeError("Cannot initialize the same private elements twice on an object")}function jo(t,e){return t.get(sr(t,e))}function Zs(t,e,n){Js(t,e),e.set(t,n)}function Qs(t,e,n){return t.set(sr(t,e),n),n}function xi(t,e,n){let o=J(),r=Se(),s=$e();!o||!r||!s||(Sn(o,"confirm",n),Sn(r,"deny",n),Sn(s,"cancel",n),Si(o,r,s,n),n.reverseButtons&&(n.toast?(t.insertBefore(s,o),t.insertBefore(r,o)):(t.insertBefore(s,e),t.insertBefore(r,e),t.insertBefore(o,e))))}function Si(t,e,n,o){if(!o.buttonsStyling){K([t,e,n],a.styled);return}b([t,e,n],a.styled),o.confirmButtonColor&&(t.style.backgroundColor=o.confirmButtonColor,b(t,a["default-outline"])),o.denyButtonColor&&(e.style.backgroundColor=o.denyButtonColor,b(e,a["default-outline"])),o.cancelButtonColor&&(n.style.backgroundColor=o.cancelButtonColor,b(n,a["default-outline"]))}function Sn(t,e,n){let o=In(e);lt(t,n[`show${o}Button`],"inline-block"),W(t,n[`${e}ButtonText`]||""),t.setAttribute("aria-label",n[`${e}ButtonAriaLabel`]||""),t.className=a[e],U(t,n,`${e}Button`)}function _i(t,e){typeof e=="string"?t.style.background=e:e||b([document.documentElement,document.body],a["no-backdrop"])}function Pi(t,e){e&&(e in a?b(t,a[e]):(M('The "position" parameter is not valid, defaulting to "center"'),b(t,a.center)))}function Bi(t,e){e&&b(t,a[`grow-${e}`])}function _r(t,e,n,o){Yt()?er(t,o):(ni(n).then(()=>er(t,o)),xr(m)),Tr?(e.setAttribute("style","display:none !important"),e.removeAttribute("class"),e.innerHTML=""):e.remove(),$n()&&(ga(),fa(),Ar()),ya()}function ya(){K([document.documentElement,document.body],[a.shown,a["height-auto"],a["no-backdrop"],a["toast-shown"]])}function fe(t){t=va(t);let e=Re.swalPromiseResolve.get(this),n=ba(this);this.isAwaitingPromise?t.isDismissed||(ut(this),e(t)):n&&e(t)}function Pr(t){let e=Re.swalPromiseReject.get(this);ut(this),e&&e(t)}function Ia(t,e,n){let o=he(t,a.select);if(!o)return;let r=(s,l,u)=>{let p=document.createElement("option");p.value=u,W(p,l),p.selected=Br(u,n.inputValue),s.appendChild(p)};e.forEach(s=>{let l=s[0],u=s[1];if(Array.isArray(u)){let p=document.createElement("optgroup");p.label=l,p.disabled=!1,o.appendChild(p),u.forEach(C=>r(p,C[1],C[0]))}else r(o,u,l)}),o.focus()}function Da(t,e,n){let o=he(t,a.radio);if(!o)return;e.forEach(s=>{let l=s[0],u=s[1],p=document.createElement("input"),C=document.createElement("label");p.type="radio",p.name=a.radio,p.value=l,Br(l,n.inputValue)&&(p.checked=!0);let B=document.createElement("span");W(B,u),B.className=a.label,C.appendChild(p),C.appendChild(B),o.appendChild(C)});let r=o.querySelectorAll("input");r.length&&r[0].focus()}function Ht(){let t=x.innerParams.get(this);if(!t)return;let e=x.domCache.get(this);R(e.loader),Yt()?t.icon&&L(Me()):Ma(e),K([e.popup,e.actions],a.loading),e.popup.removeAttribute("aria-busy"),e.popup.removeAttribute("data-loading"),e.confirmButton.disabled=!1,e.denyButton.disabled=!1,e.cancelButton.disabled=!1}function Lr(){let t=x.innerParams.get(this),e=x.domCache.get(this);return e?jt(e.popup,t.input):null}function Or(t,e,n){let o=x.domCache.get(t);e.forEach(r=>{o[r].disabled=n})}function Rr(t,e){let n=v();if(!(!n||!t))if(t.type==="radio"){let o=n.querySelectorAll(`[name="${a.radio}"]`);for(let r=0;r0&&arguments[0]!==void 0?arguments[0]:"data-swal-template";_n[t]=this,or||(document.body.addEventListener("click",ll),or=!0)}function Al(t){t.inputValidator||(t.input==="email"&&(t.inputValidator=rr.email),t.input==="url"&&(t.inputValidator=rr.url))}function Tl(t){(!t.target||typeof t.target=="string"&&!document.querySelector(t.target)||typeof t.target!="string"&&!t.target.appendChild)&&(M('Target parameter is not valid, defaulting to "body"'),t.target="body")}function _l(t){Al(t),t.showLoaderOnConfirm&&!t.preConfirm&&M(`showLoaderOnConfirm is set to true, but preConfirm is not defined. -showLoaderOnConfirm should be used together with preConfirm, see usage example: -https://sweetalert2.github.io/#ajax-request`),Tl(t),typeof t.title=="string"&&(t.title=t.title.split(` -`).join("
")),vi(t)}var ei,m,ti,ni,ir,oi,a,ri,Ot,ar,In,M,xe,Ko,si,lr,Ut,Dn,st,Ln,$,it,z,v,Me,ii,cr,On,ur,Rn,zt,J,$e,Se,ai,qe,at,dr,Wt,Nn,li,Mn,$n,Yt,ci,W,re,ui,U,jt,pr,hr,b,K,he,ke,L,R,qn,Go,lt,H,di,Xo,fr,Hn,pi,hi,fi,mi,ve,wi,gi,yi,bi,vi,Fn,ki,Ci,Ei,Ai,Ti,x,Ii,Di,Li,Oi,Ri,Ni,Vn,ct,mr,Rt,P,Mi,$i,qi,Jo,wr,Hi,Fi,Zo,Vi,Qo,Ui,Un,gr,yr,br,vr,zi,Wi,Nt,Mt,$t,kr,Yi,ji,Ki,Gi,Xi,Ji,Cr,Zi,Er,Qi,ea,He,xr,ta,Tn,Sr,na,oa,ra,sa,ia,aa,Re,la,Ar,Tr,ca,ua,da,pa,ha,fa,ma,Le,wa,ga,ba,ut,va,ka,Ca,er,Ne,Ea,xa,Sa,Aa,Ta,_a,Pa,Ba,qt,Br,La,Oa,Ra,Ir,Na,zn,tr,Dr,Wn,Ma,Oe,$a,qa,Ha,Vr,Ur,zr,Fa,Va,Ua,Wr,za,Wa,Kr,An,Ya,ja,Ka,Ga,Ft,Xa,Ja,Za,Qa,nr,el,ol,Gr,Xr,rl,sl,il,or,_n,ll,Pn,cl,ul,dl,pl,Bn,Jr,hl,fl,ml,wl,gl,yl,bl,vl,kl,Ce,Zr,Cl,Vt,El,xl,Sl,rr,X,Lt,_,Pl,Bl,Il,Dl,Ll,Ol,Rl,Nl,Ee,Yn=y(()=>{ei=100,m={},ti=()=>{m.previousActiveElement instanceof HTMLElement?(m.previousActiveElement.focus(),m.previousActiveElement=null):document.body&&document.body.focus()},ni=t=>new Promise(e=>{if(!t)return e();let n=window.scrollX,o=window.scrollY;m.restoreFocusTimeout=setTimeout(()=>{ti(),e()},ei),window.scrollTo(n,o)}),ir="swal2-",oi=["container","shown","height-auto","iosfix","popup","modal","no-backdrop","no-transition","toast","toast-shown","show","hide","close","title","html-container","actions","confirm","deny","cancel","default-outline","footer","icon","icon-content","image","input","file","range","select","radio","checkbox","label","textarea","inputerror","input-label","validation-message","progress-steps","active-progress-step","progress-step","progress-step-line","loader","loading","styled","top","top-start","top-end","top-left","top-right","center","center-start","center-end","center-left","center-right","bottom","bottom-start","bottom-end","bottom-left","bottom-right","grow-row","grow-column","grow-fullscreen","rtl","timer-progress-bar","timer-progress-bar-container","scrollbar-measure","icon-success","icon-warning","icon-info","icon-question","icon-error","draggable","dragging"],a=oi.reduce((t,e)=>(t[e]=ir+e,t),{}),ri=["success","warning","info","question","error"],Ot=ri.reduce((t,e)=>(t[e]=ir+e,t),{}),ar="SweetAlert2:",In=t=>t.charAt(0).toUpperCase()+t.slice(1),M=t=>{console.warn(`${ar} ${typeof t=="object"?t.join(" "):t}`)},xe=t=>{console.error(`${ar} ${t}`)},Ko=[],si=t=>{Ko.includes(t)||(Ko.push(t),M(t))},lr=function(t){let e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:null;si(`"${t}" is deprecated and will be removed in the next major release.${e?` Use "${e}" instead.`:""}`)},Ut=t=>typeof t=="function"?t():t,Dn=t=>t&&typeof t.toPromise=="function",st=t=>Dn(t)?t.toPromise():Promise.resolve(t),Ln=t=>t&&Promise.resolve(t)===t,$=()=>document.body.querySelector(`.${a.container}`),it=t=>{let e=$();return e?e.querySelector(t):null},z=t=>it(`.${t}`),v=()=>z(a.popup),Me=()=>z(a.icon),ii=()=>z(a["icon-content"]),cr=()=>z(a.title),On=()=>z(a["html-container"]),ur=()=>z(a.image),Rn=()=>z(a["progress-steps"]),zt=()=>z(a["validation-message"]),J=()=>it(`.${a.actions} .${a.confirm}`),$e=()=>it(`.${a.actions} .${a.cancel}`),Se=()=>it(`.${a.actions} .${a.deny}`),ai=()=>z(a["input-label"]),qe=()=>it(`.${a.loader}`),at=()=>z(a.actions),dr=()=>z(a.footer),Wt=()=>z(a["timer-progress-bar"]),Nn=()=>z(a.close),li=` +}`,t="origingui-styles",n=document.getElementById(t)||document.createElement("style");n.id=t,n.textContent=e,n.parentNode||document.head.appendChild(n)})();var c=window._,r=c.player,kt=()=>{try{return window._.instance?.game?.state?.getCurrentState()??null}catch{return null}},ge=new Proxy({},{get(e,t){let n=kt();return n?n[t]:void 0},set(e,t,n){let o=kt();return o&&(o[t]=n),!0}}),Ct=c.instance,Pe=Ct.game,Y=Ct.prodigy,Ds=c.instance.game.state.states.get("Boot").gameData,Ae=(e,t)=>c.gameData[e].find(n=>n.ID===t)??null,P=9e9,M=()=>{c.network.processPlayer=!0,c.player.forceSaveCharacter()};function Ot(e,t,n){if(typeof e=="function"?e===t:e.has(t))return arguments.length<3?t:n;throw new TypeError("Private element is not present on this object")}function zn(e,t){if(t.has(e))throw new TypeError("Cannot initialize the same private elements twice on an object")}function xt(e,t){return e.get(Ot(e,t))}function qn(e,t,n){zn(e,t),t.set(e,n)}function Fn(e,t,n){return e.set(Ot(e,t),n),n}var jn=100,w={},Wn=()=>{w.previousActiveElement instanceof HTMLElement?(w.previousActiveElement.focus(),w.previousActiveElement=null):document.body&&document.body.focus()},Yn=e=>new Promise(t=>{if(!e)return t();let n=window.scrollX,o=window.scrollY;w.restoreFocusTimeout=setTimeout(()=>{Wn(),t()},jn),window.scrollTo(n,o)}),Ht="swal2-",Vn=["container","shown","height-auto","iosfix","popup","modal","no-backdrop","no-transition","toast","toast-shown","show","hide","close","title","html-container","actions","confirm","deny","cancel","default-outline","footer","icon","icon-content","image","input","file","range","select","radio","checkbox","label","textarea","inputerror","input-label","validation-message","progress-steps","active-progress-step","progress-step","progress-step-line","loader","loading","styled","top","top-start","top-end","top-left","top-right","center","center-start","center-end","center-left","center-right","bottom","bottom-start","bottom-end","bottom-left","bottom-right","grow-row","grow-column","grow-fullscreen","rtl","timer-progress-bar","timer-progress-bar-container","scrollbar-measure","icon-success","icon-warning","icon-info","icon-question","icon-error","draggable","dragging"],i=Vn.reduce((e,t)=>(e[t]=Ht+t,e),{}),Un=["success","warning","info","question","error"],Te=Un.reduce((e,t)=>(e[t]=Ht+t,e),{}),_t="SweetAlert2:",tt=e=>e.charAt(0).toUpperCase()+e.slice(1),D=e=>{console.warn(`${_t} ${typeof e=="object"?e.join(" "):e}`)},Q=e=>{console.error(`${_t} ${e}`)},Et=[],Gn=e=>{Et.includes(e)||(Et.push(e),D(e))},Nt=function(e){let t=arguments.length>1&&arguments[1]!==void 0?arguments[1]:null;Gn(`"${e}" is deprecated and will be removed in the next major release.${t?` Use "${t}" instead.`:""}`)},Ne=e=>typeof e=="function"?e():e,nt=e=>e&&typeof e.toPromise=="function",he=e=>nt(e)?e.toPromise():Promise.resolve(e),ot=e=>e&&Promise.resolve(e)===e,B=()=>document.body.querySelector(`.${i.container}`),ye=e=>{let t=B();return t?t.querySelector(e):null},O=e=>ye(`.${e}`),y=()=>O(i.popup),se=()=>O(i.icon),Kn=()=>O(i["icon-content"]),Rt=()=>O(i.title),rt=()=>O(i["html-container"]),zt=()=>O(i.image),st=()=>O(i["progress-steps"]),Re=()=>O(i["validation-message"]),R=()=>ye(`.${i.actions} .${i.confirm}`),ae=()=>ye(`.${i.actions} .${i.cancel}`),ee=()=>ye(`.${i.actions} .${i.deny}`),Xn=()=>O(i["input-label"]),ie=()=>ye(`.${i.loader}`),be=()=>O(i.actions),qt=()=>O(i.footer),ze=()=>O(i["timer-progress-bar"]),at=()=>O(i.close),Zn=` a[href], area[href], input:not([disabled]), @@ -193,58 +190,61 @@ https://sweetalert2.github.io/#ajax-request`),Tl(t),typeof t.title=="string"&&(t audio[controls], video[controls], summary -`,Mn=()=>{let t=v();if(!t)return[];let e=t.querySelectorAll('[tabindex]:not([tabindex="-1"]):not([tabindex="0"])'),n=Array.from(e).sort((s,l)=>{let u=parseInt(s.getAttribute("tabindex")||"0"),p=parseInt(l.getAttribute("tabindex")||"0");return u>p?1:us.getAttribute("tabindex")!=="-1");return[...new Set(n.concat(r))].filter(s=>H(s))},$n=()=>re(document.body,a.shown)&&!re(document.body,a["toast-shown"])&&!re(document.body,a["no-backdrop"]),Yt=()=>{let t=v();return t?re(t,a.toast):!1},ci=()=>{let t=v();return t?t.hasAttribute("data-loading"):!1},W=(t,e)=>{if(t.textContent="",e){let o=new DOMParser().parseFromString(e,"text/html"),r=o.querySelector("head");r&&Array.from(r.childNodes).forEach(l=>{t.appendChild(l)});let s=o.querySelector("body");s&&Array.from(s.childNodes).forEach(l=>{l instanceof HTMLVideoElement||l instanceof HTMLAudioElement?t.appendChild(l.cloneNode(!0)):t.appendChild(l)})}},re=(t,e)=>{if(!e)return!1;let n=e.split(/\s+/);for(let o=0;o{Array.from(t.classList).forEach(n=>{!Object.values(a).includes(n)&&!Object.values(Ot).includes(n)&&!Object.values(e.showClass||{}).includes(n)&&t.classList.remove(n)})},U=(t,e,n)=>{if(ui(t,e),!e.customClass)return;let o=e.customClass[n];if(o){if(typeof o!="string"&&!o.forEach){M(`Invalid type of customClass.${n}! Expected string or iterable object, got "${typeof o}"`);return}b(t,o)}},jt=(t,e)=>{if(!e)return null;switch(e){case"select":case"textarea":case"file":return t.querySelector(`.${a.popup} > .${a[e]}`);case"checkbox":return t.querySelector(`.${a.popup} > .${a.checkbox} input`);case"radio":return t.querySelector(`.${a.popup} > .${a.radio} input:checked`)||t.querySelector(`.${a.popup} > .${a.radio} input:first-child`);case"range":return t.querySelector(`.${a.popup} > .${a.range} input`);default:return t.querySelector(`.${a.popup} > .${a.input}`)}},pr=t=>{if(t.focus(),t.type!=="file"){let e=t.value;t.value="",t.value=e}},hr=(t,e,n)=>{!t||!e||(typeof e=="string"&&(e=e.split(/\s+/).filter(Boolean)),e.forEach(o=>{Array.isArray(t)?t.forEach(r=>{n?r.classList.add(o):r.classList.remove(o)}):n?t.classList.add(o):t.classList.remove(o)}))},b=(t,e)=>{hr(t,e,!0)},K=(t,e)=>{hr(t,e,!1)},he=(t,e)=>{let n=Array.from(t.children);for(let o=0;o{n===`${parseInt(n)}`&&(n=parseInt(n)),n||parseInt(n)===0?t.style.setProperty(e,typeof n=="number"?`${n}px`:n):t.style.removeProperty(e)},L=function(t){let e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:"flex";t&&(t.style.display=e)},R=t=>{t&&(t.style.display="none")},qn=function(t){let e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:"block";t&&new MutationObserver(()=>{lt(t,t.innerHTML,e)}).observe(t,{childList:!0,subtree:!0})},Go=(t,e,n,o)=>{let r=t.querySelector(e);r&&r.style.setProperty(n,o)},lt=function(t,e){let n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:"flex";e?L(t,n):R(t)},H=t=>!!(t&&(t.offsetWidth||t.offsetHeight||t.getClientRects().length)),di=()=>!H(J())&&!H(Se())&&!H($e()),Xo=t=>t.scrollHeight>t.clientHeight,fr=t=>{let e=window.getComputedStyle(t),n=parseFloat(e.getPropertyValue("animation-duration")||"0"),o=parseFloat(e.getPropertyValue("transition-duration")||"0");return n>0||o>0},Hn=function(t){let e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!1,n=Wt();n&&H(n)&&(e&&(n.style.transition="none",n.style.width="100%"),setTimeout(()=>{n.style.transition=`width ${t/1e3}s linear`,n.style.width="0%"},10))},pi=()=>{let t=Wt();if(!t)return;let e=parseInt(window.getComputedStyle(t).width);t.style.removeProperty("transition"),t.style.width="100%";let n=parseInt(window.getComputedStyle(t).width),o=e/n*100;t.style.width=`${o}%`},hi=()=>typeof window>"u"||typeof document>"u",fi=` -
- -
    -
    - -

    -
    - - -
    +`,it=()=>{let e=y();if(!e)return[];let t=e.querySelectorAll('[tabindex]:not([tabindex="-1"]):not([tabindex="0"])'),n=Array.from(t).sort((a,d)=>{let p=parseInt(a.getAttribute("tabindex")||"0"),f=parseInt(d.getAttribute("tabindex")||"0");return p>f?1:pa.getAttribute("tabindex")!=="-1");return[...new Set(n.concat(s))].filter(a=>L(a))},lt=()=>F(document.body,i.shown)&&!F(document.body,i["toast-shown"])&&!F(document.body,i["no-backdrop"]),qe=()=>{let e=y();return e?F(e,i.toast):!1},Jn=()=>{let e=y();return e?e.hasAttribute("data-loading"):!1},H=(e,t)=>{if(e.textContent="",t){let o=new DOMParser().parseFromString(t,"text/html"),s=o.querySelector("head");s&&Array.from(s.childNodes).forEach(d=>{e.appendChild(d)});let a=o.querySelector("body");a&&Array.from(a.childNodes).forEach(d=>{d instanceof HTMLVideoElement||d instanceof HTMLAudioElement?e.appendChild(d.cloneNode(!0)):e.appendChild(d)})}},F=(e,t)=>{if(!t)return!1;let n=t.split(/\s+/);for(let o=0;o{Array.from(e.classList).forEach(n=>{!Object.values(i).includes(n)&&!Object.values(Te).includes(n)&&!Object.values(t.showClass||{}).includes(n)&&e.classList.remove(n)})},$=(e,t,n)=>{if(Qn(e,t),!t.customClass)return;let o=t.customClass[n];if(o){if(typeof o!="string"&&!o.forEach){D(`Invalid type of customClass.${n}! Expected string or iterable object, got "${typeof o}"`);return}h(e,o)}},Fe=(e,t)=>{if(!t)return null;switch(t){case"select":case"textarea":case"file":return e.querySelector(`.${i.popup} > .${i[t]}`);case"checkbox":return e.querySelector(`.${i.popup} > .${i.checkbox} input`);case"radio":return e.querySelector(`.${i.popup} > .${i.radio} input:checked`)||e.querySelector(`.${i.popup} > .${i.radio} input:first-child`);case"range":return e.querySelector(`.${i.popup} > .${i.range} input`);default:return e.querySelector(`.${i.popup} > .${i.input}`)}},Ft=e=>{if(e.focus(),e.type!=="file"){let t=e.value;e.value="",e.value=t}},jt=(e,t,n)=>{!e||!t||(typeof t=="string"&&(t=t.split(/\s+/).filter(Boolean)),t.forEach(o=>{Array.isArray(e)?e.forEach(s=>{n?s.classList.add(o):s.classList.remove(o)}):n?e.classList.add(o):e.classList.remove(o)}))},h=(e,t)=>{jt(e,t,!0)},_=(e,t)=>{jt(e,t,!1)},V=(e,t)=>{let n=Array.from(e.children);for(let o=0;o{n===`${parseInt(n)}`&&(n=parseInt(n)),n||parseInt(n)===0?e.style.setProperty(t,typeof n=="number"?`${n}px`:n):e.style.removeProperty(t)},A=function(e){let t=arguments.length>1&&arguments[1]!==void 0?arguments[1]:"flex";e&&(e.style.display=t)},T=e=>{e&&(e.style.display="none")},ct=function(e){let t=arguments.length>1&&arguments[1]!==void 0?arguments[1]:"block";e&&new MutationObserver(()=>{ve(e,e.innerHTML,t)}).observe(e,{childList:!0,subtree:!0})},St=(e,t,n,o)=>{let s=e.querySelector(t);s&&s.style.setProperty(n,o)},ve=function(e,t){let n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:"flex";t?A(e,n):T(e)},L=e=>!!(e&&(e.offsetWidth||e.offsetHeight||e.getClientRects().length)),eo=()=>!L(R())&&!L(ee())&&!L(ae()),Pt=e=>e.scrollHeight>e.clientHeight,Wt=e=>{let t=window.getComputedStyle(e),n=parseFloat(t.getPropertyValue("animation-duration")||"0"),o=parseFloat(t.getPropertyValue("transition-duration")||"0");return n>0||o>0},ut=function(e){let t=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!1,n=ze();n&&L(n)&&(t&&(n.style.transition="none",n.style.width="100%"),setTimeout(()=>{n.style.transition=`width ${e/1e3}s linear`,n.style.width="0%"},10))},to=()=>{let e=ze();if(!e)return;let t=parseInt(window.getComputedStyle(e).width);e.style.removeProperty("transition"),e.style.width="100%";let n=parseInt(window.getComputedStyle(e).width),o=t/n*100;e.style.width=`${o}%`},no=()=>typeof window>"u"||typeof document>"u",oo=` +
    + +
      +
      + +

      +
      + + +
      - -
      -