@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
package/README.md CHANGED
@@ -1,3 +1,263 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/oss-idle/addon-sdk/HEAD/icon.svg" alt="" width="112">
3
+ </p>
2
4
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
5
+ <h1 align="center">oss!idle Addon SDK</h1>
6
+
7
+ <p align="center">
8
+ Build <a href="https://github.com/oss-idle/oss-idle">oss!idle</a> add-ons in
9
+ TypeScript, with your dependencies bundled.
10
+ </p>
11
+
12
+ ```sh
13
+ npx @oss-idle/addon-sdk-create@latest my-addon
14
+ cd my-addon
15
+ npm install
16
+ npm run build
17
+ ```
18
+
19
+ That emits one self-contained ES module. Paste it into the in-game add-on editor
20
+ (Options → Add-ons → Manage) and enable it.
21
+
22
+ ## The contract
23
+
24
+ The game loads an add-on by importing its source as a Blob ES module and calling
25
+ `mount` / `unmount`. `mount` is handed the game's host (`this.host` in an
26
+ `Addon`): its version, its React, and the editor for add-ons of the Editor type.
27
+ Add-ons run unsandboxed in the page realm and reach for the rest themselves. The
28
+ full guide, the editor API included, is at
29
+ https://oss.idle.rhythmgamers.net/web/docs/addons.
30
+
31
+ ```ts
32
+ import { Addon, defineAddon } from '@oss-idle/addon-sdk';
33
+
34
+ class Hello extends Addon {
35
+ constructor() { super({ name: 'Hello' }); }
36
+
37
+ protected onMount() {
38
+ this.interval(() => this.log.info('tick'), 1000);
39
+ }
40
+ }
41
+
42
+ export const { mount, unmount } = defineAddon(() => new Hello());
43
+ ```
44
+
45
+ Two things follow from that, and they shape the whole SDK:
46
+
47
+ 1. **A Blob module resolves nothing** - no base URL, no import map. React, CSS,
48
+ workers and any game code you use must already be inside the one emitted
49
+ file. That is what `oss-idle-addon build` produces.
50
+ 2. **`unmount` has to genuinely undo everything**, because add-ons are enabled,
51
+ disabled, updated and removed without a page reload. Anything you register
52
+ through `this.on`, `this.interval`, `this.timeout` or `this.own` is torn down
53
+ for you, in reverse order, even if one teardown throws.
54
+
55
+ ## Editor add-ons
56
+
57
+ An add-on of the Editor type (picked when you make it in game) reaches the map
58
+ editor while it is open. Everything registered through the editor's API goes when
59
+ the editor closes or the add-on unmounts.
60
+
61
+ ```ts
62
+ class Mirror extends Addon {
63
+ constructor() { super({ name: 'Mirror' }); }
64
+
65
+ protected onMount() {
66
+ this.editor(editor => {
67
+ editor.addAction({
68
+ id: 'mirror-selection', label: 'Mirror the selected notes', keys: ['Alt+M'],
69
+ run: () => {
70
+ const picked = new Set(editor.selection());
71
+ editor.edit({
72
+ notes: editor.notes().map(note => picked.has(note)
73
+ ? { ...note, column: editor.keys - 1 - note.column } : note),
74
+ });
75
+ },
76
+ });
77
+ });
78
+ }
79
+ }
80
+ ```
81
+
82
+ `EditorApi` and the other host types are exported from the package.
83
+
84
+ ## React overlays
85
+
86
+ ```tsx
87
+ import { ReactAddon } from '@oss-idle/addon-sdk/react';
88
+ import styles from './styles.css';
89
+
90
+ export class MyAddon extends ReactAddon {
91
+ private open = false;
92
+
93
+ constructor() { super({ name: 'My add-on', styles }); }
94
+
95
+ protected override onReady() {
96
+ this.own(this.hotkeys.register('Ctrl+Shift+K', () => {
97
+ this.open = !this.open;
98
+ this.refresh();
99
+ }, { allowInText: true }));
100
+ }
101
+
102
+ protected render() { return <Panel open={this.open} />; }
103
+ }
104
+ ```
105
+
106
+ `ReactAddon` renders into a **shadow root**, so the game's CSS cannot reach your
107
+ overlay and yours cannot restyle the game. For another framework, subclass
108
+ `Addon` and use `createOverlay` directly.
109
+
110
+ Render your panel hidden rather than returning `null`: `null` unmounts the tree,
111
+ discarding its state and running cleanup, so anything the overlay started dies
112
+ the moment the player closes it.
113
+
114
+ ## Staying out of the game's way
115
+
116
+ The client binds gameplay controls as **bubble-phase listeners on `window`**, and
117
+ nothing in the capture phase - which is what lets an overlay coexist with it.
118
+
119
+ - `isolateInput(root)`, applied for you by `createOverlay`, stops input events at
120
+ the overlay's root while they bubble. The event still reached its target, so a
121
+ text field types normally, but it never continues to `window` - typing "speed"
122
+ into a filter box cannot press four gameplay keys, and scrolling a list cannot
123
+ change the game's volume.
124
+ - `HotkeyManager` listens in the **capture** phase, ahead of every game handler,
125
+ so a shortcut claims its combo before gameplay sees it.
126
+ - Overlays default to `z-index: 50000` - above the game's UI, below its drawn
127
+ cursor. Stack above the cursor layer (99999+) and the player loses their
128
+ pointer entirely, because the game hides the system cursor with
129
+ `cursor: none`, which inherits through the shadow boundary.
130
+
131
+ ## Reading the game's state
132
+
133
+ Add-ons cannot import the client's modules, so the SDK talks to what the page
134
+ exposes:
135
+
136
+ ```ts
137
+ import {
138
+ gameDb, gameCharacter, beatmapStore, playlists, recentPlayCounts,
139
+ } from '@oss-idle/addon-sdk';
140
+
141
+ await gameDb.ready(); // the client opens it lazily
142
+ const me = await gameCharacter.live(); // who is being played
143
+ const maps = await beatmapStore.difficulties(); // downloaded charts
144
+ const played = await recentPlayCounts(me.id); // plays per map, last 24h
145
+ await playlists.replace('My rotation', [101, 102]);
146
+ ```
147
+
148
+ `gameDb` wraps the sql.js handle the client publishes on `window`. Reads go
149
+ through its read path, **writes through its write path** - that is what persists
150
+ the database back to IndexedDB; a write applied on the read handle lives only
151
+ until reload.
152
+
153
+ `gameCharacter.live()` resolves who is playing from the most recent stored score,
154
+ because the client keeps that answer in module state an add-on cannot see. Note
155
+ that `character.current = 1` marks the live **guest**, not the signed-in
156
+ character - reading it directly is a quiet way to simulate the wrong player.
157
+
158
+ `beatmapStore` opens the beatmap IndexedDB **without a version number**, on
159
+ purpose: passing one could trigger an upgrade and take out the player's library.
160
+
161
+ `playlists` reads and writes the client's own playlist tables. `replace()` keeps
162
+ an existing playlist's id, so regenerating one does not orphan anything pointing
163
+ at it. The client caches playlists behind a version counter it bumps itself and
164
+ an add-on cannot reach, so **song select keeps showing the old list until it
165
+ re-reads** - say so rather than leaving the player wondering.
166
+
167
+ `playCountsSince` and `recentPlayCounts` count plays per map from the `score`
168
+ table, in one query rather than per map. The 24h/50-play window
169
+ `recentPlayCounts` uses is the one the server scores repetition over, so an
170
+ add-on predicting what a play is worth has to read it the same way.
171
+
172
+ ## Remembering settings
173
+
174
+ ```ts
175
+ import { createSettings } from '@oss-idle/addon-sdk';
176
+ import { usePersistedSettings } from '@oss-idle/addon-sdk/react';
177
+
178
+ const DEFAULTS = { skill: 'total', runs: 3 };
179
+ const store = createSettings('my-addon', DEFAULTS);
180
+
181
+ const [settings, update] = usePersistedSettings(store, DEFAULTS);
182
+ ```
183
+
184
+ Keys live under `oss-idle-addon:<namespace>`, which matters: the client writes
185
+ its own options under bare, unprefixed keys and parses them without a guard, so a
186
+ collision would corrupt a game setting. Stored values merge over your defaults,
187
+ so shipping a new setting does not invalidate what players saved, and every
188
+ access is guarded - storage can be unavailable or full.
189
+
190
+ Use it for settings, not data. localStorage is shared with the client, so filling
191
+ it breaks the game and not just your add-on.
192
+
193
+ ## Building
194
+
195
+ ```sh
196
+ oss-idle-addon build [--minify] [--watch] [--config addon.config.json]
197
+ ```
198
+
199
+ ```jsonc
200
+ {
201
+ "name": "My Add-on",
202
+ "entry": "src/index.ts",
203
+ "outfile": "dist/my-addon.js"
204
+ }
205
+ ```
206
+
207
+ The version comes from `package.json`. The build stamps it into the banner and
208
+ into the bundle, so `this.meta.version` reports the version that was built:
209
+
210
+ ```ts
211
+ super({ name: 'My Add-on' }); // version filled in by the bundler
212
+ this.meta.version // "0.1.0"
213
+ buildMeta().gameVersion // the oss!idle release it was built against
214
+ ```
215
+
216
+ The build fails if the output would not export `mount` - a bundle missing it
217
+ loads without error and does nothing, which is miserable to debug from inside the
218
+ game.
219
+
220
+ Output is **not minified by default**: published add-ons are read by a human
221
+ moderator before they go live.
222
+
223
+ | Feature | Why it exists |
224
+ |---|---|
225
+ | Everything inlined into one ES module | A Blob module resolves nothing at load time. |
226
+ | `import styles from './x.css'` → string | For injecting into the overlay's shadow root. |
227
+ | `import make from 'worker:./x.worker'` | Workers must travel inside the same file. Heavy work belongs off the main thread - the game is a rhythm game sharing it. |
228
+ | `import bytes from './x.wasm?base64'` | Same reason: a Blob module cannot fetch a sibling asset. |
229
+ | `gameRoot` → resolves `@oss-idle/shared/*` | The game's package is unpublished, so its TypeScript is compiled from a checkout (`$OSS_IDLE_PATH`). |
230
+ | `dedupe` | Your sources and the game's come from different checkouts, so shared packages would otherwise be bundled twice - which silently breaks prototype patches and `instanceof`. |
231
+ | `stubModules` | Trims a heavy dependency a game module imports but your add-on never calls. Stubs **throw** if reached. |
232
+ | `plugins` | Extra esbuild plugins for the add-on and its workers, run before the SDK's own - e.g. to redirect one of the game's imports to your own module. |
233
+
234
+ ### Using the game's own code
235
+
236
+ An add-on can bundle oss!idle's simulation, skills and helpers straight from a
237
+ checkout:
238
+
239
+ ```jsonc
240
+ {
241
+ "gameRoot": "/path/to/oss-idle", // or set $OSS_IDLE_PATH
242
+ "dedupe": ["osu-classes", "osu-parsers", "osu-mania-stable"]
243
+ }
244
+ ```
245
+
246
+ ```ts
247
+ import { ManiaGame } from '@oss-idle/shared/sim/maniaGame';
248
+ ```
249
+
250
+ Doing so makes your add-on a derivative work of oss!idle, which is **AGPL-3.0** -
251
+ so it must be AGPL-3.0 too. Pin the game version you built against; the sources
252
+ are internal and do move between releases.
253
+
254
+ ### The game's React
255
+
256
+ Set `"sharedReact": true` in `addon.config.json` (the template does) and the
257
+ bundle reads React from the game instead of shipping its own: one React on the
258
+ page, and a smaller add-on. A game too old to hand out its React refuses the
259
+ add-on with a message; leave the option off to support those.
260
+
261
+ ## Licence
262
+
263
+ AGPL-3.0-only.
package/bin/cli.mjs ADDED
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * oss-idle-addon - the oss!idle Addon SDK command line.
4
+ *
5
+ * oss-idle-addon init [dir] [--name "My Add-on"] [--template react|plain]
6
+ * oss-idle-addon build [--config addon.config.json] [--minify] [--watch]
7
+ */
8
+ import { readFile } from 'node:fs/promises';
9
+ import { existsSync, watch } from 'node:fs';
10
+ import { resolve, relative } from 'node:path';
11
+ import { scaffold, TEMPLATE_NAMES } from './scaffold.mjs';
12
+
13
+ const argv = process.argv.slice(2);
14
+ const command = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'build';
15
+ const args = argv.slice(argv[0] === command ? 1 : 0);
16
+
17
+ const flag = (name) => args.includes(`--${name}`);
18
+ const option = (name, fallback) => {
19
+ const i = args.indexOf(`--${name}`);
20
+ return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : fallback;
21
+ };
22
+ /** First argument that is not a flag or a flag's value. */
23
+ const positional = () => {
24
+ for (let i = 0; i < args.length; i++) {
25
+ if (args[i].startsWith('--')) { i++; continue; }
26
+ return args[i];
27
+ }
28
+ return undefined;
29
+ };
30
+
31
+ const usage = `oss!idle Addon SDK
32
+
33
+ oss-idle-addon init [dir] create a new add-on project
34
+ --name "My Add-on" display name (default: the directory name)
35
+ --template ${TEMPLATE_NAMES.join('|').padEnd(20)}starting point (default: react)
36
+ --license <spdx> default: AGPL-3.0-only
37
+ --force write into a non-empty directory
38
+
39
+ oss-idle-addon build bundle the add-on into one ES module
40
+ --config <path> default: addon.config.json
41
+ --minify smaller, but harder for a reviewer to read
42
+ --sourcemap inline source map
43
+ --watch rebuild on change
44
+ `;
45
+
46
+ const die = (message) => { console.error(message); process.exit(1); };
47
+
48
+ if (flag('help') || flag('h') || command === 'help') {
49
+ console.log(usage);
50
+ process.exit(0);
51
+ }
52
+
53
+ if (command === 'init') {
54
+ const dir = positional() ?? '.';
55
+ const name = option('name', resolve(dir).split(/[\\/]/).pop());
56
+
57
+ const result = await scaffold({
58
+ dir,
59
+ name,
60
+ template: option('template', 'react'),
61
+ license: option('license', 'AGPL-3.0-only'),
62
+ force: flag('force'),
63
+ }).catch(e => die(e.message));
64
+
65
+ const where = relative(process.cwd(), result.target) || '.';
66
+ console.log(`Created ${name} in ${where}/ (${result.template} template)\n`);
67
+ console.log('Next:');
68
+ if (where !== '.') console.log(` cd ${where}`);
69
+ console.log(' npm install');
70
+ console.log(' npm run build\n');
71
+ console.log(`Then paste ${result.outfile} into the in-game add-on editor.`);
72
+ process.exit(0);
73
+ }
74
+
75
+ if (command !== 'build') die(`Unknown command "${command}".\n\n${usage}`);
76
+
77
+ // ---- build ---------------------------------------------------------------
78
+
79
+ const { bundleAddon } = await import('../dist/build/index.js');
80
+
81
+ const configPath = resolve(option('config', 'addon.config.json'));
82
+ if (!existsSync(configPath)) {
83
+ die(`No config at ${configPath}.\nRun "oss-idle-addon init" to create a project, or pass --config.`);
84
+ }
85
+
86
+ const config = JSON.parse(await readFile(configPath, 'utf8'));
87
+ const cwd = resolve(configPath, '..');
88
+
89
+ if (!config.entry || !config.outfile) {
90
+ die(`${configPath} needs both "entry" and "outfile".`);
91
+ }
92
+
93
+ /**
94
+ * The add-on's version, from package.json - the one file that must carry one.
95
+ * Repeating it in addon.config.json is how a bundle ends up claiming a version
96
+ * it is not.
97
+ */
98
+ const pkgVersion = await readFile(resolve(cwd, 'package.json'), 'utf8')
99
+ .then(raw => JSON.parse(raw).version)
100
+ .catch(() => undefined);
101
+
102
+ const run = async () => {
103
+ const started = Date.now();
104
+ const { outfile, bytes } = await bundleAddon({
105
+ entry: resolve(cwd, config.entry),
106
+ outfile: resolve(cwd, config.outfile),
107
+ gameRoot: process.env.OSS_IDLE_PATH ?? config.gameRoot,
108
+ minify: flag('minify') || !!config.minify,
109
+ sourcemap: flag('sourcemap'),
110
+ stubModules: config.stubModules,
111
+ dedupe: config.dedupe,
112
+ dedupeFrom: cwd,
113
+ sharedReact: !!config.sharedReact,
114
+ meta: {
115
+ name: config.name,
116
+ version: pkgVersion,
117
+ gameVersion: process.env.OSS_IDLE_GAME_VERSION ?? config.gameVersion,
118
+ license: config.license,
119
+ },
120
+ });
121
+ console.log(
122
+ `built ${relative(process.cwd(), outfile)} - ${(bytes / 1024).toFixed(1)} kB`
123
+ + ` in ${Date.now() - started}ms`,
124
+ );
125
+ };
126
+
127
+ await run().catch(e => die(e.message ?? e));
128
+
129
+ if (flag('watch')) {
130
+ const src = resolve(cwd, 'src');
131
+ console.log(`watching ${relative(process.cwd(), src)}/ ...`);
132
+ let queued;
133
+ watch(src, { recursive: true }, () => {
134
+ clearTimeout(queued);
135
+ queued = setTimeout(() => run().catch(e => console.error(e.message ?? e)), 120);
136
+ });
137
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Scaffolding a new add-on.
3
+ *
4
+ * Shared by `oss-idle-addon init` and by `npx @oss-idle/addon-sdk-create`, so both
5
+ * entry points produce exactly the same project.
6
+ *
7
+ * Templates are plain files with `__TOKEN__` placeholders rather than a
8
+ * template language: they stay readable, and `templates/react/src/Addon.tsx` is
9
+ * a working reference for someone reading the SDK itself.
10
+ */
11
+ import { cp, mkdir, readdir, readFile, writeFile, stat } from 'node:fs/promises';
12
+ import { existsSync } from 'node:fs';
13
+ import { dirname, join, resolve } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const here = dirname(fileURLToPath(import.meta.url));
17
+ const TEMPLATES = resolve(here, '..', 'templates');
18
+
19
+ /** SDK version to depend on, taken from the SDK actually doing the scaffolding. */
20
+ const sdkVersion = async () => {
21
+ try {
22
+ const pkg = JSON.parse(await readFile(resolve(here, '..', 'package.json'), 'utf8'));
23
+ return `^${pkg.version}`;
24
+ } catch {
25
+ return 'latest';
26
+ }
27
+ };
28
+
29
+ /** `My Cool Add-on` -> `my-cool-add-on`. */
30
+ export const slugify = (name) =>
31
+ name.toLowerCase()
32
+ .replace(/[^a-z0-9]+/g, '-')
33
+ .replace(/^-+|-+$/g, '')
34
+ || 'my-addon';
35
+
36
+ /** `my-cool-addon` -> `MyCoolAddon`, a usable class name. */
37
+ export const classify = (slug) => {
38
+ const name = slug.split('-')
39
+ .filter(Boolean)
40
+ .map(part => part[0].toUpperCase() + part.slice(1))
41
+ .join('');
42
+ // A class name cannot start with a digit.
43
+ return /^[0-9]/.test(name) ? `Addon${name}` : (name || 'MyAddon');
44
+ };
45
+
46
+ const fill = (text, tokens) =>
47
+ Object.entries(tokens).reduce(
48
+ (out, [token, value]) => out.split(`__${token}__`).join(value),
49
+ text,
50
+ );
51
+
52
+ /** Copy a template directory, substituting tokens in every text file. */
53
+ const copyTemplate = async (from, to, tokens) => {
54
+ for (const entry of await readdir(from, { withFileTypes: true })) {
55
+ const source = join(from, entry.name);
56
+ const target = join(to, entry.name);
57
+
58
+ if (entry.isDirectory()) {
59
+ await mkdir(target, { recursive: true });
60
+ await copyTemplate(source, target, tokens);
61
+ continue;
62
+ }
63
+
64
+ // Anything not text is copied verbatim; templates are all text today,
65
+ // but this keeps a future binary asset from being corrupted.
66
+ if (/\.(png|jpg|gif|wasm|woff2?)$/.test(entry.name)) {
67
+ await cp(source, target);
68
+ continue;
69
+ }
70
+
71
+ // npm strips `.gitignore` from published tarballs, so the template ships
72
+ // it undotted and it is restored here - otherwise every scaffolded
73
+ // project would arrive without one and commit its node_modules.
74
+ const name = entry.name === 'gitignore' ? '.gitignore' : entry.name;
75
+ const dest = join(dirname(target), name);
76
+
77
+ await mkdir(dirname(dest), { recursive: true });
78
+ await writeFile(dest, fill(await readFile(source, 'utf8'), tokens), 'utf8');
79
+ }
80
+ };
81
+
82
+ export const TEMPLATE_NAMES = ['react', 'plain'];
83
+
84
+ /**
85
+ * Create an add-on project in `dir`.
86
+ *
87
+ * Refuses to write into a directory that already holds files, unless `force` -
88
+ * scaffolding over someone's work is not recoverable.
89
+ */
90
+ export const scaffold = async ({
91
+ dir,
92
+ name,
93
+ template = 'react',
94
+ license = 'AGPL-3.0-only',
95
+ force = false,
96
+ }) => {
97
+ if (!TEMPLATE_NAMES.includes(template)) {
98
+ throw new Error(`Unknown template "${template}". Use one of: ${TEMPLATE_NAMES.join(', ')}`);
99
+ }
100
+
101
+ const target = resolve(dir);
102
+ if (existsSync(target)) {
103
+ const info = await stat(target);
104
+ if (!info.isDirectory()) throw new Error(`${target} exists and is not a directory`);
105
+ const existing = (await readdir(target)).filter(f => f !== '.git');
106
+ if (existing.length && !force) {
107
+ throw new Error(
108
+ `${target} is not empty (${existing.length} entries). `
109
+ + 'Choose an empty directory, or pass --force.',
110
+ );
111
+ }
112
+ }
113
+ await mkdir(target, { recursive: true });
114
+
115
+ const slug = slugify(name);
116
+ const tokens = {
117
+ ADDON_NAME: name,
118
+ SLUG: slug,
119
+ CLASS: classify(slug),
120
+ LICENSE: license,
121
+ OUTFILE: `dist/${slug}.js`,
122
+ // A default hotkey letter that is unlikely to collide with the game's.
123
+ KEY: (slug.match(/[a-z]/)?.[0] ?? 'k').toUpperCase(),
124
+ };
125
+
126
+ await copyTemplate(join(TEMPLATES, 'common'), target, tokens);
127
+ await copyTemplate(join(TEMPLATES, template), target, tokens);
128
+
129
+ const react = template === 'react';
130
+ const pkg = {
131
+ name: slug,
132
+ version: '0.1.0',
133
+ private: true,
134
+ type: 'module',
135
+ license,
136
+ description: `${name} - an oss!idle add-on`,
137
+ scripts: {
138
+ build: 'oss-idle-addon build',
139
+ dev: 'oss-idle-addon build --watch',
140
+ typecheck: 'tsc -p tsconfig.json --noEmit',
141
+ lint: 'eslint .',
142
+ },
143
+ dependencies: {
144
+ '@oss-idle/addon-sdk': await sdkVersion(),
145
+ ...(react ? { react: '^18.3.1', 'react-dom': '^18.3.1' } : {}),
146
+ },
147
+ devDependencies: {
148
+ ...(react ? { '@types/react': '^18.3.12', '@types/react-dom': '^18.3.1' } : {}),
149
+ eslint: '^10.13.0',
150
+ typescript: '^5.6.3',
151
+ 'typescript-eslint': '^8.72.0',
152
+ },
153
+ };
154
+ await writeFile(join(target, 'package.json'), JSON.stringify(pkg, null, 2) + '\n', 'utf8');
155
+
156
+ return { target, slug, template, outfile: tokens.OUTFILE };
157
+ };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Bridge to the game's stored beatmaps.
3
+ *
4
+ * Downloaded sets live in their own IndexedDB database (`beatmaps`), separate
5
+ * from the sql.js state database:
6
+ *
7
+ * - `meta` - one record per set (`keyPath: 'id'`). The record **is** the set's
8
+ * metadata, versions and all - the client's `.metadata` property
9
+ * comes from the `LightBeatmapSet` wrapper it builds afterwards,
10
+ * not from the stored shape.
11
+ * - `charts`- keyed by set id, holding `Record<beatmapId, osuFileText>`.
12
+ * - `files` - the set's assets (audio, backgrounds). Unused here.
13
+ *
14
+ * Only *downloaded* sets are present: the catalog lists far more than any
15
+ * player holds, and a chart is what the simulation needs, so the add-on's
16
+ * coverage is the player's own library.
17
+ *
18
+ * The database is opened **without a version**. Passing one would trigger an
19
+ * upgrade if the add-on's number ever drifted above the client's, and an
20
+ * upgrade here would wipe or block the player's whole library.
21
+ */
22
+ export type StoredVersion = {
23
+ id: number;
24
+ version: string;
25
+ difficulty: number;
26
+ mode: number;
27
+ keys: number;
28
+ total_length: number;
29
+ bpm: number;
30
+ objects: number;
31
+ rice?: number;
32
+ ln?: number;
33
+ [key: string]: unknown;
34
+ };
35
+ export type StoredSetMeta = {
36
+ id: number;
37
+ title: string;
38
+ artist: string;
39
+ creator: string;
40
+ versions: StoredVersion[];
41
+ [key: string]: unknown;
42
+ };
43
+ /** The player's downloaded beatmaps. */
44
+ export declare const beatmapStore: {
45
+ /** Metadata for every stored set. */
46
+ sets(): Promise<StoredSetMeta[]>;
47
+ /** Every stored difficulty, paired with the set it belongs to. */
48
+ difficulties(): Promise<{
49
+ set: StoredSetMeta;
50
+ version: StoredVersion;
51
+ }[]>;
52
+ /** Every chart in one set, keyed by difficulty id. */
53
+ charts(setId: number): Promise<Record<number, string>>;
54
+ /** One difficulty's `.osu` text, or `undefined` if the set is not stored. */
55
+ chart(setId: number, beatmapId: number): Promise<string | undefined>;
56
+ };
57
+ //# sourceMappingURL=beatmaps.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"beatmaps.d.ts","sourceRoot":"","sources":["../../src/bridge/beatmaps.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,MAAM,MAAM,aAAa,GAAG;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACvB,CAAC;AA8BF,wCAAwC;AACxC,eAAO,MAAM,YAAY;IAExB,qCAAqC;YACvB,OAAO,CAAC,aAAa,EAAE,CAAC;IAKtC,kEAAkE;oBAC5C,OAAO,CAAC;QAAE,GAAG,EAAE,aAAa,CAAC;QAAC,OAAO,EAAE,aAAa,CAAA;KAAE,EAAE,CAAC;IAK/E,sDAAsD;kBAClC,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAI5D,6EAA6E;iBAC1D,MAAM,aAAa,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;CAG1E,CAAC"}
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Bridge to the game's stored beatmaps.
3
+ *
4
+ * Downloaded sets live in their own IndexedDB database (`beatmaps`), separate
5
+ * from the sql.js state database:
6
+ *
7
+ * - `meta` - one record per set (`keyPath: 'id'`). The record **is** the set's
8
+ * metadata, versions and all - the client's `.metadata` property
9
+ * comes from the `LightBeatmapSet` wrapper it builds afterwards,
10
+ * not from the stored shape.
11
+ * - `charts`- keyed by set id, holding `Record<beatmapId, osuFileText>`.
12
+ * - `files` - the set's assets (audio, backgrounds). Unused here.
13
+ *
14
+ * Only *downloaded* sets are present: the catalog lists far more than any
15
+ * player holds, and a chart is what the simulation needs, so the add-on's
16
+ * coverage is the player's own library.
17
+ *
18
+ * The database is opened **without a version**. Passing one would trigger an
19
+ * upgrade if the add-on's number ever drifted above the client's, and an
20
+ * upgrade here would wipe or block the player's whole library.
21
+ */
22
+ const DB_NAME = 'beatmaps';
23
+ let connection;
24
+ const open = () => connection ??= new Promise((resolve, reject) => {
25
+ // No version argument - see the note above.
26
+ const request = indexedDB.open(DB_NAME);
27
+ request.onsuccess = () => resolve(request.result);
28
+ request.onerror = () => reject(request.error);
29
+ request.onblocked = () => reject(new Error('beatmap store blocked by another tab'));
30
+ }).catch((e) => {
31
+ // Don't cache a failed open - the next call retries.
32
+ connection = undefined;
33
+ throw e;
34
+ });
35
+ const request = (store, fn) => open().then(db => new Promise((resolve, reject) => {
36
+ if (!db.objectStoreNames.contains(store)) {
37
+ resolve(undefined);
38
+ return;
39
+ }
40
+ const tx = db.transaction(store, 'readonly');
41
+ const req = fn(tx.objectStore(store));
42
+ req.onsuccess = () => resolve(req.result);
43
+ req.onerror = () => reject(req.error);
44
+ }));
45
+ /** The player's downloaded beatmaps. */
46
+ export const beatmapStore = {
47
+ /** Metadata for every stored set. */
48
+ async sets() {
49
+ const records = await request('meta', s => s.getAll());
50
+ return (records ?? []).filter(record => Array.isArray(record?.versions));
51
+ },
52
+ /** Every stored difficulty, paired with the set it belongs to. */
53
+ async difficulties() {
54
+ const sets = await this.sets();
55
+ return sets.flatMap(set => (set.versions ?? []).map(version => ({ set, version })));
56
+ },
57
+ /** Every chart in one set, keyed by difficulty id. */
58
+ async charts(setId) {
59
+ return (await request('charts', s => s.get(setId))) ?? {};
60
+ },
61
+ /** One difficulty's `.osu` text, or `undefined` if the set is not stored. */
62
+ async chart(setId, beatmapId) {
63
+ return (await this.charts(setId))[beatmapId];
64
+ },
65
+ };