diff --git a/.gitea/workflows/deploy-simpleasm.yml b/.gitea/workflows/deploy-simpleasm.yml
new file mode 100644
index 0000000..099eaf0
--- /dev/null
+++ b/.gitea/workflows/deploy-simpleasm.yml
@@ -0,0 +1,51 @@
+name: deploy simpleasm
+# asm.famzheng.me — 汇编教学小游戏。host shell runner(fam 用户)。
+
+on:
+ push:
+ branches: [master]
+ paths:
+ - 'apps/simpleasm/**'
+ - 'crates/cube-core/**'
+ - 'Cargo.toml'
+ - 'Cargo.lock'
+ - '.gitea/workflows/deploy-simpleasm.yml'
+ workflow_dispatch:
+
+jobs:
+ build-and-deploy:
+ runs-on: ubuntu-latest
+ env:
+ APP: simpleasm
+ IMAGE: registry.famzheng.me/mochi/simpleasm
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Resolve image tag
+ id: tag
+ run: |
+ echo "sha=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT"
+
+ - name: Build rust (musl static)
+ run: |
+ export PATH="$HOME/.cargo/bin:$PATH"
+ cargo build --release --target x86_64-unknown-linux-musl -p "$APP"
+
+ - name: Build frontend
+ run: |
+ cd "apps/$APP/frontend"
+ npm ci
+ npm run build
+
+ - name: Build & push image
+ env:
+ REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
+ run: |
+ echo "$REGISTRY_TOKEN" | docker login registry.famzheng.me -u mochi --password-stdin
+ docker build -f "apps/$APP/Dockerfile" -t "$IMAGE:${{ steps.tag.outputs.sha }}" .
+ docker push "$IMAGE:${{ steps.tag.outputs.sha }}"
+
+ - name: Roll out to k3s
+ run: |
+ kubectl -n "cube-$APP" set image "deploy/$APP" "$APP=$IMAGE:${{ steps.tag.outputs.sha }}"
+ kubectl -n "cube-$APP" rollout status "deploy/$APP" --timeout=120s
diff --git a/Cargo.lock b/Cargo.lock
index dc21489..f54e099 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,18 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -95,6 +107,16 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+[[package]]
+name = "cc"
+version = "1.2.61"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -130,6 +152,24 @@ dependencies = [
"windows-sys",
]
+[[package]]
+name = "fallible-iterator"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
+
+[[package]]
+name = "fallible-streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -178,6 +218,24 @@ dependencies = [
"slab",
]
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+dependencies = [
+ "ahash",
+]
+
+[[package]]
+name = "hashlink"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
+dependencies = [
+ "hashbrown",
+]
+
[[package]]
name = "http"
version = "1.4.0"
@@ -282,6 +340,17 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+[[package]]
+name = "libsqlite3-sys"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
+dependencies = [
+ "cc",
+ "pkg-config",
+ "vcpkg",
+]
+
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -395,6 +464,12 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -439,6 +514,20 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+[[package]]
+name = "rusqlite"
+version = "0.32.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
+dependencies = [
+ "bitflags",
+ "fallible-iterator",
+ "fallible-streaming-iterator",
+ "hashlink",
+ "libsqlite3-sys",
+ "smallvec",
+]
+
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -464,6 +553,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
+ "serde_derive",
]
[[package]]
@@ -531,6 +621,12 @@ dependencies = [
"lazy_static",
]
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@@ -541,6 +637,19 @@ dependencies = [
"libc",
]
+[[package]]
+name = "simpleasm"
+version = "0.1.0"
+dependencies = [
+ "axum",
+ "cube-core",
+ "rusqlite",
+ "serde",
+ "serde_json",
+ "tokio",
+ "tracing",
+]
+
[[package]]
name = "slab"
version = "0.4.12"
@@ -777,6 +886,18 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -798,6 +919,26 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "zerocopy"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "zmij"
version = "1.0.21"
diff --git a/Cargo.toml b/Cargo.toml
index 34191c4..b57fa32 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,6 +3,7 @@ resolver = "2"
members = [
"crates/cube-core",
"apps/cube",
+ "apps/simpleasm",
]
[workspace.package]
@@ -17,6 +18,9 @@ tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+rusqlite = { version = "0.32", features = ["bundled"] }
[profile.release]
opt-level = "z"
diff --git a/apps/cube/frontend/src/apps.ts b/apps/cube/frontend/src/apps.ts
index f549cac..109b95d 100644
--- a/apps/cube/frontend/src/apps.ts
+++ b/apps/cube/frontend/src/apps.ts
@@ -33,9 +33,9 @@ export const apps: App[] = [
{
slug: 'simpleasm',
name: 'simpleasm',
- description: '汇编教学/玩具。从 oci 迁移中(原 asm.oci.euphon.net)。',
- url: 'https://simpleasm.famzheng.me',
- status: 'pending',
+ description: '汇编教学小游戏。',
+ url: 'https://asm.famzheng.me',
+ status: 'live',
},
{
slug: 'guitar',
diff --git a/apps/simpleasm/Cargo.toml b/apps/simpleasm/Cargo.toml
new file mode 100644
index 0000000..f72b2f7
--- /dev/null
+++ b/apps/simpleasm/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "simpleasm"
+version = "0.1.0"
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+description = "asm.famzheng.me — 汇编教学小游戏,玩家进度持久化在 sqlite"
+
+[dependencies]
+cube-core = { path = "../../crates/cube-core" }
+axum = { workspace = true }
+tokio = { workspace = true }
+tracing = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+rusqlite = { workspace = true }
diff --git a/apps/simpleasm/Dockerfile b/apps/simpleasm/Dockerfile
new file mode 100644
index 0000000..865bcdf
--- /dev/null
+++ b/apps/simpleasm/Dockerfile
@@ -0,0 +1,8 @@
+# simpleasm — asm.famzheng.me
+# Build context = repo root(同 cube),所以路径都是 apps/simpleasm/...
+# Rust + frontend 都在 host 上 build,容器只是拼装。
+FROM scratch
+COPY target/x86_64-unknown-linux-musl/release/simpleasm /simpleasm
+COPY apps/simpleasm/frontend/dist /dist
+EXPOSE 8080
+ENTRYPOINT ["/simpleasm"]
diff --git a/apps/simpleasm/frontend/index.html b/apps/simpleasm/frontend/index.html
new file mode 100644
index 0000000..381cd1d
--- /dev/null
+++ b/apps/simpleasm/frontend/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Simple ASM - Assembly Adventure
+
+
+
+
+
+
+
+
+
diff --git a/apps/simpleasm/frontend/package-lock.json b/apps/simpleasm/frontend/package-lock.json
new file mode 100644
index 0000000..0105a2c
--- /dev/null
+++ b/apps/simpleasm/frontend/package-lock.json
@@ -0,0 +1,1214 @@
+{
+ "name": "simpleasm",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "simpleasm",
+ "version": "1.0.0",
+ "dependencies": {
+ "@modyfi/vite-plugin-yaml": "^1.1.1",
+ "pinia": "^2.1.7",
+ "vue": "^3.4.21",
+ "vue-router": "^4.3.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.0.4",
+ "vite": "^5.4.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
+ "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
+ },
+ "node_modules/@modyfi/vite-plugin-yaml": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@modyfi/vite-plugin-yaml/-/vite-plugin-yaml-1.1.1.tgz",
+ "integrity": "sha512-rEbfFNlMGLKpAYs2RsfLAhxCHFa6M4QKHHk0A4EYcCJAUwFtFO6qiEdLjUGUTtnRUxAC7GxxCa+ZbeUILSDvqQ==",
+ "dependencies": {
+ "@rollup/pluginutils": "5.1.0",
+ "js-yaml": "4.1.0",
+ "tosource": "2.0.0-alpha.3"
+ },
+ "peerDependencies": {
+ "vite": ">=3.2.7"
+ }
+ },
+ "node_modules/@rollup/pluginutils": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.0.tgz",
+ "integrity": "sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "estree-walker": "^2.0.2",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
+ "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
+ "cpu": [
+ "arm"
+ ],
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
+ "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
+ "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
+ "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
+ "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
+ "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
+ "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
+ "cpu": [
+ "arm"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
+ "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
+ "cpu": [
+ "arm"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
+ "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
+ "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
+ "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
+ "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
+ "cpu": [
+ "loong64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
+ "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
+ "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
+ "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
+ "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
+ "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
+ "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
+ "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
+ "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
+ "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
+ "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
+ "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
+ "cpu": [
+ "ia32"
+ ],
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
+ "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
+ "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
+ "cpu": [
+ "x64"
+ ],
+ "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=="
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
+ "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+ "dev": true,
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0 || ^6.0.0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.5.32",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz",
+ "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==",
+ "dependencies": {
+ "@babel/parser": "^7.29.2",
+ "@vue/shared": "3.5.32",
+ "entities": "^7.0.1",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.5.32",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz",
+ "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==",
+ "dependencies": {
+ "@vue/compiler-core": "3.5.32",
+ "@vue/shared": "3.5.32"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.5.32",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz",
+ "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==",
+ "dependencies": {
+ "@babel/parser": "^7.29.2",
+ "@vue/compiler-core": "3.5.32",
+ "@vue/compiler-dom": "3.5.32",
+ "@vue/compiler-ssr": "3.5.32",
+ "@vue/shared": "3.5.32",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.21",
+ "postcss": "^8.5.8",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.5.32",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz",
+ "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.32",
+ "@vue/shared": "3.5.32"
+ }
+ },
+ "node_modules/@vue/devtools-api": {
+ "version": "6.6.4",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.32",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz",
+ "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==",
+ "dependencies": {
+ "@vue/shared": "3.5.32"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.5.32",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz",
+ "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==",
+ "dependencies": {
+ "@vue/reactivity": "3.5.32",
+ "@vue/shared": "3.5.32"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.5.32",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz",
+ "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==",
+ "dependencies": {
+ "@vue/reactivity": "3.5.32",
+ "@vue/runtime-core": "3.5.32",
+ "@vue/shared": "3.5.32",
+ "csstype": "^3.2.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.5.32",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz",
+ "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==",
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.32",
+ "@vue/shared": "3.5.32"
+ },
+ "peerDependencies": {
+ "vue": "3.5.32"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.5.32",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz",
+ "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg=="
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "hasInstallScript": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+ "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "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=="
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pinia": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz",
+ "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==",
+ "dependencies": {
+ "@vue/devtools-api": "^6.6.3",
+ "vue-demi": "^0.14.10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/posva"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.4.4",
+ "vue": "^2.7.0 || ^3.5.11"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.8",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+ "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "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"
+ }
+ ],
+ "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.60.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
+ "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
+ "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.60.1",
+ "@rollup/rollup-android-arm64": "4.60.1",
+ "@rollup/rollup-darwin-arm64": "4.60.1",
+ "@rollup/rollup-darwin-x64": "4.60.1",
+ "@rollup/rollup-freebsd-arm64": "4.60.1",
+ "@rollup/rollup-freebsd-x64": "4.60.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.1",
+ "@rollup/rollup-linux-arm64-musl": "4.60.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.1",
+ "@rollup/rollup-linux-loong64-musl": "4.60.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.1",
+ "@rollup/rollup-linux-x64-gnu": "4.60.1",
+ "@rollup/rollup-linux-x64-musl": "4.60.1",
+ "@rollup/rollup-openbsd-x64": "4.60.1",
+ "@rollup/rollup-openharmony-arm64": "4.60.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.1",
+ "@rollup/rollup-win32-x64-gnu": "4.60.1",
+ "@rollup/rollup-win32-x64-msvc": "4.60.1",
+ "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==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tosource": {
+ "version": "2.0.0-alpha.3",
+ "resolved": "https://registry.npmjs.org/tosource/-/tosource-2.0.0-alpha.3.tgz",
+ "integrity": "sha512-KAB2lrSS48y91MzFPFuDg4hLbvDiyTjOVgaK7Erw+5AmZXNq4sFRVn8r6yxSLuNs15PaokrDRpS61ERY9uZOug==",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.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",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue": {
+ "version": "3.5.32",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz",
+ "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.32",
+ "@vue/compiler-sfc": "3.5.32",
+ "@vue/runtime-dom": "3.5.32",
+ "@vue/server-renderer": "3.5.32",
+ "@vue/shared": "3.5.32"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-demi": {
+ "version": "0.14.10",
+ "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
+ "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
+ "hasInstallScript": true,
+ "bin": {
+ "vue-demi-fix": "bin/vue-demi-fix.js",
+ "vue-demi-switch": "bin/vue-demi-switch.js"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@vue/composition-api": "^1.0.0-rc.1",
+ "vue": "^3.0.0-0 || ^2.6.0"
+ },
+ "peerDependenciesMeta": {
+ "@vue/composition-api": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-router": {
+ "version": "4.6.4",
+ "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
+ "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
+ "dependencies": {
+ "@vue/devtools-api": "^6.6.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/posva"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ }
+ }
+}
diff --git a/apps/simpleasm/frontend/package.json b/apps/simpleasm/frontend/package.json
new file mode 100644
index 0000000..dc09618
--- /dev/null
+++ b/apps/simpleasm/frontend/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "simpleasm",
+ "private": true,
+ "version": "1.0.0",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@modyfi/vite-plugin-yaml": "^1.1.1",
+ "pinia": "^2.1.7",
+ "vue": "^3.4.21",
+ "vue-router": "^4.3.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.0.4",
+ "vite": "^5.4.0"
+ }
+}
diff --git a/apps/simpleasm/frontend/src/App.vue b/apps/simpleasm/frontend/src/App.vue
new file mode 100644
index 0000000..f648b03
--- /dev/null
+++ b/apps/simpleasm/frontend/src/App.vue
@@ -0,0 +1,57 @@
+
+
+
+
+
diff --git a/apps/simpleasm/frontend/src/components/CodeEditor.vue b/apps/simpleasm/frontend/src/components/CodeEditor.vue
new file mode 100644
index 0000000..0746b4b
--- /dev/null
+++ b/apps/simpleasm/frontend/src/components/CodeEditor.vue
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
diff --git a/apps/simpleasm/frontend/src/components/LevelComplete.vue b/apps/simpleasm/frontend/src/components/LevelComplete.vue
new file mode 100644
index 0000000..4d45b5b
--- /dev/null
+++ b/apps/simpleasm/frontend/src/components/LevelComplete.vue
@@ -0,0 +1,106 @@
+
+
+
+
+
+
🎉
+
Level Complete!
+
{{ level.title }}
+
+
+ ★
+
+
+
+
Instructions{{ instructionCount }}
+
Stars Earned{{ stars }} / 3
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/simpleasm/frontend/src/components/MachineState.vue b/apps/simpleasm/frontend/src/components/MachineState.vue
new file mode 100644
index 0000000..05c109f
--- /dev/null
+++ b/apps/simpleasm/frontend/src/components/MachineState.vue
@@ -0,0 +1,139 @@
+
+
+
⚙ Machine State
+
+
+
Registers
+
+
+ {{ reg }}
+ {{ registers[reg] }}
+ {{ hex(registers[reg]) }}
+ {{ bin(registers[reg]) }}
+
+
+
+
+
+
+
+
Stack ({{ stack.length }})
+
+
+
+
+
Memory
+
+
+
+ {{ (row*16).toString(16).toUpperCase().padStart(2,'0') }}
+
+ {{ memory[row*16+col-1].toString(16).toUpperCase().padStart(2,'0') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/simpleasm/frontend/src/components/OutputConsole.vue b/apps/simpleasm/frontend/src/components/OutputConsole.vue
new file mode 100644
index 0000000..386cb08
--- /dev/null
+++ b/apps/simpleasm/frontend/src/components/OutputConsole.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
+ {{ m.pfx }}
+ {{ m.text }}
+
+
Waiting to run...
+
+
+
+
+
+
+
diff --git a/apps/simpleasm/frontend/src/components/TutorialPanel.vue b/apps/simpleasm/frontend/src/components/TutorialPanel.vue
new file mode 100644
index 0000000..48470da
--- /dev/null
+++ b/apps/simpleasm/frontend/src/components/TutorialPanel.vue
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
{{ sec.title }}
+
+
{{ sec.code }}
+
+
+
+
+
+
+
+
diff --git a/apps/simpleasm/frontend/src/lib/levels.js b/apps/simpleasm/frontend/src/lib/levels.js
new file mode 100644
index 0000000..baa2c1a
--- /dev/null
+++ b/apps/simpleasm/frontend/src/lib/levels.js
@@ -0,0 +1,5 @@
+const modules = import.meta.glob('./levels/*.yaml', { eager: true })
+
+export const levels = Object.values(modules)
+ .map(m => m.default)
+ .sort((a, b) => a.id - b.id)
diff --git a/apps/simpleasm/frontend/src/lib/levels/01.yaml b/apps/simpleasm/frontend/src/lib/levels/01.yaml
new file mode 100644
index 0000000..dda2095
--- /dev/null
+++ b/apps/simpleasm/frontend/src/lib/levels/01.yaml
@@ -0,0 +1,50 @@
+id: 1
+title: Meet the Registers
+subtitle: The robot's memory slots
+description: Learn the MOV instruction to load values into registers
+
+tutorial:
+ - title: What is a register?
+ text: >
+ The CPU is the brain of the computer, and **registers** are the tiny drawers
+ right next to it — the fastest storage there is! Our machine has 8 registers:
+ **R0** through **R7**.
+ - title: The MOV instruction
+ text: >
+ `MOV` puts a number into a register. Numbers are prefixed with **#** to mark
+ them as immediate values:
+ code: |
+ MOV R0, #42 ; put 42 into R0
+ MOV R1, #100 ; put 100 into R1
+ - title: The XHLT instruction
+ text: >
+ Every program ends with `XHLT` (halt = stop) to tell the machine "we're done!"
+ code: |
+ MOV R0, #42
+ XHLT
+
+goal: Put the number **42** into the **R0** register
+
+initialState: {}
+
+testCases:
+ - init: {}
+ expected:
+ registers:
+ R0: 42
+
+hints:
+ - "MOV format: MOV register, #number"
+ - "Try: MOV R0, #???"
+ - "Answer: MOV R0, #42 then XHLT"
+
+starThresholds: [2, 3, 5]
+
+starterCode: |
+ ; Put 42 into the R0 register
+ ; Tip: prefix numbers with #
+
+
+ XHLT
+
+showMemory: false
diff --git a/apps/simpleasm/frontend/src/lib/levels/02.yaml b/apps/simpleasm/frontend/src/lib/levels/02.yaml
new file mode 100644
index 0000000..a768c87
--- /dev/null
+++ b/apps/simpleasm/frontend/src/lib/levels/02.yaml
@@ -0,0 +1,45 @@
+id: 2
+title: Data Mover
+subtitle: Copying between registers
+description: Learn how to copy data between registers
+
+tutorial:
+ - title: Register-to-register copy
+ text: >
+ MOV can also **copy** the value of one register into another (no # needed):
+ code: |
+ MOV R1, R0 ; copy R0 into R1
+ - title: It's a copy, not a move!
+ text: >
+ Despite being called "MOV" (move), it's actually a **copy**. After it runs
+ R0 still holds its value, and R1 now matches it.
+
+goal: R0 already holds **7** — copy it into both **R1** and **R2**
+
+initialState:
+ registers:
+ R0: 7
+
+testCases:
+ - init: {}
+ expected:
+ registers:
+ R0: 7
+ R1: 7
+ R2: 7
+
+hints:
+ - "MOV register, register — copies right into left"
+ - "MOV R1, R0 copies R0 into R1"
+ - "Answer: MOV R1, R0 / MOV R2, R0 / XHLT"
+
+starThresholds: [3, 4, 6]
+
+starterCode: |
+ ; R0 = 7
+ ; Copy R0 into R1 and R2
+
+
+ XHLT
+
+showMemory: false
diff --git a/apps/simpleasm/frontend/src/lib/levels/03.yaml b/apps/simpleasm/frontend/src/lib/levels/03.yaml
new file mode 100644
index 0000000..04123ff
--- /dev/null
+++ b/apps/simpleasm/frontend/src/lib/levels/03.yaml
@@ -0,0 +1,53 @@
+id: 3
+title: Add and Subtract
+subtitle: The power of three operands
+description: Learn the ADD and SUB instructions
+
+tutorial:
+ - title: ADD — three-operand addition
+ text: >
+ ARM-style addition is neat: **three operands**! The first is the destination,
+ the other two are the values being combined:
+ code: |
+ ADD R2, R0, R1 ; R2 = R0 + R1
+ ADD R0, R0, #10 ; R0 = R0 + 10
+ - title: SUB — subtraction
+ text: >
+ SUB works the same way, three operands:
+ code: |
+ SUB R2, R0, R1 ; R2 = R0 - R1
+ SUB R0, R0, #5 ; R0 = R0 - 5
+ - title: Why three operands?
+ text: >
+ You can drop the result straight into a new register, **no extra copy needed**!
+
+goal: R0=**15**, R1=**27** — compute R0+R1 into **R2** (R0 and R1 unchanged)
+
+initialState:
+ registers:
+ R0: 15
+ R1: 27
+
+testCases:
+ - init: {}
+ expected:
+ registers:
+ R0: 15
+ R1: 27
+ R2: 42
+
+hints:
+ - "First operand of ADD is the destination, the next two are added"
+ - "ADD R2, R0, R1 — result goes in R2"
+ - "Answer: ADD R2, R0, R1 / XHLT"
+
+starThresholds: [2, 3, 5]
+
+starterCode: |
+ ; R0=15, R1=27
+ ; Compute R0 + R1 into R2
+
+
+ XHLT
+
+showMemory: false
diff --git a/apps/simpleasm/frontend/src/lib/levels/04.yaml b/apps/simpleasm/frontend/src/lib/levels/04.yaml
new file mode 100644
index 0000000..a066671
--- /dev/null
+++ b/apps/simpleasm/frontend/src/lib/levels/04.yaml
@@ -0,0 +1,47 @@
+id: 4
+title: Multiply and Divide
+subtitle: Stronger arithmetic
+description: Learn the MUL and XDIV instructions
+
+tutorial:
+ - title: MUL — multiplication
+ text: >
+ MUL is also three operands:
+ code: |
+ MOV R0, #6
+ MOV R1, #7
+ MUL R2, R0, R1 ; R2 = 6 × 7 = 42
+ - title: XDIV — integer division
+ text: >
+ XDIV does integer division (the fractional part is discarded):
+ code: |
+ MOV R0, #100
+ MOV R1, #4
+ XDIV R2, R0, R1 ; R2 = 100 ÷ 4 = 25
+
+goal: Compute **6 × 7** into R0 and **100 ÷ 4** into R1
+
+initialState: {}
+
+testCases:
+ - init: {}
+ expected:
+ registers:
+ R0: 42
+ R1: 25
+
+hints:
+ - "MOV the numbers into registers first, then MUL/XDIV"
+ - "MUL R0, R2, R3 puts R2×R3 into R0"
+ - "Answer: MOV R2, #6 / MOV R3, #7 / MUL R0, R2, R3 / MOV R2, #100 / MOV R3, #4 / XDIV R1, R2, R3 / XHLT"
+
+starThresholds: [7, 9, 12]
+
+starterCode: |
+ ; Compute 6×7 into R0
+ ; Compute 100÷4 into R1
+
+
+ XHLT
+
+showMemory: false
diff --git a/apps/simpleasm/frontend/src/lib/levels/05.yaml b/apps/simpleasm/frontend/src/lib/levels/05.yaml
new file mode 100644
index 0000000..7a90034
--- /dev/null
+++ b/apps/simpleasm/frontend/src/lib/levels/05.yaml
@@ -0,0 +1,58 @@
+id: 5
+title: Bitwise Magic
+subtitle: The secret life of 0s and 1s
+description: Learn the AND, ORR, EOR, and MVN instructions
+
+tutorial:
+ - title: The binary world
+ text: >
+ Computers store everything as **0**s and **1**s. 42 in binary is `00101010`.
+ The state panel on the right shows the binary form of every register!
+ - title: AND — both must be 1
+ text: >
+ AND compares bit by bit, returning 1 only when both bits are 1. Great for
+ "extracting" specific bits:
+ code: |
+ ; 11111111 (255)
+ ; AND 00001111 (15)
+ ; = 00001111 (15)
+ AND R0, R0, #15
+ - title: Other bitwise ops
+ text: >
+ **ORR** — 1 if either bit is 1 (OR)
+
+ **EOR** — 1 if the bits differ (XOR)
+
+ **MVN** — flip every bit (NOT)
+ code: |
+ ORR R0, R0, #240 ; set the upper 4 bits
+ EOR R0, R0, #255 ; flip the lower 8 bits
+ MVN R0, R0 ; flip every bit
+
+goal: R0 = **255** (binary 11111111). Use AND to extract the **lower 4 bits** so R0 becomes **15**
+
+initialState:
+ registers:
+ R0: 255
+
+testCases:
+ - init: {}
+ expected:
+ registers:
+ R0: 15
+
+hints:
+ - "AND keeps the bits you want and clears the rest"
+ - "The mask for the lower 4 bits is 15 (binary 00001111)"
+ - "Answer: AND R0, R0, #15 / XHLT"
+
+starThresholds: [2, 3, 5]
+
+starterCode: |
+ ; R0 = 255 (binary 11111111)
+ ; Use AND to extract the lower 4 bits
+
+
+ XHLT
+
+showMemory: false
diff --git a/apps/simpleasm/frontend/src/lib/levels/06.yaml b/apps/simpleasm/frontend/src/lib/levels/06.yaml
new file mode 100644
index 0000000..1a3399c
--- /dev/null
+++ b/apps/simpleasm/frontend/src/lib/levels/06.yaml
@@ -0,0 +1,54 @@
+id: 6
+title: Shift Operations
+subtitle: A dance of bits
+description: Learn the LSL and LSR instructions
+
+tutorial:
+ - title: LSL — Logical Shift Left
+ text: >
+ All bits move left, zeros fill in on the right. **Shifting left by 1 = multiplying by 2**.
+ Shifting left by 3 = multiplying by 8:
+ code: |
+ ; 5 = 00000101
+ LSL R0, R0, #1 ; 00001010 = 10 (×2)
+ LSL R0, R0, #1 ; 00010100 = 20 (×2)
+ - title: LSR — Logical Shift Right
+ text: >
+ All bits move right, zeros fill in on the left. **Shifting right by 1 = dividing by 2**:
+ code: |
+ MOV R0, #40
+ LSR R0, R0, #1 ; 20 (÷2)
+ LSR R0, R0, #2 ; 5 (÷4)
+ - title: A programmer's trick
+ text: >
+ On a real ARM CPU, shifts are far cheaper than multiply/divide!
+ `LSL R0, R0, #3` beats `MUL R0, R0, #8`.
+
+goal: R0 = **5**. Use **shift only** to make it **40** (40 = 5 × 8 = 5 × 2³)
+
+initialState:
+ registers:
+ R0: 5
+
+testCases:
+ - init: {}
+ expected:
+ registers:
+ R0: 40
+
+hints:
+ - "8 = 2³ — multiplying by 8 is shifting left by 3"
+ - "LSL R0, R0, #3"
+ - "It's just one instruction!"
+
+starThresholds: [2, 3, 5]
+blockedOps: [MUL, XDIV]
+
+starterCode: |
+ ; R0 = 5
+ ; Use LSL to turn R0 into 40 (no MUL allowed)
+
+
+ XHLT
+
+showMemory: false
diff --git a/apps/simpleasm/frontend/src/lib/levels/07.yaml b/apps/simpleasm/frontend/src/lib/levels/07.yaml
new file mode 100644
index 0000000..19ecc61
--- /dev/null
+++ b/apps/simpleasm/frontend/src/lib/levels/07.yaml
@@ -0,0 +1,62 @@
+id: 7
+title: Memory Read and Write
+subtitle: Open up the bigger storage
+description: Learn the LDR and STR instructions
+
+tutorial:
+ - title: What is memory?
+ text: >
+ 8 registers isn't a lot! **Memory** is like a row of 256 lockers, each with
+ its own number (0-255).
+ - title: LDR — load from memory
+ text: >
+ Put the address into a register, then use `LDR` to read from there:
+ code: |
+ MOV R1, #0 ; address = 0
+ LDR R0, [R1] ; R0 = memory[0]
+ - title: STR — store into memory
+ text: >
+ `STR` writes a register's value into memory:
+ code: |
+ MOV R1, #5 ; address = 5
+ STR R0, [R1] ; memory[5] = R0
+ - title: Offset addressing
+ text: >
+ You can also add an offset: `[R1, #4]` means address R1+4:
+ code: |
+ MOV R1, #0
+ LDR R0, [R1, #0] ; memory[0]
+ LDR R2, [R1, #1] ; memory[1]
+
+goal: memory[0]=**10**, memory[1]=**20** — store their sum into **memory[2]**
+
+initialState:
+ memory:
+ 0: 10
+ 1: 20
+
+testCases:
+ - init: {}
+ expected:
+ memory:
+ 2: 30
+
+hints:
+ - "LDR the values into registers, add them, then STR the result back"
+ - "MOV R3, #0 sets a base address; LDR R0, [R3, #0] reads the first value"
+ - "Answer: MOV R3, #0 / LDR R0, [R3, #0] / LDR R1, [R3, #1] / ADD R2, R0, R1 / STR R2, [R3, #2] / XHLT"
+
+starThresholds: [6, 8, 10]
+
+starterCode: |
+ ; memory[0]=10, memory[1]=20
+ ; Compute the sum and store into memory[2]
+ ;
+ ; Tip: MOV an address into a register first
+ ; then use LDR/STR to read/write memory
+
+
+ XHLT
+
+showMemory: true
+memoryRange: [0, 15]
diff --git a/apps/simpleasm/frontend/src/lib/levels/08.yaml b/apps/simpleasm/frontend/src/lib/levels/08.yaml
new file mode 100644
index 0000000..6967d97
--- /dev/null
+++ b/apps/simpleasm/frontend/src/lib/levels/08.yaml
@@ -0,0 +1,79 @@
+id: 8
+title: Compare and Branch
+subtitle: Letting the program decide
+description: Learn CMP and conditional branch instructions
+
+tutorial:
+ - title: Up to now...
+ text: >
+ Programs ran top to bottom in order. With **branching**, the program can
+ finally make decisions!
+ - title: CMP — compare
+ text: >
+ `CMP` compares two values and remembers the result (the values themselves
+ are unchanged):
+ code: |
+ CMP R0, #10 ; compare R0 with 10
+ - title: Conditional branches
+ text: >
+ After comparing, use **B** (Branch) to jump:
+ code: |
+ BEQ label ; jump if Equal
+ BNE label ; jump if Not Equal
+ BGT label ; jump if Greater Than
+ BLT label ; jump if Less Than
+ B label ; unconditional jump
+ - title: Labels
+ text: >
+ A **label** is a marker in your code that branches jump to. Add a colon after the name:
+ code: |
+ CMP R0, #10
+ BGT big
+ MOV R1, #0 ; R0 <= 10
+ B done ; skip the next part
+ big:
+ MOV R1, #1 ; R0 > 10
+ done:
+ XHLT
+
+goal: R0=**15**. If R0 > 10 then R1 = **1**; otherwise R1 = **0**
+
+initialState:
+ registers:
+ R0: 15
+
+testCases:
+ - init:
+ registers:
+ R0: 15
+ expected:
+ registers:
+ R1: 1
+ - init:
+ registers:
+ R0: 5
+ expected:
+ registers:
+ R1: 0
+ - init:
+ registers:
+ R0: 10
+ expected:
+ registers:
+ R1: 0
+
+hints:
+ - "Default R1 to 0, then compare R0 with 10"
+ - "If R0 > 10, jump to a label that sets R1 to 1"
+ - "Answer: MOV R1, #0 / CMP R0, #10 / BLE done / MOV R1, #1 / done: XHLT"
+
+starThresholds: [5, 7, 9]
+
+starterCode: |
+ ; If R0 > 10 then R1 = 1
+ ; Otherwise R1 = 0
+
+
+ XHLT
+
+showMemory: false
diff --git a/apps/simpleasm/frontend/src/lib/levels/09.yaml b/apps/simpleasm/frontend/src/lib/levels/09.yaml
new file mode 100644
index 0000000..4dfc105
--- /dev/null
+++ b/apps/simpleasm/frontend/src/lib/levels/09.yaml
@@ -0,0 +1,52 @@
+id: 9
+title: Loops
+subtitle: The power of repetition
+description: Use branches to build a loop
+
+tutorial:
+ - title: What is a loop?
+ text: >
+ A loop runs the same code **over and over**. In assembly, a loop is just
+ **branching back to a label**!
+ - title: Loop structure
+ text: >
+ ① initialize ② do work ③ update the counter ④ test + branch back:
+ code: |
+ MOV R4, #0 ; ① initialize
+ loop: ; loop start
+ ADD R4, R4, #1 ; ②③ counter += 1
+ CMP R4, #5 ; ④ reached 5?
+ BLE loop ; not yet, jump back
+ XHLT
+ - title: Watch out!
+ text: >
+ Forget to update the counter and you've made an **infinite loop**
+ (don't worry — execution stops automatically after 10000 steps).
+
+goal: Compute **1+2+3+...+10** into **R0** (the answer is 55)
+
+initialState: {}
+
+testCases:
+ - init: {}
+ expected:
+ registers:
+ R0: 55
+
+hints:
+ - "R0 accumulates the sum, R4 is the counter (1 to 10)"
+ - "Loop body: ADD R0, R0, R4 / ADD R4, R4, #1 / CMP R4, #10 / BLE loop"
+ - "Full: MOV R0, #0 / MOV R4, #1 / loop: ADD R0, R0, R4 / ADD R4, R4, #1 / CMP R4, #10 / BLE loop / XHLT"
+
+starThresholds: [7, 9, 12]
+
+starterCode: |
+ ; Compute 1+2+3+...+10
+ ; Result goes in R0
+ ;
+ ; Tip: use a register as the counter
+
+
+ XHLT
+
+showMemory: false
diff --git a/apps/simpleasm/frontend/src/lib/levels/10.yaml b/apps/simpleasm/frontend/src/lib/levels/10.yaml
new file mode 100644
index 0000000..e745615
--- /dev/null
+++ b/apps/simpleasm/frontend/src/lib/levels/10.yaml
@@ -0,0 +1,81 @@
+id: 10
+title: Final Challenge
+subtitle: Find the maximum
+description: Bring all your skills together!
+
+tutorial:
+ - title: The last level!
+ text: >
+ You've learned registers, arithmetic, bitwise ops, memory, branches and loops.
+ Now combine **all of it**!
+ - title: The challenge
+ text: >
+ Memory addresses 0-4 hold five numbers. Find the **maximum value** and its
+ **position**. You'll need: a loop + memory reads + compare-and-branch.
+ - title: Approach
+ text: |
+ 1. Assume the first number is the largest (R0 = memory[0], R1 = position 0)
+ 2. Loop through the remaining numbers
+ 3. If a larger one shows up, update both the max and its position
+ 4. Continue until all 5 numbers are checked
+ code: |
+ ; Pseudocode:
+ ; R0 = max = mem[0]
+ ; R1 = maxIdx = 0
+ ; for R4 = 1 to 4:
+ ; R5 = mem[R4]
+ ; if R5 > R0: R0=R5, R1=R4
+
+goal: memory[0..4] holds 5 numbers — store the **maximum** in **R0** and its **position** in **R1**
+
+initialState:
+ memory:
+ 0: 5
+ 1: 3
+ 2: 8
+ 3: 1
+ 4: 7
+
+testCases:
+ - init:
+ memory:
+ 0: 5
+ 1: 3
+ 2: 8
+ 3: 1
+ 4: 7
+ expected:
+ registers:
+ R0: 8
+ R1: 2
+ - init:
+ memory:
+ 0: 1
+ 1: 9
+ 2: 4
+ 3: 9
+ 4: 2
+ expected:
+ registers:
+ R0: 9
+ R1: 1
+
+hints:
+ - "R0 = max, R1 = position, R4 = loop counter, R5 = current value"
+ - "Use MOV R3, R4 / LDR R5, [R3] to read mem[R4]"
+ - "Loop body: MOV R3, R4 / LDR R5, [R3] / CMP R5, R0 / BLE skip / MOV R0, R5 / MOV R1, R4 / skip: ADD R4, R4, #1 / CMP R4, #5 / BLT loop"
+
+starThresholds: [12, 15, 20]
+
+starterCode: |
+ ; memory[0..4] = [5, 3, 8, 1, 7]
+ ; Find the max into R0, the position into R1
+ ;
+ ; Tip: use R4 as the loop variable
+ ; use MOV + LDR to read memory
+
+
+ XHLT
+
+showMemory: true
+memoryRange: [0, 15]
diff --git a/apps/simpleasm/frontend/src/lib/vm.js b/apps/simpleasm/frontend/src/lib/vm.js
new file mode 100644
index 0000000..1b91686
--- /dev/null
+++ b/apps/simpleasm/frontend/src/lib/vm.js
@@ -0,0 +1,375 @@
+const REGISTERS = ['R0','R1','R2','R3','R4','R5','R6','R7']
+const OPCODES = [
+ 'MOV','ADD','SUB','MUL','XDIV','XMOD',
+ 'AND','ORR','EOR','MVN','LSL','LSR',
+ 'CMP','B','BEQ','BNE','BGT','BLT','BGE','BLE',
+ 'LDR','STR','PUSH','POP','XOUT','XHLT','NOP',
+]
+const BRANCH_OPS = ['B','BEQ','BNE','BGT','BLT','BGE','BLE']
+const MAX_STEPS = 10000
+
+function parseNumber(s) {
+ s = s.trim()
+ if (/^0x[0-9a-fA-F]+$/i.test(s)) return parseInt(s, 16)
+ if (/^0b[01]+$/i.test(s)) return parseInt(s.slice(2), 2)
+ if (/^-?\d+$/.test(s)) return parseInt(s, 10)
+ return null
+}
+
+function splitOperands(str) {
+ const result = []; let cur = ''; let depth = 0
+ for (const ch of str) {
+ if (ch === '[' || ch === '{') depth++
+ if (ch === ']' || ch === '}') depth--
+ if (ch === ',' && depth === 0) { result.push(cur.trim()); cur = '' }
+ else cur += ch
+ }
+ if (cur.trim()) result.push(cur.trim())
+ return result
+}
+
+function parseOperand(s) {
+ s = s.trim()
+ const upper = s.toUpperCase()
+ if (REGISTERS.includes(upper)) return { type: 'reg', value: upper }
+
+ // Immediate: #42, #0xFF, #0b101
+ if (s.startsWith('#')) {
+ const n = parseNumber(s.slice(1))
+ if (n !== null) return { type: 'imm', value: n }
+ }
+
+ // Memory: [R0], [R0, #4], [#addr]
+ const memMatch = s.match(/^\[([^\],]+)(?:,\s*(.+))?\]$/)
+ if (memMatch) {
+ const base = memMatch[1].trim()
+ const baseUp = base.toUpperCase()
+ if (REGISTERS.includes(baseUp)) {
+ let offset = 0
+ if (memMatch[2]) {
+ let off = memMatch[2].trim()
+ if (off.startsWith('#')) off = off.slice(1)
+ offset = parseNumber(off) || 0
+ }
+ return { type: 'mem', base: baseUp, offset }
+ }
+ // [#addr] direct
+ let addr = base.startsWith('#') ? base.slice(1) : base
+ const n = parseNumber(addr)
+ if (n !== null) return { type: 'mem_direct', addr: n & 0xFF }
+ }
+
+ // {R0} for PUSH/POP
+ const braceMatch = s.match(/^\{(.+)\}$/)
+ if (braceMatch) {
+ const inner = braceMatch[1].trim().toUpperCase()
+ if (REGISTERS.includes(inner)) return { type: 'reg', value: inner }
+ }
+
+ // Label
+ if (/^[a-zA-Z_]\w*$/.test(s)) return { type: 'label', value: s.toLowerCase() }
+
+ // Bare number (lenient)
+ const n = parseNumber(s)
+ if (n !== null) return { type: 'imm', value: n }
+
+ return { type: 'unknown', raw: s }
+}
+
+export function parse(code) {
+ const lines = code.split('\n')
+ const instructions = []
+ const labels = {}
+ const errors = []
+
+ // Pass 1: find labels
+ let idx = 0
+ for (let i = 0; i < lines.length; i++) {
+ let line = lines[i]
+ const ci = line.indexOf(';'); if (ci >= 0) line = line.slice(0, ci)
+ line = line.trim(); if (!line) continue
+ const lm = line.match(/^([a-zA-Z_]\w*)\s*:(.*)$/)
+ if (lm) { labels[lm[1].toLowerCase()] = idx; line = lm[2].trim(); if (!line) continue }
+ idx++
+ }
+
+ // Pass 2: parse instructions
+ for (let i = 0; i < lines.length; i++) {
+ let line = lines[i]
+ const ci = line.indexOf(';'); if (ci >= 0) line = line.slice(0, ci)
+ line = line.trim(); if (!line) continue
+ const lm = line.match(/^([a-zA-Z_]\w*)\s*:(.*)$/)
+ if (lm) { line = lm[2].trim(); if (!line) continue }
+
+ const parts = line.match(/^(\w+)(?:\s+(.*))?$/)
+ if (!parts) { errors.push({ line: i, msg: `Syntax error: ${lines[i].trim()}` }); continue }
+
+ const opcode = parts[1].toUpperCase()
+ if (!OPCODES.includes(opcode)) { errors.push({ line: i, msg: `Unknown instruction: ${parts[1]}` }); continue }
+
+ let operands = []
+ if (parts[2]) {
+ operands = splitOperands(parts[2]).map(parseOperand)
+ const bad = operands.find(o => o.type === 'unknown')
+ if (bad) { errors.push({ line: i, msg: `Unrecognized operand: ${bad.raw}` }); continue }
+ }
+ instructions.push({ opcode, operands, srcLine: i })
+ }
+ return { instructions, labels, errors }
+}
+
+export function createVM() {
+ const state = {
+ registers: { R0:0,R1:0,R2:0,R3:0,R4:0,R5:0,R6:0,R7:0 },
+ flags: { zero:false, carry:false, negative:false, overflow:false },
+ memory: new Array(256).fill(0),
+ stack: [],
+ pc: 0, halted: false, error: null,
+ output: [], input: [],
+ stepCount: 0, instructions: [], labels: {},
+ cmpA: 0, cmpB: 0,
+ }
+
+ function getVal(op) {
+ switch (op.type) {
+ case 'reg': return state.registers[op.value]
+ case 'imm': return op.value
+ case 'mem': return state.memory[(state.registers[op.base] + (op.offset||0)) & 0xFF]
+ case 'mem_direct': return state.memory[op.addr]
+ default: throw new Error(`Cannot read: ${JSON.stringify(op)}`)
+ }
+ }
+
+ function setReg(op, val, changes) {
+ val = ((val % 65536) + 65536) % 65536
+ if (op.type !== 'reg') throw new Error('Destination must be a register')
+ const old = state.registers[op.value]
+ state.registers[op.value] = val
+ changes.push({ type: 'reg', name: op.value, old, val })
+ }
+
+ function setMem(addr, val, changes) {
+ addr = addr & 0xFF
+ val = ((val % 65536) + 65536) % 65536
+ const old = state.memory[addr]
+ state.memory[addr] = val
+ changes.push({ type: 'mem', addr, old, val })
+ }
+
+ function updateFlags(val) {
+ val = ((val % 65536) + 65536) % 65536
+ state.flags.zero = val === 0
+ state.flags.negative = (val & 0x8000) !== 0
+ }
+
+ function condMet(op) {
+ const a = state.cmpA, b = state.cmpB
+ switch (op) {
+ case 'B': return true
+ case 'BEQ': return a === b
+ case 'BNE': return a !== b
+ case 'BGT': return a > b
+ case 'BLT': return a < b
+ case 'BGE': return a >= b
+ case 'BLE': return a <= b
+ }
+ return false
+ }
+
+ // For 2-or-3 operand arithmetic: if 3 ops → Rd = op(Rn, Rm); if 2 ops → Rd = op(Rd, Rm)
+ function arith3(ops, fn) {
+ if (ops.length >= 3) return fn(getVal(ops[1]), getVal(ops[2]))
+ return fn(getVal(ops[0]), getVal(ops[1]))
+ }
+
+ function step() {
+ if (state.halted || state.error) return null
+ if (state.pc >= state.instructions.length) { state.halted = true; return null }
+ if (state.stepCount >= MAX_STEPS) {
+ state.error = 'Exceeded maximum step count (10000) — likely an infinite loop!'; return null
+ }
+
+ const instr = state.instructions[state.pc]
+ const { opcode, operands: ops } = instr
+ const changes = []
+ let jumped = false
+ state.stepCount++
+
+ try {
+ switch (opcode) {
+ case 'NOP': break
+ case 'XHLT': state.halted = true; break
+
+ case 'MOV':
+ setReg(ops[0], getVal(ops[1]), changes)
+ break
+
+ case 'ADD': { const r = arith3(ops, (a,b) => a+b); state.flags.carry = r > 0xFFFF; setReg(ops[0], r, changes); updateFlags(r); break }
+ case 'SUB': { const r = arith3(ops, (a,b) => a-b); state.flags.carry = r < 0; setReg(ops[0], r, changes); updateFlags(r); break }
+ case 'MUL': { const r = arith3(ops, (a,b) => a*b); state.flags.carry = r > 0xFFFF; setReg(ops[0], r, changes); updateFlags(r); break }
+ case 'XDIV': {
+ const r = arith3(ops, (a,b) => { if(b===0) throw new Error('Division by zero!'); return Math.floor(a/b) })
+ setReg(ops[0], r, changes); updateFlags(r); break
+ }
+ case 'XMOD': {
+ const r = arith3(ops, (a,b) => { if(b===0) throw new Error('Division by zero!'); return a%b })
+ setReg(ops[0], r, changes); updateFlags(r); break
+ }
+
+ case 'AND': { const r = arith3(ops, (a,b) => a&b); setReg(ops[0], r, changes); updateFlags(r); break }
+ case 'ORR': { const r = arith3(ops, (a,b) => a|b); setReg(ops[0], r, changes); updateFlags(r); break }
+ case 'EOR': { const r = arith3(ops, (a,b) => a^b); setReg(ops[0], r, changes); updateFlags(r); break }
+ case 'MVN': { const r = (~getVal(ops[1])) & 0xFFFF; setReg(ops[0], r, changes); updateFlags(r); break }
+ case 'LSL': { const r = arith3(ops, (a,b) => (a << b) & 0xFFFF); setReg(ops[0], r, changes); updateFlags(r); break }
+ case 'LSR': { const r = arith3(ops, (a,b) => a >>> b); setReg(ops[0], r, changes); updateFlags(r); break }
+
+ case 'CMP': {
+ state.cmpA = getVal(ops[0]); state.cmpB = getVal(ops[1])
+ const d = state.cmpA - state.cmpB
+ state.flags.zero = d === 0; state.flags.negative = d < 0; state.flags.carry = state.cmpA < state.cmpB
+ break
+ }
+
+ case 'B': case 'BEQ': case 'BNE': case 'BGT': case 'BLT': case 'BGE': case 'BLE': {
+ if (condMet(opcode)) {
+ const lbl = ops[0].value
+ if (state.labels[lbl] === undefined) throw new Error(`Unknown label: ${lbl}`)
+ state.pc = state.labels[lbl]; jumped = true
+ }
+ break
+ }
+
+ case 'LDR': {
+ const memOp = ops[1]
+ let addr
+ if (memOp.type === 'mem') addr = (state.registers[memOp.base] + (memOp.offset||0)) & 0xFF
+ else if (memOp.type === 'mem_direct') addr = memOp.addr
+ else throw new Error('LDR needs a memory address, e.g. [R0] or [R0, #4]')
+ const v = state.memory[addr]
+ setReg(ops[0], v, changes)
+ changes.push({ type: 'mem_read', addr })
+ break
+ }
+ case 'STR': {
+ const memOp = ops[1]
+ let addr
+ if (memOp.type === 'mem') addr = (state.registers[memOp.base] + (memOp.offset||0)) & 0xFF
+ else if (memOp.type === 'mem_direct') addr = memOp.addr
+ else throw new Error('STR needs a memory address, e.g. [R0] or [R0, #4]')
+ setMem(addr, getVal(ops[0]), changes)
+ break
+ }
+
+ case 'PUSH': {
+ if (state.stack.length >= 64) throw new Error('Stack overflow!')
+ const v = getVal(ops[0])
+ state.stack.push(v)
+ changes.push({ type: 'stack_push', val: v })
+ break
+ }
+ case 'POP': {
+ if (state.stack.length === 0) throw new Error('Stack is empty!')
+ setReg(ops[0], state.stack.pop(), changes)
+ changes.push({ type: 'stack_pop' })
+ break
+ }
+
+ case 'XOUT': {
+ const v = getVal(ops[0])
+ state.output.push(v)
+ changes.push({ type: 'output', val: v })
+ break
+ }
+ }
+ } catch (e) {
+ state.error = `Line ${instr.srcLine + 1}: ${e.message}`
+ return { pc: state.pc, instr, changes, error: state.error }
+ }
+
+ if (!jumped) state.pc++
+ return { pc: state.pc, instr, changes, srcLine: instr.srcLine }
+ }
+
+ function loadProgram(code) {
+ const result = parse(code)
+ if (result.errors.length > 0) { state.error = result.errors[0].msg; return result }
+ state.instructions = result.instructions
+ state.labels = result.labels
+ state.pc = 0; state.halted = false; state.error = null; state.stepCount = 0
+ state.output = []; state.stack = []
+ state.flags = { zero:false, carry:false, negative:false, overflow:false }
+ state.cmpA = 0; state.cmpB = 0
+ return result
+ }
+
+ function run() {
+ while (!state.halted && !state.error && state.stepCount < MAX_STEPS) step()
+ if (state.stepCount >= MAX_STEPS && !state.halted && !state.error)
+ state.error = 'Exceeded maximum step count (10000) — likely an infinite loop!'
+ }
+
+ function reset() {
+ state.registers = { R0:0,R1:0,R2:0,R3:0,R4:0,R5:0,R6:0,R7:0 }
+ state.flags = { zero:false, carry:false, negative:false, overflow:false }
+ state.memory = new Array(256).fill(0)
+ state.stack = []; state.pc = 0; state.halted = false; state.error = null
+ state.output = []; state.input = []; state.stepCount = 0
+ state.cmpA = 0; state.cmpB = 0
+ }
+
+ return { state, step, run, loadProgram, reset }
+}
+
+export function countInstructions(code) {
+ let c = 0
+ for (const line of code.split('\n')) {
+ let l = line; const ci = l.indexOf(';'); if (ci >= 0) l = l.slice(0, ci); l = l.trim()
+ if (!l) continue
+ const lm = l.match(/^[a-zA-Z_]\w*\s*:(.*)$/); if (lm) { l = lm[1].trim(); if (!l) continue }
+ c++
+ }
+ return c
+}
+
+export function validateLevel(level, vm) {
+ const s = vm.state
+ if (s.error) return { passed: false, msg: s.error }
+
+ for (const tc of level.testCases) {
+ vm.reset()
+ if (level.initialState) {
+ if (level.initialState.registers) Object.assign(vm.state.registers, level.initialState.registers)
+ if (level.initialState.memory) for (const [a,v] of Object.entries(level.initialState.memory)) vm.state.memory[+a] = v
+ if (level.initialState.input) vm.state.input = [...level.initialState.input]
+ }
+ if (tc.init) {
+ if (tc.init.registers) Object.assign(vm.state.registers, tc.init.registers)
+ if (tc.init.memory) for (const [a,v] of Object.entries(tc.init.memory)) vm.state.memory[+a] = v
+ if (tc.init.input) vm.state.input = [...tc.init.input]
+ }
+
+ vm.run()
+ if (vm.state.error) return { passed: false, msg: vm.state.error }
+
+ const exp = tc.expected
+ if (exp.registers) {
+ for (const [r,v] of Object.entries(exp.registers)) {
+ if (vm.state.registers[r] !== v)
+ return { passed: false, msg: `${r} should be ${v}, but got ${vm.state.registers[r]}` }
+ }
+ }
+ if (exp.memory) {
+ for (const [a,v] of Object.entries(exp.memory)) {
+ if (vm.state.memory[+a] !== v)
+ return { passed: false, msg: `memory[${a}] should be ${v}, but got ${vm.state.memory[+a]}` }
+ }
+ }
+ if (exp.output) {
+ for (let i = 0; i < exp.output.length; i++) {
+ if (vm.state.output[i] !== exp.output[i])
+ return { passed: false, msg: `Output #${i+1} should be ${exp.output[i]}, but got ${vm.state.output[i] ?? 'none'}` }
+ }
+ }
+ }
+ return { passed: true, msg: 'Passed!' }
+}
diff --git a/apps/simpleasm/frontend/src/main.js b/apps/simpleasm/frontend/src/main.js
new file mode 100644
index 0000000..c2b1175
--- /dev/null
+++ b/apps/simpleasm/frontend/src/main.js
@@ -0,0 +1,9 @@
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import App from './App.vue'
+import router from './router/index.js'
+
+const app = createApp(App)
+app.use(createPinia())
+app.use(router)
+app.mount('#app')
diff --git a/apps/simpleasm/frontend/src/router/index.js b/apps/simpleasm/frontend/src/router/index.js
new file mode 100644
index 0000000..ddc290e
--- /dev/null
+++ b/apps/simpleasm/frontend/src/router/index.js
@@ -0,0 +1,12 @@
+import { createRouter, createWebHistory } from 'vue-router'
+
+const routes = [
+ { path: '/', name: 'welcome', component: () => import('../views/WelcomeView.vue') },
+ { path: '/levels', name: 'levels', component: () => import('../views/LevelSelectView.vue') },
+ { path: '/level/:id', name: 'level', component: () => import('../views/LevelView.vue'), props: true },
+]
+
+export default createRouter({
+ history: createWebHistory(),
+ routes,
+})
diff --git a/apps/simpleasm/frontend/src/stores/game.js b/apps/simpleasm/frontend/src/stores/game.js
new file mode 100644
index 0000000..4516653
--- /dev/null
+++ b/apps/simpleasm/frontend/src/stores/game.js
@@ -0,0 +1,84 @@
+import { defineStore } from 'pinia'
+
+export const useGameStore = defineStore('game', {
+ state: () => ({
+ playerName: localStorage.getItem('asm_playerName') || '',
+ playerId: parseInt(localStorage.getItem('asm_playerId')) || null,
+ progress: JSON.parse(localStorage.getItem('asm_progress') || '{}'),
+ }),
+
+ getters: {
+ isLoggedIn: (state) => !!state.playerName && !!state.playerId,
+ totalStars: (state) => Object.values(state.progress).reduce((s, p) => s + (p.stars || 0), 0),
+ levelsCompleted: (state) => Object.values(state.progress).filter(p => p.completed).length,
+ isLevelUnlocked() {
+ return (levelId) => {
+ if (levelId === 1) return true
+ return !!this.progress[levelId - 1]?.completed
+ }
+ },
+ },
+
+ actions: {
+ async login(name) {
+ try {
+ const res = await fetch('/api/players', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name }),
+ })
+ const data = await res.json()
+ this.playerName = data.name
+ this.playerId = data.id
+ if (data.progress) {
+ this.progress = { ...this.progress, ...data.progress }
+ }
+ this._persist()
+ return true
+ } catch {
+ // offline mode - just save locally
+ this.playerName = name
+ this.playerId = Date.now()
+ this._persist()
+ return true
+ }
+ },
+
+ async saveProgress(levelId, stars, code) {
+ const existing = this.progress[levelId]
+ const bestStars = Math.max(stars, existing?.stars || 0)
+ this.progress[levelId] = { completed: true, stars: bestStars, code }
+ this._persist()
+
+ try {
+ await fetch('/api/progress', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ player_id: this.playerId,
+ level_id: levelId,
+ stars: bestStars,
+ code,
+ }),
+ })
+ } catch {
+ // ok, saved locally
+ }
+ },
+
+ logout() {
+ this.playerName = ''
+ this.playerId = null
+ this.progress = {}
+ localStorage.removeItem('asm_playerName')
+ localStorage.removeItem('asm_playerId')
+ localStorage.removeItem('asm_progress')
+ },
+
+ _persist() {
+ localStorage.setItem('asm_playerName', this.playerName)
+ localStorage.setItem('asm_playerId', String(this.playerId))
+ localStorage.setItem('asm_progress', JSON.stringify(this.progress))
+ },
+ },
+})
diff --git a/apps/simpleasm/frontend/src/views/LevelSelectView.vue b/apps/simpleasm/frontend/src/views/LevelSelectView.vue
new file mode 100644
index 0000000..872f20e
--- /dev/null
+++ b/apps/simpleasm/frontend/src/views/LevelSelectView.vue
@@ -0,0 +1,110 @@
+
+
+
+
+
Select Level
+
Complete a level to unlock the next one. Up to 3 stars per level.
+
+
+
+
{{ level.id }}
+
{{ level.title }}
+
{{ level.subtitle }}
+
{{ level.description }}
+
+ ★
+
+
🔒
+
+
+
+
+
+
+
+
diff --git a/apps/simpleasm/frontend/src/views/LevelView.vue b/apps/simpleasm/frontend/src/views/LevelView.vue
new file mode 100644
index 0000000..d5278a1
--- /dev/null
+++ b/apps/simpleasm/frontend/src/views/LevelView.vue
@@ -0,0 +1,270 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ level.hints[hintIdx] }}
+
+
+
+
+
+
+
+
+
diff --git a/apps/simpleasm/frontend/src/views/WelcomeView.vue b/apps/simpleasm/frontend/src/views/WelcomeView.vue
new file mode 100644
index 0000000..55cdd37
--- /dev/null
+++ b/apps/simpleasm/frontend/src/views/WelcomeView.vue
@@ -0,0 +1,158 @@
+
+
+
+
+
+
⚡
+
Simple ASM
+
Assembly Adventure
+
+
Ready to explore how computers think?
Enter your name to begin the adventure!
+
+
+
Welcome back, {{ store.playerName }}!
+
You have {{ store.totalStars }} stars
+
Continue →
+
+
+
+
+
+
+
+
+
diff --git a/apps/simpleasm/frontend/vite.config.js b/apps/simpleasm/frontend/vite.config.js
new file mode 100644
index 0000000..3c13822
--- /dev/null
+++ b/apps/simpleasm/frontend/vite.config.js
@@ -0,0 +1,12 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import yaml from '@modyfi/vite-plugin-yaml'
+
+export default defineConfig({
+ plugins: [vue(), yaml()],
+ server: {
+ proxy: {
+ '/api': 'http://localhost:8080'
+ }
+ }
+})
diff --git a/apps/simpleasm/k8s/deployment.yaml b/apps/simpleasm/k8s/deployment.yaml
new file mode 100644
index 0000000..72f94cc
--- /dev/null
+++ b/apps/simpleasm/k8s/deployment.yaml
@@ -0,0 +1,58 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: simpleasm
+ namespace: cube-simpleasm
+ labels:
+ app: simpleasm
+spec:
+ replicas: 1
+ strategy:
+ # PVC 是 RWO,rolling 上线时新旧 pod 抢 PVC 会卡住,直接 Recreate
+ type: Recreate
+ selector:
+ matchLabels:
+ app: simpleasm
+ template:
+ metadata:
+ labels:
+ app: simpleasm
+ spec:
+ imagePullSecrets:
+ - name: registry-creds
+ containers:
+ - name: simpleasm
+ image: registry.famzheng.me/mochi/simpleasm:latest
+ imagePullPolicy: IfNotPresent
+ ports:
+ - containerPort: 8080
+ name: http
+ env:
+ - name: DB_PATH
+ value: /data/app.db
+ readinessProbe:
+ httpGet:
+ path: /healthz
+ port: http
+ initialDelaySeconds: 1
+ periodSeconds: 5
+ livenessProbe:
+ httpGet:
+ path: /healthz
+ port: http
+ initialDelaySeconds: 5
+ periodSeconds: 15
+ resources:
+ requests:
+ cpu: 10m
+ memory: 16Mi
+ limits:
+ cpu: 200m
+ memory: 64Mi
+ volumeMounts:
+ - name: data
+ mountPath: /data
+ volumes:
+ - name: data
+ persistentVolumeClaim:
+ claimName: simpleasm-data
diff --git a/apps/simpleasm/k8s/ingress.yaml b/apps/simpleasm/k8s/ingress.yaml
new file mode 100644
index 0000000..dc56a58
--- /dev/null
+++ b/apps/simpleasm/k8s/ingress.yaml
@@ -0,0 +1,18 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: simpleasm
+ namespace: cube-simpleasm
+spec:
+ ingressClassName: traefik
+ rules:
+ - host: asm.famzheng.me
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: simpleasm
+ port:
+ number: 80
diff --git a/apps/simpleasm/k8s/namespace.yaml b/apps/simpleasm/k8s/namespace.yaml
new file mode 100644
index 0000000..ecf9665
--- /dev/null
+++ b/apps/simpleasm/k8s/namespace.yaml
@@ -0,0 +1,4 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: cube-simpleasm
diff --git a/apps/simpleasm/k8s/pvc.yaml b/apps/simpleasm/k8s/pvc.yaml
new file mode 100644
index 0000000..f8ad702
--- /dev/null
+++ b/apps/simpleasm/k8s/pvc.yaml
@@ -0,0 +1,12 @@
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: simpleasm-data
+ namespace: cube-simpleasm
+spec:
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 1Gi
+ # storageClassName 留空 → 走 k3s 默认 local-path(hostPath,单节点足够)
diff --git a/apps/simpleasm/k8s/service.yaml b/apps/simpleasm/k8s/service.yaml
new file mode 100644
index 0000000..0694226
--- /dev/null
+++ b/apps/simpleasm/k8s/service.yaml
@@ -0,0 +1,12 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: simpleasm
+ namespace: cube-simpleasm
+spec:
+ selector:
+ app: simpleasm
+ ports:
+ - name: http
+ port: 80
+ targetPort: 8080
diff --git a/apps/simpleasm/src/main.rs b/apps/simpleasm/src/main.rs
new file mode 100644
index 0000000..9923627
--- /dev/null
+++ b/apps/simpleasm/src/main.rs
@@ -0,0 +1,214 @@
+//! asm.famzheng.me — 汇编教学小游戏。
+//!
+//! 4 个 endpoint:
+//! - `GET /api/health` 前端 ping 用
+//! - `POST /api/players` 按 name upsert,返回 id + 已有进度 map
+//! - `POST /api/progress` 单关卡 upsert(stars 只能增加,code 覆盖)
+//! - `GET /api/leaderboard` top 50(按 total_stars desc, levels_completed desc)
+//!
+//! 静态前端 + SPA fallback 由 cube-core::base 处理。
+
+use std::collections::HashMap;
+use std::sync::{Arc, Mutex};
+
+use axum::{
+ extract::State,
+ http::StatusCode,
+ response::{IntoResponse, Json as JsonResp},
+ routing::{get, post},
+ Json, Router,
+};
+use rusqlite::{params, Connection, OptionalExtension};
+use serde::{Deserialize, Serialize};
+use serde_json::json;
+
+type Db = Arc>;
+
+#[tokio::main]
+async fn main() -> std::io::Result<()> {
+ cube_core::init_tracing();
+
+ let db_path = std::env::var("DB_PATH").unwrap_or_else(|_| "/data/app.db".into());
+ let dist = std::env::var("SIMPLEASM_DIST_DIR").unwrap_or_else(|_| "/dist".into());
+
+ let conn = Connection::open(&db_path).expect("open sqlite");
+ conn.execute_batch(
+ "PRAGMA journal_mode=WAL;
+ CREATE TABLE IF NOT EXISTS players (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT UNIQUE NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE TABLE IF NOT EXISTS progress (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ player_id INTEGER NOT NULL,
+ level_id INTEGER NOT NULL,
+ stars INTEGER NOT NULL DEFAULT 0,
+ code TEXT,
+ completed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (player_id) REFERENCES players(id),
+ UNIQUE (player_id, level_id)
+ );",
+ )
+ .expect("init schema");
+ tracing::info!(%db_path, "sqlite ready");
+
+ let db: Db = Arc::new(Mutex::new(conn));
+
+ let api = Router::new()
+ .route("/health", get(|| async { "ok" }))
+ .route("/players", post(create_or_get_player))
+ .route("/progress", post(save_progress))
+ .route("/leaderboard", get(leaderboard))
+ .with_state(db);
+
+ let app = cube_core::base(dist).nest("/api", api);
+ cube_core::serve(app, 8080).await
+}
+
+#[derive(Deserialize)]
+struct PlayerCreate {
+ name: String,
+}
+
+#[derive(Deserialize)]
+struct ProgressSave {
+ player_id: i64,
+ level_id: i64,
+ stars: i64,
+ #[serde(default)]
+ code: String,
+}
+
+#[derive(Serialize)]
+struct ProgressItem {
+ stars: i64,
+ code: String,
+ completed: bool,
+}
+
+#[derive(Serialize)]
+struct LeaderboardRow {
+ name: String,
+ total_stars: i64,
+ levels_completed: i64,
+}
+
+/// `POST /api/players` — 按 name upsert。返回 `{id, name, progress: {level_id: {...}}}`。
+async fn create_or_get_player(
+ State(db): State,
+ Json(data): Json,
+) -> Result {
+ let name = data.name.trim().to_string();
+ if name.is_empty() {
+ return Err(AppError::bad_request("Name cannot be empty"));
+ }
+
+ let conn = db.lock().unwrap();
+ let player_id: i64 = match conn
+ .query_row(
+ "SELECT id FROM players WHERE name = ?1",
+ params![name],
+ |r| r.get(0),
+ )
+ .optional()?
+ {
+ Some(id) => id,
+ None => {
+ conn.execute("INSERT INTO players (name) VALUES (?1)", params![name])?;
+ conn.last_insert_rowid()
+ }
+ };
+
+ let mut stmt = conn.prepare(
+ "SELECT level_id, stars, code FROM progress WHERE player_id = ?1",
+ )?;
+ let mut rows = stmt.query(params![player_id])?;
+ let mut progress: HashMap = HashMap::new();
+ while let Some(r) = rows.next()? {
+ let level_id: i64 = r.get(0)?;
+ let stars: i64 = r.get(1)?;
+ let code: String = r.get::<_, Option>(2)?.unwrap_or_default();
+ progress.insert(
+ level_id.to_string(),
+ ProgressItem { stars, code, completed: true },
+ );
+ }
+
+ Ok(JsonResp(json!({
+ "id": player_id,
+ "name": name,
+ "progress": progress,
+ })))
+}
+
+/// `POST /api/progress` — 单关 upsert。stars 取 max(old, new),code 永远覆盖。
+async fn save_progress(
+ State(db): State,
+ Json(data): Json,
+) -> Result {
+ let conn = db.lock().unwrap();
+ conn.execute(
+ "INSERT INTO progress (player_id, level_id, stars, code)
+ VALUES (?1, ?2, ?3, ?4)
+ ON CONFLICT (player_id, level_id)
+ DO UPDATE SET stars = MAX(stars, excluded.stars),
+ code = excluded.code,
+ completed_at = CURRENT_TIMESTAMP",
+ params![data.player_id, data.level_id, data.stars, data.code],
+ )?;
+ Ok(JsonResp(json!({ "success": true })))
+}
+
+/// `GET /api/leaderboard` — top 50 by (total_stars desc, levels_completed desc)。
+async fn leaderboard(State(db): State) -> Result {
+ let conn = db.lock().unwrap();
+ let mut stmt = conn.prepare(
+ "SELECT p.name, COALESCE(SUM(pr.stars), 0) AS total_stars,
+ COUNT(pr.id) AS levels_completed
+ FROM players p
+ LEFT JOIN progress pr ON p.id = pr.player_id
+ GROUP BY p.id
+ ORDER BY total_stars DESC, levels_completed DESC
+ LIMIT 50",
+ )?;
+ let rows = stmt
+ .query_map([], |r| {
+ Ok(LeaderboardRow {
+ name: r.get(0)?,
+ total_stars: r.get(1)?,
+ levels_completed: r.get(2)?,
+ })
+ })?
+ .collect::, _>>()?;
+ Ok(JsonResp(json!(rows)))
+}
+
+enum AppError {
+ BadRequest(String),
+ Db(rusqlite::Error),
+}
+
+impl AppError {
+ fn bad_request(msg: impl Into) -> Self {
+ Self::BadRequest(msg.into())
+ }
+}
+
+impl From for AppError {
+ fn from(e: rusqlite::Error) -> Self {
+ Self::Db(e)
+ }
+}
+
+impl IntoResponse for AppError {
+ fn into_response(self) -> axum::response::Response {
+ match self {
+ Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg).into_response(),
+ Self::Db(e) => {
+ tracing::error!(error = %e, "sqlite error");
+ (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response()
+ }
+ }
+ }
+}