Skip to content

Commit 1475ae1

Browse files
committed
Merge branch with recording feature, resolve conflict by keeping both resetLives and resetFuel
2 parents d5f93bc + 2759f3e commit 1475ae1

Some content is hidden

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

77 files changed

+5787
-699
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changes
22

3+
## 1.3.0 - 2025.10.26
4+
5+
Game recording, validation, and replay
6+
Change defaults to modern rendering and gray background
7+
38
## 1.2.1 - 2025.10.19
49

510
Modern rendering option requires modern collision model

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "continuum",
3-
"version": "1.2.1",
3+
"version": "1.3.0",
44
"type": "module",
55
"description": "Recreation of the 68000 Mac game Continuum for the web",
66
"scripts": {
@@ -17,10 +17,12 @@
1717
"format": "prettier --write .",
1818
"format:check": "prettier --check .",
1919
"typecheck": "tsc --noEmit",
20+
"typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json",
2021
"export-sprites": "tsx scripts/export-sprites.ts",
2122
"convert-sprites": "tsx scripts/convert-white-to-transparent.ts",
2223
"convert-digits": "tsx scripts/convert-digit-sprites.ts",
23-
"fix-shipshot": "tsx scripts/fix-shipshot.ts"
24+
"fix-shipshot": "tsx scripts/fix-shipshot.ts",
25+
"validate-recording": "tsx scripts/validate-recording.ts"
2426
},
2527
"dependencies": {
2628
"@reduxjs/toolkit": "^2.8.2",

scripts/canvas-shim.d.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Type shim to provide DOM canvas types in Node.js environment
3+
* These types are compatible with the 'canvas' package
4+
*/
5+
6+
// Provide minimal DOM canvas types for bitmap utilities
7+
declare class ImageData {
8+
constructor(width: number, height: number)
9+
constructor(data: Uint8ClampedArray, width: number, height?: number)
10+
readonly data: Uint8ClampedArray
11+
readonly width: number
12+
readonly height: number
13+
}
14+
15+
type HTMLCanvasElement = {
16+
width: number
17+
height: number
18+
}
19+
20+
type CanvasRenderingContext2D = {
21+
readonly canvas: HTMLCanvasElement
22+
createImageData(width: number, height: number): ImageData
23+
getImageData(sx: number, sy: number, sw: number, sh: number): ImageData
24+
putImageData(imageData: ImageData, dx: number, dy: number): void
25+
}

scripts/export-sprites.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
* Usage: npm run export-sprites
99
*/
1010

11+
/// <reference types="canvas" />
12+
1113
import { createCanvas } from 'canvas'
1214
import { readFileSync, writeFileSync, mkdirSync } from 'fs'
1315
import { join, dirname } from 'path'

scripts/gzip.node.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* @fileoverview Node.js-specific gzip implementation using zlib
3+
*/
4+
5+
import { promisify } from 'util'
6+
import { gzip as zlibGzip, gunzip as zlibGunzip } from 'zlib'
7+
8+
const gzipAsync = promisify(zlibGzip)
9+
const gunzipAsync = promisify(zlibGunzip)
10+
11+
/**
12+
* Compress data using gzip (Node.js implementation)
13+
*/
14+
export const compress = async (data: ArrayBuffer): Promise<ArrayBuffer> => {
15+
const buffer = Buffer.from(data)
16+
const compressed = await gzipAsync(buffer)
17+
// Return a properly-sized ArrayBuffer (not a pooled buffer view)
18+
return compressed.buffer.slice(
19+
compressed.byteOffset,
20+
compressed.byteOffset + compressed.byteLength
21+
)
22+
}
23+
24+
/**
25+
* Decompress gzip data (Node.js implementation)
26+
*/
27+
export const decompress = async (data: ArrayBuffer): Promise<ArrayBuffer> => {
28+
const buffer = Buffer.from(data)
29+
const decompressed = await gunzipAsync(buffer)
30+
// Return a properly-sized ArrayBuffer (not a pooled buffer view)
31+
return decompressed.buffer.slice(
32+
decompressed.byteOffset,
33+
decompressed.byteOffset + decompressed.byteLength
34+
)
35+
}

scripts/tsconfig.json

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2020",
4+
"lib": ["ES2020"],
5+
"types": ["node", "canvas"],
6+
"module": "ESNext",
7+
"moduleResolution": "bundler",
8+
"skipLibCheck": true,
9+
"resolveJsonModule": true,
10+
"isolatedModules": true,
11+
"noEmit": true,
12+
13+
/* Linting */
14+
"strict": true,
15+
"noUnusedLocals": true,
16+
"noUnusedParameters": true,
17+
"noFallthroughCasesInSwitch": true,
18+
"noUncheckedIndexedAccess": true,
19+
20+
/* Path mapping - same as main tsconfig */
21+
"baseUrl": "..",
22+
"paths": {
23+
"@/*": ["src/*"],
24+
"@core/*": ["src/core/*"],
25+
"@lib/*": ["src/lib/*"],
26+
"@dev/*": ["src/dev/*"],
27+
"@render/*": ["src/render/*"],
28+
"@render-modern/*": ["src/render-modern/*"]
29+
}
30+
},
31+
"include": ["./**/*.ts"],
32+
"exclude": ["node_modules"]
33+
}

