moonscratch 0.1.0-alpha.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.
Files changed (151) hide show
  1. package/.agents/skills/moonbit-agent-guide/LICENSE +202 -0
  2. package/.agents/skills/moonbit-agent-guide/SKILL.mbt.md +1126 -0
  3. package/.agents/skills/moonbit-agent-guide/SKILL.md +1126 -0
  4. package/.agents/skills/moonbit-agent-guide/ide.md +116 -0
  5. package/.agents/skills/moonbit-agent-guide/references/advanced-moonbit-build.md +106 -0
  6. package/.agents/skills/moonbit-agent-guide/references/moonbit-language-fundamentals.mbt.md +422 -0
  7. package/.agents/skills/moonbit-agent-guide/references/moonbit-language-fundamentals.md +422 -0
  8. package/.agents/skills/moonbit-practice/SKILL.md +258 -0
  9. package/.agents/skills/moonbit-practice/assets/ci.yaml +25 -0
  10. package/.agents/skills/moonbit-practice/reference/agents.md +1469 -0
  11. package/.agents/skills/moonbit-practice/reference/configuration.md +228 -0
  12. package/.agents/skills/moonbit-practice/reference/ffi.md +229 -0
  13. package/.agents/skills/moonbit-practice/reference/ide.md +189 -0
  14. package/.agents/skills/moonbit-practice/reference/performance.md +217 -0
  15. package/.agents/skills/moonbit-practice/reference/refactor.md +154 -0
  16. package/.agents/skills/moonbit-practice/reference/stdlib.md +351 -0
  17. package/.agents/skills/moonbit-practice/reference/testing.md +228 -0
  18. package/.agents/skills/moonbit-refactoring/LICENSE +21 -0
  19. package/.agents/skills/moonbit-refactoring/SKILL.md +323 -0
  20. package/.githooks/README.md +23 -0
  21. package/.githooks/pre-commit +3 -0
  22. package/.github/workflows/copilot-setup-steps.yml +40 -0
  23. package/.turbo/turbo-typecheck.log +2 -0
  24. package/AGENTS.md +91 -0
  25. package/LICENSE +21 -0
  26. package/PLAN.md +64 -0
  27. package/README.mbt.md +77 -0
  28. package/README.md +84 -0
  29. package/TODO.md +120 -0
  30. package/a.png +0 -0
  31. package/benchmarks/calc.bench.ts +144 -0
  32. package/benchmarks/draw.bench.ts +215 -0
  33. package/benchmarks/load.bench.ts +28 -0
  34. package/benchmarks/render.bench.ts +53 -0
  35. package/benchmarks/run.bench.ts +8 -0
  36. package/benchmarks/types.d.ts +15 -0
  37. package/docs/scratch-vm-specs/eventloop.md +103 -0
  38. package/docs/scratch-vm-specs/moonscratch-time-separation.md +50 -0
  39. package/index.html +91 -0
  40. package/js/AGENTS.md +5 -0
  41. package/js/a.ts +52 -0
  42. package/js/assets/AGENTS.md +5 -0
  43. package/js/assets/base64.test.ts +14 -0
  44. package/js/assets/base64.ts +21 -0
  45. package/js/assets/build-asset.test.ts +26 -0
  46. package/js/assets/build-asset.ts +28 -0
  47. package/js/assets/create.test.ts +142 -0
  48. package/js/assets/create.ts +122 -0
  49. package/js/assets/index.test.ts +15 -0
  50. package/js/assets/index.ts +2 -0
  51. package/js/assets/types.ts +26 -0
  52. package/js/assets/validation.test.ts +34 -0
  53. package/js/assets/validation.ts +25 -0
  54. package/js/assets.test.ts +14 -0
  55. package/js/assets.ts +1 -0
  56. package/js/index.test.ts +26 -0
  57. package/js/index.ts +3 -0
  58. package/js/render/index.test.ts +65 -0
  59. package/js/render/index.ts +13 -0
  60. package/js/render/sharp.ts +87 -0
  61. package/js/render/svg.ts +68 -0
  62. package/js/render/types.ts +35 -0
  63. package/js/render/utils.ts +108 -0
  64. package/js/render/webgl.ts +274 -0
  65. package/js/sharp-optional.d.ts +16 -0
  66. package/js/test/helpers.ts +116 -0
  67. package/js/test/hikkaku-sample.test.ts +37 -0
  68. package/js/test/rubik-components.input-motion.test.ts +60 -0
  69. package/js/test/rubik-components.lists.test.ts +49 -0
  70. package/js/test/rubik-components.operators.test.ts +104 -0
  71. package/js/test/rubik-components.pen.test.ts +112 -0
  72. package/js/test/rubik-components.procedures-loops.test.ts +72 -0
  73. package/js/test/rubik-components.variables-branches.test.ts +57 -0
  74. package/js/test/rubik-components.visibility-entry.test.ts +31 -0
  75. package/js/test/test-projects.ts +598 -0
  76. package/js/test/variable.ts +200 -0
  77. package/js/test/warp.test.ts +59 -0
  78. package/js/vm/AGENTS.md +6 -0
  79. package/js/vm/README.md +183 -0
  80. package/js/vm/bindings.test.ts +13 -0
  81. package/js/vm/bindings.ts +5 -0
  82. package/js/vm/compare-operators.test.ts +145 -0
  83. package/js/vm/constants.test.ts +11 -0
  84. package/js/vm/constants.ts +4 -0
  85. package/js/vm/effect-guards.test.ts +68 -0
  86. package/js/vm/effect-guards.ts +44 -0
  87. package/js/vm/factory.test.ts +486 -0
  88. package/js/vm/factory.ts +615 -0
  89. package/js/vm/headless-vm.test.ts +131 -0
  90. package/js/vm/headless-vm.ts +342 -0
  91. package/js/vm/index.test.ts +28 -0
  92. package/js/vm/index.ts +5 -0
  93. package/js/vm/internal-types.ts +32 -0
  94. package/js/vm/json.test.ts +40 -0
  95. package/js/vm/json.ts +273 -0
  96. package/js/vm/normalize.test.ts +48 -0
  97. package/js/vm/normalize.ts +65 -0
  98. package/js/vm/options.test.ts +30 -0
  99. package/js/vm/options.ts +55 -0
  100. package/js/vm/pen-transparency.test.ts +115 -0
  101. package/js/vm/program-wasm.ts +322 -0
  102. package/js/vm/scheduler-render.test.ts +401 -0
  103. package/js/vm/scratch-assets.test.ts +136 -0
  104. package/js/vm/scratch-assets.ts +202 -0
  105. package/js/vm/types.ts +358 -0
  106. package/js/vm/value-guards.test.ts +25 -0
  107. package/js/vm/value-guards.ts +18 -0
  108. package/moon.mod.json +10 -0
  109. package/package.json +33 -0
  110. package/scripts/preinstall.ts +4 -0
  111. package/src/AGENTS.md +6 -0
  112. package/src/api.mbt +161 -0
  113. package/src/api_aot_commands.mbt +184 -0
  114. package/src/api_effects_json.mbt +72 -0
  115. package/src/api_options.mbt +60 -0
  116. package/src/api_program_wasm.mbt +1647 -0
  117. package/src/api_program_wat.mbt +2206 -0
  118. package/src/api_snapshot_json.mbt +44 -0
  119. package/src/cmd/AGENTS.md +5 -0
  120. package/src/cmd/main/AGENTS.md +5 -0
  121. package/src/cmd/main/main.mbt +29 -0
  122. package/src/cmd/main/moon.pkg +7 -0
  123. package/src/cmd/main/pkg.generated.mbti +13 -0
  124. package/src/json_helpers.mbt +176 -0
  125. package/src/moon.pkg +65 -0
  126. package/src/moonscratch.mbt +3 -0
  127. package/src/moonscratch_wbtest.mbt +40 -0
  128. package/src/parser_sb3.mbt +890 -0
  129. package/src/pkg.generated.mbti +479 -0
  130. package/src/runtime_eval.mbt +2844 -0
  131. package/src/runtime_exec.mbt +3850 -0
  132. package/src/runtime_render.mbt +2550 -0
  133. package/src/runtime_state.mbt +870 -0
  134. package/src/test/AGENTS.md +3 -0
  135. package/src/test/projects/AGENTS.md +6 -0
  136. package/src/test/projects/moon.pkg +4 -0
  137. package/src/test/projects/moonscratch_compat_test.mbt +642 -0
  138. package/src/test/projects/moonscratch_core_test.mbt +1332 -0
  139. package/src/test/projects/moonscratch_runtime_test.mbt +1087 -0
  140. package/src/test/projects/pkg.generated.mbti +13 -0
  141. package/src/test/projects/test_support.mbt +35 -0
  142. package/src/types_effects.mbt +20 -0
  143. package/src/types_error.mbt +4 -0
  144. package/src/types_options.mbt +31 -0
  145. package/src/types_runtime_structs.mbt +254 -0
  146. package/src/types_vm.mbt +109 -0
  147. package/tsconfig.json +29 -0
  148. package/viewer/index.ts +399 -0
  149. package/viewer/vite.d.ts +1 -0
  150. package/viewer/worker.ts +161 -0
  151. package/vite.config.ts +11 -0
