sci2-ts 0.15.0 → 0.15.1

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/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@ All notable changes to this project. Versions follow [Semantic Versioning](https
4
4
  and the entries are generated from commit messages
5
5
  ([Conventional Commits](https://www.conventionalcommits.org/)) when a release is made.
6
6
 
7
+ ## [0.15.1](https://github.com/rlueder/sci-ts/compare/v0.15.0...v0.15.1) (2026-10-02)
8
+
9
+ ### Fixed
10
+
11
+ * **viewer:** fetch game files with the build's version ([#18](https://github.com/rlueder/sci-ts/issues/18)) ([370e490](https://github.com/rlueder/sci-ts/commit/370e490d9c618d78223fbba1df4850a8c6ebb0a2))
12
+
7
13
  ## [0.15.0](https://github.com/rlueder/sci-ts/compare/v0.14.0...v0.15.0) (2026-10-02)
8
14
 
9
15
  ### Added
@@ -4,6 +4,11 @@ export declare const BASE: string;
4
4
  /**
5
5
  * FileSource over game/: the dev server's mount (see vite.config.ts), or the game copied
6
6
  * next to a built site, with its file names in game/files.json (`pnpm game site`).
7
+ *
8
+ * A built site's files.json also has the build's `version`. It's always fetched fresh, and
9
+ * every game file is fetched with the version on its URL, so after a new build a browser
10
+ * can't mix its cached files with the new ones (an old RESOURCE.000 under a new RESOURCE.MAP
11
+ * doesn't start).
7
12
  */
8
13
  export declare const httpFiles: FileSource;
9
14
  /**
@@ -5,14 +5,19 @@ export const BASE = import.meta.env.BASE_URL;
5
5
  /**
6
6
  * FileSource over game/: the dev server's mount (see vite.config.ts), or the game copied
7
7
  * next to a built site, with its file names in game/files.json (`pnpm game site`).
8
+ *
9
+ * A built site's files.json also has the build's `version`. It's always fetched fresh, and
10
+ * every game file is fetched with the version on its URL, so after a new build a browser
11
+ * can't mix its cached files with the new ones (an old RESOURCE.000 under a new RESOURCE.MAP
12
+ * doesn't start).
8
13
  */
9
14
  export const httpFiles = {
10
15
  async read(path) {
11
- const res = await fetch(`${BASE}game/${path}`);
16
+ const res = await fetch(await gameUrl(path));
12
17
  return res.ok ? new Uint8Array(await res.arrayBuffer()) : undefined;
13
18
  },
14
19
  async readRange(path, offset, length) {
15
- const res = await fetch(`${BASE}game/${path}`, { headers: { Range: `bytes=${offset}-${offset + length - 1}` } });
20
+ const res = await fetch(await gameUrl(path), { headers: { Range: `bytes=${offset}-${offset + length - 1}` } });
16
21
  const bytes = new Uint8Array(await res.arrayBuffer());
17
22
  // A server that ignores Range (Python's http.server) sends the whole file.
18
23
  return res.status === 206 ? bytes : bytes.subarray(offset, offset + length);
@@ -20,11 +25,20 @@ export const httpFiles = {
20
25
  async list(dir) {
21
26
  if (import.meta.env.DEV)
22
27
  return (await fetch(`/__list?dir=${encodeURIComponent(dir)}`)).json();
23
- staticListing ??= fetch(`${BASE}game/files.json`).then((r) => (r.ok ? r.json() : {}));
24
- return (await staticListing)[dir] ?? [];
28
+ const files = (await staticListing())[dir];
29
+ return Array.isArray(files) ? files : [];
25
30
  },
26
31
  };
27
- let staticListing;
32
+ let listing;
33
+ const staticListing = () => (listing ??= fetch(`${BASE}game/files.json`, { cache: "no-store" }).then((r) => (r.ok ? r.json() : Promise.resolve({}))));
34
+ /** A game file's URL: on a built site, with the build's version (see httpFiles). */
35
+ async function gameUrl(path) {
36
+ const url = `${BASE}game/${path}`;
37
+ if (import.meta.env.DEV)
38
+ return url;
39
+ const version = (await staticListing()).version;
40
+ return typeof version === "string" ? `${url}?v=${encodeURIComponent(version)}` : url;
41
+ }
28
42
  // --- Mods -------------------------------------------------------------------------------
29
43
  const MODS_KEY = `${APP_ID}.mods`;
30
44
  /**
@@ -7,25 +7,40 @@ export const BASE = import.meta.env.BASE_URL;
7
7
  /**
8
8
  * FileSource over game/: the dev server's mount (see vite.config.ts), or the game copied
9
9
  * next to a built site, with its file names in game/files.json (`pnpm game site`).
10
+ *
11
+ * A built site's files.json also has the build's `version`. It's always fetched fresh, and
12
+ * every game file is fetched with the version on its URL, so after a new build a browser
13
+ * can't mix its cached files with the new ones (an old RESOURCE.000 under a new RESOURCE.MAP
14
+ * doesn't start).
10
15
  */
11
16
  export const httpFiles: FileSource = {
12
17
  async read(path) {
13
- const res = await fetch(`${BASE}game/${path}`);
18
+ const res = await fetch(await gameUrl(path));
14
19
  return res.ok ? new Uint8Array(await res.arrayBuffer()) : undefined;
15
20
  },
16
21
  async readRange(path, offset, length) {
17
- const res = await fetch(`${BASE}game/${path}`, { headers: { Range: `bytes=${offset}-${offset + length - 1}` } });
22
+ const res = await fetch(await gameUrl(path), { headers: { Range: `bytes=${offset}-${offset + length - 1}` } });
18
23
  const bytes = new Uint8Array(await res.arrayBuffer());
19
24
  // A server that ignores Range (Python's http.server) sends the whole file.
20
25
  return res.status === 206 ? bytes : bytes.subarray(offset, offset + length);
21
26
  },
22
27
  async list(dir) {
23
28
  if (import.meta.env.DEV) return (await fetch(`/__list?dir=${encodeURIComponent(dir)}`)).json();
24
- staticListing ??= fetch(`${BASE}game/files.json`).then((r) => (r.ok ? r.json() : {}));
25
- return (await staticListing)[dir] ?? [];
29
+ const files = (await staticListing())[dir];
30
+ return Array.isArray(files) ? files : [];
26
31
  },
27
32
  };
28
- let staticListing: Promise<Record<string, string[]>> | undefined;
33
+
34
+ let listing: Promise<Record<string, unknown>> | undefined;
35
+ const staticListing = () => (listing ??= fetch(`${BASE}game/files.json`, { cache: "no-store" }).then((r): Promise<Record<string, unknown>> => (r.ok ? r.json() : Promise.resolve({}))));
36
+
37
+ /** A game file's URL: on a built site, with the build's version (see httpFiles). */
38
+ async function gameUrl(path: string): Promise<string> {
39
+ const url = `${BASE}game/${path}`;
40
+ if (import.meta.env.DEV) return url;
41
+ const version = (await staticListing()).version;
42
+ return typeof version === "string" ? `${url}?v=${encodeURIComponent(version)}` : url;
43
+ }
29
44
 
30
45
  // --- Mods -------------------------------------------------------------------------------
31
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sci2-ts",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "An SCI2 adventure game engine in TypeScript, and the tools to make games with it",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -6,3 +6,5 @@ export declare function buildSite(gameDir: string, outDir: string, options?: {
6
6
  game: string;
7
7
  soundFont: boolean;
8
8
  }>;
9
+ /** A short hash of the game's files: changes whenever any of them does. */
10
+ export declare function buildVersion(files: Record<string, Uint8Array>): string;
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
2
3
  import { basename, dirname, join, relative, resolve } from "node:path";
3
4
  import { writeResourceArchive } from "../../packages/sci/src/index.js";
@@ -40,7 +41,8 @@ export async function buildSite(gameDir, outDir, options = {}) {
40
41
  for (const [name, data] of Object.entries(files))
41
42
  writeFileSync(join(play, "game", name), data);
42
43
  // What the player lists in place of the dev server's directory listings.
43
- writeFileSync(join(play, "game/files.json"), JSON.stringify({ "": Object.keys(files) }));
44
+ // With the build's version, which the player fetches every file with (apps/viewer/src/files.ts).
45
+ writeFileSync(join(play, "game/files.json"), JSON.stringify({ "": Object.keys(files), version: buildVersion(files) }));
44
46
  const sf2 = [join(resolve(gameDir), SOUNDFONT), join(ROOT, SOUNDFONT)].find((f) => existsSync(f));
45
47
  const soundFont = sf2 !== undefined;
46
48
  if (sf2)
@@ -110,3 +112,10 @@ ${body}
110
112
  </html>
111
113
  `;
112
114
  }
115
+ /** A short hash of the game's files: changes whenever any of them does. */
116
+ export function buildVersion(files) {
117
+ const hash = createHash("sha256");
118
+ for (const name of Object.keys(files).sort())
119
+ hash.update(name).update(files[name]);
120
+ return hash.digest("hex").slice(0, 12);
121
+ }
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
2
3
  import { basename, dirname, join, relative, resolve } from "node:path";
3
4
  import { writeResourceArchive } from "../../packages/sci/src/index.ts";
@@ -46,7 +47,8 @@ export async function buildSite(
46
47
  mkdirSync(join(play, "game"), { recursive: true });
47
48
  for (const [name, data] of Object.entries(files)) writeFileSync(join(play, "game", name), data);
48
49
  // What the player lists in place of the dev server's directory listings.
49
- writeFileSync(join(play, "game/files.json"), JSON.stringify({ "": Object.keys(files) }));
50
+ // With the build's version, which the player fetches every file with (apps/viewer/src/files.ts).
51
+ writeFileSync(join(play, "game/files.json"), JSON.stringify({ "": Object.keys(files), version: buildVersion(files) }));
50
52
  const sf2 = [join(resolve(gameDir), SOUNDFONT), join(ROOT, SOUNDFONT)].find((f) => existsSync(f));
51
53
  const soundFont = sf2 !== undefined;
52
54
  if (sf2) cpSync(sf2, join(play, "soundfonts", basename(sf2)));
@@ -116,3 +118,10 @@ ${body}
116
118
  </html>
117
119
  `;
118
120
  }
121
+
122
+ /** A short hash of the game's files: changes whenever any of them does. */
123
+ export function buildVersion(files: Record<string, Uint8Array>): string {
124
+ const hash = createHash("sha256");
125
+ for (const name of Object.keys(files).sort()) hash.update(name).update(files[name]!);
126
+ return hash.digest("hex").slice(0, 12);
127
+ }