scripts/validate-recording.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { createRecordingService } from '@core/recording'
2+
import { decodeRecordingAuto } from '@core/recording/binaryCodec'
3+
import {
4+
createHeadlessGameEngine,
5+
createRecordingValidator,
6+
createHeadlessStore
7+
} from '@core/validation'
8+
import { decompress } from './gzip.node'
9+
import { createGalaxyServiceNode } from '@/core/galaxy/createGalaxyServiceNode'
10+
import { createRandomService } from '@/core/shared'
11+
import { createCollisionService } from '@core/collision'
12+
import { createSpriteServiceNode } from '@core/sprites/createSpriteServiceNode'
13+
import { SCRWTH, VIEWHT } from '@core/screen'
14+
import { GALAXIES } from '@/game/galaxyConfig'
15+
import fs from 'fs'
16+
import path from 'path'
17+
18+
const main = async (): Promise<void> => {
19+
const args = process.argv.slice(2)
20+
21+
if (args.length === 0) {
22+
console.log('Usage: npm run validate-recording <recording-file.json|.bin>')
23+
process.exit(1)
24+
}
25+
26+
// Load recording from file
27+
const filePath = args[0]
28+
if (!filePath) {
29+
console.error('Error: No file path provided')
30+
process.exit(1)
31+
}
32+
33+
// Load and decode recording (auto-detect format: gzipped binary, binary, or JSON)
34+
const fileBuffer = fs.readFileSync(filePath)
35+
36+
// Create a properly-sized ArrayBuffer (Node.js Buffer.buffer may be a pooled view)
37+
const arrayBuffer = fileBuffer.buffer.slice(
38+
fileBuffer.byteOffset,
39+
fileBuffer.byteOffset + fileBuffer.byteLength
40+
)
41+
42+
// Auto-detect format
43+
const recording = await decodeRecordingAuto(arrayBuffer, decompress)
44+
45+
// Map galaxy ID to path
46+
const galaxyConfig = GALAXIES.find(g => g.id === recording.galaxyId)
47+
if (!galaxyConfig) {
48+
console.error(`Unknown galaxy ID: ${recording.galaxyId}`)
49+
console.error(`Available galaxies: ${GALAXIES.map(g => g.id).join(', ')}`)
50+
process.exit(1)
51+
}
52+
53+
// Map web path to file system path
54+
// Web paths like "/release_galaxy.bin" -> "src/game/public/release_galaxy.bin"
55+
// Web paths like "/galaxies/continuum_galaxy.bin" -> "src/game/public/galaxies/continuum_galaxy.bin"
56+
const galaxyFilePath = path.join('src/game/public', galaxyConfig.path)
57+
58+
// Sprite resource path (rsrc_260.bin contains all the sprite data)
59+
const spriteFilePath = path.join('src/game/public', 'rsrc_260.bin')
60+
61+
// Create minimal services for headless validation (no fizz service needed)
62+
const galaxyService = createGalaxyServiceNode(galaxyFilePath)
63+
const randomService = createRandomService()
64+
const recordingService = createRecordingService()
65+
const collisionService = createCollisionService()
66+
collisionService.initialize({ width: SCRWTH, height: VIEWHT })
67+
const spriteService = createSpriteServiceNode(spriteFilePath)
68+
69+
const services = {
70+
galaxyService,
71+
randomService,
72+
recordingService,
73+
collisionService,
74+
spriteService
75+
}
76+
77+
// Create headless store (no UI, sound, or persistence)
78+
const store = createHeadlessStore(
79+
services,
80+
recording.initialState.lives,
81+
recording.startLevel
82+
)
83+
84+
// Create headless engine (uses frame counter instead of real fizz service)
85+
const engine = createHeadlessGameEngine(
86+
store,
87+
galaxyService,
88+
randomService,
89+
recording.galaxyId,
90+
recording.initialState.lives
91+
)
92+
93+
const validator = createRecordingValidator(engine, store, recordingService)
94+
95+
console.log(`Validating: ${filePath}`)
96+
console.log(`Galaxy: ${recording.galaxyId}`)
97+
console.log(`Start level: ${recording.startLevel}`)
98+
console.log(
99+
`Total frames: ${recording.inputs[recording.inputs.length - 1]?.frame ?? 0}`
100+
)
101+
102+
const report = validator.validate(recording)
103+
104+
console.log(`\nResult: ${report.success ? 'PASS' : 'FAIL'}`)
105+
console.log(`Frames validated: ${report.framesValidated}`)
106+
console.log(`Snapshots checked: ${report.snapshotsChecked}`)
107+
108+
if (report.divergenceFrame !== null) {
109+
console.log(`\nDivergence at frame: ${report.divergenceFrame}`)
110+
const error = report.errors[0]
111+
112+
if (error?.stateDiff) {
113+
// Full state diff available - show detailed comparison
114+
console.log(`\nState differences (${error.stateDiff.length} slices):`)
115+
for (const diff of error.stateDiff) {
116+
console.log(`\n ${diff.path}:`)
117+
118+
// For planet slice, do a more detailed comparison
119+
if (
120+
diff.path === 'planet' &&
121+
typeof diff.expected === 'object' &&
122+
typeof diff.actual === 'object'
123+
) {
124+
const expected = diff.expected as Record<string, unknown>
125+
const actual = diff.actual as Record<string, unknown>
126+
127+
console.log(' Checking array lengths:')
128+
for (const key of Object.keys(expected)) {
129+
if (Array.isArray(expected[key])) {
130+
const expArray = expected[key] as unknown[]
131+
const actArray = actual[key] as unknown[] | undefined
132+
const match = expArray.length === (actArray?.length ?? -1)
133+
console.log(
134+
` ${key}: ${expArray.length} vs ${actArray?.length ?? 'undefined'} ${match ? '✓' : '✗'}`
135+
)
136+
}
137+
}
138+
139+
console.log(' Checking first elements:')
140+
const arrays = [
141+
'lines',
142+
'bunkers',
143+
'fuels',
144+
'craters',
145+
'gravityPoints'
146+
]
147+
for (const arrName of arrays) {
148+
const expectedArr = expected[arrName] as unknown[] | undefined
149+
const actualArr = actual[arrName] as unknown[] | undefined
150+
if (expectedArr && actualArr && expectedArr.length > 0) {
151+
const exp0 = JSON.stringify(expectedArr[0])
152+
const act0 = JSON.stringify(actualArr[0])
153+
const match = exp0 === act0
154+
console.log(` ${arrName}[0] match: ${match ? '✓' : '✗'}`)
155+
if (!match) {
156+
console.log(` Expected: ${exp0.substring(0, 150)}`)
157+
console.log(` Actual: ${act0.substring(0, 150)}`)
158+
}
159+
}
160+
}
161+
162+
// Check all elements for a mismatch
163+
console.log(' Searching for differences...')
164+
for (const arrName of arrays) {
165+
const expectedArr = expected[arrName] as unknown[] | undefined
166+
const actualArr = actual[arrName] as unknown[] | undefined
167+
if (expectedArr && actualArr) {
168+
for (
169+
let i = 0;
170+
i < Math.min(expectedArr.length, actualArr.length);
171+
i++
172+
) {
173+
const exp = JSON.stringify(expectedArr[i])
174+
const act = JSON.stringify(actualArr[i])
175+
if (exp !== act) {
176+
console.log(` Found difference in ${arrName}[${i}]`)
177+
console.log(` Expected: ${exp.substring(0, 200)}`)
178+
console.log(` Actual: ${act.substring(0, 200)}`)
179+
break
180+
}
181+
}
182+
}
183+
}
184+
} else {
185+
console.log(
186+
` Expected: ${JSON.stringify(diff.expected).substring(0, 200)}...`
187+
)
188+
console.log(
189+
` Actual: ${JSON.stringify(diff.actual).substring(0, 200)}...`
190+
)
191+
}
192+
}
193+
} else if (error?.expectedHash && error?.actualHash) {
194+
// Only hash available - show hash comparison
195+
console.log(` Expected hash: ${error.expectedHash}`)
196+
console.log(` Actual hash: ${error.actualHash}`)
197+
}
198+
}
199+
200+
process.exit(report.success ? 0 : 1)
201+
}
202+
203+
main().catch(err => {
204+
console.error('Fatal error:', err)
205+
process.exit(1)
206+
})

0 commit comments

Comments
 (0)