@@ -0,0 +1,136 @@
1
+ import { describe, expect, test, vi } from 'vite-plus/test'
2
+
3
+ import { resolveMissingScratchAssets } from './scratch-assets.ts'
4
+
5
+ const RGBA_ASSET = {
6
+ width: 1,
7
+ height: 1,
8
+ rgbaBase64: 'AP8A/w==',
9
+ }
10
+
11
+ const STAGE_PROJECT_WITH_COSTUMES = {
12
+ targets: [
13
+ {
14
+ isStage: true,
15
+ name: 'Stage',
16
+ variables: {},
17
+ lists: {},
18
+ blocks: {},
19
+ costumes: [
20
+ {
21
+ name: 'backdrop1',
22
+ assetId: 'bg_green',
23
+ md5ext: 'bg_green.png',
24
+ },
25
+ ],
26
+ },
27
+ ],
28
+ }
29
+
30
+ describe('moonscratch/js/vm/scratch-assets.ts', () => {
31
+ test('downloads missing assets from Scratch CDN and maps by assetId + md5ext', async () => {
32
+ const fetchAsset = vi.fn(async () => ({
33
+ ok: true,
34
+ status: 200,
35
+ statusText: 'OK',
36
+ arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
37
+ }))
38
+ const decodeImageBytes = vi.fn(async () => RGBA_ASSET)
39
+
40
+ const assets = await resolveMissingScratchAssets({
41
+ projectJson: STAGE_PROJECT_WITH_COSTUMES,
42
+ fetchAsset,
43
+ decodeImageBytes,
44
+ })
45
+
46
+ expect(fetchAsset).toHaveBeenCalledWith(
47
+ 'https://cdn.scratch.mit.edu/internalapi/asset/bg_green.png/get/',
48
+ )
49
+ expect(decodeImageBytes).toHaveBeenCalledTimes(1)
50
+ expect(assets.bg_green).toEqual(RGBA_ASSET)
51
+ expect(assets['bg_green.png']).toEqual(RGBA_ASSET)
52
+ })
53
+
54
+ test('does not download when existing asset is keyed by md5ext', async () => {
55
+ const fetchAsset = vi.fn(async () => ({
56
+ ok: true,
57
+ status: 200,
58
+ statusText: 'OK',
59
+ arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
60
+ }))
61
+
62
+ const assets = await resolveMissingScratchAssets({
63
+ projectJson: STAGE_PROJECT_WITH_COSTUMES,
64
+ assets: {
65
+ 'bg_green.png': RGBA_ASSET,
66
+ },
67
+ fetchAsset,
68
+ })
69
+
70
+ expect(fetchAsset).not.toHaveBeenCalled()
71
+ expect(assets.bg_green).toEqual(RGBA_ASSET)
72
+ expect(assets['bg_green.png']).toEqual(RGBA_ASSET)
73
+ })
74
+
75
+ test('deduplicates repeated md5ext downloads', async () => {
76
+ const fetchAsset = vi.fn(async () => ({
77
+ ok: true,
78
+ status: 200,
79
+ statusText: 'OK',
80
+ arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
81
+ }))
82
+ const decodeImageBytes = vi.fn(async () => RGBA_ASSET)
83
+
84
+ const assets = await resolveMissingScratchAssets({
85
+ projectJson: {
86
+ targets: [
87
+ {
88
+ isStage: true,
89
+ name: 'Stage',
90
+ variables: {},
91
+ lists: {},
92
+ blocks: {},
93
+ costumes: [
94
+ {
95
+ name: 'backdrop1',
96
+ assetId: 'bg_green',
97
+ md5ext: 'bg_green.png',
98
+ },
99
+ {
100
+ name: 'backdrop2',
101
+ assetId: 'bg_green_2',
102
+ md5ext: 'bg_green.png',
103
+ },
104
+ ],
105
+ },
106
+ ],
107
+ },
108
+ fetchAsset,
109
+ decodeImageBytes,
110
+ })
111
+
112
+ expect(fetchAsset).toHaveBeenCalledTimes(1)
113
+ expect(decodeImageBytes).toHaveBeenCalledTimes(1)
114
+ expect(assets.bg_green).toEqual(RGBA_ASSET)
115
+ expect(assets.bg_green_2).toEqual(RGBA_ASSET)
116
+ expect(assets['bg_green.png']).toEqual(RGBA_ASSET)
117
+ })
118
+
119
+ test('throws when asset download fails', async () => {
120
+ const fetchAsset = vi.fn(async () => ({
121
+ ok: false,
122
+ status: 404,
123
+ statusText: 'Not Found',
124
+ arrayBuffer: async () => new ArrayBuffer(0),
125
+ }))
126
+
127
+ await expect(
128
+ resolveMissingScratchAssets({
129
+ projectJson: STAGE_PROJECT_WITH_COSTUMES,
130
+ fetchAsset,
131
+ }),
132
+ ).rejects.toThrow(
133
+ 'failed to download Scratch asset bg_green.png: 404 Not Found',
134
+ )
135
+ })
136
+ })
@@ -0,0 +1,202 @@
1
+ import { fromImageBytes } from '../assets/create.ts'
2
+ import { parseJson } from './json.ts'
3
+ import type {
4
+ DecodeImageBytes,
5
+ FetchAsset,
6
+ JsonValue,
7
+ ProjectJson,
8
+ ResolveMissingScratchAssetsOptions,
9
+ } from './types.ts'
10
+ import { isObjectRecord } from './value-guards.ts'
11
+
12
+ const DEFAULT_SCRATCH_CDN_BASE_URL =
13
+ 'https://cdn.scratch.mit.edu/internalapi/asset'
14
+
15
+ type AssetObject = {
16
+ width: number
17
+ height: number
18
+ rgbaBase64: string
19
+ }
20
+
21
+ type CostumeRef = {
22
+ assetId: string
23
+ md5ext: string
24
+ }
25
+
26
+ const hasOwn = (record: Record<string, JsonValue>, key: string): boolean =>
27
+ Object.hasOwn(record, key)
28
+
29
+ const normalizeAssets = (
30
+ assets: ResolveMissingScratchAssetsOptions['assets'],
31
+ ): Record<string, JsonValue> => {
32
+ if (assets === undefined) {
33
+ return {}
34
+ }
35
+
36
+ if (typeof assets === 'string') {
37
+ const parsed = parseJson<unknown>(assets, 'assets')
38
+ if (!isObjectRecord(parsed)) {
39
+ throw new Error('assets must be a JSON object')
40
+ }
41
+ return { ...(parsed as Record<string, JsonValue>) }
42
+ }
43
+
44
+ return { ...assets }
45
+ }
46
+
47
+ const normalizeProject = (
48
+ projectJson: string | ProjectJson,
49
+ ): Record<string, unknown> => {
50
+ const parsed =
51
+ typeof projectJson === 'string'
52
+ ? parseJson<unknown>(projectJson, 'projectJson')
53
+ : projectJson
54
+ if (!isObjectRecord(parsed)) {
55
+ throw new Error('projectJson must be a JSON object')
56
+ }
57
+ return parsed
58
+ }
59
+
60
+ const collectCostumeRefs = (project: Record<string, unknown>): CostumeRef[] => {
61
+ const refs: CostumeRef[] = []
62
+ const targets = project.targets
63
+ if (!Array.isArray(targets)) {
64
+ return refs
65
+ }
66
+
67
+ for (const target of targets) {
68
+ if (!isObjectRecord(target)) {
69
+ continue
70
+ }
71
+ const costumes = target.costumes
72
+ if (!Array.isArray(costumes)) {
73
+ continue
74
+ }
75
+
76
+ for (const costume of costumes) {
77
+ if (!isObjectRecord(costume)) {
78
+ continue
79
+ }
80
+ const rawMd5ext = costume.md5ext
81
+ const md5ext = typeof rawMd5ext === 'string' ? rawMd5ext.trim() : ''
82
+ if (md5ext.length === 0) {
83
+ continue
84
+ }
85
+ const rawAssetId = costume.assetId
86
+ const assetId = typeof rawAssetId === 'string' ? rawAssetId.trim() : ''
87
+ refs.push({ assetId, md5ext })
88
+ }
89
+ }
90
+
91
+ return refs
92
+ }
93
+
94
+ const normalizeCdnBaseUrl = (input: string | undefined): string => {
95
+ const base = (input ?? DEFAULT_SCRATCH_CDN_BASE_URL).trim()
96
+ if (base.length === 0) {
97
+ throw new Error('scratchCdnBaseUrl must not be empty')
98
+ }
99
+ return base.endsWith('/') ? base.slice(0, -1) : base
100
+ }
101
+
102
+ const defaultFetchAsset = (): FetchAsset => {
103
+ const fetchAsset = (globalThis as { fetch?: unknown }).fetch
104
+ if (typeof fetchAsset !== 'function') {
105
+ throw new Error('global fetch is not available; pass fetchAsset option')
106
+ }
107
+ return fetchAsset as FetchAsset
108
+ }
109
+
110
+ const validateDecodedAsset = (
111
+ md5ext: string,
112
+ decoded: AssetObject,
113
+ ): AssetObject => {
114
+ if (!Number.isFinite(decoded.width) || decoded.width <= 0) {
115
+ throw new Error(`decodeImageBytes returned invalid width for ${md5ext}`)
116
+ }
117
+ if (!Number.isFinite(decoded.height) || decoded.height <= 0) {
118
+ throw new Error(`decodeImageBytes returned invalid height for ${md5ext}`)
119
+ }
120
+ if (
121
+ typeof decoded.rgbaBase64 !== 'string' ||
122
+ decoded.rgbaBase64.length === 0
123
+ ) {
124
+ throw new Error(
125
+ `decodeImageBytes returned invalid rgbaBase64 for ${md5ext}`,
126
+ )
127
+ }
128
+ return decoded
129
+ }
130
+
131
+ const toJsonAsset = (asset: AssetObject): Record<string, JsonValue> => ({
132
+ width: asset.width,
133
+ height: asset.height,
134
+ rgbaBase64: asset.rgbaBase64,
135
+ })
136
+
137
+ const buildAssetUrl = (baseUrl: string, md5ext: string): string =>
138
+ `${baseUrl}/${encodeURIComponent(md5ext)}/get/`
139
+
140
+ export const resolveMissingScratchAssets = async ({
141
+ projectJson,
142
+ assets,
143
+ scratchCdnBaseUrl,
144
+ fetchAsset,
145
+ decodeImageBytes,
146
+ }: ResolveMissingScratchAssetsOptions): Promise<Record<string, JsonValue>> => {
147
+ const resolvedAssets = normalizeAssets(assets)
148
+ const refs = collectCostumeRefs(normalizeProject(projectJson))
149
+ if (refs.length === 0) {
150
+ return resolvedAssets
151
+ }
152
+
153
+ const baseUrl = normalizeCdnBaseUrl(scratchCdnBaseUrl)
154
+ const fetchFn = fetchAsset ?? defaultFetchAsset()
155
+ const decodeFn: DecodeImageBytes = decodeImageBytes ?? fromImageBytes
156
+ const cache = new Map<string, Record<string, JsonValue>>()
157
+
158
+ for (const { assetId, md5ext } of refs) {
159
+ if (assetId.length > 0 && hasOwn(resolvedAssets, assetId)) {
160
+ const asset = resolvedAssets[assetId]
161
+ if (asset === undefined) {
162
+ continue
163
+ }
164
+ if (!hasOwn(resolvedAssets, md5ext)) {
165
+ resolvedAssets[md5ext] = asset
166
+ }
167
+ continue
168
+ }
169
+ if (hasOwn(resolvedAssets, md5ext)) {
170
+ const asset = resolvedAssets[md5ext]
171
+ if (asset === undefined) {
172
+ continue
173
+ }
174
+ if (assetId.length > 0 && !hasOwn(resolvedAssets, assetId)) {
175
+ resolvedAssets[assetId] = asset
176
+ }
177
+ continue
178
+ }
179
+
180
+ let loaded = cache.get(md5ext)
181
+ if (!loaded) {
182
+ const url = buildAssetUrl(baseUrl, md5ext)
183
+ const response = await fetchFn(url)
184
+ if (!response.ok) {
185
+ throw new Error(
186
+ `failed to download Scratch asset ${md5ext}: ${response.status} ${response.statusText}`,
187
+ )
188
+ }
189
+ const bytes = await response.arrayBuffer()
190
+ const decoded = validateDecodedAsset(md5ext, await decodeFn(bytes))
191
+ loaded = toJsonAsset(decoded)
192
+ cache.set(md5ext, loaded)
193
+ }
194
+
195
+ resolvedAssets[md5ext] = loaded
196
+ if (assetId.length > 0) {
197
+ resolvedAssets[assetId] = loaded
198
+ }
199
+ }
200
+
201
+ return resolvedAssets
202
+ }
package/js/vm/types.ts ADDED
@@ -0,0 +1,358 @@
1
+ import type { ScratchProject } from 'sb3-types'
2
+
3
+ type MaybePromise<T> = T | Promise<T>
4
+
5
+ export type JsonPrimitive = string | number | boolean | null
6
+ export type JsonValue =
7
+ | JsonPrimitive
8
+ | { [key: string]: JsonValue }
9
+ | JsonValue[]
10
+ export type ProjectJson = JsonValue | ScratchProject
11
+
12
+ export interface VMOptions {
13
+ turbo: boolean
14
+ compatibility30tps: boolean
15
+ maxClones: number
16
+ deterministic: boolean
17
+ seed: number
18
+ penWidth: number
19
+ penHeight: number
20
+ stepTimeoutTicks: number
21
+ }
22
+
23
+ export interface VMOptionsInput extends Partial<VMOptions> {
24
+ compatibility_30tps?: boolean
25
+ max_clones?: number
26
+ pen_width?: number
27
+ pen_height?: number
28
+ step_timeout_ticks?: number
29
+ stepTimeoutTicks?: number
30
+ }
31
+
32
+ export type FrameStopReason = 'finished' | 'timeout' | 'rerender' | 'warp-exit'
33
+
34
+ export interface FrameReport {
35
+ activeThreads: number
36
+ ticks: number
37
+ ops: number
38
+ emittedEffects: number
39
+ stopReason: FrameStopReason
40
+ shouldRender: boolean
41
+ isInWarp: boolean
42
+ }
43
+
44
+ export type RunEndedBy = 'idle' | 'frame_limit'
45
+
46
+ export interface RunReport {
47
+ frames: number
48
+ ticks: number
49
+ ops: number
50
+ activeThreads: number
51
+ endedBy: RunEndedBy
52
+ }
53
+
54
+ export interface RunUntilIdleOptions {
55
+ maxFrames?: number
56
+ }
57
+
58
+ export type VMInputEvent =
59
+ | {
60
+ type: 'answer'
61
+ answer: string
62
+ }
63
+ | {
64
+ type: 'mouse'
65
+ x: number
66
+ y: number
67
+ isDown?: boolean
68
+ }
69
+ | {
70
+ type: 'keys_down'
71
+ keys: string[]
72
+ }
73
+ | {
74
+ type: 'touching'
75
+ touching: Record<string, string[]>
76
+ }
77
+ | {
78
+ type: 'mouse_targets'
79
+ stage?: boolean
80
+ target?: string
81
+ targets?: string[]
82
+ }
83
+ | {
84
+ type: 'backdrop'
85
+ backdrop: string | string[]
86
+ }
87
+
88
+ export type {
89
+ RenderFrame,
90
+ RenderFrameLike,
91
+ RenderImageData,
92
+ RenderWithSharpOptions,
93
+ RenderWithWebGLOptions,
94
+ WebGLRenderResult,
95
+ } from '../render/types.ts'
96
+
97
+ export interface VMSnapshotTarget {
98
+ id: string
99
+ name: string
100
+ isStage: boolean
101
+ x: number
102
+ y: number
103
+ direction: number
104
+ size: number
105
+ volume: number
106
+ musicInstrument: number
107
+ textToSpeechVoice: string
108
+ visible: boolean
109
+ currentCostume: number
110
+ variables: Record<string, JsonValue>
111
+ lists: Record<string, JsonValue[]>
112
+ }
113
+
114
+ export interface VMSnapshot {
115
+ runId: number
116
+ nowMs: number
117
+ running: boolean
118
+ answer: string
119
+ musicTempo: number
120
+ textToSpeechLanguage: string
121
+ activeThreads: number
122
+ targets: VMSnapshotTarget[]
123
+ }
124
+
125
+ export interface PlaySoundEffect {
126
+ type: 'play_sound'
127
+ target: string
128
+ sound: string
129
+ }
130
+
131
+ export interface MusicPlayNoteEffect {
132
+ type: 'music_play_note'
133
+ target: string
134
+ note: number
135
+ beats: number
136
+ instrument: number
137
+ tempo: number
138
+ }
139
+
140
+ export interface MusicPlayDrumEffect {
141
+ type: 'music_play_drum'
142
+ target: string
143
+ drum: number
144
+ beats: number
145
+ tempo: number
146
+ }
147
+
148
+ export interface TextToSpeechEffect {
149
+ type: 'text_to_speech'
150
+ target: string
151
+ words: string
152
+ voice: string
153
+ language: string
154
+ waitKey: string
155
+ }
156
+
157
+ export interface TranslateRequestEffect {
158
+ type: 'translate_request'
159
+ words: string
160
+ language: string
161
+ }
162
+
163
+ export interface StopAllSoundsEffect {
164
+ type: 'stop_all_sounds'
165
+ }
166
+
167
+ export interface SayEffect {
168
+ type: 'say'
169
+ target: string
170
+ message: string
171
+ }
172
+
173
+ export interface ThinkEffect {
174
+ type: 'think'
175
+ target: string
176
+ message: string
177
+ }
178
+
179
+ export interface AskEffect {
180
+ type: 'ask'
181
+ question: string
182
+ }
183
+
184
+ export interface BroadcastEffect {
185
+ type: 'broadcast'
186
+ message: string
187
+ }
188
+
189
+ export interface LogEffect {
190
+ type: 'log'
191
+ level: string
192
+ message: string
193
+ }
194
+
195
+ export interface UnknownEffect {
196
+ type: string
197
+ [key: string]: JsonValue
198
+ }
199
+
200
+ type KnownEffect =
201
+ | PlaySoundEffect
202
+ | MusicPlayNoteEffect
203
+ | MusicPlayDrumEffect
204
+ | TextToSpeechEffect
205
+ | TranslateRequestEffect
206
+ | StopAllSoundsEffect
207
+ | SayEffect
208
+ | ThinkEffect
209
+ | AskEffect
210
+ | BroadcastEffect
211
+ | LogEffect
212
+
213
+ export type VMEffect = KnownEffect | UnknownEffect
214
+
215
+ export type TranslateCache = Record<string, Record<string, string>>
216
+
217
+ export interface EffectHandlers {
218
+ translate?: (
219
+ effect: TranslateRequestEffect,
220
+ ) => MaybePromise<string | null | undefined>
221
+ textToSpeech?: (effect: TextToSpeechEffect) => MaybePromise<void>
222
+ musicNote?: (effect: MusicPlayNoteEffect) => MaybePromise<void>
223
+ musicDrum?: (effect: MusicPlayDrumEffect) => MaybePromise<void>
224
+ effect?: (effect: VMEffect) => MaybePromise<void>
225
+ }
226
+
227
+ export interface CompileProjectToWatOptions {
228
+ projectJson: string | ProjectJson
229
+ assets?: string | Record<string, JsonValue>
230
+ }
231
+
232
+ export interface ProgramManifest {
233
+ abiVersion: number
234
+ opcodeSetVersion: number
235
+ projectByteLength: number
236
+ assetsByteLength: number
237
+ buildFingerprint: string
238
+ }
239
+
240
+ export interface CompileProjectToWatResult {
241
+ wat: string
242
+ abiVersion: number
243
+ manifest: ProgramManifest
244
+ }
245
+
246
+ export type ProgramWasmBytes = Uint8Array | ArrayBuffer
247
+
248
+ export type WatToWasm = (wat: string) => ProgramWasmBytes
249
+
250
+ export interface CompileProjectToWasmOptions
251
+ extends CompileProjectToWatOptions {
252
+ watToWasm?: WatToWasm
253
+ }
254
+
255
+ export interface CompileProjectToWasmResult extends CompileProjectToWatResult {
256
+ wasmBytes: Uint8Array
257
+ }
258
+
259
+ export interface RuntimeHandle {
260
+ raw: unknown
261
+ abiVersion: number
262
+ }
263
+
264
+ export interface ProgramPayload {
265
+ projectJson: string
266
+ assetsJson: string
267
+ commandsJson?: string
268
+ }
269
+
270
+ export interface ProgramWasmExecHost {
271
+ getVarNumber: (targetIndex: number, variableId: string) => number
272
+ setVarNumber: (targetIndex: number, variableId: string, value: number) => void
273
+ setVarJson: (
274
+ targetIndex: number,
275
+ variableId: string,
276
+ valueJson: string,
277
+ ) => void
278
+ execHostOpcode: (targetIndex: number, pc: number) => number
279
+ execHostTail: (targetIndex: number, startPc: number) => number
280
+ execDrawOpcode: (
281
+ targetIndex: number,
282
+ opcode: string,
283
+ arg0: number,
284
+ arg1: number,
285
+ extra: number,
286
+ ) => number
287
+ }
288
+
289
+ export type ProgramWasmExecRunner = () => number
290
+
291
+ export interface ProgramModule {
292
+ raw: WebAssembly.Instance
293
+ abiVersion: number
294
+ manifest: ProgramManifest
295
+ readPayload: () => ProgramPayload
296
+ hasWasmExec: () => boolean
297
+ createWasmExecRunner: (
298
+ host: ProgramWasmExecHost,
299
+ ) => ProgramWasmExecRunner | null
300
+ }
301
+
302
+ export interface CreateProgramModuleOptions {
303
+ wasmBytes: ProgramWasmBytes
304
+ manifest?: ProgramManifest
305
+ }
306
+
307
+ export interface CreateProgramModuleFromProjectOptions
308
+ extends CompileProjectToWasmOptions {}
309
+
310
+ export interface PrecompileProgramForRuntimeOptions {
311
+ program: ProgramModule
312
+ runtime?: RuntimeHandle
313
+ }
314
+
315
+ export interface CreateHeadlessVMOptions {
316
+ program: ProgramModule
317
+ runtime?: RuntimeHandle
318
+ options?: string | VMOptionsInput
319
+ initialNowMs?: number
320
+ viewerLanguage?: string
321
+ translateCache?: TranslateCache
322
+ }
323
+
324
+ export interface CreateHeadlessVMFromProjectOptions
325
+ extends Omit<CreateHeadlessVMOptions, 'program'>,
326
+ CreateProgramModuleFromProjectOptions {}
327
+
328
+ export interface ScratchAssetResponse {
329
+ ok: boolean
330
+ status: number
331
+ statusText: string
332
+ arrayBuffer(): Promise<ArrayBuffer>
333
+ }
334
+
335
+ export type FetchAsset = (url: string) => Promise<ScratchAssetResponse>
336
+
337
+ export type DecodeImageBytes = (bytes: ArrayBuffer | Uint8Array) => Promise<{
338
+ width: number
339
+ height: number
340
+ rgbaBase64: string
341
+ }>
342
+
343
+ export interface ResolveMissingScratchAssetsOptions {
344
+ projectJson: string | ProjectJson
345
+ assets?: string | Record<string, JsonValue>
346
+ scratchCdnBaseUrl?: string
347
+ fetchAsset?: FetchAsset
348
+ decodeImageBytes?: DecodeImageBytes
349
+ }
350
+
351
+ export interface CreateHeadlessVMWithScratchAssetsOptions
352
+ extends Omit<CreateHeadlessVMFromProjectOptions, 'assets'> {
353
+ projectJson: string | ProjectJson
354
+ assets?: string | Record<string, JsonValue>
355
+ scratchCdnBaseUrl?: string
356
+ fetchAsset?: FetchAsset
357
+ decodeImageBytes?: DecodeImageBytes
358
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, test } from 'vite-plus/test'
2
+
3
+ import {
4
+ hasNumberField,
5
+ hasStringField,
6
+ isObjectRecord,
7
+ } from './value-guards.ts'
8
+
9
+ describe('moonscratch/js/vm/value-guards.ts', () => {
10
+ test('isObjectRecord identifies plain objects', () => {
11
+ expect(isObjectRecord({ foo: 'bar' })).toBe(true)
12
+ expect(isObjectRecord(null)).toBe(false)
13
+ expect(isObjectRecord([])).toBe(false)
14
+ })
15
+
16
+ test('hasStringField validates string fields', () => {
17
+ expect(hasStringField({ name: 'Sprite1' }, 'name')).toBe(true)
18
+ expect(hasStringField({ name: 1 }, 'name')).toBe(false)
19
+ })
20
+
21
+ test('hasNumberField validates number fields', () => {
22
+ expect(hasNumberField({ beats: 1.5 }, 'beats')).toBe(true)
23
+ expect(hasNumberField({ beats: '1.5' }, 'beats')).toBe(false)
24
+ })
25
+ })