Skip to content

Commit b338f06

Browse files
committed
Make the game fully static
1 parent cf3a992 commit b338f06

49 files changed

Lines changed: 6061 additions & 1344 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/deploy-pages.yml‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
name: Deploy GitHub Pages
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: read
11+
pages: write
12+
id-token: write
13+
14+
concurrency:
15+
group: pages
16+
cancel-in-progress: true
17+
18+
jobs:
19+
build:
20+
runs-on: ubuntu-latest
21+
steps:
22+
- name: Check out repository
23+
uses: actions/checkout@v4
24+
25+
- name: Set up Node.js
26+
uses: actions/setup-node@v4
27+
with:
28+
node-version: 22
29+
cache: npm
30+
31+
- name: Install dependencies
32+
run: npm ci
33+
34+
- name: Check and build
35+
run: npm run check
36+
37+
- name: Configure Pages
38+
uses: actions/configure-pages@v5
39+
with:
40+
enablement: true
41+
42+
- name: Upload Pages artifact
43+
uses: actions/upload-pages-artifact@v3
44+
with:
45+
path: ./dist
46+
47+
deploy:
48+
environment:
49+
name: github-pages
50+
url: ${{ steps.deployment.outputs.page_url }}
51+
runs-on: ubuntu-latest
52+
needs: build
53+
steps:
54+
- name: Deploy to GitHub Pages
55+
id: deployment
56+
uses: actions/deploy-pages@v4

‎README.md‎

Lines changed: 152 additions & 91 deletions
Large diffs are not rendered by default.

‎docs/player-profiles-and-case-library.md‎

Lines changed: 185 additions & 72 deletions
Large diffs are not rendered by default.

