@oss-idle/addon-sdk 0.0.0-stage → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +262 -2
  3. package/bin/cli.mjs +137 -0
  4. package/bin/scaffold.mjs +157 -0
  5. package/dist/bridge/beatmaps.d.ts +57 -0
  6. package/dist/bridge/beatmaps.d.ts.map +1 -0
  7. package/dist/bridge/beatmaps.js +65 -0
  8. package/dist/bridge/character.d.ts +91 -0
  9. package/dist/bridge/character.d.ts.map +1 -0
  10. package/dist/bridge/character.js +107 -0
  11. package/dist/bridge/db.d.ts +44 -0
  12. package/dist/bridge/db.d.ts.map +1 -0
  13. package/dist/bridge/db.js +83 -0
  14. package/dist/bridge/playlists.d.ts +55 -0
  15. package/dist/bridge/playlists.d.ts.map +1 -0
  16. package/dist/bridge/playlists.js +85 -0
  17. package/dist/bridge/scores.d.ts +28 -0
  18. package/dist/bridge/scores.d.ts.map +1 -0
  19. package/dist/bridge/scores.js +43 -0
  20. package/dist/build/index.d.ts +73 -0
  21. package/dist/build/index.d.ts.map +1 -0
  22. package/dist/build/index.js +126 -0
  23. package/dist/build/plugins.d.ts +95 -0
  24. package/dist/build/plugins.d.ts.map +1 -0
  25. package/dist/build/plugins.js +240 -0
  26. package/dist/index.d.ts +12 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +11 -0
  29. package/dist/react/index.d.ts +4 -0
  30. package/dist/react/index.d.ts.map +1 -0
  31. package/dist/react/index.js +3 -0
  32. package/dist/react/overlay.d.ts +56 -0
  33. package/dist/react/overlay.d.ts.map +1 -0
  34. package/dist/react/overlay.js +63 -0
  35. package/dist/react/reactAddon.d.ts +37 -0
  36. package/dist/react/reactAddon.d.ts.map +1 -0
  37. package/dist/react/reactAddon.js +51 -0
  38. package/dist/react/useSettings.d.ts +16 -0
  39. package/dist/react/useSettings.d.ts.map +1 -0
  40. package/dist/react/useSettings.js +30 -0
  41. package/dist/runtime/addon.d.ts +94 -0
  42. package/dist/runtime/addon.d.ts.map +1 -0
  43. package/dist/runtime/addon.js +129 -0
  44. package/dist/runtime/buildMeta.d.ts +11 -0
  45. package/dist/runtime/buildMeta.d.ts.map +1 -0
  46. package/dist/runtime/buildMeta.js +1 -0
  47. package/dist/runtime/define.d.ts +26 -0
  48. package/dist/runtime/define.d.ts.map +1 -0
  49. package/dist/runtime/define.js +40 -0
  50. package/dist/runtime/host.d.ts +113 -0
  51. package/dist/runtime/host.d.ts.map +1 -0
  52. package/dist/runtime/host.js +8 -0
  53. package/dist/runtime/input.d.ts +58 -0
  54. package/dist/runtime/input.d.ts.map +1 -0
  55. package/dist/runtime/input.js +119 -0
  56. package/dist/runtime/settings.d.ts +36 -0
  57. package/dist/runtime/settings.d.ts.map +1 -0
  58. package/dist/runtime/settings.js +74 -0
  59. package/package.json +83 -4
  60. package/templates/common/README.md +50 -0
  61. package/templates/common/addon.config.json +7 -0
  62. package/templates/common/eslint.config.js +54 -0
  63. package/templates/common/gitignore +3 -0
  64. package/templates/common/src/env.d.ts +17 -0
  65. package/templates/common/tsconfig.json +14 -0
  66. package/templates/plain/src/index.ts +32 -0
  67. package/templates/react/src/Addon.tsx +44 -0
  68. package/templates/react/src/Panel.tsx +41 -0
  69. package/templates/react/src/index.ts +12 -0
  70. package/templates/react/src/styles.css +61 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Keeping an overlay's input out of the game.
