diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f06235c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+node_modules
+dist
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..d0d4b6f
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,10 @@
+FROM node:22-alpine AS build
+WORKDIR /app
+COPY package*.json ./
+RUN npm ci
+COPY . .
+RUN npm run build
+
+FROM nginx:alpine
+COPY --from=build /app/dist /usr/share/nginx/html
+EXPOSE 80
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..96a93c6
--- /dev/null
+++ b/index.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+ Quantum Runner
+
+
+
+
+
+
+
+
+ Turn: 0
+ Future: 0/0
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..f808e12
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1119 @@
+{
+ "name": "quantum-runner",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "quantum-runner",
+ "version": "1.0.0",
+ "devDependencies": {
+ "typescript": "~5.6.0",
+ "vite": "^6.0.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
+ "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
+ "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
+ "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
+ "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
+ "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
+ "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
+ "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
+ "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
+ "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
+ "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
+ "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
+ "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
+ "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
+ "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
+ "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
+ "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
+ "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
+ "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
+ "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
+ "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
+ "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
+ "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
+ "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.8",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+ "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
+ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.59.0",
+ "@rollup/rollup-android-arm64": "4.59.0",
+ "@rollup/rollup-darwin-arm64": "4.59.0",
+ "@rollup/rollup-darwin-x64": "4.59.0",
+ "@rollup/rollup-freebsd-arm64": "4.59.0",
+ "@rollup/rollup-freebsd-x64": "4.59.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.59.0",
+ "@rollup/rollup-linux-arm64-musl": "4.59.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.59.0",
+ "@rollup/rollup-linux-loong64-musl": "4.59.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.59.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.59.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-musl": "4.59.0",
+ "@rollup/rollup-openbsd-x64": "4.59.0",
+ "@rollup/rollup-openharmony-arm64": "4.59.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.59.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.59.0",
+ "@rollup/rollup-win32-x64-gnu": "4.59.0",
+ "@rollup/rollup-win32-x64-msvc": "4.59.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.6.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
+ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
+ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..81d7501
--- /dev/null
+++ b/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "quantum-runner",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "typescript": "~5.6.0",
+ "vite": "^6.0.0"
+ }
+}
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..b5e1b74
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,35 @@
+export const CONFIG = {
+ // Grid
+ GRID_COLS: 12,
+ GRID_ROWS: 10,
+ CELL_SIZE: 52,
+
+ // Futures
+ NUM_FUTURES: 3,
+ MIN_FUTURES: 2,
+ MAX_FUTURES: 5,
+
+ // Enemy AI
+ ENEMY_CHASE_BIAS: 0.6,
+ ENEMY_RANDOM_MOVE_CHANCE: 0.25,
+ ENEMY_STAY_CHANCE: 0.15,
+
+ // Player movement
+ PLAYER_GOAL_BIAS: 0.3,
+ PLAYER_STAY_CHANCE: 0.1,
+
+ // Colors
+ COLOR_BG: '#0a0a0f',
+ COLOR_GRID: '#1a3a2a',
+ COLOR_PLAYER: '#00ffcc',
+ COLOR_ENEMY: '#ff6b35',
+ COLOR_GOAL: '#39ff14',
+ COLOR_WALL: '#2a2a3a',
+ COLOR_FLOOR: '#12121f',
+ COLOR_DANGER: '#ff1744',
+ COLOR_UI_TEXT: '#b0ffb0',
+ COLOR_ACCENT: '#ffeb3b',
+ COLOR_FUTURE_GOOD: 'rgba(0, 255, 204, 0.35)',
+ COLOR_FUTURE_BAD: 'rgba(255, 23, 68, 0.35)',
+ COLOR_FUTURE_NEUTRAL: 'rgba(255, 235, 59, 0.35)',
+} as const;
diff --git a/src/engine/EnemyAI.ts b/src/engine/EnemyAI.ts
new file mode 100644
index 0000000..fdb9bee
--- /dev/null
+++ b/src/engine/EnemyAI.ts
@@ -0,0 +1,19 @@
+import { Entity, CellType } from '../types';
+import { CONFIG } from '../config';
+import { sampleEnemyMove } from './movement';
+
+export function moveEnemy(
+ enemy: Entity,
+ playerPos: { x: number; y: number },
+ grid: CellType[][],
+): Entity {
+ const newPos = sampleEnemyMove(
+ grid,
+ enemy,
+ playerPos,
+ CONFIG.ENEMY_CHASE_BIAS,
+ CONFIG.ENEMY_RANDOM_MOVE_CHANCE,
+ CONFIG.ENEMY_STAY_CHANCE,
+ );
+ return { ...enemy, x: newPos.x, y: newPos.y };
+}
diff --git a/src/engine/FuturesEngine.ts b/src/engine/FuturesEngine.ts
new file mode 100644
index 0000000..6e8fbb5
--- /dev/null
+++ b/src/engine/FuturesEngine.ts
@@ -0,0 +1,111 @@
+import { GameState, Future } from '../types';
+import { CONFIG } from '../config';
+import { cloneState } from '../state/GameState';
+import { samplePlayerMove } from './movement';
+import { moveEnemy } from './EnemyAI';
+import { manhattanDistance } from '../state/Grid';
+
+function generateOneFuture(state: GameState): Future {
+ const next = cloneState(state);
+ next.turn = state.turn + 1;
+
+ // Move player
+ const playerMove = samplePlayerMove(
+ next.grid,
+ state.player,
+ state.goalPos,
+ CONFIG.PLAYER_GOAL_BIAS,
+ CONFIG.PLAYER_STAY_CHANCE,
+ );
+ next.player.x = playerMove.x;
+ next.player.y = playerMove.y;
+
+ // Move enemies
+ next.enemies = state.enemies.map(e => moveEnemy(e, playerMove, next.grid));
+
+ // Check win/lose
+ if (next.player.x === next.goalPos.x && next.player.y === next.goalPos.y) {
+ next.status = 'won';
+ } else {
+ for (const enemy of next.enemies) {
+ if (enemy.x === next.player.x && enemy.y === next.player.y) {
+ next.status = 'lost';
+ break;
+ }
+ }
+ }
+
+ // Compute quality: based on distance to goal and proximity to enemies
+ const distToGoal = manhattanDistance(next.player, next.goalPos);
+ const prevDistToGoal = manhattanDistance(state.player, state.goalPos);
+ const goalImprovement = prevDistToGoal - distToGoal;
+
+ const minEnemyDist = Math.min(...next.enemies.map(e => manhattanDistance(next.player, e)));
+ const prevMinEnemyDist = Math.min(...state.enemies.map(e => manhattanDistance(state.player, e)));
+ const enemyChange = minEnemyDist - prevMinEnemyDist; // positive = enemies farther
+
+ let quality = 0;
+ if (next.status === 'won') quality = 1;
+ else if (next.status === 'lost') quality = -1;
+ else {
+ quality = (goalImprovement * 0.4 + enemyChange * 0.3) / 2;
+ quality = Math.max(-1, Math.min(1, quality));
+ }
+
+ // Description
+ let desc = '';
+ if (next.status === 'won') {
+ desc = 'You reach the exit! Victory!';
+ } else if (next.status === 'lost') {
+ desc = 'An enemy catches you! Captured!';
+ } else {
+ const dirX = playerMove.x - state.player.x;
+ const dirY = playerMove.y - state.player.y;
+ const moveDir =
+ dirX === 0 && dirY === 0 ? 'stays in place' :
+ dirX > 0 ? 'moves right' :
+ dirX < 0 ? 'moves left' :
+ dirY < 0 ? 'moves up' : 'moves down';
+ const dangerLevel = minEnemyDist <= 2 ? ' (danger nearby!)' : minEnemyDist <= 4 ? '' : ' (safe zone)';
+ desc = `Observer ${moveDir}${dangerLevel}`;
+ }
+
+ return { state: next, description: desc, quality };
+}
+
+export function generateFutures(state: GameState, count: number = CONFIG.NUM_FUTURES): Future[] {
+ const n = Math.max(CONFIG.MIN_FUTURES, Math.min(CONFIG.MAX_FUTURES, count));
+ const futures: Future[] = [];
+
+ // Generate candidates — oversample to ensure quality spread
+ const candidates: Future[] = [];
+ const attempts = n * 4;
+ for (let i = 0; i < attempts; i++) {
+ candidates.push(generateOneFuture(state));
+ }
+
+ // Sort by quality
+ candidates.sort((a, b) => b.quality - a.quality);
+
+ // Pick with spread: best, worst, and fill from middle
+ if (candidates.length >= n) {
+ futures.push(candidates[0]!); // best
+ futures.push(candidates[candidates.length - 1]!); // worst
+ // Fill remaining from evenly spaced positions
+ const remaining = n - 2;
+ for (let i = 0; i < remaining; i++) {
+ const idx = Math.floor((i + 1) * (candidates.length / (remaining + 1)));
+ futures.push(candidates[idx]!);
+ }
+ } else {
+ futures.push(...candidates);
+ }
+
+ // Shuffle so the player doesn't know best is always first
+ for (let i = futures.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [futures[i], futures[j]] = [futures[j]!, futures[i]!];
+ }
+
+ return futures;
+}
diff --git a/src/engine/movement.ts b/src/engine/movement.ts
new file mode 100644
index 0000000..cb40680
--- /dev/null
+++ b/src/engine/movement.ts
@@ -0,0 +1,74 @@
+import { Position, CellType } from '../types';
+import { getWalkableNeighbors, manhattanDistance } from '../state/Grid';
+
+/**
+ * Weighted random pick from an array of (item, weight) tuples.
+ */
+export function weightedPick(items: { item: T; weight: number }[]): T {
+ const total = items.reduce((s, i) => s + i.weight, 0);
+ let r = Math.random() * total;
+ for (const { item, weight } of items) {
+ r -= weight;
+ if (r <= 0) return item;
+ }
+ return items[items.length - 1]!.item;
+}
+
+/**
+ * Sample a player move biased toward a target (goal).
+ * Also includes a chance to stay in place.
+ */
+export function samplePlayerMove(
+ grid: CellType[][],
+ current: Position,
+ target: Position,
+ goalBias: number,
+ stayChance: number,
+): Position {
+ if (Math.random() < stayChance) return { ...current };
+
+ const neighbors = getWalkableNeighbors(grid, current);
+ if (neighbors.length === 0) return { ...current };
+
+ const currentDist = manhattanDistance(current, target);
+ const weighted = neighbors.map(n => {
+ const dist = manhattanDistance(n, target);
+ const improvement = currentDist - dist; // positive = closer to goal
+ const weight = improvement > 0 ? 1 + goalBias : improvement < 0 ? Math.max(0.1, 1 - goalBias) : 0.5;
+ return { item: n, weight };
+ });
+
+ return weightedPick(weighted);
+}
+
+/**
+ * Sample an enemy move biased toward chasing the player.
+ */
+export function sampleEnemyMove(
+ grid: CellType[][],
+ current: Position,
+ playerPos: Position,
+ chaseBias: number,
+ randomChance: number,
+ stayChance: number,
+): Position {
+ if (Math.random() < stayChance) return { ...current };
+
+ const neighbors = getWalkableNeighbors(grid, current);
+ if (neighbors.length === 0) return { ...current };
+
+ // Occasionally move randomly
+ if (Math.random() < randomChance) {
+ return neighbors[Math.floor(Math.random() * neighbors.length)]!;
+ }
+
+ const currentDist = manhattanDistance(current, playerPos);
+ const weighted = neighbors.map(n => {
+ const dist = manhattanDistance(n, playerPos);
+ const closer = currentDist - dist;
+ const weight = closer > 0 ? 1 + chaseBias : closer < 0 ? Math.max(0.1, 1 - chaseBias) : 0.5;
+ return { item: n, weight };
+ });
+
+ return weightedPick(weighted);
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..2609fdf
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,5 @@
+import './style.css';
+import { GameController } from './ui/GameController';
+
+const canvas = document.getElementById('game-canvas') as HTMLCanvasElement;
+new GameController(canvas);
diff --git a/src/render/EntityRenderer.ts b/src/render/EntityRenderer.ts
new file mode 100644
index 0000000..c021447
--- /dev/null
+++ b/src/render/EntityRenderer.ts
@@ -0,0 +1,80 @@
+import { Entity, Position } from '../types';
+import { CONFIG } from '../config';
+
+export function renderPlayer(ctx: CanvasRenderingContext2D, player: Entity): void {
+ const { CELL_SIZE, COLOR_PLAYER } = CONFIG;
+ const cx = player.x * CELL_SIZE + CELL_SIZE / 2;
+ const cy = player.y * CELL_SIZE + CELL_SIZE / 2;
+ const r = CELL_SIZE * 0.35;
+
+ // Glow
+ ctx.shadowColor = COLOR_PLAYER;
+ ctx.shadowBlur = 12;
+
+ // Diamond shape for player (the "observer")
+ ctx.fillStyle = COLOR_PLAYER;
+ ctx.beginPath();
+ ctx.moveTo(cx, cy - r);
+ ctx.lineTo(cx + r, cy);
+ ctx.lineTo(cx, cy + r);
+ ctx.lineTo(cx - r, cy);
+ ctx.closePath();
+ ctx.fill();
+
+ // Eye symbol in center
+ ctx.shadowBlur = 0;
+ ctx.fillStyle = CONFIG.COLOR_BG;
+ ctx.font = `bold ${CELL_SIZE * 0.3}px "JetBrains Mono", monospace`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText('◉', cx, cy);
+
+ ctx.shadowBlur = 0;
+}
+
+export function renderEnemy(ctx: CanvasRenderingContext2D, enemy: Entity): void {
+ const { CELL_SIZE, COLOR_ENEMY } = CONFIG;
+ const cx = enemy.x * CELL_SIZE + CELL_SIZE / 2;
+ const cy = enemy.y * CELL_SIZE + CELL_SIZE / 2;
+ const r = CELL_SIZE * 0.32;
+
+ // Glow
+ ctx.shadowColor = COLOR_ENEMY;
+ ctx.shadowBlur = 8;
+
+ // Triangle shape for enemies
+ ctx.fillStyle = COLOR_ENEMY;
+ ctx.beginPath();
+ ctx.moveTo(cx, cy - r);
+ ctx.lineTo(cx + r, cy + r * 0.8);
+ ctx.lineTo(cx - r, cy + r * 0.8);
+ ctx.closePath();
+ ctx.fill();
+
+ // X symbol
+ ctx.shadowBlur = 0;
+ ctx.fillStyle = CONFIG.COLOR_BG;
+ ctx.font = `bold ${CELL_SIZE * 0.25}px "JetBrains Mono", monospace`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText('✕', cx, cy + 2);
+
+ ctx.shadowBlur = 0;
+}
+
+export function renderGoalMarker(ctx: CanvasRenderingContext2D, goal: Position): void {
+ const { CELL_SIZE, COLOR_GOAL } = CONFIG;
+ const cx = goal.x * CELL_SIZE + CELL_SIZE / 2;
+ const cy = goal.y * CELL_SIZE + CELL_SIZE / 2;
+
+ // Pulsing ring
+ const time = Date.now() / 1000;
+ const pulse = 0.4 + 0.2 * Math.sin(time * 3);
+ ctx.globalAlpha = pulse;
+ ctx.strokeStyle = COLOR_GOAL;
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.arc(cx, cy, CELL_SIZE * 0.4, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.globalAlpha = 1;
+}
diff --git a/src/render/FutureOverlay.ts b/src/render/FutureOverlay.ts
new file mode 100644
index 0000000..e67df51
--- /dev/null
+++ b/src/render/FutureOverlay.ts
@@ -0,0 +1,133 @@
+import { GameState, Future } from '../types';
+import { CONFIG } from '../config';
+
+export function renderFutureOverlay(
+ ctx: CanvasRenderingContext2D,
+ current: GameState,
+ future: Future,
+): void {
+ const { CELL_SIZE } = CONFIG;
+ const next = future.state;
+
+ // Determine overlay color based on quality
+ let overlayColor: string;
+ if (future.quality > 0.15) {
+ overlayColor = CONFIG.COLOR_FUTURE_GOOD;
+ } else if (future.quality < -0.15) {
+ overlayColor = CONFIG.COLOR_FUTURE_BAD;
+ } else {
+ overlayColor = CONFIG.COLOR_FUTURE_NEUTRAL;
+ }
+
+ // Draw ghost player at future position
+ const fpx = next.player.x * CELL_SIZE + CELL_SIZE / 2;
+ const fpy = next.player.y * CELL_SIZE + CELL_SIZE / 2;
+ const cpx = current.player.x * CELL_SIZE + CELL_SIZE / 2;
+ const cpy = current.player.y * CELL_SIZE + CELL_SIZE / 2;
+
+ // Arrow from current to future player position
+ if (next.player.x !== current.player.x || next.player.y !== current.player.y) {
+ ctx.strokeStyle = CONFIG.COLOR_PLAYER;
+ ctx.globalAlpha = 0.6;
+ ctx.lineWidth = 2;
+ ctx.setLineDash([6, 4]);
+ ctx.beginPath();
+ ctx.moveTo(cpx, cpy);
+ ctx.lineTo(fpx, fpy);
+ ctx.stroke();
+ ctx.setLineDash([]);
+
+ // Arrowhead
+ const angle = Math.atan2(fpy - cpy, fpx - cpx);
+ const headLen = 10;
+ ctx.beginPath();
+ ctx.moveTo(fpx, fpy);
+ ctx.lineTo(fpx - headLen * Math.cos(angle - 0.4), fpy - headLen * Math.sin(angle - 0.4));
+ ctx.lineTo(fpx - headLen * Math.cos(angle + 0.4), fpy - headLen * Math.sin(angle + 0.4));
+ ctx.closePath();
+ ctx.fillStyle = CONFIG.COLOR_PLAYER;
+ ctx.fill();
+ ctx.globalAlpha = 1;
+ }
+
+ // Ghost player diamond
+ ctx.globalAlpha = 0.45;
+ ctx.fillStyle = CONFIG.COLOR_PLAYER;
+ const pr = CELL_SIZE * 0.3;
+ ctx.beginPath();
+ ctx.moveTo(fpx, fpy - pr);
+ ctx.lineTo(fpx + pr, fpy);
+ ctx.lineTo(fpx, fpy + pr);
+ ctx.lineTo(fpx - pr, fpy);
+ ctx.closePath();
+ ctx.fill();
+
+ // Dashed outline
+ ctx.strokeStyle = CONFIG.COLOR_PLAYER;
+ ctx.lineWidth = 2;
+ ctx.setLineDash([4, 4]);
+ ctx.beginPath();
+ ctx.moveTo(fpx, fpy - pr);
+ ctx.lineTo(fpx + pr, fpy);
+ ctx.lineTo(fpx, fpy + pr);
+ ctx.lineTo(fpx - pr, fpy);
+ ctx.closePath();
+ ctx.stroke();
+ ctx.setLineDash([]);
+ ctx.globalAlpha = 1;
+
+ // Ghost enemies
+ for (let i = 0; i < next.enemies.length; i++) {
+ const fe = next.enemies[i]!;
+ const ce = current.enemies[i];
+ const fex = fe.x * CELL_SIZE + CELL_SIZE / 2;
+ const fey = fe.y * CELL_SIZE + CELL_SIZE / 2;
+
+ // Arrow from current to future enemy
+ if (ce && (fe.x !== ce.x || fe.y !== ce.y)) {
+ const cex = ce.x * CELL_SIZE + CELL_SIZE / 2;
+ const cey = ce.y * CELL_SIZE + CELL_SIZE / 2;
+ ctx.strokeStyle = CONFIG.COLOR_ENEMY;
+ ctx.globalAlpha = 0.4;
+ ctx.lineWidth = 2;
+ ctx.setLineDash([4, 4]);
+ ctx.beginPath();
+ ctx.moveTo(cex, cey);
+ ctx.lineTo(fex, fey);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+
+ // Ghost enemy triangle
+ ctx.globalAlpha = 0.35;
+ ctx.fillStyle = CONFIG.COLOR_ENEMY;
+ const er = CELL_SIZE * 0.28;
+ ctx.beginPath();
+ ctx.moveTo(fex, fey - er);
+ ctx.lineTo(fex + er, fey + er * 0.8);
+ ctx.lineTo(fex - er, fey + er * 0.8);
+ ctx.closePath();
+ ctx.fill();
+
+ ctx.strokeStyle = CONFIG.COLOR_ENEMY;
+ ctx.lineWidth = 2;
+ ctx.setLineDash([3, 3]);
+ ctx.beginPath();
+ ctx.moveTo(fex, fey - er);
+ ctx.lineTo(fex + er, fey + er * 0.8);
+ ctx.lineTo(fex - er, fey + er * 0.8);
+ ctx.closePath();
+ ctx.stroke();
+ ctx.setLineDash([]);
+ ctx.globalAlpha = 1;
+ }
+
+ // Highlight the future player cell with quality-based overlay
+ ctx.fillStyle = overlayColor;
+ ctx.fillRect(
+ next.player.x * CELL_SIZE + 2,
+ next.player.y * CELL_SIZE + 2,
+ CELL_SIZE - 4,
+ CELL_SIZE - 4,
+ );
+}
diff --git a/src/render/GridRenderer.ts b/src/render/GridRenderer.ts
new file mode 100644
index 0000000..874007f
--- /dev/null
+++ b/src/render/GridRenderer.ts
@@ -0,0 +1,48 @@
+import { CellType } from '../types';
+import { CONFIG } from '../config';
+
+export function renderGrid(ctx: CanvasRenderingContext2D, grid: CellType[][]): void {
+ const { CELL_SIZE, COLOR_FLOOR, COLOR_WALL, COLOR_GOAL, COLOR_GRID } = CONFIG;
+
+ for (let y = 0; y < grid.length; y++) {
+ const row = grid[y]!;
+ for (let x = 0; x < row.length; x++) {
+ const cell = row[x]!;
+ const px = x * CELL_SIZE;
+ const py = y * CELL_SIZE;
+
+ // Fill cell
+ if (cell === 'wall') {
+ ctx.fillStyle = COLOR_WALL;
+ } else if (cell === 'goal') {
+ ctx.fillStyle = COLOR_GOAL;
+ ctx.globalAlpha = 0.25;
+ } else {
+ ctx.fillStyle = COLOR_FLOOR;
+ }
+ ctx.fillRect(px, py, CELL_SIZE, CELL_SIZE);
+ ctx.globalAlpha = 1;
+
+ // Grid lines
+ ctx.strokeStyle = COLOR_GRID;
+ ctx.lineWidth = 1;
+ ctx.strokeRect(px, py, CELL_SIZE, CELL_SIZE);
+
+ // Goal marker
+ if (cell === 'goal') {
+ ctx.fillStyle = CONFIG.COLOR_GOAL;
+ ctx.globalAlpha = 0.6;
+ const margin = CELL_SIZE * 0.2;
+ ctx.fillRect(px + margin, py + margin, CELL_SIZE - margin * 2, CELL_SIZE - margin * 2);
+ ctx.globalAlpha = 1;
+
+ // Draw "EXIT" text
+ ctx.fillStyle = CONFIG.COLOR_GOAL;
+ ctx.font = `bold ${CELL_SIZE * 0.25}px "JetBrains Mono", monospace`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText('EXIT', px + CELL_SIZE / 2, py + CELL_SIZE / 2);
+ }
+ }
+ }
+}
diff --git a/src/render/Renderer.ts b/src/render/Renderer.ts
new file mode 100644
index 0000000..5c6c939
--- /dev/null
+++ b/src/render/Renderer.ts
@@ -0,0 +1,74 @@
+import { GameState, Future } from '../types';
+import { CONFIG } from '../config';
+import { renderGrid } from './GridRenderer';
+import { renderPlayer, renderEnemy, renderGoalMarker } from './EntityRenderer';
+import { renderFutureOverlay } from './FutureOverlay';
+
+export class Renderer {
+ private ctx: CanvasRenderingContext2D;
+ private canvas: HTMLCanvasElement;
+ private animFrameId: number | null = null;
+
+ constructor(canvas: HTMLCanvasElement) {
+ this.canvas = canvas;
+ this.ctx = canvas.getContext('2d')!;
+ this.resize();
+ }
+
+ resize(): void {
+ this.canvas.width = CONFIG.GRID_COLS * CONFIG.CELL_SIZE;
+ this.canvas.height = CONFIG.GRID_ROWS * CONFIG.CELL_SIZE;
+ }
+
+ renderState(state: GameState, activeFuture?: Future): void {
+ const ctx = this.ctx;
+
+ // Clear
+ ctx.fillStyle = CONFIG.COLOR_BG;
+ ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
+
+ // Grid
+ renderGrid(ctx, state.grid);
+
+ // Goal pulsing marker
+ renderGoalMarker(ctx, state.goalPos);
+
+ // Entities
+ for (const enemy of state.enemies) {
+ renderEnemy(ctx, enemy);
+ }
+ renderPlayer(ctx, state.player);
+
+ // Future overlay
+ if (activeFuture) {
+ renderFutureOverlay(ctx, state, activeFuture);
+ }
+
+ // Scanline effect
+ this.renderScanlines();
+ }
+
+ private renderScanlines(): void {
+ const ctx = this.ctx;
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.04)';
+ for (let y = 0; y < this.canvas.height; y += 3) {
+ ctx.fillRect(0, y, this.canvas.width, 1);
+ }
+ }
+
+ startPulseAnimation(state: GameState, activeFuture?: Future): void {
+ this.stopAnimation();
+ const animate = () => {
+ this.renderState(state, activeFuture);
+ this.animFrameId = requestAnimationFrame(animate);
+ };
+ animate();
+ }
+
+ stopAnimation(): void {
+ if (this.animFrameId !== null) {
+ cancelAnimationFrame(this.animFrameId);
+ this.animFrameId = null;
+ }
+ }
+}
diff --git a/src/state/GameState.ts b/src/state/GameState.ts
new file mode 100644
index 0000000..60cbc59
--- /dev/null
+++ b/src/state/GameState.ts
@@ -0,0 +1,29 @@
+import { GameState, Entity } from '../types';
+import { createGrid, getGoalPosition } from './Grid';
+import { createPlayer, createEnemies } from './entities';
+
+export function createInitialState(): GameState {
+ return {
+ grid: createGrid(),
+ player: createPlayer(),
+ enemies: createEnemies(),
+ goalPos: getGoalPosition(),
+ turn: 0,
+ status: 'playing',
+ };
+}
+
+export function cloneState(state: GameState): GameState {
+ return {
+ grid: state.grid.map(row => [...row]),
+ player: { ...state.player },
+ enemies: state.enemies.map(e => ({ ...e })),
+ goalPos: { ...state.goalPos },
+ turn: state.turn,
+ status: state.status,
+ };
+}
+
+export function cloneEntity(entity: Entity): Entity {
+ return { ...entity };
+}
diff --git a/src/state/Grid.ts b/src/state/Grid.ts
new file mode 100644
index 0000000..21eecb0
--- /dev/null
+++ b/src/state/Grid.ts
@@ -0,0 +1,55 @@
+import { CellType, Position } from '../types';
+import { CONFIG } from '../config';
+
+const W: CellType = 'wall';
+const F: CellType = 'floor';
+const G: CellType = 'goal';
+
+// 12x10 grid — corridors with dead ends for interesting choices
+// Row 0 = top, Row 9 = bottom
+const LEVEL: CellType[][] = [
+ [W, W, W, W, W, W, W, W, W, W, W, W],
+ [W, F, F, F, W, F, F, F, F, F, G, W],
+ [W, F, W, F, W, F, W, W, W, F, F, W],
+ [W, F, W, F, F, F, F, F, W, F, W, W],
+ [W, F, W, W, W, F, W, F, F, F, F, W],
+ [W, F, F, F, F, F, W, F, W, W, F, W],
+ [W, W, W, F, W, W, W, F, F, F, F, W],
+ [W, F, F, F, F, F, F, F, W, F, W, W],
+ [W, F, W, F, W, F, W, F, F, F, F, W],
+ [W, W, W, W, W, W, W, W, W, W, W, W],
+];
+
+export function createGrid(): CellType[][] {
+ return LEVEL.map(row => [...row]);
+}
+
+export function getGoalPosition(): Position {
+ for (let y = 0; y < LEVEL.length; y++) {
+ const row = LEVEL[y]!;
+ for (let x = 0; x < row.length; x++) {
+ if (row[x] === 'goal') return { x, y };
+ }
+ }
+ return { x: 10, y: 1 }; // fallback
+}
+
+export function isWalkable(grid: CellType[][], x: number, y: number): boolean {
+ if (x < 0 || y < 0 || y >= CONFIG.GRID_ROWS || x >= CONFIG.GRID_COLS) return false;
+ const cell = grid[y]?.[x];
+ return cell === 'floor' || cell === 'goal';
+}
+
+export function getWalkableNeighbors(grid: CellType[][], pos: Position): Position[] {
+ const dirs = [
+ { x: pos.x, y: pos.y - 1 },
+ { x: pos.x, y: pos.y + 1 },
+ { x: pos.x - 1, y: pos.y },
+ { x: pos.x + 1, y: pos.y },
+ ];
+ return dirs.filter(p => isWalkable(grid, p.x, p.y));
+}
+
+export function manhattanDistance(a: Position, b: Position): number {
+ return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
+}
diff --git a/src/state/entities.ts b/src/state/entities.ts
new file mode 100644
index 0000000..80fbe6e
--- /dev/null
+++ b/src/state/entities.ts
@@ -0,0 +1,12 @@
+import { Entity } from '../types';
+
+export function createPlayer(): Entity {
+ return { type: 'player', x: 1, y: 8, id: 'player' };
+}
+
+export function createEnemies(): Entity[] {
+ return [
+ { type: 'enemy', x: 5, y: 5, id: 'enemy-1' },
+ { type: 'enemy', x: 9, y: 2, id: 'enemy-2' },
+ ];
+}
diff --git a/src/style.css b/src/style.css
new file mode 100644
index 0000000..bfdfd75
--- /dev/null
+++ b/src/style.css
@@ -0,0 +1,108 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ background: #0a0a0f;
+ color: #b0ffb0;
+ font-family: 'JetBrains Mono', monospace;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 100vh;
+ overflow: hidden;
+}
+
+#game-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+}
+
+#game-canvas {
+ border: 2px solid #1a3a2a;
+ border-radius: 4px;
+ image-rendering: pixelated;
+}
+
+#hud {
+ display: flex;
+ gap: 24px;
+ font-size: 14px;
+ color: #b0ffb0;
+}
+
+#future-description {
+ font-size: 15px;
+ color: #ffeb3b;
+ min-height: 22px;
+ text-align: center;
+}
+
+#controls {
+ display: flex;
+ gap: 16px;
+}
+
+#controls.hidden {
+ visibility: hidden;
+}
+
+.game-btn {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 15px;
+ font-weight: 700;
+ padding: 10px 28px;
+ border: 2px solid #1a3a2a;
+ border-radius: 4px;
+ background: #12121f;
+ color: #b0ffb0;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.game-btn:hover {
+ background: #1a3a2a;
+ border-color: #00ffcc;
+ color: #00ffcc;
+}
+
+.game-btn:active {
+ transform: scale(0.96);
+}
+
+#btn-accept {
+ border-color: #39ff14;
+ color: #39ff14;
+}
+
+#btn-accept:hover {
+ background: rgba(57, 255, 20, 0.15);
+}
+
+#btn-pass {
+ border-color: #ff6b35;
+ color: #ff6b35;
+}
+
+#btn-pass:hover {
+ background: rgba(255, 107, 53, 0.15);
+}
+
+#btn-pass.forced {
+ border-color: #ff1744;
+ color: #ff1744;
+ animation: pulse-danger 0.8s ease-in-out infinite;
+}
+
+@keyframes pulse-danger {
+ 0%, 100% { border-color: #ff1744; }
+ 50% { border-color: #ff6b35; }
+}
+
+#screen-overlay.hidden {
+ display: none;
+}
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..1cd141b
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,32 @@
+export type CellType = 'floor' | 'wall' | 'goal';
+
+export interface Position {
+ x: number;
+ y: number;
+}
+
+export interface Entity {
+ type: 'player' | 'enemy';
+ x: number;
+ y: number;
+ id: string;
+}
+
+export interface GameState {
+ grid: CellType[][];
+ player: Entity;
+ enemies: Entity[];
+ goalPos: Position;
+ turn: number;
+ status: 'playing' | 'won' | 'lost';
+}
+
+export interface Future {
+ state: GameState;
+ description: string;
+ quality: number; // -1 to 1
+}
+
+export type GamePhase = 'title' | 'playing' | 'won' | 'lost';
+
+export type Direction = 'up' | 'down' | 'left' | 'right' | 'stay';
diff --git a/src/ui/GameController.ts b/src/ui/GameController.ts
new file mode 100644
index 0000000..319823f
--- /dev/null
+++ b/src/ui/GameController.ts
@@ -0,0 +1,173 @@
+import { GameState, GamePhase, Future } from '../types';
+import { createInitialState } from '../state/GameState';
+import { Renderer } from '../render/Renderer';
+import { TurnManager } from './TurnManager';
+import { HUD } from './HUD';
+import { renderTitleScreen, renderWinScreen, renderLoseScreen } from './screens';
+
+export class GameController {
+ private phase: GamePhase = 'title';
+ private state: GameState;
+ private renderer: Renderer;
+ private turnManager: TurnManager;
+ private hud: HUD;
+ private canvas: HTMLCanvasElement;
+ private controls: HTMLElement;
+ private btnAccept: HTMLElement;
+ private btnPass: HTMLElement;
+ private animFrameId: number | null = null;
+
+ constructor(canvas: HTMLCanvasElement) {
+ this.canvas = canvas;
+ this.renderer = new Renderer(canvas);
+ this.turnManager = new TurnManager();
+ this.hud = new HUD();
+ this.state = createInitialState();
+ this.controls = document.getElementById('controls')!;
+ this.btnAccept = document.getElementById('btn-accept')!;
+ this.btnPass = document.getElementById('btn-pass')!;
+
+ this.turnManager.setCallbacks(
+ (future, index, total) => this.onFuturePresented(future, index, total),
+ (newState) => this.onTurnResolved(newState),
+ );
+
+ this.setupInput();
+ this.showTitle();
+ }
+
+ private setupInput(): void {
+ this.btnAccept.addEventListener('click', () => this.handleAccept());
+ this.btnPass.addEventListener('click', () => this.handlePass());
+
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ if (this.phase === 'title' || this.phase === 'won' || this.phase === 'lost') {
+ this.startGame();
+ } else if (this.phase === 'playing') {
+ this.handleAccept();
+ }
+ } else if (e.key === ' ') {
+ e.preventDefault();
+ if (this.phase === 'playing') {
+ this.handlePass();
+ }
+ }
+ });
+
+ // Click on canvas for title/end screens
+ this.canvas.addEventListener('click', () => {
+ if (this.phase === 'title' || this.phase === 'won' || this.phase === 'lost') {
+ this.startGame();
+ }
+ });
+ }
+
+ private showTitle(): void {
+ this.phase = 'title';
+ this.controls.classList.add('hidden');
+ this.hud.hide();
+ this.startScreenAnimation(() => {
+ const ctx = this.canvas.getContext('2d')!;
+ renderTitleScreen(ctx, this.canvas.width, this.canvas.height);
+ });
+ }
+
+ private startGame(): void {
+ this.stopAnimation();
+ this.phase = 'playing';
+ this.state = createInitialState();
+ this.controls.classList.remove('hidden');
+ this.hud.show();
+ this.hud.updateTurn(0);
+ this.renderer.renderState(this.state);
+ this.startTurn();
+ }
+
+ private startTurn(): void {
+ this.turnManager.startTurn(this.state);
+ }
+
+ private onFuturePresented(future: Future, index: number, total: number): void {
+ this.hud.updateFutures(index, total);
+ this.hud.updateDescription(future.description);
+
+ // Update pass button text
+ if (this.turnManager.isLastFuture()) {
+ this.btnPass.textContent = 'Forced [Space]';
+ this.btnPass.classList.add('forced');
+ } else {
+ this.btnPass.textContent = 'Pass [Space]';
+ this.btnPass.classList.remove('forced');
+ }
+
+ // Animate the current state with future overlay
+ this.renderer.startPulseAnimation(this.state, future);
+ }
+
+ private onTurnResolved(newState: GameState): void {
+ this.renderer.stopAnimation();
+ this.state = newState;
+ this.hud.updateTurn(this.state.turn);
+ this.hud.clearDescription();
+ this.renderer.renderState(this.state);
+
+ if (this.state.status === 'won') {
+ this.showWin();
+ } else if (this.state.status === 'lost') {
+ this.showLose();
+ } else {
+ // Small delay before next turn for readability
+ setTimeout(() => this.startTurn(), 300);
+ }
+ }
+
+ private handleAccept(): void {
+ if (this.phase !== 'playing') return;
+ this.turnManager.accept();
+ }
+
+ private handlePass(): void {
+ if (this.phase !== 'playing') return;
+ this.turnManager.pass();
+ }
+
+ private showWin(): void {
+ this.phase = 'won';
+ this.controls.classList.add('hidden');
+ this.startScreenAnimation(() => {
+ // First render the final game state
+ this.renderer.renderState(this.state);
+ const ctx = this.canvas.getContext('2d')!;
+ renderWinScreen(ctx, this.canvas.width, this.canvas.height, this.state.turn);
+ });
+ }
+
+ private showLose(): void {
+ this.phase = 'lost';
+ this.controls.classList.add('hidden');
+ this.startScreenAnimation(() => {
+ this.renderer.renderState(this.state);
+ const ctx = this.canvas.getContext('2d')!;
+ renderLoseScreen(ctx, this.canvas.width, this.canvas.height, this.state.turn);
+ });
+ }
+
+ private startScreenAnimation(renderFn: () => void): void {
+ this.stopAnimation();
+ const animate = () => {
+ renderFn();
+ this.animFrameId = requestAnimationFrame(animate);
+ };
+ animate();
+ }
+
+ private stopAnimation(): void {
+ if (this.animFrameId !== null) {
+ cancelAnimationFrame(this.animFrameId);
+ this.animFrameId = null;
+ }
+ this.renderer.stopAnimation();
+ }
+}
diff --git a/src/ui/HUD.ts b/src/ui/HUD.ts
new file mode 100644
index 0000000..418cc07
--- /dev/null
+++ b/src/ui/HUD.ts
@@ -0,0 +1,38 @@
+export class HUD {
+ private turnEl: HTMLElement;
+ private futuresEl: HTMLElement;
+ private descEl: HTMLElement;
+
+ constructor() {
+ this.turnEl = document.getElementById('turn-counter')!;
+ this.futuresEl = document.getElementById('futures-counter')!;
+ this.descEl = document.getElementById('future-description')!;
+ }
+
+ updateTurn(turn: number): void {
+ this.turnEl.textContent = `Turn: ${turn}`;
+ }
+
+ updateFutures(current: number, total: number): void {
+ this.futuresEl.textContent = `Future: ${current + 1}/${total}`;
+ }
+
+ updateDescription(text: string): void {
+ this.descEl.textContent = text;
+ }
+
+ clearDescription(): void {
+ this.descEl.textContent = '';
+ }
+
+ show(): void {
+ this.turnEl.style.display = '';
+ this.futuresEl.style.display = '';
+ }
+
+ hide(): void {
+ this.turnEl.style.display = 'none';
+ this.futuresEl.style.display = 'none';
+ this.descEl.textContent = '';
+ }
+}
diff --git a/src/ui/TurnManager.ts b/src/ui/TurnManager.ts
new file mode 100644
index 0000000..59b90f1
--- /dev/null
+++ b/src/ui/TurnManager.ts
@@ -0,0 +1,56 @@
+import { GameState, Future } from '../types';
+import { generateFutures } from '../engine/FuturesEngine';
+import { CONFIG } from '../config';
+
+export class TurnManager {
+ private futures: Future[] = [];
+ private currentIndex = 0;
+ private onFuturePresented: ((future: Future, index: number, total: number) => void) | null = null;
+ private onTurnResolved: ((state: GameState) => void) | null = null;
+
+ setCallbacks(
+ onFuturePresented: (future: Future, index: number, total: number) => void,
+ onTurnResolved: (state: GameState) => void,
+ ): void {
+ this.onFuturePresented = onFuturePresented;
+ this.onTurnResolved = onTurnResolved;
+ }
+
+ startTurn(state: GameState): void {
+ this.futures = generateFutures(state, CONFIG.NUM_FUTURES);
+ this.currentIndex = 0;
+ this.presentCurrent();
+ }
+
+ private presentCurrent(): void {
+ const future = this.futures[this.currentIndex];
+ if (future && this.onFuturePresented) {
+ this.onFuturePresented(future, this.currentIndex, this.futures.length);
+ }
+ }
+
+ accept(): void {
+ const future = this.futures[this.currentIndex];
+ if (future && this.onTurnResolved) {
+ this.onTurnResolved(future.state);
+ }
+ }
+
+ pass(): void {
+ if (this.currentIndex < this.futures.length - 1) {
+ this.currentIndex++;
+ this.presentCurrent();
+ } else {
+ // Last future — auto-accept
+ this.accept();
+ }
+ }
+
+ getCurrentFuture(): Future | null {
+ return this.futures[this.currentIndex] ?? null;
+ }
+
+ isLastFuture(): boolean {
+ return this.currentIndex === this.futures.length - 1;
+ }
+}
diff --git a/src/ui/screens.ts b/src/ui/screens.ts
new file mode 100644
index 0000000..9437bb3
--- /dev/null
+++ b/src/ui/screens.ts
@@ -0,0 +1,104 @@
+import { CONFIG } from '../config';
+
+export function renderTitleScreen(ctx: CanvasRenderingContext2D, width: number, height: number): void {
+ ctx.fillStyle = CONFIG.COLOR_BG;
+ ctx.fillRect(0, 0, width, height);
+
+ // Scanlines
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.04)';
+ for (let y = 0; y < height; y += 3) {
+ ctx.fillRect(0, y, width, 1);
+ }
+
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+
+ // Title
+ ctx.shadowColor = CONFIG.COLOR_PLAYER;
+ ctx.shadowBlur = 20;
+ ctx.fillStyle = CONFIG.COLOR_PLAYER;
+ ctx.font = 'bold 36px "JetBrains Mono", monospace';
+ ctx.fillText('QUANTUM RUNNER', width / 2, height / 3);
+ ctx.shadowBlur = 0;
+
+ // Subtitle
+ ctx.fillStyle = CONFIG.COLOR_UI_TEXT;
+ ctx.font = '16px "JetBrains Mono", monospace';
+ ctx.fillText('Collapse the future. Reach the exit.', width / 2, height / 3 + 50);
+
+ // Instructions
+ ctx.fillStyle = CONFIG.COLOR_ACCENT;
+ ctx.font = '14px "JetBrains Mono", monospace';
+ const instructions = [
+ 'You are the Observer ◉ — you don\'t move directly.',
+ 'Each turn, possible futures are revealed one by one.',
+ 'Accept a future or pass to see the next.',
+ 'If you pass all futures, the last one is forced.',
+ 'Reach the EXIT. Avoid the enemies ✕.',
+ ];
+ instructions.forEach((line, i) => {
+ ctx.fillText(line, width / 2, height / 2 + 20 + i * 26);
+ });
+
+ // Start prompt
+ const pulse = 0.5 + 0.5 * Math.sin(Date.now() / 500);
+ ctx.globalAlpha = pulse;
+ ctx.fillStyle = CONFIG.COLOR_GOAL;
+ ctx.font = 'bold 20px "JetBrains Mono", monospace';
+ ctx.fillText('[ Press ENTER or Click to Start ]', width / 2, height - 60);
+ ctx.globalAlpha = 1;
+}
+
+export function renderWinScreen(ctx: CanvasRenderingContext2D, width: number, height: number, turns: number): void {
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.85)';
+ ctx.fillRect(0, 0, width, height);
+
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+
+ ctx.shadowColor = CONFIG.COLOR_GOAL;
+ ctx.shadowBlur = 20;
+ ctx.fillStyle = CONFIG.COLOR_GOAL;
+ ctx.font = 'bold 40px "JetBrains Mono", monospace';
+ ctx.fillText('COLLAPSED!', width / 2, height / 3);
+ ctx.shadowBlur = 0;
+
+ ctx.fillStyle = CONFIG.COLOR_UI_TEXT;
+ ctx.font = '18px "JetBrains Mono", monospace';
+ ctx.fillText('The observer reached the exit.', width / 2, height / 3 + 50);
+ ctx.fillText(`Turns taken: ${turns}`, width / 2, height / 3 + 80);
+
+ const pulse = 0.5 + 0.5 * Math.sin(Date.now() / 500);
+ ctx.globalAlpha = pulse;
+ ctx.fillStyle = CONFIG.COLOR_ACCENT;
+ ctx.font = 'bold 18px "JetBrains Mono", monospace';
+ ctx.fillText('[ Press ENTER or Click to Restart ]', width / 2, height - 60);
+ ctx.globalAlpha = 1;
+}
+
+export function renderLoseScreen(ctx: CanvasRenderingContext2D, width: number, height: number, turns: number): void {
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.85)';
+ ctx.fillRect(0, 0, width, height);
+
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+
+ ctx.shadowColor = CONFIG.COLOR_DANGER;
+ ctx.shadowBlur = 20;
+ ctx.fillStyle = CONFIG.COLOR_DANGER;
+ ctx.font = 'bold 40px "JetBrains Mono", monospace';
+ ctx.fillText('DECOHERENCE', width / 2, height / 3);
+ ctx.shadowBlur = 0;
+
+ ctx.fillStyle = CONFIG.COLOR_UI_TEXT;
+ ctx.font = '18px "JetBrains Mono", monospace';
+ ctx.fillText('The observer was caught.', width / 2, height / 3 + 50);
+ ctx.fillText(`Survived ${turns} turns.`, width / 2, height / 3 + 80);
+
+ const pulse = 0.5 + 0.5 * Math.sin(Date.now() / 500);
+ ctx.globalAlpha = pulse;
+ ctx.fillStyle = CONFIG.COLOR_ACCENT;
+ ctx.font = 'bold 18px "JetBrains Mono", monospace';
+ ctx.fillText('[ Press ENTER or Click to Restart ]', width / 2, height - 60);
+ ctx.globalAlpha = 1;
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..c63d14e
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "forceConsistentCasingInFileNames": true
+ },
+ "include": ["src"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..e59758d
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ build: {
+ target: 'ES2022',
+ },
+});