@moonbase-machines/player 0.9.1 → 0.11.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.
- package/README.md +34 -20
- package/bin/moonbase-play.mjs +19 -8
- package/disk/disk.js +14 -11
- package/disk/local.js +2 -2
- package/dist/moonbase-player.js +17 -18
- package/dist/moonbase-player.js.map +4 -4
- package/engines/original.js +155 -0
- package/engines/scenes.js +213 -0
- package/index.js +10 -5
- package/keys.js +28 -0
- package/package.json +6 -6
- package/play.js +195 -0
- package/player.css +10 -9
- package/provider.js +27 -0
- package/remake/card.js +4 -3
- package/remake/load.js +12 -3
- package/saves.js +22 -0
- package/settings.js +82 -0
- package/test/play.test.js +152 -0
- package/original.js +0 -200
- package/remake/main.js +0 -51
- package/remake/scenes.js +0 -243
package/play.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// The player: one way in for every title and every way to play it, the Original (the game's own disk on the machine
|
|
2
|
+
// it was made for) and the Remake (its scenes on the remake's engine, or the title's own engine). The player makes its
|
|
3
|
+
// part of the page and owns it, the same for all: the section and its classes, the stage and its size, the options
|
|
4
|
+
// line and the status, the keys, the sound, the printer, the saves on the title's disk, and what the page asks of it;
|
|
5
|
+
// the engine plays the game on the stage and does nothing of the page's. The page tells the player everything: the
|
|
6
|
+
// disk and the settings are the page's to give (its provider, provider.js), and the player reads no address and
|
|
7
|
+
// keeps nothing in the browser itself.
|
|
8
|
+
//
|
|
9
|
+
// await play({ way, slug, title, data, root, disk, settings, preview, words, screen, watch, claim, pace, keys,
|
|
10
|
+
// route, mini, roms, studies, downloadDisk })
|
|
11
|
+
// way 'original' or 'remake' (the default)
|
|
12
|
+
// slug the title's name in addresses: its section's class, its disk
|
|
13
|
+
// title the title's folder, an absolute URL (title.js, its scenes, compiled/, its own modules), where it
|
|
14
|
+
// has one; a game without one (a study) has only its data
|
|
15
|
+
// data its game's data, an absolute URL (<name>.json: its disks' files, its kind, its saves), or for a
|
|
16
|
+
// title of its own engine the folder its data is in
|
|
17
|
+
// root the page's section the player is made in
|
|
18
|
+
// disk the title's disk (disk/disk.js `openDisk`, `title/<slug>`), the page's to open where it keeps files
|
|
19
|
+
// settings the person's settings, the page's typed store (settings.js): each declared setting's value, kept
|
|
20
|
+
// preview the preview's build: what only it has may be used
|
|
21
|
+
// words the page's words the player shows: { printer: { line, print, tear } }
|
|
22
|
+
// screen [width, height], the screen's shape in its own dots where the page draws its own over it
|
|
23
|
+
// and what a page that follows the game asks:
|
|
24
|
+
// watch called with { engine, press, quick } once the game is ready: its engine (where it has one), a key
|
|
25
|
+
// pressed as the player presses one, and the game at full speed or not
|
|
26
|
+
// claim k => true to take a key of the player's before the game does
|
|
27
|
+
// pace how much of the original's waits: 1 all (the default), 0 none, between a part of each; a number or
|
|
28
|
+
// a function, read at each wait
|
|
29
|
+
// and what only a workshop has, given by its pages, never looked for here:
|
|
30
|
+
// keys keys to press at the start, by name (space, return, left...), to go straight to a scene
|
|
31
|
+
// route a route to play first, { name, route, keys }: its keys, and the pilots' key handling
|
|
32
|
+
// mini a mini view for a title with an `inset`, (at, { memory }) => { mode(m) }
|
|
33
|
+
// roms the makers' ROMs this machine has (engines/original.js; a Commodore 64 remake's { chargen })
|
|
34
|
+
// studies files of its own for a Commodore 64 game's disk, in the drive, not kept
|
|
35
|
+
// downloadDisk a Commodore 64 game's disk offered to download, with what is saved on it
|
|
36
|
+
//
|
|
37
|
+
// An engine (engines/original.js, engines/scenes.js, or the title's own, its title.js `engine` a module in its folder)
|
|
38
|
+
// exports `play(game)`: given `game`, the player's own for it (below), it declares the settings it acts on
|
|
39
|
+
// (`game.setting(declaration)`, settings.js, each shown only so), makes what it draws on the stage and returns
|
|
40
|
+
// its handle, or nothing where it cannot start (and has said why in the status):
|
|
41
|
+
// { run(), press(k)?, key(e, k)?, keys(list)?, route(route)?, start()?, sound(on)?, quick(on)?,
|
|
42
|
+
// engine?, diskImage()? }
|
|
43
|
+
// run() starts it; press(k) a key, the machine's code (none for a title of its own engine with no machine's keys,
|
|
44
|
+
// which reads the keyboard itself); key(e, k), for an engine that reads the keyboard itself, each
|
|
45
|
+
// keydown and keyup with its code (undefined where the machine has none); keys(list), the page's `keys` at the start;
|
|
46
|
+
// route, the page's route; start(), at the player's first gesture (sound may start only then); sound(on), the
|
|
47
|
+
// player's speaker on or off (the Sound setting; a game's own sound setting stays the game's); quick(on) the game at
|
|
48
|
+
// full speed; engine, what `watch` gives a page; diskImage(), the disk to download.
|
|
49
|
+
import { titleOf, loadTitle, read } from './remake/load.js';
|
|
50
|
+
import { savesFor } from './saves.js';
|
|
51
|
+
import { code, named } from './keys.js';
|
|
52
|
+
import { paperBox, phoneKeys, KEYS_MARKUP } from './parts.js';
|
|
53
|
+
|
|
54
|
+
// each machine's screen, in its own dots: an Apple's hi-res page, a Commodore 64's frame, border and all
|
|
55
|
+
const SCREENS = { prodos: [280, 192], dos33: [280, 192], c64: [384, 272] };
|
|
56
|
+
const ENGINES = { original: () => import('./engines/original.js'), card: () => import('./engines/scenes.js'), vector: () => import('./engines/scenes.js') };
|
|
57
|
+
// the settings the player itself acts on (settings.js): the speaker; a Commodore 64's printer; a title's version with
|
|
58
|
+
// its bugs fixed
|
|
59
|
+
const SOUND = { name: 'sound', type: 'boolean', default: true, owner: 'person', words: 'Sound' };
|
|
60
|
+
const PRINTER = { name: 'printer', type: 'boolean', default: true, owner: 'person', words: 'Printer' };
|
|
61
|
+
const FIXED = { name: 'fixed', type: 'boolean', default: false, owner: 'person', words: 'Bugs fixed', title: 'The game with its bugs fixed: it starts again' };
|
|
62
|
+
|
|
63
|
+
export async function play(options) {
|
|
64
|
+
const { way = 'remake', slug, root, disk, settings, preview = false, words } = options;
|
|
65
|
+
root.classList.add('player', way);
|
|
66
|
+
if (slug) root.classList.add(slug);
|
|
67
|
+
root.innerHTML = `<div class="stage"></div><p class="options"><span id="status">Loading.</span>${KEYS_MARKUP}<span class="settings"></span>`
|
|
68
|
+
+ '<button id="disk" type="button" hidden>Download the disk</button></p>';
|
|
69
|
+
root.append(paperBox(words.printer));
|
|
70
|
+
const $ = id => root.querySelector('#' + id);
|
|
71
|
+
const stage = root.querySelector('.stage'), status = $('status'), line = root.querySelector('.options .settings');
|
|
72
|
+
const fail = err => { status.textContent = 'The game stopped: ' + err.message; status.className = 'error'; console.error(err); };
|
|
73
|
+
// A setting the player or its engine acts on, by its declaration (settings.js): its value from the page's store,
|
|
74
|
+
// and its control on the options line where the page offers it, a box for a boolean, a menu for an enum, each
|
|
75
|
+
// change kept and told; `first`, before the others (the sound)
|
|
76
|
+
const setting = (def, { first = false } = {}) => {
|
|
77
|
+
const s = { value: settings.get(def), told: [], on(f) { this.told.push(f); return this; } };
|
|
78
|
+
if (!settings.offered(def)) return s;
|
|
79
|
+
const label = document.createElement('label'), choice = Array.isArray(def.type);
|
|
80
|
+
if (def.title) label.title = def.title;
|
|
81
|
+
const control = choice
|
|
82
|
+
? Object.assign(document.createElement('select'), { innerHTML: def.type.map((v, i) => `<option value="${v}">${def.labels[i]}</option>`).join('') })
|
|
83
|
+
: Object.assign(document.createElement('input'), { type: 'checkbox' });
|
|
84
|
+
control.id = def.name;
|
|
85
|
+
if (choice) { control.value = s.value; label.append(def.words + ' ', control); } else { control.checked = s.value; label.append(control, ' ' + def.words); }
|
|
86
|
+
control.addEventListener('change', () => { s.value = choice ? control.value : control.checked; settings.set(def, s.value); for (const f of s.told) f(s.value); });
|
|
87
|
+
line[first ? 'prepend' : 'append'](label);
|
|
88
|
+
return s;
|
|
89
|
+
};
|
|
90
|
+
try {
|
|
91
|
+
if (!disk || !settings) throw new Error('the page gives the player its disk and its settings');
|
|
92
|
+
// the title with its bugs fixed, for one that has such a version (its title.js `fixed`), a setting: its scripts
|
|
93
|
+
// chosen as it loads, so a change loads the page again
|
|
94
|
+
const fixed = settings.get(FIXED);
|
|
95
|
+
const folder = options.title ? new URL(options.title) : null, data = new URL(options.data);
|
|
96
|
+
// the title, and for its remake its scripts; the game's data (none here for a title of its own engine, which
|
|
97
|
+
// reads its own from its folder)
|
|
98
|
+
let title = null, scripts = {}, game = null;
|
|
99
|
+
if (way === 'remake') ({ title, scripts, disk: game } = await loadTitle(folder, { data, fixed }));
|
|
100
|
+
else if (folder) title = await titleOf(folder);
|
|
101
|
+
if (!game && /\.json$/.test(data.pathname)) game = await read(data, 'json');
|
|
102
|
+
const kind = game?.kind;
|
|
103
|
+
// the title's own styles, where it has them (its title.js `style`), in place before it draws by them
|
|
104
|
+
if (title?.style && !document.querySelector(`link[data-title="${title.slug}"]`)) {
|
|
105
|
+
const link = Object.assign(document.createElement('link'), { rel: 'stylesheet', href: new URL(title.style, folder).href });
|
|
106
|
+
link.dataset.title = title.slug; document.head.append(link);
|
|
107
|
+
await new Promise(done => { link.onload = link.onerror = done; });
|
|
108
|
+
}
|
|
109
|
+
// (a save the disk's backend could not store, said, not lost)
|
|
110
|
+
disk.failed = err => { status.textContent = 'A file was not kept: ' + err.message; status.className = 'error'; };
|
|
111
|
+
const [dots, lines] = options.screen ?? title?.screen ?? SCREENS[kind] ?? SCREENS.prodos;
|
|
112
|
+
fit(stage, root, dots, lines);
|
|
113
|
+
|
|
114
|
+
const g = {
|
|
115
|
+
way, slug, title, scripts, game, folder, data, stage, status, fail, disk, preview, words, fixed, $, setting,
|
|
116
|
+
saves: game ? savesFor(game, disk) : null, roms: options.roms, studies: options.studies, mini: options.mini ?? null,
|
|
117
|
+
chargen: null, // (the engine's Commodore 64 characters, for the printer's paper)
|
|
118
|
+
pace: () => { const p = options.pace; return typeof p === 'function' ? p() : p ?? 1; },
|
|
119
|
+
// an engine's own part of the options line that is no setting (Aztlan's preview selector), after the settings
|
|
120
|
+
control(html) { line.insertAdjacentHTML('afterend', html); },
|
|
121
|
+
};
|
|
122
|
+
// a printer on device 4, for a Commodore 64 game: on unless the setting is off, what it prints drawn below the
|
|
123
|
+
// screen as a sheet of paper, until it is torn off
|
|
124
|
+
if (kind === 'c64') {
|
|
125
|
+
const { Printer, paper } = await import('@moonbase-machines/c64/printer.js');
|
|
126
|
+
const on = setting(PRINTER);
|
|
127
|
+
const printer = g.printer = new Printer({ on: on.value });
|
|
128
|
+
on.on(v => { printer.on = v; });
|
|
129
|
+
const sheet = $('paper'), shown = $('paper-box');
|
|
130
|
+
printer.changed = () => { shown.hidden = !printer.lines.length; if (printer.lines.length) paper(sheet, printer, g.chargen); };
|
|
131
|
+
$('tear').addEventListener('click', () => printer.tear());
|
|
132
|
+
$('print-paper').addEventListener('click', () => window.print()); // (the sheet alone: its print style)
|
|
133
|
+
}
|
|
134
|
+
if (way === 'remake' && title?.fixed) setting(FIXED).on(() => location.reload());
|
|
135
|
+
|
|
136
|
+
const engine = way === 'original' ? await ENGINES.original() : title.own ?? await ENGINES[title.engine]();
|
|
137
|
+
const handle = await engine.play(g);
|
|
138
|
+
if (!handle) return;
|
|
139
|
+
|
|
140
|
+
// the sound, where the engine has it: the player's speaker, on or off
|
|
141
|
+
if (handle.sound) { const on = setting(SOUND, { first: true }); handle.sound(on.value); on.on(v => handle.sound(v)); }
|
|
142
|
+
// the keys, one listener for every engine: the machine's code for each (keys.js), offered to the page first
|
|
143
|
+
// (`claim`), then the engine's: the event itself where it reads the keyboard, else the code pressed
|
|
144
|
+
const keyed = e => {
|
|
145
|
+
const k = code(e, kind), down = e.type === 'keydown';
|
|
146
|
+
if (down && k !== undefined && !e.repeat && options.claim?.(k)) { e.preventDefault(); return; }
|
|
147
|
+
if (down) handle.start?.();
|
|
148
|
+
if (handle.key) { handle.key(e, k); return; }
|
|
149
|
+
if (!down || k === undefined || e.repeat || !handle.press) return; // (a held key is one key)
|
|
150
|
+
e.preventDefault(); handle.press(k);
|
|
151
|
+
};
|
|
152
|
+
addEventListener('keydown', keyed); addEventListener('keyup', keyed);
|
|
153
|
+
stage.addEventListener('pointerdown', () => handle.start?.());
|
|
154
|
+
phoneKeys(root, stage);
|
|
155
|
+
// the disk as it stands, its own files and those saved, to download where the page offers it (a .d64 that VICE
|
|
156
|
+
// or a real 64 can use)
|
|
157
|
+
if (options.downloadDisk && handle.diskImage) {
|
|
158
|
+
const button = $('disk'), name = data.pathname.split('/').pop().replace(/\.json$/, '');
|
|
159
|
+
button.hidden = false;
|
|
160
|
+
button.addEventListener('click', () => {
|
|
161
|
+
const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(new Blob([handle.diskImage()], { type: 'application/octet-stream' })), download: name + '.d64' });
|
|
162
|
+
a.click(); URL.revokeObjectURL(a.href);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
// a page that follows the game: its engine, a key pressed as the player presses one, the game at full speed
|
|
166
|
+
options.watch?.({ engine: handle.engine, press: k => { handle.start?.(); handle.press?.(k); }, quick: on => handle.quick?.(on) });
|
|
167
|
+
// a route the page gives, played first; then the game; then the page's keys, to go straight to a scene
|
|
168
|
+
if (options.route) {
|
|
169
|
+
if (!handle.route) throw new Error('a route cannot be played on the ' + way);
|
|
170
|
+
await handle.route(options.route);
|
|
171
|
+
}
|
|
172
|
+
handle.run();
|
|
173
|
+
if (options.keys?.length) { const list = options.keys.map(n => named(n, kind)); if (handle.keys) handle.keys(list); else for (const k of list) handle.press?.(k); }
|
|
174
|
+
} catch (err) { fail(err); }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// The screen sized evenly: a whole number of the device's pixels to each of the screen's dots, so every dot is drawn
|
|
178
|
+
// the same size and a line one dot thick is as thick as any other; the next whole number up from the column's width,
|
|
179
|
+
// a little wider than it and centred on it, unless that is wider than the window or taller than the stage may be (its
|
|
180
|
+
// max-height, where the page sets one: a phone on its side); kept so as the window, or its zoom, changes. The scale,
|
|
181
|
+
// in the page's pixels to a dot, is the section's --scale too, for a title drawn in CSS.
|
|
182
|
+
function fit(stage, column, dots, lines) {
|
|
183
|
+
const size = () => {
|
|
184
|
+
const dpr = window.devicePixelRatio || 1, pad = parseFloat(getComputedStyle(column).paddingLeft) * 2;
|
|
185
|
+
const room = column.clientWidth - pad, most = document.documentElement.clientWidth - 16, tall = parseFloat(getComputedStyle(stage).maxHeight);
|
|
186
|
+
let k = Math.ceil(room * dpr / dots - 0.01);
|
|
187
|
+
if (dots * k / dpr > most) k = Math.max(1, Math.floor(most * dpr / dots));
|
|
188
|
+
if (tall && lines * k / dpr > tall) k = Math.max(1, Math.floor(tall * dpr / lines));
|
|
189
|
+
const w = dots * k / dpr;
|
|
190
|
+
Object.assign(stage.style, { width: w + 'px', height: lines * k / dpr + 'px', aspectRatio: 'auto', marginLeft: (room - w) / 2 + 'px' });
|
|
191
|
+
column.style.setProperty('--scale', k / dpr);
|
|
192
|
+
};
|
|
193
|
+
size();
|
|
194
|
+
addEventListener('resize', size);
|
|
195
|
+
}
|
package/player.css
CHANGED
|
@@ -3,16 +3,17 @@
|
|
|
3
3
|
where the page names them (--screen, --muted, --gold, --alert, --line, --surface, --ink, --bg, --accent,
|
|
4
4
|
--accent-ink, --sans), else these. */
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
.
|
|
8
|
-
.
|
|
6
|
+
/* (the stage's size the player's, play.js `fit`: a whole number of the device's pixels to each of the screen's dots) */
|
|
7
|
+
.player .stage { position: relative; margin: 0 auto; background: var(--screen, #000); border-radius: 10px; overflow: hidden; box-shadow: 0 10px 30px -12px #0006; }
|
|
8
|
+
.player .stage canvas, .player .stage svg { position: absolute; inset: 0; width: 100%; height: 100%; }
|
|
9
|
+
.player .stage canvas { image-rendering: pixelated; }
|
|
9
10
|
/* the remake's mini view, where the page gives one: in the band at the top of the screen, placed by the title's inset */
|
|
10
|
-
.
|
|
11
|
-
.
|
|
12
|
-
.
|
|
13
|
-
.
|
|
14
|
-
.
|
|
15
|
-
.
|
|
11
|
+
.player .stage .mini { position: absolute; overflow: hidden; border-radius: 3px; background: #000; pointer-events: none; }
|
|
12
|
+
.player .stage .mini.toggle { pointer-events: auto; cursor: pointer; }
|
|
13
|
+
.player .stage .mini canvas, .player .stage .mini img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; image-rendering: auto; }
|
|
14
|
+
.player.remake .stage { background: #fff; }
|
|
15
|
+
.player.remake .stage svg .key { cursor: pointer; }
|
|
16
|
+
.player.remake .stage svg .key:hover rect { stroke: var(--gold, #a8801f); }
|
|
16
17
|
.options { display: flex; flex-wrap: wrap; gap: 8px 18px; align-items: center; justify-content: center; font-size: 15px; color: var(--muted, #5d6475); margin: 14px 0; }
|
|
17
18
|
#status.error { color: var(--alert, #9b3d2e); }
|
|
18
19
|
#paper { display: block; width: 100%; height: auto; border-radius: 2px; box-shadow: 0 2px 8px #0005; }
|
package/provider.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The provider: where a visit keeps a person's files and settings, chosen once, and every page's disks and settings
|
|
2
|
+
// from it. A site makes one a page from a module of its own, choosing the backend (this browser's; an account's, the
|
|
3
|
+
// site's API's, when signed in) and giving a visit's values and its policy; a page with no site (moonbase-play's) makes
|
|
4
|
+
// this one with nothing given. The player and the programs never choose: a page passes the title's disk and the
|
|
5
|
+
// settings to play() (play.js), and a program that saves opens its disks here. Nothing here knows of an address.
|
|
6
|
+
//
|
|
7
|
+
// const { disk, settings } = await provider({ backend, visit, fixed, hidden })
|
|
8
|
+
// backend where files are kept (disk/disk.js says what one is); none given, this browser's, IndexedDB
|
|
9
|
+
// (disk/local.js), or where the browser keeps nothing (a private window may not) memory, for this
|
|
10
|
+
// visit only, and the disks say so (`kept`)
|
|
11
|
+
// visit def => a setting's value for this visit, the site's to give (settings.js); fixed and hidden, the
|
|
12
|
+
// page's policy of the settings
|
|
13
|
+
// await disk(name) a disk by its name, on the backend: titleDisk(slug), programDisk(name) (disk/disk.js)
|
|
14
|
+
// settings the person's settings, the typed store (settings.js), on the disk settingsDisk('person')
|
|
15
|
+
import { openDisk } from './disk/disk.js';
|
|
16
|
+
import { local } from './disk/local.js';
|
|
17
|
+
import { memory } from './disk/memory.js';
|
|
18
|
+
import { settingsStore } from './settings.js';
|
|
19
|
+
|
|
20
|
+
export async function provider({ backend, visit, fixed = {}, hidden = [] } = {}) {
|
|
21
|
+
if (!backend) { try { backend = await local(); } catch { backend = memory({ kept: false }); } }
|
|
22
|
+
const disk = name => openDisk(name, { backend });
|
|
23
|
+
const settings = settingsStore({ disks: { person: await disk(settingsDisk('person')) }, visit, fixed, hidden });
|
|
24
|
+
return { disk, settings };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const settingsDisk = owner => `settings/${owner}`;
|
package/remake/card.js
CHANGED
|
@@ -135,8 +135,9 @@ export function cardRoutines(title) {
|
|
|
135
135
|
else installRom({ cpu, game: title.disk, sw: { lcRead: 0 }, io: a => io(engine, a), file: n => files.file(n), get files() { return files.files; }, put: (path, f) => files.put(path, f), remove: path => files.remove(path), clicks: { push: c => engine.clicks.push(engine.cycles + c - romFrom) }, log() {}, halted: false });
|
|
136
136
|
const rom = (e, at) => {
|
|
137
137
|
const f = cpu.hooks.get(at);
|
|
138
|
-
// a Commodore 64's BASIC, which MENU runs NOTEBOOK (compiled BASIC) and
|
|
139
|
-
|
|
138
|
+
// a Commodore 64's BASIC, which a BASIC program calls, as Zoopak's MENU runs NOTEBOOK (compiled BASIC) and
|
|
139
|
+
// ZOOTIME (BASIC) with
|
|
140
|
+
if (!f && c64 && at >= 0xa000 && at < 0xc000) throw new Error('a BASIC program, calling BASIC\'s ROM at $' + hex4(at) + ': not written yet in the remake');
|
|
140
141
|
if (!f) throw new Error('no ROM routine at $' + hex4(at));
|
|
141
142
|
engine = e;
|
|
142
143
|
// (the stack pointer as the call to the routine left it: the frames' place, less the call's two bytes)
|
|
@@ -437,7 +438,7 @@ export function cardRoutines(title) {
|
|
|
437
438
|
// as the observer has the original take it (the pilots' observer), and then the VIC's raster
|
|
438
439
|
// interrupt, where the game has turned it on ($D01A), at the line it set (Find Me's split of its
|
|
439
440
|
// screen into two character sets), $D019 saying which it is. On the page's clock (
|
|
440
|
-
// scenes.js sets `e.clock`), each is taken as the original's chips took it: the CIA's every 16,421
|
|
441
|
+
// engines/scenes.js sets `e.clock`), each is taken as the original's chips took it: the CIA's every 16,421
|
|
441
442
|
// cycles of the original's time, the raster's when its line comes (routines.before). Not while
|
|
442
443
|
// the game has them masked (SEI). The routine starts as the KERNAL's own entry ($FF48) leaves
|
|
443
444
|
// it, with A, X and Y pushed, and the KERNAL's end ($EA31, $EA7B, $EA7E, $EA81), or its own PLAs
|
package/remake/load.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
// One way to start a title's remake, for the
|
|
1
|
+
// One way to start a title's remake, for the player (play.js), a replay and Node (a pilot's observer): the
|
|
2
2
|
// title's title.js, from the folder it is in, with the modules it names (its problems, its paint); its scripts,
|
|
3
3
|
// compiled to JavaScript (bin/moonbase-compile.mjs) or, with `scenes`, the scene files themselves to run line by line;
|
|
4
4
|
// its game's data, from where the page keeps it; and an engine made from them.
|
|
5
5
|
//
|
|
6
|
+
// const title = await titleOf(folder) the title alone: its title.js and the modules it names
|
|
6
7
|
// const { title, scripts, disk, folder } = await loadTitle(folder, { data })
|
|
7
8
|
// folder the title's folder, an absolute URL (its title.js, its scenes, compiled/, its own modules)
|
|
8
9
|
// data its game's data, an absolute URL (<slug>.json: its disks' files, its kind, its saves)
|
|
@@ -11,7 +12,7 @@
|
|
|
11
12
|
// With `fixed`, a title that has a version with its bugs fixed (its title.js `fixed`, a script for each it replaces)
|
|
12
13
|
// runs those scripts in place of its own. A title whose engine is its own (its title.js `engine` the name of a module
|
|
13
14
|
// in its folder, not 'card' or 'vector') has no scripts here: its engine is loaded with its other modules, as
|
|
14
|
-
// `title.own`, and runs the game itself (
|
|
15
|
+
// `title.own`, and runs the game itself (play.js).
|
|
15
16
|
import { Engine } from './engine.js';
|
|
16
17
|
import { vectorRoutines } from './vector.js';
|
|
17
18
|
import { cardRoutines } from './card.js';
|
|
@@ -29,13 +30,19 @@ async function read(url, as) {
|
|
|
29
30
|
}
|
|
30
31
|
const defaultOf = async url => (await import(url.href)).default;
|
|
31
32
|
|
|
32
|
-
export async function
|
|
33
|
+
export async function titleOf(folder) {
|
|
33
34
|
folder = new URL(folder);
|
|
34
35
|
const title = { ...(await defaultOf(new URL('title.js', folder))) };
|
|
35
36
|
// the modules a title has of its own, as its title.js names them (problems.js, paint.js), and its view's own
|
|
36
37
|
// drawing (view.js), where it says it has one
|
|
37
38
|
for (const name of title.modules ?? []) title[name] = await defaultOf(new URL(name + '.js', folder));
|
|
38
39
|
if (title.view) title.viewSteps = await defaultOf(new URL('view.js', folder));
|
|
40
|
+
return title;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function loadTitle(folder, { data, scenes = false, fixed = false } = {}) {
|
|
44
|
+
folder = new URL(folder);
|
|
45
|
+
const title = await titleOf(folder);
|
|
39
46
|
if (!ENGINES[title.engine]) {
|
|
40
47
|
title.own = await import(new URL(title.engine + '.js', folder).href);
|
|
41
48
|
return { title, scripts: {}, disk: null, folder };
|
|
@@ -46,6 +53,8 @@ export async function loadTitle(folder, { data, scenes = false, fixed = false }
|
|
|
46
53
|
return { title, scripts, disk, folder };
|
|
47
54
|
}
|
|
48
55
|
|
|
56
|
+
export { read };
|
|
57
|
+
|
|
49
58
|
export function makeEngine(title, scripts) {
|
|
50
59
|
// (a ProDOS title's memory starts with what ProDOS leaves, as the original's does)
|
|
51
60
|
const memory = { ...(title.disk?.kind === 'prodos' ? started(title.disk) : {}), ...title.memory };
|
package/saves.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// What a game saves, on the player's disk, the one way for every way to play (play.js gives it to the engine as
|
|
2
|
+
// `game.saves`): an Apple game's volume it saves on (@moonbase-machines/apple2e/saves.js, its data's `saves`), a
|
|
3
|
+
// Commodore 64 game's drive (@moonbase-machines/c64/saves.js). A game saved in one way loads in the other.
|
|
4
|
+
//
|
|
5
|
+
// const saves = savesFor(data, disk)
|
|
6
|
+
// saves.data the game's data with what is kept put on its volume, for the machine or the engine
|
|
7
|
+
// saves.keep(files) the game's files watched (a machine's, an engine's, a drive's on line 1), each change kept
|
|
8
|
+
// saves.drive(drive) a Commodore 64's drive given what is kept, and what it saves kept
|
|
9
|
+
// saves.disks(disks) line 1: what is kept written onto the disk images Apple's ROMs boot
|
|
10
|
+
// saves.driveFiles(m, disks) line 1: the files as the drive's tracks hold them, for `keep`
|
|
11
|
+
import { withSaves, keepSaves, onDisks, driveFiles } from '@moonbase-machines/apple2e/saves.js';
|
|
12
|
+
import { keepDrive } from '@moonbase-machines/c64/saves.js';
|
|
13
|
+
|
|
14
|
+
export function savesFor(data, disk) {
|
|
15
|
+
return {
|
|
16
|
+
data: withSaves(data, disk),
|
|
17
|
+
keep: files => keepSaves(data, files, disk),
|
|
18
|
+
drive: drive => keepDrive(drive, disk),
|
|
19
|
+
disks: disks => onDisks(data, disks, disk),
|
|
20
|
+
driveFiles: (m, disks) => driveFiles(m, disks, data),
|
|
21
|
+
};
|
|
22
|
+
}
|
package/settings.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// A person's settings: what the player, its engines and a site's pages let a person choose (the sound, the printer,
|
|
2
|
+
// the old style text, a page's own choices), each declared where it acts, in one shape, and kept through the provider
|
|
3
|
+
// (provider.js) on a disk of the owner's, settings/<owner>, where the person's files go. The store is typed against
|
|
4
|
+
// the declarations: only a declared setting can be read or kept, every value checked against its type. Nothing here
|
|
5
|
+
// knows of an address: a site that lets its address set a setting for a visit reads it itself and gives the value.
|
|
6
|
+
//
|
|
7
|
+
// A declaration: { name, type, default, owner, words, labels?, title? }
|
|
8
|
+
// name its name, in the store's file (and for a site, in whatever sets it for a visit)
|
|
9
|
+
// type 'boolean', or an array of its fixed values, an enum
|
|
10
|
+
// default its value where nothing gives one
|
|
11
|
+
// owner whose it is: 'person' (the only owner yet; a parent's or teacher's later is another layer here, with
|
|
12
|
+
// nothing changed where the setting is declared)
|
|
13
|
+
// words what its control calls it; labels, an enum's values' words in order; title, a note on the control
|
|
14
|
+
//
|
|
15
|
+
// check(type, value) whether a value is of the type
|
|
16
|
+
// describe(type) the type in words, for saying what was refused
|
|
17
|
+
//
|
|
18
|
+
// const settings = settingsStore({ disks, visit, fixed, hidden })
|
|
19
|
+
// disks { owner: disk }, the owner's settings disk (settings/<owner>), opened by the provider; its one file,
|
|
20
|
+
// `settings`, the values by name
|
|
21
|
+
// visit def => a value for this visit, or undefined: the site's, from wherever it lets a visit set one (its
|
|
22
|
+
// address, say); checked here, a value not of the type refused and said once; never kept
|
|
23
|
+
// fixed { name: value }, what the page fixes, over everything; hidden, [name], what it does not offer
|
|
24
|
+
// settings.get(def) its value: the page's fixed one, else the visit's, else the kept one where it is of
|
|
25
|
+
// the type (one that is not falls back, said), else its default
|
|
26
|
+
// settings.set(def, value) kept, for the next visit; a promise, kept when stored (a value not of the type throws)
|
|
27
|
+
// settings.offered(def) whether its control is shown: not where the page hides it or fixes it
|
|
28
|
+
import { textBytes, bytesText } from './disk/disk.js';
|
|
29
|
+
|
|
30
|
+
const FILE = 'settings';
|
|
31
|
+
|
|
32
|
+
export function check(type, value) {
|
|
33
|
+
if (Array.isArray(type)) return type.includes(value);
|
|
34
|
+
if (type === 'boolean') return typeof value === 'boolean';
|
|
35
|
+
throw new Error('settings: no such type: ' + JSON.stringify(type));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const describe = type => Array.isArray(type) ? 'one of ' + type.map(v => v === '' ? 'nothing' : v).join(', ') : 'on or off';
|
|
39
|
+
|
|
40
|
+
export function settingsStore({ disks, visit = () => undefined, fixed = {}, hidden = [] }) {
|
|
41
|
+
const said = new Set();
|
|
42
|
+
const say = (key, what) => { if (!said.has(key)) { said.add(key); console.warn(what); } };
|
|
43
|
+
const layers = Object.fromEntries(Object.entries(disks).map(([owner, disk]) => {
|
|
44
|
+
let kept = null;
|
|
45
|
+
try { kept = JSON.parse(bytesText(disk.read(FILE)) ?? '{}'); } catch {}
|
|
46
|
+
return [owner, { disk, kept: kept && typeof kept === 'object' && !Array.isArray(kept) ? kept : {} }];
|
|
47
|
+
}));
|
|
48
|
+
// (the declaration itself checked as it is first met: its owner has a store here, its default is of its type)
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
const layer = def => {
|
|
51
|
+
const l = layers[def.owner];
|
|
52
|
+
if (!l) throw new Error(`settings: no store for the owner ${def.owner} (${def.name})`);
|
|
53
|
+
if (!seen.has(def.name)) { seen.add(def.name); if (!check(def.type, def.default)) throw new Error(`settings: ${def.name}'s default ${JSON.stringify(def.default)} is not ${describe(def.type)}`); }
|
|
54
|
+
return l;
|
|
55
|
+
};
|
|
56
|
+
return {
|
|
57
|
+
get(def) {
|
|
58
|
+
const l = layer(def);
|
|
59
|
+
if (def.name in fixed) {
|
|
60
|
+
if (!check(def.type, fixed[def.name])) throw new Error(`settings: ${def.name} fixed at ${JSON.stringify(fixed[def.name])}, not ${describe(def.type)}`);
|
|
61
|
+
return fixed[def.name];
|
|
62
|
+
}
|
|
63
|
+
const v = visit(def);
|
|
64
|
+
if (v !== undefined) {
|
|
65
|
+
if (check(def.type, v)) return v;
|
|
66
|
+
say('visit ' + def.name, `${def.name}: ${JSON.stringify(v)} for this visit refused: not ${describe(def.type)}`);
|
|
67
|
+
}
|
|
68
|
+
if (def.name in l.kept) {
|
|
69
|
+
if (check(def.type, l.kept[def.name])) return l.kept[def.name];
|
|
70
|
+
say('kept ' + def.name, `${def.name}: the kept ${JSON.stringify(l.kept[def.name])} is not ${describe(def.type)}; its default used`);
|
|
71
|
+
}
|
|
72
|
+
return def.default;
|
|
73
|
+
},
|
|
74
|
+
set(def, value) {
|
|
75
|
+
const l = layer(def);
|
|
76
|
+
if (!check(def.type, value)) throw new Error(`settings: ${def.name} cannot be ${JSON.stringify(value)}: not ${describe(def.type)}`);
|
|
77
|
+
l.kept[def.name] = value;
|
|
78
|
+
return l.disk.write(FILE, textBytes(JSON.stringify(l.kept)));
|
|
79
|
+
},
|
|
80
|
+
offered: def => !hidden.includes(def.name) && !(def.name in fixed),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// The player's parts that every way to play shares (play.js): the keys as each machine takes them (keys.js), the
|
|
2
|
+
// saves on the player's disk (saves.js), a write the disk's backend cannot store (disk/disk.js); the settings store
|
|
3
|
+
// typed against its declarations (settings.js) and the provider giving a page its disks and settings on one backend
|
|
4
|
+
// (provider.js); the package knowing nothing of an address and keeping nothing in the browser itself; and no
|
|
5
|
+
// title named in the packages' code, only in their comments, as examples.
|
|
6
|
+
//
|
|
7
|
+
// node --test test/*.test.js
|
|
8
|
+
import { test } from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { code, named } from '@moonbase-machines/player/keys.js';
|
|
13
|
+
import { savesFor } from '@moonbase-machines/player/saves.js';
|
|
14
|
+
import { openDisk } from '@moonbase-machines/player/disk/disk.js';
|
|
15
|
+
import { memory } from '@moonbase-machines/player/disk/memory.js';
|
|
16
|
+
import { settingsStore, check } from '@moonbase-machines/player/settings.js';
|
|
17
|
+
import { provider, settingsDisk } from '@moonbase-machines/player/provider.js';
|
|
18
|
+
import { Drive } from '@moonbase-machines/c64/drive.js';
|
|
19
|
+
import { make } from '@moonbase-machines/c64/d64.js';
|
|
20
|
+
|
|
21
|
+
const key = (k, mods = {}) => ({ key: k, ctrlKey: false, metaKey: false, altKey: false, ...mods });
|
|
22
|
+
|
|
23
|
+
test('a key is the Apple\'s code or the Commodore 64\'s, by the machine\'s kind', () => {
|
|
24
|
+
assert.equal(code(key('ArrowLeft'), 'prodos'), 8);
|
|
25
|
+
assert.equal(code(key('ArrowLeft'), 'c64'), 157);
|
|
26
|
+
assert.equal(code(key('Tab'), 'dos33'), 9);
|
|
27
|
+
assert.equal(code(key('a'), 'prodos'), 97, 'the Apple a small letter as typed');
|
|
28
|
+
assert.equal(code(key('a'), 'c64'), 65, 'the 64 its capital');
|
|
29
|
+
assert.equal(code(key('e', { ctrlKey: true }), 'prodos'), 5, 'control-E');
|
|
30
|
+
assert.equal(code(key('a', { metaKey: true }), 'prodos'), undefined, 'the browser\'s own');
|
|
31
|
+
assert.equal(code(key('a'), undefined), undefined, 'a title of its own engine has no table');
|
|
32
|
+
assert.equal(code(key('Shift'), 'prodos'), undefined);
|
|
33
|
+
assert.deepEqual(['space', 'left', 'A'].map(n => named(n, 'prodos')), [32, 8, 65]);
|
|
34
|
+
assert.deepEqual(['left', 'delete'].map(n => named(n, 'c64')), [157, 20]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('the saves: a game\'s volume given what is kept, and a change to its files kept', async () => {
|
|
38
|
+
const disk = await openDisk('title/one', { backend: memory() });
|
|
39
|
+
disk.write('KEPT', [1, 2], { type: 'BIN', aux: 0x2000 });
|
|
40
|
+
const game = { kind: 'prodos', volumes: { GAME: {} }, saves: { volume: 'SAVES', dos: 'prodos' } };
|
|
41
|
+
const saves = savesFor(game, disk);
|
|
42
|
+
assert.deepEqual(Object.keys(saves.data.volumes), ['GAME', 'SAVES']);
|
|
43
|
+
assert.equal(saves.data.volumes.SAVES.KEPT.type, 'BIN');
|
|
44
|
+
// a Commodore 64's drive: what it saves is kept on the disk
|
|
45
|
+
const drive = new Drive(make({ name: 'DISK', id: 'MB', files: [] }));
|
|
46
|
+
savesFor({ kind: 'c64' }, disk).drive(drive);
|
|
47
|
+
drive.keep('SAVED', Uint8Array.of(1, 8, 9));
|
|
48
|
+
await disk.written();
|
|
49
|
+
assert.deepEqual([...disk.read('SAVED')], [1, 8, 9]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a write the backend cannot store is said, and the writes after it are stored', async () => {
|
|
53
|
+
const backend = memory(), put = backend.put;
|
|
54
|
+
let refuse = true;
|
|
55
|
+
backend.put = (d, f) => refuse && f.name === 'BAD' ? Promise.reject(new Error('full')) : put.call(backend, d, f);
|
|
56
|
+
const disk = await openDisk('title/two', { backend }), said = [];
|
|
57
|
+
disk.failed = err => said.push(err.message);
|
|
58
|
+
await assert.rejects(disk.write('BAD', [1]), /full/);
|
|
59
|
+
await disk.write('GOOD', [2]);
|
|
60
|
+
await disk.written();
|
|
61
|
+
assert.deepEqual(said, ['full']);
|
|
62
|
+
refuse = false;
|
|
63
|
+
assert.deepEqual([...(await openDisk('title/two', { backend })).read('GOOD')], [2]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// (what the console is told, for the tests of what is refused)
|
|
67
|
+
const warned = async f => { const said = [], warn = console.warn; console.warn = m => said.push(m); try { await f(); } finally { console.warn = warn; } return said; };
|
|
68
|
+
|
|
69
|
+
test('a setting\'s type: on or off, or one of fixed values', () => {
|
|
70
|
+
assert.ok(check('boolean', false) && !check('boolean', 'off') && check(['a', 'b'], 'a') && !check(['a', 'b'], 'c'));
|
|
71
|
+
assert.throws(() => check('text', 'x'), /no such type/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const SOUND = { name: 'sound', type: 'boolean', default: true, owner: 'person', words: 'Sound' };
|
|
75
|
+
const MINI = { name: 'mini', type: ['', 'live', 'pictures'], default: '', owner: 'person', words: 'Mini view', labels: ['Off', 'Live', 'Pictures'] };
|
|
76
|
+
const OLD = { name: 'old-style', type: 'boolean', default: false, owner: 'person', words: 'Old style text' };
|
|
77
|
+
const FIXED = { name: 'fixed', type: 'boolean', default: false, owner: 'person', words: 'Bugs fixed' };
|
|
78
|
+
|
|
79
|
+
test('a setting by its declaration: the page\'s fixed value, else the visit\'s, else the kept one, else its default; offered unless hidden or fixed', async () => {
|
|
80
|
+
const backend = memory(), person = () => openDisk(settingsDisk('person'), { backend });
|
|
81
|
+
const first = settingsStore({ disks: { person: await person() } });
|
|
82
|
+
assert.equal(first.get(SOUND), true, 'its default');
|
|
83
|
+
await first.set(SOUND, false); await first.set(MINI, 'pictures');
|
|
84
|
+
const visit = def => ({ 'old-style': true, mini: 'live', sound: false })[def.name];
|
|
85
|
+
const again = settingsStore({ disks: { person: await person() }, visit, fixed: { fixed: true }, hidden: ['mini'] });
|
|
86
|
+
assert.equal(again.get(SOUND), false, 'the visit\'s, and kept');
|
|
87
|
+
assert.equal(again.get(MINI), 'live', 'the visit\'s over the kept');
|
|
88
|
+
assert.equal(again.get(OLD), true, 'the visit\'s on');
|
|
89
|
+
assert.equal(again.get(FIXED), true, 'the page\'s fixed value over all');
|
|
90
|
+
assert.deepEqual([SOUND, MINI, FIXED].map(again.offered), [true, false, false]);
|
|
91
|
+
assert.deepEqual(JSON.parse(new TextDecoder().decode((await person()).read('settings'))), { sound: false, mini: 'pictures' }, 'the visit\'s values never kept');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('a setting\'s value is checked: a visit\'s value not of the type refused and said once, a kept value not of the type its default, a set of the wrong type thrown', async () => {
|
|
95
|
+
const backend = memory(), disk = await openDisk(settingsDisk('person'), { backend });
|
|
96
|
+
disk.write('settings', new TextEncoder().encode(JSON.stringify({ sound: 'loud', mini: 'live' })));
|
|
97
|
+
const store = settingsStore({ disks: { person: disk }, visit: def => ({ mini: 'huge', 'old-style': 'maybe' })[def.name] });
|
|
98
|
+
const said = await warned(() => { assert.equal(store.get(SOUND), true, 'the kept value refused: its default'); assert.equal(store.get(MINI), 'live', 'huge refused: the kept'); store.get(MINI); assert.equal(store.get(OLD), false); });
|
|
99
|
+
assert.deepEqual(said, ['sound: the kept "loud" is not on or off; its default used', 'mini: "huge" for this visit refused: not one of nothing, live, pictures', 'old-style: "maybe" for this visit refused: not on or off']);
|
|
100
|
+
assert.throws(() => store.set(SOUND, 'off'), /sound cannot be "off": not on or off/);
|
|
101
|
+
assert.throws(() => store.set(MINI, 'big'), /not one of nothing, live, pictures/);
|
|
102
|
+
assert.throws(() => store.get({ ...SOUND, owner: 'teacher' }), /no store for the owner teacher/);
|
|
103
|
+
assert.throws(() => store.get({ name: 'bad', type: 'boolean', default: 'on', owner: 'person' }), /default "on" is not on or off/);
|
|
104
|
+
assert.throws(() => settingsStore({ disks: { person: disk }, fixed: { sound: 'off' } }).get(SOUND), /fixed at "off"/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('the provider: every disk on the one backend it is given, and the settings kept there too', async () => {
|
|
108
|
+
const backend = memory();
|
|
109
|
+
const { disk, settings } = await provider({ backend, visit: def => def.name === 'sound' ? false : undefined, hidden: ['vectors'] });
|
|
110
|
+
const title = await disk('title/one');
|
|
111
|
+
await title.write('SAVED', [1]);
|
|
112
|
+
await settings.set(MINI, 'live');
|
|
113
|
+
assert.deepEqual((await backend.load('title/one')).map(f => f.name), ['SAVED']);
|
|
114
|
+
assert.deepEqual((await backend.load('settings/person')).map(f => f.name), ['settings']);
|
|
115
|
+
assert.equal(settings.get(SOUND), false);
|
|
116
|
+
assert.equal(settings.offered({ name: 'vectors', type: 'boolean', default: false, owner: 'person' }), false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// the package's own code knows nothing of an address and keeps nothing in the browser: the page gives it everything
|
|
120
|
+
// (bin/moonbase-play.mjs is a page of its own, the one place an address is read, by that page)
|
|
121
|
+
test('the player, its engines, its settings and its provider know no address and no browser storage; the player opens no disk', () => {
|
|
122
|
+
const own = ['play.js', 'settings.js', 'provider.js', 'engines/original.js', 'engines/scenes.js', 'saves.js', 'keys.js', 'parts.js', 'disk/disk.js', 'disk/dialogs.js'];
|
|
123
|
+
const found = [];
|
|
124
|
+
for (const f of own) {
|
|
125
|
+
const lines = readFileSync(join(packages, 'player', f), 'utf8').replace(/\/\*[\s\S]*?\*\//g, c => c.replace(/[^\n]/g, '')).split('\n');
|
|
126
|
+
lines.forEach((l, i) => {
|
|
127
|
+
const c = l.replace(/(^|\s)\/\/.*$/, '');
|
|
128
|
+
if (/location\.(search|hash|href)|URLSearchParams|localStorage|sessionStorage/.test(c)) found.push(`${f}:${i + 1}`);
|
|
129
|
+
if (/openDisk/.test(c) && !['provider.js', 'disk/disk.js'].includes(f)) found.push(`${f}:${i + 1} opens a disk`);
|
|
130
|
+
if (/\blocal\(/.test(c) && f !== 'provider.js') found.push(`${f}:${i + 1} chooses a backend`);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
assert.deepEqual(found, []);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// the titles, by name and by address; the packages' code, their comments taken out
|
|
137
|
+
const TITLES = /pieces[ -]of[ -]eight|detect-a-pet|goldfields|zoopak|nature[ -]park|animal[ -]rescue|aztlan|crayon|po8/i;
|
|
138
|
+
const packages = new URL('../../', import.meta.url).pathname;
|
|
139
|
+
const sources = dir => readdirSync(dir).flatMap(n => {
|
|
140
|
+
const f = join(dir, n);
|
|
141
|
+
if (['node_modules', 'dist', 'test'].includes(n)) return [];
|
|
142
|
+
return statSync(f).isDirectory() ? sources(f) : /\.m?js$/.test(n) ? [f] : [];
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('no title is named in the packages\' code, only in their comments', () => {
|
|
146
|
+
const found = [];
|
|
147
|
+
for (const f of sources(packages)) {
|
|
148
|
+
const lines = readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, c => c.replace(/[^\n]/g, '')).split('\n');
|
|
149
|
+
lines.forEach((l, i) => { const c = l.replace(/(^|\s)\/\/.*$/, ''); if (TITLES.test(c)) found.push(`${f.slice(packages.length)}:${i + 1}`); });
|
|
150
|
+
}
|
|
151
|
+
assert.deepEqual(found, []);
|
|
152
|
+
});
|