3
+ *
4
+ * The client binds its gameplay controls as **bubble-phase listeners on
5
+ * `window`** (`input/Controls.ts`), and the same is true of the scene-level
6
+ * shortcuts and the wheel handlers that drive volume and the song carousel.
7
+ * Nothing is bound in the capture phase, which is what makes an overlay able to
8
+ * coexist with it:
9
+ *
10
+ * - **Inside the overlay**, {@link isolateInput} stops these events at the
11
+ * overlay's root while they bubble. The event still reached its target, so a
12
+ * text field types normally, but it never continues up to `window` - so typing
13
+ * "speed" into a filter box cannot press four gameplay keys, and scrolling a
14
+ * results list cannot change the game's volume.
15
+ * - **Outside the overlay**, {@link HotkeyManager} listens in the capture phase,
16
+ * ahead of every game handler, so a shortcut can claim its combo before
17
+ * gameplay interprets it.
18
+ */
19
+ /** Events the game acts on globally, which an overlay must not leak upward. */
20
+ const ISOLATED_EVENTS = [
21
+ 'keydown', 'keyup', 'keypress',
22
+ 'wheel',
23
+ 'pointerdown', 'pointerup', 'mousedown', 'mouseup', 'click', 'dblclick',
24
+ ];
25
+ /**
26
+ * Stop the game from seeing input that belongs to `root`.
27
+ *
28
+ * Bubble phase on purpose: the event has already done its job inside the
29
+ * overlay by the time it is stopped here. Capturing instead would take the
30
+ * keystroke away from the very field the player is typing into.
31
+ */
32
+ export const isolateInput = (root) => {
33
+ const stop = (event) => event.stopPropagation();
34
+ for (const type of ISOLATED_EVENTS) {
35
+ root.addEventListener(type, stop, { capture: false });
36
+ }
37
+ return () => {
38
+ for (const type of ISOLATED_EVENTS) {
39
+ root.removeEventListener(type, stop, { capture: false });
40
+ }
41
+ };
42
+ };
43
+ /** Parse `"Ctrl+Shift+F"` into a {@link Hotkey}. Order and case do not matter. */
44
+ export const parseHotkey = (combo) => {
45
+ const parts = combo.split('+').map(p => p.trim().toLowerCase()).filter(Boolean);
46
+ const hotkey = { key: '', ctrl: false, shift: false, alt: false, meta: false };
47
+ for (const part of parts) {
48
+ if (part === 'ctrl' || part === 'control')
49
+ hotkey.ctrl = true;
50
+ else if (part === 'shift')
51
+ hotkey.shift = true;
52
+ else if (part === 'alt' || part === 'option')
53
+ hotkey.alt = true;
54
+ else if (part === 'meta' || part === 'cmd' || part === 'super')
55
+ hotkey.meta = true;
56
+ else
57
+ hotkey.key = part;
58
+ }
59
+ if (!hotkey.key)
60
+ throw new Error(`Hotkey "${combo}" names no key`);
61
+ return hotkey;
62
+ };
63
+ const matches = (hotkey, event) => event.key.toLowerCase() === hotkey.key
64
+ && event.ctrlKey === hotkey.ctrl
65
+ && event.shiftKey === hotkey.shift
66
+ && event.altKey === hotkey.alt
67
+ && event.metaKey === hotkey.meta;
68
+ /** Whether the event is going somewhere the player is typing. */
69
+ const isTextEntry = (target) => {
70
+ const el = target;
71
+ if (!el?.tagName)
72
+ return false;
73
+ return el.tagName === 'INPUT'
74
+ || el.tagName === 'TEXTAREA'
75
+ || el.tagName === 'SELECT'
76
+ || el.isContentEditable;
77
+ };
78
+ /**
79
+ * Global shortcuts that win against the game.
80
+ *
81
+ * One capture-phase listener on `window` serves every binding. A claimed combo
82
+ * is stopped with `stopImmediatePropagation` and `preventDefault`, so neither
83
+ * the game's handlers nor the browser's own act on it.
84
+ */
85
+ export class HotkeyManager {
86
+ bindings = new Set();
87
+ onKeyDown = (event) => {
88
+ if (event.repeat)
89
+ return;
90
+ for (const binding of this.bindings) {
91
+ if (!matches(binding.hotkey, event))
92
+ continue;
93
+ // A shortcut must not fire while the player is typing - unless it is
94
+ // the combo that closes the overlay they are typing in.
95
+ if (!binding.allowInText && isTextEntry(event.target))
96
+ continue;
97
+ event.preventDefault();
98
+ event.stopImmediatePropagation();
99
+ binding.handler(event);
100
+ return;
101
+ }
102
+ };
103
+ constructor() {
104
+ window.addEventListener('keydown', this.onKeyDown, { capture: true });
105
+ }
106
+ /**
107
+ * Bind a combo. `allowInText` lets it fire even from a focused field, which
108
+ * is what a close/escape shortcut wants.
109
+ */
110
+ register(combo, handler, { allowInText = false } = {}) {
111
+ const binding = { hotkey: parseHotkey(combo), handler, allowInText };
112
+ this.bindings.add(binding);
113
+ return () => this.bindings.delete(binding);
114
+ }
115
+ dispose() {
116
+ this.bindings.clear();
117
+ window.removeEventListener('keydown', this.onKeyDown, { capture: true });
118
+ }
119
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Persisted add-on settings.
3
+ *
4
+ * An add-on is remounted on every boot and on every enable/disable, so anything
5
+ * held only in component state is re-entered by the player each time. Settings
6
+ * belong in `localStorage`, which is the same store the client keeps its own
7
+ * options in and survives reloads without touching the game's database.
8
+ *
9
+ * **Keys are namespaced deliberately.** The client writes its settings under
10
+ * bare, unprefixed keys (`db/settings.ts`), and reads them back with a bare
11
+ * `JSON.parse`. An add-on writing a colliding key would corrupt a game setting,
12
+ * so everything here lives under `oss-idle-addon:<namespace>`.
13
+ *
14
+ * Stored values are merged **over** the defaults on load, so adding a setting in
15
+ * a later version does not invalidate what a player already saved, and only keys
16
+ * the defaults still declare are kept - a removed setting cleans itself up.
17
+ */
18
+ export type SettingsStore<T extends object> = {
19
+ /** The full storage key, for debugging. */
20
+ readonly key: string;
21
+ /** Stored settings merged over the defaults. Never throws. */
22
+ load(): T;
23
+ /** Persist the whole settings object. Never throws. */
24
+ save(value: T): void;
25
+ /** Forget the stored settings. */
26
+ clear(): void;
27
+ };
28
+ /**
29
+ * A namespaced settings store.
30
+ *
31
+ * Every operation is guarded: `localStorage` can be unavailable (private
32
+ * browsing, storage disabled) or full, and losing settings must never take the
33
+ * add-on - or the game - down with it.
34
+ */
35
+ export declare const createSettings: <T extends object>(namespace: string, defaults: T) => SettingsStore<T>;
36
+ //# sourceMappingURL=settings.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"settings.d.ts","sourceRoot":"","sources":["../../src/runtime/settings.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,MAAM,MAAM,aAAa,CAAC,CAAC,SAAS,MAAM,IAAI;IAC7C,2CAA2C;IAC3C,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,8DAA8D;IAC9D,IAAI,IAAI,CAAC,CAAC;IACV,uDAAuD;IACvD,IAAI,CAAC,KAAK,EAAE,CAAC,GAAG,IAAI,CAAC;IACrB,kCAAkC;IAClC,KAAK,IAAI,IAAI,CAAC;CACd,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,GAAI,CAAC,SAAS,MAAM,EAC9C,WAAW,MAAM,EACjB,UAAU,CAAC,KACT,aAAa,CAAC,CAAC,CA6CjB,CAAC"}
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Persisted add-on settings.
3
+ *
4
+ * An add-on is remounted on every boot and on every enable/disable, so anything
5
+ * held only in component state is re-entered by the player each time. Settings
6
+ * belong in `localStorage`, which is the same store the client keeps its own
7
+ * options in and survives reloads without touching the game's database.
8
+ *
9
+ * **Keys are namespaced deliberately.** The client writes its settings under
10
+ * bare, unprefixed keys (`db/settings.ts`), and reads them back with a bare
11
+ * `JSON.parse`. An add-on writing a colliding key would corrupt a game setting,
12
+ * so everything here lives under `oss-idle-addon:<namespace>`.
13
+ *
14
+ * Stored values are merged **over** the defaults on load, so adding a setting in
15
+ * a later version does not invalidate what a player already saved, and only keys
16
+ * the defaults still declare are kept - a removed setting cleans itself up.
17
+ */
18
+ const PREFIX = 'oss-idle-addon:';
19
+ /** {@link PREFIX} before the rename to oss!idle: still read, so saved settings carry over. */
20
+ const LEGACY_PREFIX = 'osu-idle-addon:';
21
+ /**
22
+ * A namespaced settings store.
23
+ *
24
+ * Every operation is guarded: `localStorage` can be unavailable (private
25
+ * browsing, storage disabled) or full, and losing settings must never take the
26
+ * add-on - or the game - down with it.
27
+ */
28
+ export const createSettings = (namespace, defaults) => {
29
+ const key = `${PREFIX}${namespace}`;
30
+ return {
31
+ key,
32
+ load() {
33
+ try {
34
+ const raw = localStorage.getItem(key) ?? localStorage.getItem(`${LEGACY_PREFIX}${namespace}`);
35
+ if (!raw)
36
+ return { ...defaults };
37
+ const stored = JSON.parse(raw);
38
+ if (!stored || typeof stored !== 'object')
39
+ return { ...defaults };
40
+ // Take only keys the defaults still declare, and only when the
41
+ // stored value has the same shape - a hand-edited or stale entry
42
+ // must not put a string where a number is expected.
43
+ const merged = { ...defaults };
44
+ for (const name of Object.keys(defaults)) {
45
+ const value = stored[name];
46
+ if (value === undefined)
47
+ continue;
48
+ if (typeof value !== typeof defaults[name])
49
+ continue;
50
+ merged[name] = value;
51
+ }
52
+ return merged;
53
+ }
54
+ catch {
55
+ return { ...defaults };
56
+ }
57
+ },
58
+ save(value) {
59
+ try {
60
+ localStorage.setItem(key, JSON.stringify(value));
61
+ }
62
+ catch {
63
+ // Full or unavailable storage: the add-on still works, it just
64
+ // will not remember. Not worth interrupting the player over.
65
+ }
66
+ },
67
+ clear() {
68
+ try {
69
+ localStorage.removeItem(key);
70
+ }
71
+ catch { /* nothing to do */ }
72
+ },
73
+ };
74
+ };
package/package.json CHANGED
@@ -1,6 +1,85 @@
1
1
  {
2
2
  "name": "@oss-idle/addon-sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.6.0",
4
+ "type": "module",
5
+ "license": "AGPL-3.0-only",
6
+ "description": "oss!idle Addon SDK - build oss!idle add-ons in TypeScript. An Addon base class with automatic teardown, shadow-root React overlays, bridges to the game's database and beatmap store, and a bundler that emits the single-file ES module the add-on runtime loads.",
7
+ "keywords": [
8
+ "osu",
9
+ "oss-idle",
10
+ "addon",
11
+ "sdk",
12
+ "plugin",
13
+ "typescript",
14
+ "esbuild"
15
+ ],
16
+ "homepage": "https://github.com/oss-idle/addon-sdk#readme",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/oss-idle/addon-sdk.git",
20
+ "directory": "packages/addon-sdk"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/oss-idle/addon-sdk/issues"
24
+ },
25
+ "engines": {
26
+ "node": ">=22"
27
+ },
28
+ "main": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ },
35
+ "./react": {
36
+ "types": "./dist/react/index.d.ts",
37
+ "default": "./dist/react/index.js"
38
+ },
39
+ "./build": {
40
+ "types": "./dist/build/index.d.ts",
41
+ "default": "./dist/build/index.js"
42
+ },
43
+ "./package.json": "./package.json",
44
+ "./scaffold": "./bin/scaffold.mjs"
45
+ },
46
+ "bin": {
47
+ "oss-idle-addon": "bin/cli.mjs"
48
+ },
49
+ "files": [
50
+ "dist",
51
+ "bin",
52
+ "templates",
53
+ "README.md",
54
+ "LICENSE"
55
+ ],
56
+ "publishConfig": {
57
+ "access": "public"
58
+ },
59
+ "scripts": {
60
+ "build": "tsc -p tsconfig.json",
61
+ "dev": "tsc -p tsconfig.json --watch",
62
+ "prepublishOnly": "npm run build"
63
+ },
64
+ "dependencies": {
65
+ "esbuild": "^0.24.0"
66
+ },
67
+ "peerDependencies": {
68
+ "react": "^18.3.1",
69
+ "react-dom": "^18.3.1"
70
+ },
71
+ "peerDependenciesMeta": {
72
+ "react": {
73
+ "optional": true
74
+ },
75
+ "react-dom": {
76
+ "optional": true
77
+ }
78
+ },
79
+ "devDependencies": {
80
+ "@types/node": "^26.4.1",
81
+ "@types/react": "^18.3.12",
82
+ "@types/react-dom": "^18.3.1",
83
+ "typescript": "^5.6.3"
84
+ }
85
+ }
@@ -0,0 +1,50 @@
1
+ # __ADDON_NAME__
2
+
3
+ An add-on for [oss!idle](https://github.com/oss-idle/oss-idle), built with the
4
+ [oss!idle Addon SDK](https://github.com/oss-idle/addon-sdk).
5
+
6
+ ## Build
7
+
8
+ ```sh
9
+ npm install
10
+ npm run build
11
+ ```
12
+
13
+ That emits `__OUTFILE__` - a single self-contained ES module. Paste its contents
14
+ into the in-game add-on editor (Options → Add-ons → Manage) and enable it.
15
+
16
+ `npm run dev` rebuilds on save; re-paste to see changes.
17
+
18
+ ## Publish from a repository
19
+
20
+ Instead of pasting, oss!idle can build your add-on from a public git repository
21
+ (any host: GitHub, GitLab, Codeberg...):
22
+
23
+ 1. Push this folder with its `package-lock.json` (`dist/` stays out of git).
24
+ 2. In game, create an add-on, choose **Built from a git repository** and give the
25
+ repository's https address.
26
+ 3. Add the webhook address it shows to your repository as a push webhook.
27
+ 4. Release by tagging: `npm version patch && git push --follow-tags`.
28
+
29
+ oss!idle refuses a built file that can run code nobody reviewed: `eval`, `new Function`,
30
+ `import()`, imports from an address, script elements, string timers and the like.
31
+ `npm run lint` flags them in your own code before you push.
32
+
33
+ Each new version tag is built by oss!idle itself (`npm ci`, then `npm run build`,
34
+ offline, with install scripts skipped) and goes to review; the moderator reads the
35
+ repository's changes, never the bundle.
36
+
37
+ ## How an add-on runs
38
+
39
+ The game imports your built file as an ES module from a Blob URL and calls its
40
+ `mount` / `unmount` exports. Nothing is injected and nothing resolves at load
41
+ time, so every dependency is bundled into that one file.
42
+
43
+ `unmount` has to genuinely undo everything, because add-ons are enabled,
44
+ disabled, updated and removed without a page reload. The SDK's `Addon` base
45
+ class records teardown for every listener, timer and overlay it hands you and
46
+ reverses it for you.
47
+
48
+ ## Licence
49
+
50
+ __LICENSE__
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "__ADDON_NAME__",
3
+ "license": "__LICENSE__",
4
+ "entry": "src/index.ts",
5
+ "outfile": "dist/__SLUG__.js",
6
+ "sharedReact": true
7
+ }
@@ -0,0 +1,54 @@
1
+ import tseslint from 'typescript-eslint';
2
+
3
+ // oss!idle refuses an add-on whose built file can run code nobody reviewed: text turned
4
+ // into code, or code loaded from elsewhere. It checks the bundle when you publish;
5
+ // `npm run lint` tells you sooner, in your own code.
6
+ const NOT_REVIEWED = 'oss!idle refuses code that runs what was not reviewed';
7
+
8
+ export default tseslint.config(
9
+ { ignores: ['dist/', 'node_modules/'] },
10
+ {
11
+ files: ['**/*.{js,jsx,mjs,ts,tsx,mts}'],
12
+ languageOptions: {
13
+ parser: tseslint.parser,
14
+ // no-implied-eval only knows the timers as globals
15
+ globals: Object.fromEntries(['window', 'self', 'globalThis', 'setTimeout', 'setInterval']
16
+ .map(name => [name, 'readonly'])),
17
+ },
18
+ rules: {
19
+ 'no-eval': 'error',
20
+ 'no-implied-eval': 'error',
21
+ 'no-new-func': 'error',
22
+ 'no-script-url': 'error',
23
+ 'no-restricted-globals': ['error', 'eval', 'Function', 'importScripts'],
24
+ 'no-restricted-properties': ['error',
25
+ { object: 'document', property: 'write', message: NOT_REVIEWED },
26
+ { object: 'document', property: 'writeln', message: NOT_REVIEWED },
27
+ { property: 'eval', message: NOT_REVIEWED },
28
+ { property: 'Function', message: NOT_REVIEWED },
29
+ { property: 'srcdoc', message: NOT_REVIEWED },
30
+ { property: 'createContextualFragment', message: NOT_REVIEWED },
31
+ { property: 'serviceWorker', message: NOT_REVIEWED },
32
+ ],
33
+ 'no-restricted-syntax': ['error',
34
+ { selector: 'ImportExpression', message: `import() loads code from elsewhere: ${NOT_REVIEWED}` },
35
+ {
36
+ selector: 'ImportDeclaration[source.value=/^([a-z]+:|\\/\\/)/i]',
37
+ message: `Imports from an address load code from elsewhere: ${NOT_REVIEWED}`,
38
+ },
39
+ {
40
+ selector: 'CallExpression[callee.property.name=/^createElement(NS)?$/] > Literal[value=/^\\s*script\\s*$/i]',
41
+ message: `A script element loads or runs code: ${NOT_REVIEWED}`,
42
+ },
43
+ {
44
+ selector: 'CallExpression[callee.property.name=/^setAttribute(NS)?$/] > Literal[value=/^on/i]',
45
+ message: `An on… attribute runs its text as code: use addEventListener. ${NOT_REVIEWED}`,
46
+ },
47
+ {
48
+ selector: 'CallExpression[callee.property.name="constructor"], NewExpression[callee.property.name="constructor"]',
49
+ message: `A function's constructor builds code from text: ${NOT_REVIEWED}`,
50
+ },
51
+ ],
52
+ },
53
+ },
54
+ );
@@ -0,0 +1,3 @@
1
+ node_modules/
2
+ dist/
3
+ *.tsbuildinfo
@@ -0,0 +1,17 @@
1
+ /** Stylesheets are inlined as strings and injected into the overlay's shadow root. */
2
+ declare module '*.css' {
3
+ const css: string;
4
+ export default css;
5
+ }
6
+
7
+ /** Workers are inlined into the single-file bundle and spawned from a Blob URL. */
8
+ declare module 'worker:*' {
9
+ const createWorker: () => Worker;
10
+ export default createWorker;
11
+ }
12
+
13
+ /** Binary assets inlined as base64 (e.g. `import wasm from './x.wasm?base64'`). */
14
+ declare module '*?base64' {
15
+ const base64: string;
16
+ export default base64;
17
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
5
+ "module": "ES2022",
6
+ "moduleResolution": "bundler",
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "noUncheckedIndexedAccess": true,
10
+ "skipLibCheck": true,
11
+ "noEmit": true
12
+ },
13
+ "include": ["src"]
14
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * __ADDON_NAME__
3
+ *
4
+ * oss!idle loads an add-on by importing its built source as an ES module and
5
+ * calling `mount` / `unmount`. That pair is the whole contract - `defineAddon`
6
+ * produces it from the class below.
7
+ */
8
+
9
+ import { Addon, defineAddon, gameDb } from '@oss-idle/addon-sdk';
10
+
11
+ class __CLASS__ extends Addon {
12
+
13
+ constructor() {
14
+ super({ name: '__ADDON_NAME__' });
15
+ }
16
+
17
+ protected async onMount(): Promise<void> {
18
+ // The client opens its database lazily, so wait before querying it.
19
+ await gameDb.ready();
20
+
21
+ const played = await gameDb.value<number>('SELECT COUNT(*) FROM score');
22
+ this.log.info(`hello! you have ${played ?? 0} scores stored locally`);
23
+
24
+ // Anything registered through `this.on` / `this.interval` / `this.own`
25
+ // is torn down automatically when the add-on is disabled or updated.
26
+ this.on(window, 'keydown', event => {
27
+ if (event.key === 'F9') this.log.info('F9');
28
+ });
29
+ }
30
+ }
31
+
32
+ export const { mount, unmount } = defineAddon(() => new __CLASS__());
@@ -0,0 +1,44 @@
1
+ /**
2
+ * __ADDON_NAME__
3
+ *
4
+ * The panel is mounted for the add-on's whole life and only *hidden* when
5
+ * closed. Returning `null` instead would unmount the React tree, discarding its
6
+ * state and running cleanup - so anything the overlay had started would be
7
+ * thrown away the moment the player closed it.
8
+ */
9
+
10
+ import type { ReactNode } from 'react';
11
+ import { ReactAddon } from '@oss-idle/addon-sdk/react';
12
+ import Panel from './Panel.js';
13
+ import styles from './styles.css';
14
+
15
+ const TOGGLE = 'Ctrl+Shift+__KEY__';
16
+
17
+ export class __CLASS__ extends ReactAddon {
18
+
19
+ private open = false;
20
+
21
+ constructor() {
22
+ super({ name: '__ADDON_NAME__', overlayId: '__SLUG__', styles });
23
+ }
24
+
25
+ protected override async onReady(): Promise<void> {
26
+ // Registered in the capture phase, ahead of the game's own handlers, so
27
+ // the combo never reaches gameplay.
28
+ this.own(this.hotkeys.register(TOGGLE, () => this.toggle(), { allowInText: true }));
29
+ this.own(this.hotkeys.register('Escape', () => {
30
+ if (this.open) this.toggle();
31
+ }, { allowInText: true }));
32
+
33
+ this.log.info(`ready - press ${TOGGLE} to open`);
34
+ }
35
+
36
+ private toggle(): void {
37
+ this.open = !this.open;
38
+ this.refresh();
39
+ }
40
+
41
+ protected render(): ReactNode {
42
+ return <Panel open={this.open} version={this.meta.version ?? ''} onClose={() => this.toggle()} />;
43
+ }
44
+ }
@@ -0,0 +1,41 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { gameCharacter, type CharacterRow } from '@oss-idle/addon-sdk';
3
+
4
+ export default function Panel({
5
+ open,
6
+ version,
7
+ onClose,
8
+ }: {
9
+ open: boolean;
10
+ version: string;
11
+ onClose: () => void;
12
+ }) {
13
+ const [character, setCharacter] = useState<CharacterRow>();
14
+
15
+ // Re-read when opened: the client writes skill levels back to this row after
16
+ // every play, so a value read once at boot goes stale.
17
+ useEffect(() => {
18
+ if (open) void gameCharacter.live().then(setCharacter);
19
+ }, [open]);
20
+
21
+ return (
22
+ <div style={{ display: open ? 'contents' : 'none' }} aria-hidden={!open}>
23
+ <div className="backdrop" onClick={onClose} />
24
+ <div className="panel" role="dialog" aria-label="__ADDON_NAME__">
25
+ <header>
26
+ <h1>__ADDON_NAME__</h1>
27
+ <span className="spacer" />
28
+ <span className="hint">v{version}</span>
29
+ <button onClick={onClose}>Close</button>
30
+ </header>
31
+
32
+ <div className="body">
33
+ {character
34
+ ? <p>Playing as <strong>{character.name}</strong> (level {character.overallLevel}).</p>
35
+ : <p>Looking for your character...</p>}
36
+ <p>Edit <code>src/Panel.tsx</code> to build your add-on.</p>
37
+ </div>
38
+ </div>
39
+ </div>
40
+ );
41
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * __ADDON_NAME__
3
+ *
4
+ * oss!idle loads an add-on by importing its built source as an ES module and
5
+ * calling `mount` / `unmount`. That pair is the whole contract - `defineAddon`
6
+ * produces it from the class in `Addon.tsx`.
7
+ */
8
+
9
+ import { defineAddon } from '@oss-idle/addon-sdk';
10
+ import { __CLASS__ } from './Addon.js';
11
+
12
+ export const { mount, unmount } = defineAddon(() => new __CLASS__());
@@ -0,0 +1,61 @@
1
+ /*
2
+ * Rendered inside a shadow root, so these selectors cannot reach the game and
3
+ * the game's stylesheet cannot reach them.
4
+ */
5
+
6
+ :host, * { box-sizing: border-box; }
7
+
8
+ .backdrop {
9
+ position: absolute;
10
+ inset: 0;
11
+ background: rgb(8 6 12 / 55%);
12
+ pointer-events: auto;
13
+ }
14
+
15
+ .panel {
16
+ position: absolute;
17
+ top: 50%;
18
+ left: 50%;
19
+ transform: translate(-50%, -50%);
20
+ width: min(720px, 92vw);
21
+ max-height: 80vh;
22
+ display: flex;
23
+ flex-direction: column;
24
+ pointer-events: auto;
25
+
26
+ font-family: ui-sans-serif, system-ui, "Segoe UI", Roboto, sans-serif;
27
+ font-size: 13px;
28
+ color: #eceaf2;
29
+ background: #16141c;
30
+ border: 1px solid #322c40;
31
+ border-radius: 12px;
32
+ box-shadow: 0 24px 80px rgb(0 0 0 / 60%);
33
+ overflow: hidden;
34
+ }
35
+
36
+ header {
37
+ display: flex;
38
+ align-items: center;
39
+ gap: 12px;
40
+ padding: 14px 18px;
41
+ background: #1e1a28;
42
+ border-bottom: 1px solid #322c40;
43
+ }
44
+
45
+ header h1 { margin: 0; font-size: 15px; font-weight: 600; }
46
+ header .spacer { flex: 1; }
47
+ header .hint { color: #8b83a0; font-size: 11px; }
48
+
49
+ button {
50
+ font: inherit;
51
+ color: #eceaf2;
52
+ background: #2b2438;
53
+ border: 1px solid #3d3550;
54
+ border-radius: 6px;
55
+ padding: 6px 12px;
56
+ cursor: pointer;
57
+ }
58
+ button:hover { background: #362d47; }
59
+
60
+ .body { padding: 18px; overflow: auto; line-height: 1.7; }
61
+ code { background: #221d2e; border-radius: 4px; padding: 1px 5px; }