@oss-idle/addon-sdk 0.0.0-stage → 0.5.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 (66) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +222 -2
  3. package/bin/cli.mjs +136 -0
  4. package/bin/scaffold.mjs +154 -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 +60 -0
  21. package/dist/build/index.d.ts.map +1 -0
  22. package/dist/build/index.js +123 -0
  23. package/dist/build/plugins.d.ts +88 -0
  24. package/dist/build/plugins.d.ts.map +1 -0
  25. package/dist/build/plugins.js +213 -0
  26. package/dist/index.d.ts +11 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +10 -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 +83 -0
  42. package/dist/runtime/addon.d.ts.map +1 -0
  43. package/dist/runtime/addon.js +108 -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 +23 -0
  48. package/dist/runtime/define.d.ts.map +1 -0
  49. package/dist/runtime/define.js +37 -0
  50. package/dist/runtime/input.d.ts +58 -0
  51. package/dist/runtime/input.d.ts.map +1 -0
  52. package/dist/runtime/input.js +119 -0
  53. package/dist/runtime/settings.d.ts +36 -0
  54. package/dist/runtime/settings.d.ts.map +1 -0
  55. package/dist/runtime/settings.js +74 -0
  56. package/package.json +83 -4
  57. package/templates/common/README.md +31 -0
  58. package/templates/common/addon.config.json +6 -0
  59. package/templates/common/gitignore +3 -0
  60. package/templates/common/src/env.d.ts +17 -0
  61. package/templates/common/tsconfig.json +14 -0
  62. package/templates/plain/src/index.ts +32 -0
  63. package/templates/react/src/Addon.tsx +44 -0
  64. package/templates/react/src/Panel.tsx +41 -0
  65. package/templates/react/src/index.ts +12 -0
  66. package/templates/react/src/styles.css +61 -0
