app #1 simpleasm: 从 oci 迁过来,asm.famzheng.me 已上线
deploy cube / build-and-deploy (push) Successful in 1m18s
deploy simpleasm / build-and-deploy (push) Successful in 1m45s

- 后端 FastAPI 重写为 axum + rusqlite (musl static, 2.8MB)
- 前端原样搬运 (Vue3 + Vite + Pinia + vue-router + vite-plugin-yaml)
- k8s: cube-simpleasm ns + 1Gi PVC (k3s local-path) + Recreate strategy
- CI: 复刻 deploy-cube.yml,按 apps/simpleasm/** 触发
- cube 门户里 simpleasm 状态从 pending 改成 live
- 数据冷启 (Fam 拍板不带历史进度)
This commit is contained in:
Fam Zheng
2026-05-04 15:12:22 +01:00
parent 5b2e53c040
commit 388b505e0b
40 changed files with 3985 additions and 3 deletions
+51
View File
@@ -0,0 +1,51 @@
name: deploy simpleasm
# asm.famzheng.me — 汇编教学小游戏。host shell runnerfam 用户)。
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
Generated
+141
View File
@@ -2,6 +2,18 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 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]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.4" version = "1.1.4"
@@ -95,6 +107,16 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" 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]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.4" version = "1.0.4"
@@ -130,6 +152,24 @@ dependencies = [
"windows-sys", "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]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@@ -178,6 +218,24 @@ dependencies = [
"slab", "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]] [[package]]
name = "http" name = "http"
version = "1.4.0" version = "1.4.0"
@@ -282,6 +340,17 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" 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]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -395,6 +464,12 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@@ -439,6 +514,20 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" 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]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
@@ -464,6 +553,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [ dependencies = [
"serde_core", "serde_core",
"serde_derive",
] ]
[[package]] [[package]]
@@ -531,6 +621,12 @@ dependencies = [
"lazy_static", "lazy_static",
] ]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]] [[package]]
name = "signal-hook-registry" name = "signal-hook-registry"
version = "1.4.8" version = "1.4.8"
@@ -541,6 +637,19 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "simpleasm"
version = "0.1.0"
dependencies = [
"axum",
"cube-core",
"rusqlite",
"serde",
"serde_json",
"tokio",
"tracing",
]
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -777,6 +886,18 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 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]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.1+wasi-snapshot-preview1" version = "0.11.1+wasi-snapshot-preview1"
@@ -798,6 +919,26 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"
+4
View File
@@ -3,6 +3,7 @@ resolver = "2"
members = [ members = [
"crates/cube-core", "crates/cube-core",
"apps/cube", "apps/cube",
"apps/simpleasm",
] ]
[workspace.package] [workspace.package]
@@ -17,6 +18,9 @@ tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "trace"] } tower-http = { version = "0.6", features = ["fs", "trace"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } 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] [profile.release]
opt-level = "z" opt-level = "z"
+3 -3
View File
@@ -33,9 +33,9 @@ export const apps: App[] = [
{ {
slug: 'simpleasm', slug: 'simpleasm',
name: 'simpleasm', name: 'simpleasm',
description: '汇编教学/玩具。从 oci 迁移中(原 asm.oci.euphon.net。', description: '汇编教学小游戏。',
url: 'https://simpleasm.famzheng.me', url: 'https://asm.famzheng.me',
status: 'pending', status: 'live',
}, },
{ {
slug: 'guitar', slug: 'guitar',
+16
View File
@@ -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 }
+8
View File
@@ -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"]
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Simple ASM - Assembly Adventure</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+20
View File
@@ -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"
}
}
+57
View File
@@ -0,0 +1,57 @@
<template>
<router-view />
</template>
<style>
:root {
--bg-dark: #0a0e1a;
--bg-card: #141b2d;
--bg-surface: #1e2742;
--bg-hover: #253352;
--border: #2a3655;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent-blue: #3b82f6;
--accent-cyan: #06b6d4;
--accent-green: #10b981;
--accent-yellow: #f59e0b;
--accent-red: #ef4444;
--accent-purple: #8b5cf6;
--accent-pink: #ec4899;
--font-mono: 'JetBrains Mono', 'Courier New', monospace;
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--radius: 8px;
--radius-lg: 12px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font-sans);
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
}
a { color: var(--accent-cyan); text-decoration: none; }
a:hover { text-decoration: underline; }
button {
font-family: var(--font-sans);
cursor: pointer;
border: none;
border-radius: var(--radius);
padding: 8px 16px;
font-size: 14px;
transition: all 0.2s;
}
code, pre, .mono { font-family: var(--font-mono); }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
</style>
@@ -0,0 +1,106 @@
<template>
<div class="code-editor" :class="{ running: readOnly }">
<div class="editor-header">
<span>Code</span>
<span class="line-count mono">{{ instrCount }} {{ instrCount === 1 ? 'instruction' : 'instructions' }}</span>
</div>
<div class="editor-body">
<div class="line-numbers" ref="lineNumsEl">
<div v-for="n in totalLines" :key="n" class="ln"
:class="{ current: n-1 === currentLine, error: n-1 === errorLine }">{{ n }}</div>
</div>
<textarea
v-if="!readOnly"
ref="ta"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
@scroll="syncScroll"
@keydown.tab.prevent="insertTab"
spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"
></textarea>
<pre v-else class="code-display" ref="codeDisplay" @scroll="syncScroll"><code><template
v-for="(line, i) in displayLines" :key="i"
><span class="cl" :class="{ current: i === currentLine, error: i === errorLine }"
v-html="hl(line)"></span>
</template></code></pre>
</div>
</div>
</template>
<script setup>
import { computed, ref, nextTick, watch } from 'vue'
const props = defineProps({
modelValue: String,
currentLine: { type: Number, default: -1 },
errorLine: { type: Number, default: -1 },
readOnly: Boolean,
})
defineEmits(['update:modelValue'])
const ta = ref(null), lineNumsEl = ref(null), codeDisplay = ref(null)
const totalLines = computed(() => (props.modelValue||'').split('\n').length)
const displayLines = computed(() => (props.modelValue||'').split('\n'))
const instrCount = computed(() => {
let c = 0
for (const line of (props.modelValue||'').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
})
function hl(line) {
let h = line.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
h = h.replace(/(;.*)$/, '<span class="sc">$1</span>')
h = h.replace(/^(\s*\w+\s*:)/, '<span class="sl">$1</span>')
h = h.replace(/\b(MOV|ADD|SUB|MUL|XDIV|XMOD|AND|ORR|EOR|MVN|LSL|LSR|CMP|BEQ|BNE|BGT|BLT|BGE|BLE|LDR|STR|PUSH|POP|XOUT|XHLT|NOP|B)\b/gi,
'<span class="sk">$1</span>')
h = h.replace(/\b(R[0-7])\b/gi, '<span class="sr">$1</span>')
h = h.replace(/(\[[^\]]+\])/g, '<span class="sm">$1</span>')
h = h.replace(/(#(?:0x[0-9a-fA-F]+|0b[01]+|\d+))/g, '<span class="sn">$1</span>')
// Bare numbers not already highlighted
h = h.replace(/(?<![#\w])\b(\d+)\b(?![0-9a-fA-F])/g, '<span class="sn">$1</span>')
return h || ' '
}
function syncScroll(e) { if (lineNumsEl.value) lineNumsEl.value.scrollTop = e.target.scrollTop }
function insertTab() {
const el = ta.value; if (!el) return
const s = el.selectionStart, e2 = el.selectionEnd, v = props.modelValue
el.value = v.substring(0,s) + ' ' + v.substring(e2)
el.selectionStart = el.selectionEnd = s + 2
el.dispatchEvent(new Event('input'))
}
watch(() => props.currentLine, async (line) => {
if (line < 0) return; await nextTick()
const c = codeDisplay.value; if (!c) return
const el = c.querySelectorAll('.cl')[line]; if (el) el.scrollIntoView({ block: 'nearest' })
})
</script>
<style scoped>
.code-editor { display: flex; flex-direction: column; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; min-height: 0; }
.editor-header { display: flex; justify-content: space-between; padding: 6px 14px; background: var(--bg-surface); font-size: 12px; color: var(--text-muted); border-bottom: 1px solid var(--border); flex-shrink: 0; }
.editor-body { display: flex; flex: 1; min-height: 0; overflow: hidden; }
.line-numbers { flex-shrink: 0; padding: 10px 0; background: var(--bg-dark); user-select: none; min-width: 36px; overflow: hidden; border-right: 1px solid var(--border); }
.ln { padding: 0 8px 0 10px; font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-muted); text-align: right; }
.ln.current { color: var(--accent-yellow); background: rgba(245,158,11,0.1); }
.ln.error { color: var(--accent-red); background: rgba(239,68,68,0.1); }
textarea { flex: 1; padding: 10px 14px; background: var(--bg-dark); border: none; color: var(--text-primary); font-family: var(--font-mono); font-size: 13px; line-height: 1.6; resize: none; outline: none; tab-size: 2; white-space: pre; overflow: auto; }
.code-display { flex: 1; padding: 10px 14px; background: var(--bg-dark); margin: 0; overflow: auto; min-height: 0; }
.code-display code { display: block; }
.cl { display: block; font-size: 13px; line-height: 1.6; padding: 0 4px; border-radius: 2px; white-space: pre; min-height: 1.6em; }
.cl.current { background: rgba(245,158,11,0.15); border-left: 2px solid var(--accent-yellow); padding-left: 2px; }
.cl.error { background: rgba(239,68,68,0.15); border-left: 2px solid var(--accent-red); padding-left: 2px; }
:deep(.sk) { color: var(--accent-blue); font-weight: 600; }
:deep(.sr) { color: var(--accent-cyan); }
:deep(.sn) { color: var(--accent-yellow); }
:deep(.sc) { color: var(--text-muted); font-style: italic; }
:deep(.sm) { color: var(--accent-purple); }
:deep(.sl) { color: var(--accent-pink); }
</style>
@@ -0,0 +1,106 @@
<template>
<Teleport to="body">
<div class="overlay" @click.self="$emit('close')">
<div class="confetti" ref="confettiEl"></div>
<div class="card">
<div class="badge">&#127881;</div>
<h2>Level Complete!</h2>
<p class="lname">{{ level.title }}</p>
<div class="stars-row">
<span v-for="s in 3" :key="s"
class="star" :class="{ earned: s <= stars }"
:style="{ animationDelay: s * 0.3 + 's' }">&#9733;</span>
</div>
<div class="stats">
<div class="stat"><span class="sl">Instructions</span><span class="sv">{{ instructionCount }}</span></div>
<div class="stat"><span class="sl">Stars Earned</span><span class="sv">{{ stars }} / 3</span></div>
</div>
<div class="actions">
<button class="btn-retry" @click="$emit('retry')">Try Again</button>
<button v-if="level.id < 10" class="btn-next" @click="$emit('next')">Next Level &rarr;</button>
<button v-else class="btn-next" @click="$emit('close')">Back to Levels</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, onMounted } from 'vue'
defineProps({ level: Object, stars: Number, instructionCount: Number })
defineEmits(['next', 'retry', 'close'])
const confettiEl = ref(null)
onMounted(() => {
if (!confettiEl.value) return
const colors = ['#3b82f6','#06b6d4','#10b981','#f59e0b','#ef4444','#8b5cf6','#ec4899']
for (let i = 0; i < 60; i++) {
const p = document.createElement('div')
p.className = 'cp'
p.style.left = `${Math.random() * 100}%`
p.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)]
p.style.animationDuration = `${2 + Math.random() * 3}s`
p.style.animationDelay = `${Math.random() * 0.8}s`
p.style.width = `${6 + Math.random() * 8}px`
p.style.height = `${6 + Math.random() * 8}px`
confettiEl.value.appendChild(p)
}
})
</script>
<style scoped>
.overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.7);
display: flex; align-items: center; justify-content: center;
z-index: 1000;
animation: fadeIn 0.3s;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.confetti { position: fixed; inset: 0; pointer-events: none; overflow: hidden; }
:deep(.cp) {
position: absolute; top: -20px; border-radius: 2px;
animation: cf linear forwards;
}
@keyframes cf {
0% { transform: translateY(0) rotate(0deg); opacity: 1; }
100% { transform: translateY(100vh) rotate(720deg); opacity: 0; }
}
.card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 40px;
text-align: center; max-width: 400px; width: 90%;
position: relative; z-index: 1;
animation: cardPop 0.4s cubic-bezier(0.175,0.885,0.32,1.275);
}
@keyframes cardPop { from { transform: scale(0.8); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.badge { font-size: 56px; margin-bottom: 4px; }
h2 { font-size: 24px; margin-bottom: 4px; }
.lname { color: var(--text-secondary); margin-bottom: 24px; }
.stars-row { display: flex; justify-content: center; gap: 12px; margin-bottom: 24px; }
.star {
font-size: 48px; color: var(--border);
animation: sp 0.5s cubic-bezier(0.175,0.885,0.32,1.275) both;
}
.star.earned { color: var(--accent-yellow); text-shadow: 0 0 20px rgba(245,158,11,0.5); }
@keyframes sp { 0% { transform: scale(0) rotate(-180deg); opacity: 0; } 100% { transform: scale(1) rotate(0); opacity: 1; } }
.stats { display: flex; justify-content: center; gap: 32px; margin-bottom: 28px; }
.stat { text-align: center; }
.sl { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 4px; }
.sv { font-family: var(--font-mono); font-size: 20px; font-weight: 600; }
.actions { display: flex; gap: 12px; justify-content: center; }
.btn-retry { padding: 10px 24px; background: var(--bg-surface); color: var(--text-primary); border: 1px solid var(--border); }
.btn-retry:hover { background: var(--bg-hover); }
.btn-next { padding: 10px 24px; background: linear-gradient(135deg, var(--accent-blue), var(--accent-cyan)); color: white; font-weight: 600; }
.btn-next:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(59,130,246,0.3); }
</style>
@@ -0,0 +1,139 @@
<template>
<div class="machine-state">
<h3>&#9881; Machine State</h3>
<div class="section">
<h4>Registers</h4>
<div class="registers">
<div v-for="(reg, idx) in regs" :key="reg"
class="register" :class="{ changed: changedRegs.has(reg) }"
:style="{ borderLeftColor: regColors[idx] }">
<span class="reg-label" :style="{ color: regColors[idx] }">{{ reg }}</span>
<span class="reg-dec">{{ registers[reg] }}</span>
<span class="reg-hex">{{ hex(registers[reg]) }}</span>
<span class="reg-bin">{{ bin(registers[reg]) }}</span>
</div>
</div>
</div>
<div class="section">
<h4>Flags</h4>
<div class="flags-row">
<div class="flag" :class="{ on: flags.zero }"><span class="led"></span><span>Z</span></div>
<div class="flag" :class="{ on: flags.carry }"><span class="led"></span><span>C</span></div>
<div class="flag" :class="{ on: flags.negative }"><span class="led"></span><span>N</span></div>
</div>
</div>
<div v-if="stack && stack.length" class="section">
<h4>Stack ({{ stack.length }})</h4>
<div class="stack-list">
<div v-for="(v, i) in stackRev" :key="i" class="stack-item">{{ v }}</div>
</div>
</div>
<div v-if="showMemory" class="section">
<h4>Memory</h4>
<div class="memory-grid">
<div class="mg-header">
<span class="mg-corner"></span>
<span v-for="c in 16" :key="c" class="mg-ch">{{ (c-1).toString(16).toUpperCase() }}</span>
</div>
<div v-for="row in memRows" :key="row" class="mg-row">
<span class="mg-addr">{{ (row*16).toString(16).toUpperCase().padStart(2,'0') }}</span>
<span v-for="col in 16" :key="col"
class="mg-cell"
:class="{ nz: memory[row*16+col-1] !== 0, cg: changedMem.has(row*16+col-1) }">
{{ memory[row*16+col-1].toString(16).toUpperCase().padStart(2,'0') }}
</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({
registers: Object, flags: Object, memory: Array, stack: Array,
showMemory: Boolean, memoryRange: { type: Array, default: () => [0,31] }, changes: Array,
})
const regs = ['R0','R1','R2','R3','R4','R5','R6','R7']
const regColors = ['#06b6d4','#8b5cf6','#f59e0b','#ec4899','#10b981','#3b82f6','#f97316','#ef4444']
const changedRegs = ref(new Set())
const changedMem = ref(new Set())
const stackRev = computed(() => props.stack ? [...props.stack].reverse() : [])
const memRows = computed(() => {
if (!props.showMemory) return []
const s = Math.floor((props.memoryRange?.[0]??0)/16)
const e = Math.floor((props.memoryRange?.[1]??31)/16)
return Array.from({ length: e-s+1 }, (_,i) => s+i)
})
function hex(v) { return '0x' + (v&0xFFFF).toString(16).toUpperCase().padStart(4,'0') }
function bin(v) {
const b = (v&0xFFFF).toString(2).padStart(16,'0')
return b.slice(0,4)+' '+b.slice(4,8)+' '+b.slice(8,12)+' '+b.slice(12)
}
watch(() => props.changes, (ch) => {
changedRegs.value = new Set(); changedMem.value = new Set()
if (!ch) return
for (const c of ch) {
if (c.type === 'reg') changedRegs.value.add(c.name)
if (c.type === 'mem') changedMem.value.add(c.addr)
}
setTimeout(() => { changedRegs.value = new Set(); changedMem.value = new Set() }, 600)
}, { deep: true })
</script>
<style scoped>
.machine-state {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 14px; overflow-y: auto;
}
.machine-state > h3 { font-size: 13px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 14px; }
.section { margin-bottom: 16px; }
.section h4 { font-size: 11px; color: var(--text-muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
.registers { display: flex; flex-direction: column; gap: 3px; }
.register {
display: grid; grid-template-columns: 28px 44px 58px 1fr;
align-items: center; gap: 4px;
padding: 4px 6px; background: var(--bg-surface); border-radius: 4px;
border-left: 3px solid var(--border); transition: background 0.3s;
font-family: var(--font-mono); font-size: 12px;
}
.register.changed { animation: regfl 0.5s ease; }
@keyframes regfl { 0%,100% { background: var(--bg-surface); } 50% { background: rgba(59,130,246,0.2); } }
.reg-label { font-weight: 700; font-size: 11px; }
.reg-dec { font-weight: 600; text-align: right; font-size: 13px; }
.reg-hex { color: var(--text-muted); font-size: 10px; }
.reg-bin { color: var(--text-muted); font-size: 9px; letter-spacing: 0.5px; }
.flags-row { display: flex; gap: 8px; }
.flag {
display: flex; align-items: center; gap: 4px;
padding: 4px 10px; background: var(--bg-surface); border-radius: var(--radius);
font-family: var(--font-mono); font-weight: 600; font-size: 13px;
}
.led { width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted); transition: all 0.3s; }
.flag.on .led { background: var(--accent-green); box-shadow: 0 0 8px var(--accent-green); }
.stack-list { display: flex; flex-direction: column; gap: 2px; }
.stack-item { font-family: var(--font-mono); padding: 3px 12px; background: var(--bg-surface); border-radius: 4px; font-size: 12px; text-align: center; }
.memory-grid { font-family: var(--font-mono); font-size: 10px; overflow-x: auto; }
.mg-header, .mg-row { display: flex; gap: 1px; }
.mg-header { margin-bottom: 1px; }
.mg-corner { width: 22px; flex-shrink: 0; }
.mg-ch { width: 20px; text-align: center; color: var(--text-muted); }
.mg-addr { width: 22px; flex-shrink: 0; color: var(--text-muted); text-align: right; padding-right: 3px; }
.mg-cell { width: 20px; text-align: center; padding: 2px 0; background: var(--bg-dark); border-radius: 1px; color: var(--text-muted); transition: all 0.3s; }
.mg-cell.nz { color: var(--accent-cyan); background: rgba(6,182,212,0.08); }
.mg-cell.cg { animation: mfl 0.5s ease; }
@keyframes mfl { 0%,100% { background: rgba(6,182,212,0.08); } 50% { background: rgba(6,182,212,0.4); } }
</style>
@@ -0,0 +1,72 @@
<template>
<div class="output-console">
<div class="console-header">
<span>Output</span>
<button v-if="messages.length" @click="$emit('clear')" class="clear-btn">Clear</button>
</div>
<div class="console-body" ref="bodyEl">
<div v-for="(m, i) in messages" :key="i" class="console-line" :class="m.type">
<span class="pfx">{{ m.pfx }}</span>
<span>{{ m.text }}</span>
</div>
<div v-if="!messages.length" class="empty">Waiting to run...</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch, nextTick } from 'vue'
const props = defineProps({ output: Array, error: String, status: String })
defineEmits(['clear'])
const bodyEl = ref(null)
const messages = computed(() => {
const msgs = []
if (props.status) msgs.push({ type: 'info', pfx: '>', text: props.status })
for (const v of (props.output || [])) msgs.push({ type: 'output', pfx: 'OUT', text: String(v) })
if (props.error) msgs.push({ type: 'error', pfx: '!', text: props.error })
return msgs
})
watch(messages, async () => {
await nextTick()
if (bodyEl.value) bodyEl.value.scrollTop = bodyEl.value.scrollHeight
}, { deep: true })
</script>
<style scoped>
.output-console {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
flex-shrink: 0;
}
.console-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 5px 14px;
background: var(--bg-surface);
font-size: 12px;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
}
.clear-btn { padding: 2px 8px; font-size: 11px; background: transparent; color: var(--text-muted); border: 1px solid var(--border); }
.console-body {
padding: 8px 14px;
min-height: 40px;
max-height: 100px;
overflow-y: auto;
font-family: var(--font-mono);
font-size: 13px;
background: var(--bg-dark);
}
.console-line { padding: 1px 0; display: flex; gap: 8px; }
.pfx { color: var(--text-muted); flex-shrink: 0; min-width: 28px; }
.console-line.output { color: var(--accent-green); }
.console-line.error { color: var(--accent-red); }
.console-line.info { color: var(--accent-cyan); }
.empty { color: var(--text-muted); font-style: italic; font-size: 12px; }
</style>
@@ -0,0 +1,99 @@
<template>
<div class="tutorial-panel" :class="{ collapsed: !visible }">
<button class="toggle-btn" @click="$emit('toggle')" :title="visible ? 'Collapse' : 'Expand tutorial'">
{{ visible ? '◀' : '▶' }}
</button>
<div v-show="visible" class="tutorial-content">
<div class="goal-box">
<h4>&#127919; Goal</h4>
<p v-html="fmt(level.goal)"></p>
</div>
<div v-for="(sec, i) in level.tutorial" :key="i" class="tut-section">
<h4>{{ sec.title }}</h4>
<p v-html="fmt(sec.text)"></p>
<pre v-if="sec.code" class="tut-code"><code>{{ sec.code }}</code></pre>
</div>
</div>
</div>
</template>
<script setup>
defineProps({ level: Object, visible: Boolean })
defineEmits(['toggle'])
function fmt(t) {
if (!t) return ''
return t
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/\n/g, '<br>')
}
</script>
<style scoped>
.tutorial-panel {
position: relative;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
transition: min-width 0.3s;
display: flex;
flex-direction: column;
}
.tutorial-panel.collapsed { min-width: 32px !important; max-width: 32px !important; }
.toggle-btn {
position: absolute;
top: 8px;
right: 4px;
z-index: 2;
width: 24px;
height: 24px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
background: var(--bg-surface);
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: 4px;
}
.tutorial-content {
padding: 16px;
padding-right: 12px;
overflow-y: auto;
flex: 1;
}
.goal-box {
background: rgba(59,130,246,0.08);
border: 1px solid rgba(59,130,246,0.2);
border-radius: var(--radius);
padding: 12px;
margin-bottom: 20px;
}
.goal-box h4 { margin-bottom: 6px; font-size: 14px; }
.goal-box p { font-size: 14px; line-height: 1.5; color: var(--text-secondary); }
.goal-box :deep(strong) { color: var(--accent-yellow); }
.tut-section { margin-bottom: 18px; }
.tut-section h4 { font-size: 14px; margin-bottom: 6px; color: var(--accent-cyan); }
.tut-section p { font-size: 13px; line-height: 1.6; color: var(--text-secondary); }
.tut-section :deep(code) {
font-family: var(--font-mono);
background: var(--bg-surface);
padding: 1px 5px;
border-radius: 3px;
font-size: 12px;
color: var(--accent-yellow);
}
.tut-section :deep(strong) { color: var(--text-primary); font-weight: 600; }
.tut-code {
background: var(--bg-dark);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 14px;
margin-top: 8px;
overflow-x: auto;
}
.tut-code code { font-size: 12px; line-height: 1.5; color: var(--accent-green); }
</style>
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]
@@ -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
@@ -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
@@ -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]
+375
View File
@@ -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!' }
}
+9
View File
@@ -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')
@@ -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,
})
@@ -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))
},
},
})
@@ -0,0 +1,110 @@
<template>
<div class="level-select">
<header>
<router-link to="/" class="back">&larr; Home</router-link>
<div class="player-info">
<span class="player-name">{{ store.playerName }}</span>
<span class="total-stars">{{ store.totalStars }} &#11088;</span>
</div>
</header>
<h1>Select Level</h1>
<p class="desc">Complete a level to unlock the next one. Up to 3 stars per level.</p>
<div class="levels-grid">
<div
v-for="level in levels"
:key="level.id"
class="level-card"
:class="{ locked: !unlocked(level.id), completed: !!store.progress[level.id]?.completed }"
@click="go(level)"
>
<div class="level-num">{{ level.id }}</div>
<h3>{{ level.title }}</h3>
<p class="card-subtitle">{{ level.subtitle }}</p>
<p class="card-desc">{{ level.description }}</p>
<div class="stars">
<span v-for="s in 3" :key="s" :class="{ earned: s <= (store.progress[level.id]?.stars || 0) }">&#9733;</span>
</div>
<div v-if="!unlocked(level.id)" class="lock-overlay">&#128274;</div>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { useGameStore } from '../stores/game.js'
import { levels } from '../lib/levels.js'
const router = useRouter()
const store = useGameStore()
if (!store.isLoggedIn) router.replace('/')
const unlocked = (id) => store.isLevelUnlocked(id)
function go(level) {
if (unlocked(level.id)) router.push(`/level/${level.id}`)
}
</script>
<style scoped>
.level-select { max-width: 960px; margin: 0 auto; padding: 24px; }
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px; }
.back { color: var(--text-secondary); font-size: 14px; }
.player-info { display: flex; gap: 16px; align-items: center; }
.player-name { color: var(--accent-cyan); font-weight: 600; }
.total-stars { color: var(--accent-yellow); }
h1 { font-size: 28px; margin-bottom: 8px; text-align: center; }
.desc { text-align: center; color: var(--text-secondary); margin-bottom: 36px; }
.levels-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
}
.level-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 24px;
cursor: pointer;
transition: all 0.3s;
position: relative;
overflow: hidden;
}
.level-card:hover:not(.locked) {
border-color: var(--accent-blue);
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(59,130,246,0.12);
}
.level-card.completed { border-color: rgba(16,185,129,0.4); }
.level-card.locked { opacity: 0.45; cursor: not-allowed; }
.level-num {
position: absolute;
top: 10px;
right: 16px;
font-family: var(--font-mono);
font-size: 52px;
font-weight: 700;
color: var(--border);
line-height: 1;
}
.level-icon { font-size: 32px; margin-bottom: 10px; }
.level-card h3 { font-size: 18px; margin-bottom: 4px; }
.card-subtitle { font-size: 13px; color: var(--accent-cyan); margin-bottom: 8px; }
.card-desc { font-size: 13px; color: var(--text-secondary); margin-bottom: 16px; line-height: 1.4; }
.stars { display: flex; gap: 4px; font-size: 20px; }
.stars span { color: var(--border); transition: color 0.3s; }
.stars span.earned { color: var(--accent-yellow); }
.lock-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 40px;
background: rgba(10,14,26,0.5);
backdrop-filter: blur(2px);
}
</style>
@@ -0,0 +1,270 @@
<template>
<div class="level-page">
<header class="level-header">
<router-link to="/levels" class="back-link">&larr; Back</router-link>
<div class="hdr-center">
<span class="level-badge">Level {{ level.id }}</span>
<h1>{{ level.title }}</h1>
</div>
<div class="hdr-stars">
<span v-for="s in 3" :key="s" :class="{ earned: s <= bestStars }">&#9733;</span>
</div>
</header>
<div class="level-layout">
<TutorialPanel :level="level" :visible="showTut" @toggle="showTut = !showTut" class="panel-tut" />
<div class="panel-center">
<CodeEditor v-model="code" :currentLine="curLine" :errorLine="errLine" :readOnly="mode !== 'edit'" />
<div class="controls">
<div class="ctrls-left">
<button class="cb run" @click="runCode" :disabled="mode === 'auto'">&#9654; Run</button>
<button class="cb step" @click="stepCode" :disabled="mode === 'auto'">
&rarr;{{ mode === 'step' ? ' Next' : ' Step' }}
</button>
<button v-if="mode === 'edit'" class="cb auto" @click="autoStep">&#9193; Animate</button>
<button v-if="mode !== 'edit'" class="cb reset" @click="resetVM">&#8634; Reset</button>
</div>
<div v-if="mode === 'auto'" class="speed">
<label>Speed</label>
<input type="range" min="50" max="800" :value="800-delay" @input="delay=800-+$event.target.value">
</div>
</div>
<OutputConsole :output="out" :error="vmErr" :status="statusMsg" @clear="clearOut" />
</div>
<MachineState :registers="regs" :flags="fl" :memory="mem" :stack="stk"
:showMemory="level.showMemory" :memoryRange="level.memoryRange"
:changes="changes" class="panel-state" />
</div>
<div class="hints-bar">
<button class="hint-btn" @click="nextHint" :disabled="hintIdx >= level.hints.length - 1">
&#128161; Hint ({{ Math.min(hintIdx+2, level.hints.length) }}/{{ level.hints.length }})
</button>
<div v-if="hintIdx >= 0" class="hint-text">{{ level.hints[hintIdx] }}</div>
</div>
<LevelComplete v-if="showComp" :level="level" :stars="earnedStars" :instructionCount="iCount"
@next="goNext" @retry="doRetry" @close="showComp = false" />
</div>
</template>
<script setup>
import { ref, reactive, computed, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useGameStore } from '../stores/game.js'
import { levels } from '../lib/levels.js'
import { createVM, countInstructions, validateLevel, parse } from '../lib/vm.js'
import TutorialPanel from '../components/TutorialPanel.vue'
import CodeEditor from '../components/CodeEditor.vue'
import OutputConsole from '../components/OutputConsole.vue'
import MachineState from '../components/MachineState.vue'
import LevelComplete from '../components/LevelComplete.vue'
const props = defineProps({ id: [String, Number] })
const router = useRouter()
const store = useGameStore()
if (!store.isLoggedIn) router.replace('/')
const level = computed(() => levels.find(l => l.id === +props.id) || levels[0])
const bestStars = computed(() => store.progress[level.value.id]?.stars || 0)
const code = ref(store.progress[level.value.id]?.code || level.value.starterCode)
const mode = ref('edit')
const showTut = ref(true)
const showComp = ref(false)
const earnedStars = ref(0)
const hintIdx = ref(-1)
const delay = ref(200)
const EMPTY_REGS = { R0:0,R1:0,R2:0,R3:0,R4:0,R5:0,R6:0,R7:0 }
const regs = reactive({ ...EMPTY_REGS })
const fl = reactive({ zero:false, carry:false, negative:false, overflow:false })
const mem = ref(new Array(256).fill(0))
const stk = ref([])
const out = ref([])
const vmErr = ref('')
const statusMsg = ref('')
const curLine = ref(-1)
const errLine = ref(-1)
const changes = ref([])
const iCount = ref(0)
let vm = null
let autoTimer = null
function applyInitState() {
Object.assign(regs, EMPTY_REGS)
const m = new Array(256).fill(0)
const is = level.value.initialState
if (is) {
if (is.registers) Object.assign(regs, is.registers)
if (is.memory) for (const [a,v] of Object.entries(is.memory)) m[+a] = v
}
mem.value = m
fl.zero = false; fl.carry = false; fl.negative = false; fl.overflow = false
stk.value = []; out.value = []
}
function initVM() {
vm = createVM()
if (level.value.blockedOps) {
const p = parse(code.value)
for (const i of p.instructions) {
if (level.value.blockedOps.includes(i.opcode)) {
vmErr.value = `This level doesn't allow ${i.opcode}.`; return false
}
}
}
const r = vm.loadProgram(code.value)
if (vm.state.error) { vmErr.value = vm.state.error; if (r.errors?.length) errLine.value = r.errors[0].line; return false }
const is = level.value.initialState
if (is) {
if (is.registers) Object.assign(vm.state.registers, is.registers)
if (is.memory) for (const [a,v] of Object.entries(is.memory)) vm.state.memory[+a] = v
if (is.input) vm.state.input = [...is.input]
}
syncState(); return true
}
function syncState() {
if (!vm) return
Object.assign(regs, vm.state.registers)
Object.assign(fl, vm.state.flags)
mem.value = [...vm.state.memory]
stk.value = [...vm.state.stack]
out.value = [...vm.state.output]
if (vm.state.error) vmErr.value = vm.state.error
}
function runCode() {
stopAuto(); vmErr.value = ''; errLine.value = -1; mode.value = 'run'
if (!initVM()) { mode.value = 'edit'; return }
vm.run(); syncState(); curLine.value = -1
iCount.value = countInstructions(code.value)
if (vm.state.error) {
statusMsg.value = 'Runtime error'
if (vm.state.pc < vm.state.instructions.length) errLine.value = vm.state.instructions[vm.state.pc]?.srcLine ?? -1
return
}
checkResult()
}
function stepCode() {
if (mode.value === 'edit' || mode.value === 'run') {
vmErr.value = ''; errLine.value = -1; mode.value = 'step'
if (!initVM()) { mode.value = 'edit'; return }
statusMsg.value = 'Stepping...'
}
if (vm.state.halted || vm.state.error) { iCount.value = countInstructions(code.value); checkResult(); return }
const r = vm.step()
if (r) { changes.value = r.changes || []; curLine.value = r.srcLine ?? -1 }
syncState()
if (vm.state.halted || vm.state.error) { iCount.value = countInstructions(code.value); if (vm.state.halted) checkResult() }
}
function autoStep() {
vmErr.value = ''; errLine.value = -1; mode.value = 'auto'
if (!initVM()) { mode.value = 'edit'; return }
statusMsg.value = 'Animating...'; doAuto()
}
function doAuto() {
if (!vm || vm.state.halted || vm.state.error || mode.value !== 'auto') {
if (vm?.state.halted) { iCount.value = countInstructions(code.value); checkResult() }
syncState(); return
}
const r = vm.step()
if (r) { changes.value = r.changes || []; curLine.value = r.srcLine ?? -1 }
syncState()
autoTimer = setTimeout(doAuto, delay.value)
}
function stopAuto() { if (autoTimer) { clearTimeout(autoTimer); autoTimer = null } }
function resetVM() {
stopAuto(); vm = null; mode.value = 'edit'
curLine.value = -1; errLine.value = -1; vmErr.value = ''; statusMsg.value = ''
changes.value = []; applyInitState()
}
function clearOut() { out.value = []; vmErr.value = ''; statusMsg.value = '' }
function checkResult() {
const tv = createVM(); const lp = tv.loadProgram(code.value)
if (lp.errors && lp.errors.length > 0) {
vmErr.value = lp.errors[0].msg; statusMsg.value = 'Not passing yet — try again!'
return
}
const r = validateLevel(level.value, tv)
if (r.passed) {
iCount.value = countInstructions(code.value)
const [s3, s2] = level.value.starThresholds
earnedStars.value = iCount.value <= s3 ? 3 : iCount.value <= s2 ? 2 : 1
store.saveProgress(level.value.id, earnedStars.value, code.value)
showComp.value = true; statusMsg.value = 'Level complete!'
} else {
vmErr.value = r.msg; statusMsg.value = 'Not passing yet — try again!'
}
}
function nextHint() { if (hintIdx.value < level.value.hints.length - 1) hintIdx.value++ }
function goNext() { showComp.value = false; const n = level.value.id + 1; router.push(n <= 10 ? `/level/${n}` : '/levels') }
function doRetry() { showComp.value = false; resetVM() }
watch(() => props.id, () => {
stopAuto(); vm = null; mode.value = 'edit'
code.value = store.progress[level.value.id]?.code || level.value.starterCode
hintIdx.value = -1; showComp.value = false; curLine.value = -1; errLine.value = -1
vmErr.value = ''; statusMsg.value = ''; changes.value = []; applyInitState()
})
applyInitState()
onUnmounted(() => stopAuto())
</script>
<style scoped>
.level-page { height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
.level-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 20px; background: var(--bg-card); border-bottom: 1px solid var(--border); flex-shrink: 0; }
.back-link { color: var(--text-secondary); font-size: 14px; min-width: 60px; }
.hdr-center { text-align: center; }
.level-badge { font-size: 12px; color: var(--text-muted); }
.level-header h1 { font-size: 18px; font-weight: 600; }
.hdr-stars { display: flex; gap: 4px; font-size: 20px; min-width: 60px; justify-content: flex-end; }
.hdr-stars span { color: var(--border); }
.hdr-stars span.earned { color: var(--accent-yellow); }
.level-layout { flex: 1; display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; padding: 10px; overflow: hidden; min-height: 0; }
.panel-tut { overflow-y: auto; min-height: 0; }
.panel-center { display: flex; flex-direction: column; gap: 6px; min-height: 0; overflow: hidden; }
.panel-center .code-editor { flex: 1; min-height: 0; overflow: hidden; }
.panel-state { overflow-y: auto; min-height: 0; }
.controls { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-shrink: 0; }
.ctrls-left { display: flex; gap: 5px; }
.cb { padding: 6px 12px; font-size: 13px; font-weight: 500; }
.cb.run { background: var(--accent-green); color: #fff; }
.cb.run:hover { background: #0d9668; }
.cb.step { background: var(--accent-blue); color: #fff; }
.cb.step:hover { background: #2563eb; }
.cb.auto { background: var(--accent-purple); color: #fff; }
.cb.auto:hover { background: #7c3aed; }
.cb.reset { background: var(--bg-surface); color: var(--text-primary); border: 1px solid var(--border); }
.cb.reset:hover { background: var(--bg-hover); }
.cb:disabled { opacity: 0.5; cursor: not-allowed; }
.speed { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-muted); }
.speed input[type="range"] { width: 100px; accent-color: var(--accent-purple); }
.hints-bar { display: flex; align-items: center; gap: 12px; padding: 7px 20px; background: var(--bg-card); border-top: 1px solid var(--border); flex-shrink: 0; }
.hint-btn { padding: 5px 14px; background: rgba(245,158,11,0.1); color: var(--accent-yellow); border: 1px solid rgba(245,158,11,0.3); font-size: 13px; white-space: nowrap; flex-shrink: 0; }
.hint-btn:hover:not(:disabled) { background: rgba(245,158,11,0.2); }
.hint-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.hint-text { font-size: 13px; color: var(--accent-yellow); font-family: var(--font-mono); }
@media (max-width: 1024px) {
.level-layout { grid-template-columns: 1fr; }
.panel-tut { max-height: 200px; }
.panel-state { max-height: 250px; }
}
</style>
@@ -0,0 +1,158 @@
<template>
<div class="welcome">
<div class="binary-rain" ref="rainEl"></div>
<div class="welcome-card">
<div class="logo">
<span class="logo-icon">&#9889;</span>
<h1>Simple ASM</h1>
<p class="subtitle">Assembly Adventure</p>
</div>
<p class="intro">Ready to explore how computers think?<br>Enter your name to begin the adventure!</p>
<form @submit.prevent="start" v-if="!store.isLoggedIn">
<input v-model="name" type="text" placeholder="Enter your name..." autofocus maxlength="20" class="name-input" />
<button type="submit" class="start-btn" :disabled="!name.trim()">Start Adventure &rarr;</button>
</form>
<div v-else class="returning">
<p>Welcome back, <strong>{{ store.playerName }}</strong>!</p>
<p class="stat-line">You have <span class="star-count">{{ store.totalStars }}</span> stars</p>
<router-link to="/levels" class="continue-btn">Continue &rarr;</router-link>
<button class="logout-btn" @click="store.logout()">Switch name</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useGameStore } from '../stores/game.js'
const router = useRouter()
const store = useGameStore()
const name = ref('')
const rainEl = ref(null)
async function start() {
const n = name.value.trim()
if (!n) return
await store.login(n)
router.push('/levels')
}
onMounted(() => {
if (!rainEl.value) return
for (let i = 0; i < 25; i++) {
const col = document.createElement('div')
col.className = 'rain-col'
col.style.left = `${(i / 25) * 100 + Math.random() * 4}%`
col.style.animationDuration = `${5 + Math.random() * 8}s`
col.style.animationDelay = `${-Math.random() * 10}s`
col.style.opacity = String(0.08 + Math.random() * 0.12)
col.textContent = Array.from({ length: 50 }, () => Math.random() > 0.5 ? '1' : '0').join('\n')
rainEl.value.appendChild(col)
}
})
</script>
<style scoped>
.welcome {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
position: relative;
overflow: hidden;
}
.binary-rain { position: absolute; inset: 0; pointer-events: none; }
.rain-col {
position: absolute;
top: -60%;
font-family: var(--font-mono);
font-size: 14px;
line-height: 1.4;
color: var(--accent-blue);
white-space: pre;
animation: rain linear infinite;
user-select: none;
}
@keyframes rain {
from { transform: translateY(-40%); }
to { transform: translateY(110vh); }
}
.welcome-card {
position: relative;
z-index: 1;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 48px 40px;
max-width: 460px;
width: 92%;
text-align: center;
box-shadow: 0 0 80px rgba(59,130,246,0.08);
}
.logo { margin-bottom: 20px; }
.logo-icon { font-size: 48px; display: block; margin-bottom: 4px; }
.logo h1 {
font-family: var(--font-mono);
font-size: 36px;
font-weight: 700;
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle { font-size: 16px; color: var(--text-secondary); margin-top: 4px; }
.intro { color: var(--text-secondary); line-height: 1.6; margin-bottom: 28px; font-size: 15px; }
.name-input {
width: 100%;
padding: 14px 18px;
background: var(--bg-surface);
border: 2px solid var(--border);
border-radius: var(--radius);
color: var(--text-primary);
font-size: 16px;
font-family: var(--font-sans);
outline: none;
transition: border-color 0.3s;
margin-bottom: 16px;
}
.name-input:focus { border-color: var(--accent-blue); box-shadow: 0 0 0 3px rgba(59,130,246,0.2); }
.name-input::placeholder { color: var(--text-muted); }
.start-btn {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, var(--accent-blue), var(--accent-cyan));
color: white;
font-size: 18px;
font-weight: 600;
}
.start-btn:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(59,130,246,0.4); }
.start-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.returning { margin-top: 8px; }
.returning p { color: var(--text-secondary); margin-bottom: 8px; }
.returning strong { color: var(--accent-cyan); }
.stat-line { margin-bottom: 20px !important; }
.star-count { color: var(--accent-yellow); font-weight: 700; font-size: 18px; }
.continue-btn {
display: inline-block;
padding: 14px 32px;
background: linear-gradient(135deg, var(--accent-blue), var(--accent-cyan));
border-radius: var(--radius);
color: white;
font-weight: 600;
font-size: 16px;
transition: all 0.3s;
margin-bottom: 12px;
}
.continue-btn:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(59,130,246,0.4); text-decoration: none; }
.logout-btn {
display: block;
margin: 0 auto;
background: transparent;
color: var(--text-muted);
font-size: 13px;
padding: 4px 12px;
}
.logout-btn:hover { color: var(--text-secondary); }
</style>
+12
View File
@@ -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'
}
}
})
+58
View File
@@ -0,0 +1,58 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: simpleasm
namespace: cube-simpleasm
labels:
app: simpleasm
spec:
replicas: 1
strategy:
# PVC 是 RWOrolling 上线时新旧 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
+18
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: cube-simpleasm
+12
View File
@@ -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-pathhostPath,单节点足够)
+12
View File
@@ -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
+214
View File
@@ -0,0 +1,214 @@
//! asm.famzheng.me — 汇编教学小游戏。
//!
//! 4 个 endpoint
//! - `GET /api/health` 前端 ping 用
//! - `POST /api/players` 按 name upsert,返回 id + 已有进度 map
//! - `POST /api/progress` 单关卡 upsertstars 只能增加,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<Mutex<Connection>>;
#[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<Db>,
Json(data): Json<PlayerCreate>,
) -> Result<impl IntoResponse, AppError> {
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<String, ProgressItem> = 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<String>>(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<Db>,
Json(data): Json<ProgressSave>,
) -> Result<impl IntoResponse, AppError> {
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<Db>) -> Result<impl IntoResponse, AppError> {
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::<Result<Vec<_>, _>>()?;
Ok(JsonResp(json!(rows)))
}
enum AppError {
BadRequest(String),
Db(rusqlite::Error),
}
impl AppError {
fn bad_request(msg: impl Into<String>) -> Self {
Self::BadRequest(msg.into())
}
}
impl From<rusqlite::Error> 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()
}
}
}
}