‎package-lock.json‎

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"@fontsource/ibm-plex-mono": "^5.3.0",
2626
"@fontsource/instrument-sans": "^5.3.0",
2727
"@fontsource/newsreader": "^5.3.0",
28+
"@noble/hashes": "^2.3.0",
2829
"ajv": "^8.20.0",
2930
"lucide-static": "^1.34.0",
3031
"react": "^19.1.1",
@@ -37,6 +38,7 @@
3738
"@types/react": "^19.1.10",
3839
"@types/react-dom": "^19.1.7",
3940
"@vitejs/plugin-react": "^5.0.0",
41+
"fake-indexeddb": "^6.2.5",
4042
"happy-dom": "^20.11.6",
4143
"tsx": "^4.23.12",
4244
"typescript": "^5.9.2",
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { createHash } from 'node:crypto'
2+
import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises'
3+
import { tmpdir } from 'node:os'
4+
import { dirname, join, resolve } from 'node:path'
5+
6+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
7+
8+
import type { VerifiedPackageAsset } from '../src/case-package'
9+
import { hashCanonical } from '../src/compiler'
10+
import {
11+
generatePublicManifests,
12+
requireStaticLocalAssetSource,
13+
} from './generate-public-manifests'
14+
15+
type UnknownRecord = Record<string, unknown>
16+
17+
function record(value: unknown): UnknownRecord {
18+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
19+
throw new TypeError('Expected an object.')
20+
}
21+
return value as UnknownRecord
22+
}
23+
24+
function string(value: unknown): string {
25+
if (typeof value !== 'string') throw new TypeError('Expected a string.')
26+
return value
27+
}
28+
29+
async function json(path: string): Promise<UnknownRecord> {
30+
return record(JSON.parse(await readFile(path, 'utf8')) as unknown)
31+
}
32+
33+
function fixtureAsset(source: VerifiedPackageAsset['descriptor']['source']): VerifiedPackageAsset {
34+
return {
35+
descriptor: {
36+
id: 'fixture-asset',
37+
kind: 'image',
38+
mimeType: 'image/png',
39+
visibility: 'private',
40+
source,
41+
integrity: { algorithm: 'sha256', digest: '0'.repeat(64) },
42+
handle: { id: 'fixture-asset', kind: 'image', mimeType: 'image/png' },
43+
},
44+
}
45+
}
46+
47+
describe('static runtime manifest generation', () => {
48+
const projectRoot = resolve(import.meta.dirname, '..')
49+
let temporaryPublicDirectory = ''
50+
let generatedDirectory = ''
51+
let index: UnknownRecord
52+
53+
beforeAll(async () => {
54+
temporaryPublicDirectory = await mkdtemp(join(tmpdir(), 'detective-static-runtime-'))
55+
const result = await generatePublicManifests({
56+
casesDirectory: join(projectRoot, 'cases'),
57+
publicDirectory: temporaryPublicDirectory,
58+
})
59+
generatedDirectory = result.outputDirectory
60+
index = await json(join(generatedDirectory, 'cases.json'))
61+
}, 30_000)
62+
63+
afterAll(async () => {
64+
await rm(temporaryPublicDirectory, { recursive: true, force: true })
65+
})
66+
67+
it('emits an integrity-locked runtime and effective presentation catalogs per case', async () => {
68+
const packages = index.packages
69+
expect(Array.isArray(packages)).toBe(true)
70+
expect(packages).not.toHaveLength(0)
71+
72+
for (const rawPackage of packages as unknown[]) {
73+
const packageEntry = record(rawPackage)
74+
expect(string(packageEntry.manifestUrl).startsWith('./')).toBe(true)
75+
expect(string(packageEntry.assetManifestUrl).startsWith('./')).toBe(true)
76+
for (const rawLocale of packageEntry.locales as unknown[]) {
77+
expect(string(record(rawLocale).manifestUrl).startsWith('./')).toBe(true)
78+
}
79+
const runtimeUrl = string(packageEntry.runtimeUrl)
80+
expect(runtimeUrl.startsWith('./')).toBe(true)
81+
const runtimePath = resolve(generatedDirectory, runtimeUrl)
82+
const bundle = await json(runtimePath)
83+
const integrity = record(bundle.integrity)
84+
const bundleDigest = string(integrity.bundle)
85+
const { integrity: _integrity, ...unsigned } = bundle
86+
87+
expect(bundle.schema).toBe('case-static-runtime/v1')
88+
expect(hashCanonical(unsigned)).toBe(bundleDigest)
89+
expect(packageEntry.runtimeDigest).toBe(bundleDigest)
90+
91+
const identity = record(bundle.case)
92+
const kernel = record(bundle.kernelIr)
93+
expect(kernel.id).toBe(identity.id)
94+
expect(kernel.version).toBe(identity.version)
95+
expect(kernel.digest).toBe(identity.kernelDigest)
96+
expect(packageEntry.caseDigest).toBe(identity.kernelDigest)
97+
expect(packageEntry.packageDigest).toBe(identity.packageDigest)
98+
expect(identity.packageDigest).toMatch(/^[a-f0-9]{64}$/)
99+
100+
const presentations = record(bundle.presentations)
101+
const locales = packageEntry.locales
102+
expect(Array.isArray(locales)).toBe(true)
103+
expect(Object.keys(presentations).sort()).toEqual(
104+
(locales as unknown[]).map((locale) => string(record(locale).locale)).sort(),
105+
)
106+
for (const catalog of Object.values(presentations)) {
107+
expect(Object.keys(record(record(catalog).messages))).not.toHaveLength(0)
108+
}
109+
}
110+
})
111+
112+
it('keeps public asset delivery URLs relative to the asset manifest', async () => {
113+
for (const rawPackage of index.packages as unknown[]) {
114+
const packageEntry = record(rawPackage)
115+
const manifestPath = resolve(generatedDirectory, string(packageEntry.assetManifestUrl))
116+
const manifest = await json(manifestPath)
117+
expect(manifest.integrity).toEqual(expect.objectContaining({ algorithm: 'sha256' }))
118+
expect(Array.isArray(manifest.assets)).toBe(true)
119+
for (const rawAsset of manifest.assets as unknown[]) {
120+
const delivery = record(record(rawAsset).delivery)
121+
if (delivery.kind === 'hosted') {
122+
expect(string(delivery.url).startsWith('./assets/')).toBe(true)
123+
}
124+
}
125+
}
126+
})
127+
128+
it('copies every package-local asset under a content-addressed relative URL', async () => {
129+
const packages = index.packages as unknown[]
130+
let deliveredAssets = 0
131+
for (const rawPackage of packages) {
132+
const packageEntry = record(rawPackage)
133+
const runtimePath = resolve(generatedDirectory, string(packageEntry.runtimeUrl))
134+
const bundle = await json(runtimePath)
135+
const assets = bundle.assets
136+
expect(Array.isArray(assets)).toBe(true)
137+
138+
const authoredAssetEntries = await readdir(
139+
join(projectRoot, 'cases', string(packageEntry.slug), 'assets'),
140+
{ withFileTypes: true },
141+
)
142+
const authoredAssetCount = authoredAssetEntries.filter((entry) => (
143+
entry.isFile() && entry.name !== 'README.md'
144+
)).length
145+
expect(assets).toHaveLength(authoredAssetCount)
146+
147+
for (const rawAsset of assets as unknown[]) {
148+
deliveredAssets += 1
149+
const asset = record(rawAsset)
150+
const url = string(asset.url)
151+
const digest = string(asset.sha256)
152+
expect(url.startsWith('./assets/')).toBe(true)
153+
expect(url).toContain(digest)
154+
const path = resolve(dirname(runtimePath), url)
155+
expect((await stat(path)).isFile()).toBe(true)
156+
const bytes = await readFile(path)
157+
expect(createHash('sha256').update(bytes).digest('hex')).toBe(digest)
158+
}
159+
}
160+
expect(deliveredAssets).toBeGreaterThan(0)
161+
})
162+
163+
it('rejects remote and provider delivery with a clear static-build error', () => {
164+
expect(() => requireStaticLocalAssetSource(fixtureAsset({
165+
kind: 'https',
166+
url: 'https://media.example.test/evidence.png',
167+
}))).toThrow(/unsupported https delivery.*package-local assets/i)
168+
expect(() => requireStaticLocalAssetSource(fixtureAsset({
169+
kind: 'provider',
170+
provider: 'fixture-provider',
171+
ref: 'opaque-ref',
172+
}))).toThrow(/unsupported provider delivery.*package-local assets/i)
173+
})
174+
})

0 commit comments

Comments
 (0)