diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 4b1af02..fb5ed91 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -69,7 +69,8 @@ jobs:
retention-days: 30
# Stage 3 is open to players (src/flags.ts). To take it back, set 'off' and push: saves that have met Mr. Fugu
# keep it on. The web game has Buy me a coffee (Ko-fi, src/flags.ts); it's off in any build that doesn't say so,
- # the Steam build's included.
+ # the Steam build's included. The build also lists the open-source licences of what it bundled, and stops on a GPL,
+ # LGPL or AGPL one, or one it can't read, that the owner hasn't approved (scripts/licences.ts).
- run: npm run build
env:
VITE_STAGE3: 'on'
diff --git a/LICENSE b/LICENSE
index f94ac8f..ab8eb38 100644
--- a/LICENSE
+++ b/LICENSE
@@ -5,4 +5,9 @@ is granted to copy, modify, distribute, or use any of it, in whole or in part, w
copyright holder.
Recorded sounds by others are used under their own licenses, listed in public/sounds/CREDITS.md and in the game's
-Credits & Copyrights; those licenses apply to those files.
+Credits & Copyrights; those licenses apply to those files. The fonts in public/fonts are under the SIL Open Font
+License 1.1, whose texts sit beside them.
+
+The open-source libraries built into the game (three.js, html2canvas, Firebase and the code they bring) are not part of
+this repository and keep their own licenses. The built game lists them under Credits & Copyrights, with their full
+texts in licences.txt.
diff --git a/README.md b/README.md
index 9a59e9a..657c373 100644
--- a/README.md
+++ b/README.md
@@ -25,4 +25,4 @@ How the game works, running it locally, the tests and the feature flags are all
Copyright © 2026 ChefControl. All rights reserved. The source is public to read, but you may not copy, modify or redistribute it without permission. See [LICENSE](LICENSE).
-The recorded sounds are other people's work under their own licenses (CC BY and CC0), credited in [public/sounds/CREDITS.md](public/sounds/CREDITS.md) and under Credits & Copyrights in the game.
+The recorded sounds are other people's work under their own licenses (CC BY and CC0), credited in [public/sounds/CREDITS.md](public/sounds/CREDITS.md) and under Credits & Copyrights in the game. The fonts in [public/fonts](public/fonts) are under the SIL Open Font License 1.1, with their texts beside them. The open-source libraries built into the game (three.js, html2canvas, Firebase and the code they bring) keep their own licenses: the build lists them under Credits & Copyrights and writes their full texts to `licences.txt` ([scripts/licences.ts](scripts/licences.ts)).
diff --git a/docs/game.md b/docs/game.md
index 1be222d..96285e5 100644
--- a/docs/game.md
+++ b/docs/game.md
@@ -303,6 +303,8 @@ On the casino boat, a sign hangs from the wall behind the bar, over its window e
TypeScript + [three.js r186](https://threejs.org/), bundled with [Vite](https://vite.dev/). All textures are drawn at runtime on canvas. The fonts and the emoji ship with the game (`public/fonts`, cut down to what it writes by `scripts/fonts/build.py`), so it fetches none from the web.
+**Open-source licences.** The build credits the third-party code it actually bundles (`scripts/licences.ts`): it writes each package's licence text to `licences.txt` beside the game and lists the packages under Credits & Copyrights. It's what's bundled, not `package.json`, so dev tools never show, and a build without Firebase leaves it out. The build stops on a GPL, LGPL or AGPL licence, or one it can't read, until the owner approves that package and licence in `APPROVED` (Steam needs Valve's agreement before a game ships GPL or LGPL code). The Electron build hands in its own notices (Electron, Chromium, ffmpeg) as `licences({ extra })`, checked the same way.
+
| Command | What it does |
| --- | --- |
| `npm run dev` | Dev server with hot reload |
diff --git a/index.html b/index.html
index 89fd65c..c32392c 100644
--- a/index.html
+++ b/index.html
@@ -1554,6 +1554,10 @@
The ring's crowd
Murmur: “Crowd of 50-60 People #1” and “#2” by Joseph Sardin, BigSoundBank, CC0; layered and looped.
Applause: “Applause: 150 People” by Joseph Sardin, BigSoundBank, CC0.
+ Open-source licences
+
diff --git a/scripts/licences.ts b/scripts/licences.ts
new file mode 100644
index 0000000..a34140f
--- /dev/null
+++ b/scripts/licences.ts
@@ -0,0 +1,173 @@
+// The open-source licences of the third-party code that ships in the game (#149). The build looks at which packages'
+// code it actually put in the bundle (not package.json: dev tools never ship, and the Steam build leaves Firebase out),
+// writes their licence texts to licences.txt beside the game, and lists them under Settings → Credits & Copyrights.
+//
+// It also stops the build on a copyleft licence (GPL, LGPL, AGPL) or one it can't read: Steam needs Valve's agreement
+// before a game ships GPL or LGPL code. Anything the owner has agreed to goes in APPROVED.
+//
+// The Electron build (#159) hands in what the wrapper adds on top of the bundle as `extra` notices: Electron's own
+// licence, Chromium's notices and ffmpeg's LGPL (which needs an APPROVED entry once Valve has agreed).
+import { existsSync, readdirSync, readFileSync } from 'node:fs';
+import type { Plugin } from 'vite';
+
+/** A piece of third-party code to credit: a package from the bundle, or one handed in as `extra`. */
+export interface Notice {
+ name: string;
+ version: string;
+ /** an SPDX expression, as package.json's "license" has it: "MIT", "(MIT OR Apache-2.0)" */
+ licence: string;
+ /** the licence's full text, with its copyright line where the package has one */
+ text: string;
+ /** where the player can read more about it: the project's homepage or its repository */
+ url?: string;
+ /** packages from one repository with one licence text (Firebase's parts) are listed once */
+ repo?: string;
+}
+
+/** Copyleft or unreadable licences the owner has agreed to ship, by package name, with the licence they agreed to (a
+ * new version under another licence is checked again). For example, once Valve agrees to Electron's ffmpeg:
+ * `ffmpeg: { licence: 'LGPL-2.1-or-later', why: 'Valve agreed by email, 2026-11-02' }` */
+export const APPROVED: Record
= {};
+
+/** Licences that ask only that their notice ships with the code. */
+const PERMISSIVE = new Set(['MIT', 'ISC', '0BSD', 'BSD-2-Clause', 'BSD-3-Clause', 'Apache-2.0', 'Zlib', 'Unlicense', 'CC0-1.0', 'BlueOak-1.0.0']);
+
+type Verdict = 'ok' | 'unknown' | 'copyleft';
+const RANK: Verdict[] = ['ok', 'unknown', 'copyleft'];
+
+/** Whether code under this SPDX expression can ship without asking: 'ok', 'copyleft' (GPL, LGPL or AGPL) or 'unknown'.
+ * "A OR B" lets the game take either, so it's as good as the better one; "A AND B" binds it to both. */
+export function verdict(expr: string): Verdict {
+ const tokens = expr.match(/[()]|[^\s()]+/g) ?? [];
+ let i = 0;
+ const one = (): Verdict => {
+ const t = tokens[i++] ?? '';
+ let v: Verdict;
+ if (t === '(') { v = any(); if (tokens[i++] !== ')') return 'unknown'; }
+ else v = PERMISSIVE.has(t) ? 'ok' : /GPL/i.test(t) ? 'copyleft' : 'unknown';
+ if (tokens[i] === 'WITH') i += 2; // an exception ("GPL-2.0 WITH Classpath-exception-2.0") is still that licence
+ return v;
+ };
+ const all = (): Verdict => { let v = one(); while (tokens[i] === 'AND') { i++; const w = one(); if (RANK.indexOf(w) > RANK.indexOf(v)) v = w; } return v; };
+ const any = (): Verdict => { let v = all(); while (tokens[i] === 'OR') { i++; const w = all(); if (RANK.indexOf(w) < RANK.indexOf(v)) v = w; } return v; };
+ const v = any();
+ return i === tokens.length ? v : 'unknown';
+}
+
+/** Licence files that carry more than the package's own licence, cut where the rest starts: Vite's lists the licences
+ * of the dev tools bundled into Vite itself, and only Vite's own helpers reach the game. */
+const CUT: Record = { vite: '# Licenses of bundled dependencies' };
+
+/** A repository field as a web link: "git+https://github.com/a/b.git", "git@github.com:a/b.git", "github:a/b". */
+function repoUrl(repo: unknown) {
+ const url = typeof repo === 'string' ? repo : (repo as { url?: string } | undefined)?.url;
+ return url?.replace(/^git\+/, '').replace(/^git:\/\//, 'https://').replace(/^git@github\.com:/, 'https://github.com/')
+ .replace(/^github:/, 'https://github.com/').replace(/\.git$/, '');
+}
+
+/** A licence text without blank lines around it (keeping the first line's indent, as the Apache licence has). */
+const tidy = (text: string) => text.replace(/^(\s*\n)+/, '').trimEnd();
+
+/** The package in a folder of node_modules: its licence from package.json, and its text from its LICENSE (or COPYING,
+ * or NOTICE) files, or, for a package that ships none (Firebase's), the licence's standard text from scripts/licences/. */
+export function readPackage(dir: string): Notice {
+ const pkg = JSON.parse(readFileSync(`${dir}/package.json`, 'utf8'));
+ const licence: string = typeof pkg.license === 'string' ? pkg.license : pkg.license?.type
+ ?? (Array.isArray(pkg.licenses) ? pkg.licenses.map((l: { type: string }) => l.type).join(' OR ') : '');
+ const files = readdirSync(dir).filter(f => /^(licen[cs]e|copying|notice)\b/i.test(f)).sort();
+ const standard = new URL(`./licences/${licence}.txt`, import.meta.url);
+ let text = files.length ? files.map(f => tidy(readFileSync(`${dir}/${f}`, 'utf8'))).join('\n\n')
+ : existsSync(standard) ? readFileSync(standard, 'utf8') : '';
+ if (CUT[pkg.name]) text = text.split(CUT[pkg.name])[0];
+ const repo = repoUrl(pkg.repository);
+ return { name: pkg.name, version: pkg.version, licence, text: tidy(text), url: pkg.homepage ?? repo, repo };
+}
+
+/** The node_modules folder a module of the bundle comes from; '' for the game's own code; undefined if it can't tell.
+ * The bundler's own helpers (Vite's preload helper, Rolldown's runtime) are virtual modules, credited to their tool. */
+function packageDir(id: string, root: string) {
+ const at = id.lastIndexOf('/node_modules/');
+ if (at >= 0) return id.slice(0, at + 14) + /^(@[^/]+\/)?[^/?]+/.exec(id.slice(at + 14))![0];
+ const tool = /^\0?(vite|rolldown)\//.exec(id)?.[1];
+ if (tool) return `${root}/node_modules/${tool}`;
+ return id.startsWith(`${root}/`) ? '' : undefined;
+}
+
+/** Why each notice can't ship as it is: a copyleft or unreadable licence the owner hasn't approved, or no licence text. */
+export function refusals(notices: Notice[], approved = APPROVED) {
+ return notices.flatMap(n => {
+ const v = verdict(n.licence), ok = v === 'ok' || approved[n.name]?.licence === n.licence;
+ const why = !ok ? (v === 'copyleft' ? `${n.licence} is copyleft` : `can't read its licence "${n.licence}"`)
+ : !n.text ? 'no licence text found' : '';
+ return why ? [`${n.name} ${n.version}: ${why}`] : [];
+ });
+}
+
+/** Notices from one repository with one text (Firebase's parts), as one entry; the shortest name leads it. */
+function grouped(notices: Notice[]) {
+ const groups = new Map();
+ for (const n of [...notices].sort((a, b) => a.name.length - b.name.length || (a.name < b.name ? -1 : 1))) {
+ const key = `${n.repo ?? n.name}\n${n.text}`;
+ groups.set(key, [...groups.get(key) ?? [], n]);
+ }
+ return [...groups.values()].sort((a, b) => a[0].name.replace(/^@/, '') < b[0].name.replace(/^@/, '') ? -1 : 1);
+}
+
+/** licences.txt: every notice's name, version and licence, then its full text. */
+export function licencesText(notices: Notice[]) {
+ return ['Floe Market includes the open-source code below. Each part is listed with its version, licence and full licence text.',
+ ...grouped(notices).map(g => [
+ '='.repeat(80),
+ ...g.map(n => `${n.name} ${n.version} (${n.licence})`),
+ ...g[0].url ? [g[0].url] : [],
+ '',
+ g[0].text,
+ ].join('\n')),
+ ].join('\n\n') + '\n';
+}
+
+const esc = (s: string) => s.replace(/[&<>"]/g, c => `${c.charCodeAt(0)};`);
+const link = (href: string, words: string) => `${esc(words)}`;
+
+/** The lines under "Open-source licences" in the credits: one per entry, and a link to the full texts. */
+export function licencesList(notices: Notice[]) {
+ return [...grouped(notices).map(([n, ...more]) =>
+ `${n.url ? link(n.url, n.name) : esc(n.name)} ${esc(n.version)}${more.length ? ` (${more.length + 1} packages)` : ''}, ${esc(n.licence)}.`),
+ `${link('licences.txt', 'The full licence texts')}.`].join('\n ');
+}
+
+/** Where index.html's credits take the list. */
+const MARK = '';
+
+/** The build's licences: what's in the bundle, plus `extra` notices; checked, written to licences.txt and listed in the
+ * credits. The dev server lists none, as it bundles nothing. */
+export function licences({ extra = [] as Notice[], approved = APPROVED } = {}): Plugin {
+ let root = '';
+ return {
+ name: 'floe-licences',
+ configResolved(c) { root = c.root; },
+ transformIndexHtml: { order: 'pre', handler(page, ctx) {
+ if (!page.includes(MARK)) throw new Error('index.html: no place for the open-source licences');
+ return ctx.server ? page.replace(MARK, 'Listed in the built game.') : page;
+ } },
+ generateBundle: { order: 'post', handler(_, bundle) {
+ const dirs = new Set(), strays: string[] = [];
+ for (const chunk of Object.values(bundle)) {
+ if (chunk.type !== 'chunk') continue;
+ for (const [id, m] of Object.entries(chunk.modules)) {
+ if (!m.renderedLength) continue;
+ const dir = packageDir(id, root);
+ if (dir) dirs.add(dir);
+ else if (dir === undefined) strays.push(id.replace('\0', '\\0'));
+ }
+ }
+ if (strays.length) this.error(`can't tell which package this code comes from, to credit it:\n ${strays.join('\n ')}`);
+ const notices = [...[...dirs].map(readPackage), ...extra];
+ const refused = refusals(notices, approved);
+ if (refused.length) this.error(`these can't ship until the owner approves them (APPROVED in scripts/licences.ts):\n ${refused.join('\n ')}`);
+ this.emitFile({ type: 'asset', fileName: 'licences.txt', source: licencesText(notices) });
+ const html = bundle['index.html'];
+ if (html?.type === 'asset') html.source = String(html.source).replace(MARK, licencesList(notices));
+ } },
+ };
+}
diff --git a/scripts/licences/Apache-2.0.txt b/scripts/licences/Apache-2.0.txt
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/scripts/licences/Apache-2.0.txt
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/test/licences.test.ts b/test/licences.test.ts
new file mode 100644
index 0000000..d0698e5
--- /dev/null
+++ b/test/licences.test.ts
@@ -0,0 +1,71 @@
+import { mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { build, type Rolldown } from 'vite';
+import { describe, expect, it } from 'vitest';
+import { licences, verdict } from '../scripts/licences';
+
+describe('which licences can ship', () => {
+ it('takes permissive ones, stops copyleft and unknown ones', () => {
+ expect(verdict('MIT')).toBe('ok');
+ expect(verdict('Apache-2.0')).toBe('ok');
+ expect(verdict('(MIT OR GPL-3.0-only)')).toBe('ok'); // the game takes the MIT
+ expect(verdict('MIT AND LGPL-2.1-or-later')).toBe('copyleft');
+ expect(verdict('GPL-2.0-only WITH Classpath-exception-2.0')).toBe('copyleft');
+ expect(verdict('AGPL-3.0-only')).toBe('copyleft');
+ expect(verdict('LGPL-3.0-or-later OR SomethingOdd')).toBe('unknown');
+ expect(verdict('')).toBe('unknown');
+ expect(verdict('SEE LICENSE IN LICENSE.txt')).toBe('unknown');
+ expect(verdict('(MIT')).toBe('unknown');
+ });
+});
+
+describe('the build', () => {
+ /** A little game that imports these packages, built for real: what reaches the bundle is what gets credited. */
+ async function buildWith(packages: Record, opts?: Parameters[0]) {
+ const root = realpathSync(mkdtempSync(`${tmpdir()}/floe-licences-`));
+ writeFileSync(`${root}/index.html`, '\n\n');
+ writeFileSync(`${root}/main.js`, Object.keys(packages).map((name, i) => `import f${i} from '${name}'; f${i}();`).join('\n'));
+ for (const [name, licence] of Object.entries(packages)) {
+ mkdirSync(`${root}/node_modules/${name}`, { recursive: true });
+ writeFileSync(`${root}/node_modules/${name}/package.json`, JSON.stringify({ name, version: '1.0.0', license: licence, main: 'index.js' }));
+ writeFileSync(`${root}/node_modules/${name}/index.js`, `export default () => console.log('${name}');`);
+ writeFileSync(`${root}/node_modules/${name}/LICENSE`, `${licence} licence text of ${name}`);
+ }
+ const out = await build({ root, configFile: false, logLevel: 'silent', build: { write: false, modulePreload: false }, plugins: [licences(opts)] });
+ const files = (out as Rolldown.RolldownOutput).output;
+ const text = (name: string) => { const f = files.find(f => f.fileName === name); return f?.type === 'asset' ? String(f.source) : ''; };
+ return { page: text('index.html'), licences: text('licences.txt') };
+ }
+
+ it('credits each bundled package in the credits and in licences.txt', async () => {
+ const { page, licences } = await buildWith({ 'mit-thing': 'MIT', 'isc-thing': 'ISC' });
+ expect(page).toContain('isc-thing 1.0.0, ISC.');
+ expect(page).toContain('mit-thing 1.0.0, MIT.');
+ expect(page).toContain('The full licence texts');
+ expect(licences).toContain('mit-thing 1.0.0 (MIT)\n\nMIT licence text of mit-thing');
+ });
+
+ it('fails on a GPL package', async () => {
+ await expect(buildWith({ 'mit-thing': 'MIT', 'gpl-thing': 'GPL-3.0-only' })).rejects.toThrow(/gpl-thing 1\.0\.0: GPL-3\.0-only is copyleft/);
+ await expect(buildWith({ 'lgpl-thing': 'LGPL-2.1-or-later' })).rejects.toThrow(/lgpl-thing 1\.0\.0: LGPL-2\.1-or-later is copyleft/);
+ await expect(buildWith({ 'odd-thing': 'Odd-1.0' })).rejects.toThrow(/odd-thing 1\.0\.0: can't read its licence "Odd-1\.0"/);
+ });
+
+ it('ships one the owner approved, under the licence they approved', async () => {
+ const approved = { 'lgpl-thing': { licence: 'LGPL-2.1-or-later', why: 'a test' } };
+ expect((await buildWith({ 'lgpl-thing': 'LGPL-2.1-or-later' }, { approved })).page).toContain('lgpl-thing 1.0.0, LGPL-2.1-or-later.');
+ await expect(buildWith({ 'lgpl-thing': 'GPL-3.0-only' }, { approved })).rejects.toThrow(/copyleft/);
+ });
+
+ it('adds the notices handed in as extra (the Electron build: Chromium, ffmpeg), checked the same way', async () => {
+ const ffmpeg = { name: 'ffmpeg', version: '7.1', licence: 'LGPL-2.1-or-later', text: 'GNU LESSER GENERAL PUBLIC LICENSE…' };
+ await expect(buildWith({}, { extra: [ffmpeg] })).rejects.toThrow(/ffmpeg 7\.1: LGPL-2\.1-or-later is copyleft/);
+ const { licences } = await buildWith({}, { extra: [ffmpeg], approved: { ffmpeg: { licence: 'LGPL-2.1-or-later', why: 'a test' } } });
+ expect(licences).toContain('ffmpeg 7.1 (LGPL-2.1-or-later)\n\nGNU LESSER');
+ });
+
+ it("has the game's credits ready for the list, and the Apache licence's text for Firebase, which ships none", () => {
+ expect(readFileSync('index.html', 'utf8')).toContain('');
+ expect(readFileSync('scripts/licences/Apache-2.0.txt', 'utf8')).toContain('Apache License');
+ });
+});
diff --git a/test/node.d.ts b/test/node.d.ts
index 9eb9d82..e01b4f6 100644
--- a/test/node.d.ts
+++ b/test/node.d.ts
@@ -1,7 +1,13 @@
-// The few Node APIs the balance simulation and the rules tests use (the project doesn't depend on @types/node).
+// The few Node APIs the balance simulation, the rules tests and the build's licences (scripts/licences.ts) use (the project
+// doesn't depend on @types/node).
declare const process: { env: Record; argv: string[] };
declare module 'node:fs' {
export function writeFileSync(path: string, data: string): void;
- export function readFileSync(path: string, encoding: 'utf8'): string;
+ export function readFileSync(path: string | URL, encoding: 'utf8'): string;
+ export function existsSync(path: string | URL): boolean;
+ export function readdirSync(path: string): string[];
+ export function mkdirSync(path: string, options?: { recursive?: boolean }): void;
+ export function mkdtempSync(prefix: string): string;
+ export function realpathSync(path: string): string;
}
declare module 'node:os' { export function tmpdir(): string }
diff --git a/vite.config.ts b/vite.config.ts
index 029071e..b8bd94c 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,6 +1,7 @@
import type { Plugin } from 'vite';
import { configDefaults, defineConfig } from 'vitest/config';
import { ShardSequencer } from './test/shards.ts';
+import { licences } from './scripts/licences.ts';
/**
* The loading screen (index.html) counts the game's scripts in as they download, so it needs their names and sizes
@@ -57,7 +58,7 @@ export default defineConfig({
chunkSizeWarningLimit: 1000,
rolldownOptions: { output: { codeSplitting: { groups: [{ name: 'three', test: /node_modules[\\/]three[\\/]/ }] } } },
},
- plugins: [kofiToggle(), preloadManifest()],
+ plugins: [kofiToggle(), licences(), preloadManifest()],
// the commit the game was built from (GitHub Actions sets it), which bug reports carry (report.ts)
define: { __BUILD__: JSON.stringify(process.env.GITHUB_SHA ?? 'dev') },
test: {