package/README.md CHANGED
@@ -1,3 +1,223 @@
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`. Nothing is injected; add-ons run unsandboxed in the page
26
+ realm and reach for what they need themselves.
27
+
28
+ ```ts
29
+ import { Addon, defineAddon } from '@oss-idle/addon-sdk';
30
+
31
+ class Hello extends Addon {
32
+ constructor() { super({ name: 'Hello' }); }
33
+
34
+ protected onMount() {
35
+ this.interval(() => this.log.info('tick'), 1000);
36
+ }
37
+ }
38
+
39
+ export const { mount, unmount } = defineAddon(() => new Hello());
40
+ ```
41
+
42
+ Two things follow from that, and they shape the whole SDK:
43
+
44
+ 1. **A Blob module resolves nothing** - no base URL, no import map. React, CSS,
45
+ workers and any game code you use must already be inside the one emitted
46
+ file. That is what `oss-idle-addon build` produces.
47
+ 2. **`unmount` has to genuinely undo everything**, because add-ons are enabled,
48
+ disabled, updated and removed without a page reload. Anything you register
49
+ through `this.on`, `this.interval`, `this.timeout` or `this.own` is torn down
50
+ for you, in reverse order, even if one teardown throws.
51
+
52
+ ## React overlays
53
+
54
+ ```tsx
55
+ import { ReactAddon } from '@oss-idle/addon-sdk/react';
56
+ import styles from './styles.css';
57
+
58
+ export class MyAddon extends ReactAddon {
59
+ private open = false;
60
+
61
+ constructor() { super({ name: 'My add-on', styles }); }
62
+
63
+ protected override onReady() {
64
+ this.own(this.hotkeys.register('Ctrl+Shift+K', () => {
65
+ this.open = !this.open;
66
+ this.refresh();
67
+ }, { allowInText: true }));
68
+ }
69
+
70
+ protected render() { return <Panel open={this.open} />; }
71
+ }
72
+ ```
73
+
74
+ `ReactAddon` renders into a **shadow root**, so the game's CSS cannot reach your
75
+ overlay and yours cannot restyle the game. For another framework, subclass
76
+ `Addon` and use `createOverlay` directly.
77
+
78
+ Render your panel hidden rather than returning `null`: `null` unmounts the tree,
79
+ discarding its state and running cleanup, so anything the overlay started dies
80
+ the moment the player closes it.
81
+
82
+ ## Staying out of the game's way
83
+
84
+ The client binds gameplay controls as **bubble-phase listeners on `window`**, and
85
+ nothing in the capture phase - which is what lets an overlay coexist with it.
86
+
87
+ - `isolateInput(root)`, applied for you by `createOverlay`, stops input events at
88
+ the overlay's root while they bubble. The event still reached its target, so a
89
+ text field types normally, but it never continues to `window` - typing "speed"
90
+ into a filter box cannot press four gameplay keys, and scrolling a list cannot
91
+ change the game's volume.
92
+ - `HotkeyManager` listens in the **capture** phase, ahead of every game handler,
93
+ so a shortcut claims its combo before gameplay sees it.
94
+ - Overlays default to `z-index: 50000` - above the game's UI, below its drawn
95
+ cursor. Stack above the cursor layer (99999+) and the player loses their
96
+ pointer entirely, because the game hides the system cursor with
97
+ `cursor: none`, which inherits through the shadow boundary.
98
+
99
+ ## Reading the game's state
100
+
101
+ Add-ons cannot import the client's modules, so the SDK talks to what the page
102
+ exposes:
103
+
104
+ ```ts
105
+ import {
106
+ gameDb, gameCharacter, beatmapStore, playlists, recentPlayCounts,
107
+ } from '@oss-idle/addon-sdk';
108
+
109
+ await gameDb.ready(); // the client opens it lazily
110
+ const me = await gameCharacter.live(); // who is being played
111
+ const maps = await beatmapStore.difficulties(); // downloaded charts
112
+ const played = await recentPlayCounts(me.id); // plays per map, last 24h
113
+ await playlists.replace('My rotation', [101, 102]);
114
+ ```
115
+
116
+ `gameDb` wraps the sql.js handle the client publishes on `window`. Reads go
117
+ through its read path, **writes through its write path** - that is what persists
118
+ the database back to IndexedDB; a write applied on the read handle lives only
119
+ until reload.
120
+
121
+ `gameCharacter.live()` resolves who is playing from the most recent stored score,
122
+ because the client keeps that answer in module state an add-on cannot see. Note
123
+ that `character.current = 1` marks the live **guest**, not the signed-in
124
+ character - reading it directly is a quiet way to simulate the wrong player.
125
+
126
+ `beatmapStore` opens the beatmap IndexedDB **without a version number**, on
127
+ purpose: passing one could trigger an upgrade and take out the player's library.
128
+
129
+ `playlists` reads and writes the client's own playlist tables. `replace()` keeps
130
+ an existing playlist's id, so regenerating one does not orphan anything pointing
131
+ at it. The client caches playlists behind a version counter it bumps itself and
132
+ an add-on cannot reach, so **song select keeps showing the old list until it
133
+ re-reads** - say so rather than leaving the player wondering.
134
+
135
+ `playCountsSince` and `recentPlayCounts` count plays per map from the `score`
136
+ table, in one query rather than per map. The 24h/50-play window
137
+ `recentPlayCounts` uses is the one the server scores repetition over, so an
138
+ add-on predicting what a play is worth has to read it the same way.
139
+
140
+ ## Remembering settings
141
+
142
+ ```ts
143
+ import { createSettings } from '@oss-idle/addon-sdk';
144
+ import { usePersistedSettings } from '@oss-idle/addon-sdk/react';
145
+
146
+ const DEFAULTS = { skill: 'total', runs: 3 };
147
+ const store = createSettings('my-addon', DEFAULTS);
148
+
149
+ const [settings, update] = usePersistedSettings(store, DEFAULTS);
150
+ ```
151
+
152
+ Keys live under `oss-idle-addon:<namespace>`, which matters: the client writes
153
+ its own options under bare, unprefixed keys and parses them without a guard, so a
154
+ collision would corrupt a game setting. Stored values merge over your defaults,
155
+ so shipping a new setting does not invalidate what players saved, and every
156
+ access is guarded - storage can be unavailable or full.
157
+
158
+ Use it for settings, not data. localStorage is shared with the client, so filling
159
+ it breaks the game and not just your add-on.
160
+
161
+ ## Building
162
+
163
+ ```sh
164
+ oss-idle-addon build [--minify] [--watch] [--config addon.config.json]
165
+ ```
166
+
167
+ ```jsonc
168
+ {
169
+ "name": "My Add-on",
170
+ "entry": "src/index.ts",
171
+ "outfile": "dist/my-addon.js"
172
+ }
173
+ ```
174
+
175
+ The version comes from `package.json`. The build stamps it into the banner and
176
+ into the bundle, so `this.meta.version` reports the version that was built:
177
+
178
+ ```ts
179
+ super({ name: 'My Add-on' }); // version filled in by the bundler
180
+ this.meta.version // "0.1.0"
181
+ buildMeta().gameVersion // the oss!idle release it was built against
182
+ ```
183
+
184
+ The build fails if the output would not export `mount` - a bundle missing it
185
+ loads without error and does nothing, which is miserable to debug from inside the
186
+ game.
187
+
188
+ Output is **not minified by default**: published add-ons are read by a human
189
+ moderator before they go live.
190
+
191
+ | Feature | Why it exists |
192
+ |---|---|
193
+ | Everything inlined into one ES module | A Blob module resolves nothing at load time. |
194
+ | `import styles from './x.css'` → string | For injecting into the overlay's shadow root. |
195
+ | `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. |
196
+ | `import bytes from './x.wasm?base64'` | Same reason: a Blob module cannot fetch a sibling asset. |
197
+ | `gameRoot` → resolves `@oss-idle/shared/*` | The game's package is unpublished, so its TypeScript is compiled from a checkout (`$OSS_IDLE_PATH`). |
198
+ | `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`. |
199
+ | `stubModules` | Trims a heavy dependency a game module imports but your add-on never calls. Stubs **throw** if reached. |
200
+
201
+ ### Using the game's own code
202
+
203
+ An add-on can bundle oss!idle's simulation, skills and helpers straight from a
204
+ checkout:
205
+
206
+ ```jsonc
207
+ {
208
+ "gameRoot": "/path/to/oss-idle", // or set $OSS_IDLE_PATH
209
+ "dedupe": ["osu-classes", "osu-parsers", "osu-mania-stable"]
210
+ }
211
+ ```
212
+
213
+ ```ts
214
+ import { ManiaGame } from '@oss-idle/shared/sim/maniaGame';
215
+ ```
216
+
217
+ Doing so makes your add-on a derivative work of oss!idle, which is **AGPL-3.0** -
218
+ so it must be AGPL-3.0 too. Pin the game version you built against; the sources
219
+ are internal and do move between releases.
220
+
221
+ ## Licence
222
+
223
+ AGPL-3.0-only.
package/bin/cli.mjs ADDED
@@ -0,0 +1,136 @@
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
+ meta: {
114
+ name: config.name,
115
+ version: pkgVersion,
116
+ gameVersion: process.env.OSS_IDLE_GAME_VERSION ?? config.gameVersion,
117
+ license: config.license,
118
+ },
119
+ });
120
+ console.log(
121
+ `built ${relative(process.cwd(), outfile)} - ${(bytes / 1024).toFixed(1)} kB`
122
+ + ` in ${Date.now() - started}ms`,
123
+ );
124
+ };
125
+
126
+ await run().catch(e => die(e.message ?? e));
127
+
128
+ if (flag('watch')) {
129
+ const src = resolve(cwd, 'src');
130
+ console.log(`watching ${relative(process.cwd(), src)}/ ...`);
131
+ let queued;
132
+ watch(src, { recursive: true }, () => {
133
+ clearTimeout(queued);
134
+ queued = setTimeout(() => run().catch(e => console.error(e.message ?? e)), 120);
135
+ });
136
+ }
@@ -0,0 +1,154 @@
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
+ },
142
+ dependencies: {
143
+ '@oss-idle/addon-sdk': await sdkVersion(),
144
+ ...(react ? { react: '^18.3.1', 'react-dom': '^18.3.1' } : {}),
145
+ },
146
+ devDependencies: {
147
+ ...(react ? { '@types/react': '^18.3.12', '@types/react-dom': '^18.3.1' } : {}),
148
+ typescript: '^5.6.3',
149
+ },
150
+ };
151
+ await writeFile(join(target, 'package.json'), JSON.stringify(pkg, null, 2) + '\n', 'utf8');
152
+
153
+ return { target, slug, template, outfile: tokens.OUTFILE };
154
+ };
@@ -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
+ };