diff --git a/.claude/skills/xbcs-package/SKILL.md b/.claude/skills/xbcs-package/SKILL.md new file mode 100644 index 0000000000..dff3e06b12 --- /dev/null +++ b/.claude/skills/xbcs-package/SKILL.md @@ -0,0 +1,193 @@ +--- +name: xbcs-package +description: Unpack, edit, validate, and repack XBuilder course-series packages (`.xbcs.zip`) and the project files inside them (`.xbp`). Use this whenever the user mentions a course package, `.xbcs`, `.xbp`, 课程包, exporting or importing a course series, a course-package upload that failed or errored, a course that looks wrong after import, or wants to change a course's prompt / config / stage / assets and hand a package back for upload. Reach for it before repacking anything — rebuilding a `.xbp` with `zip -r`, Finder's Compress, or any GUI archiver silently corrupts it in a way the importer cannot survive, and the bundled script is the only safe way to rebuild one. +--- + +# Course-series packages (`.xbcs.zip`) + +A course series round-trips through a single archive: **export → edit offline → import**. Editing +happens outside the app because a series is 30-odd courses × 40-odd files each, and nobody is going +to click through that. The archive is therefore the real authoring surface, and the importer trusts +it almost completely — there is one format check and then everything is taken at face value. + +That trust is why this skill exists. A malformed archive does not get rejected with a helpful +message; it either dies mid-import having already overwritten half the projects, or imports +"successfully" into something broken. So: **always validate before handing a package back**, and +never rebuild a `.xbp` with a general-purpose zip tool. + +## The trap, and why it matters + +`xbpHelpers.load` ([spx-gui/src/models/common/xbp.ts](../../../spx-gui/src/models/common/xbp.ts)) +turns **every zip entry** into a project file: + +```ts +Object.keys(unzipped).map(async (path) => { ... files[path] = file }) +``` + +The app's exporter builds `.xbp` from an in-memory file map via fflate, so it emits file entries and +nothing else. `zip -r`, Finder's Compress, WinRAR, Keka, 360压缩 and friends walk a real filesystem +and additionally write **directory entries** — `assets/`, `assets/sprites/Lita/` — which the loader +happily ingests as project files with a path ending in `/`, zero bytes, and an empty `File.name`. +Each one is then uploaded to storage and written into the project's file index. A 31-course package +picks up ~330 of them. That is the failure mode behind "the designer's upload doesn't work". + +Two fingerprints tell you a `.xbp` was repacked by a filesystem tool: directory entries, and the +UTF-8 filename flag (general-purpose bit 11) missing on non-ASCII names. The app forces UTF-8 +decoding so the flag alone is harmless, but it confirms the diagnosis. + +`scripts/xbcs.py pack` writes only file entries, in the paths the manifest declares. Use it. + +## Workflow + +```bash +# 1. explode the archive — every .xbp becomes a directory you can edit +python3 .claude/skills/xbcs-package/scripts/xbcs.py unpack "in.xbcs.zip" work/ + +# 2. see what you're dealing with before touching anything +python3 .claude/skills/xbcs-package/scripts/xbcs.py inspect work/ + +# 3. edit files under work/ with normal tools + +# 4. rebuild — reads the manifest and emits exactly the declared paths +python3 .claude/skills/xbcs-package/scripts/xbcs.py pack work/ "out.xbcs.zip" + +# 5. prove it before handing it over +python3 .claude/skills/xbcs-package/scripts/xbcs.py validate "out.xbcs.zip" +``` + +`validate` also accepts an unpacked directory, so you can check your edits before packing. Run it on +the **input** too when you're diagnosing someone else's package — that is usually the whole job. + +The unpacked layout mirrors the archive, with each `projects/N.xbp` exploded into `projects/N/`: + +``` +work/ +├── course-series.json # the manifest: series meta + courses[] + projects[] +├── thumbnails/course-series.jpeg +├── thumbnails/courses/0.jpg # index = position in courses[] +└── projects/0/ # was projects/0.xbp + ├── builder-meta.json + ├── main.spx, Lita.spx, … # the code the learner sees + └── assets/… # index.json + costumes/sounds/backdrops +``` + +`pack` derives each `.xbp` path from `projects[].path` in the manifest rather than from the directory +listing, so a stray folder can't silently become a project and a renamed folder fails loudly instead +of producing a package that's missing an entry. + +## What you can and can't change + +Most edits are safe. These are the ones that bite: + +- **`course-series.json` is the source of truth for paths.** If you add, remove, or reorder courses, + the `courses[].thumbnail.path` and `projects[].path` values must keep matching real entries — the + importer looks them up verbatim, case- and separator-sensitive, and a miss aborts the import. +- **`projects[].fullName` must cover every course's entrypoint project.** The importer rewrites + `/editor///…` entrypoints to point at the newly imported copies, but only for owners + it has a mapping for. An entrypoint whose `/` is absent from `projects[]` is left + untouched, so the imported course silently points at the *original* owner's project and 404s at + course-start time. `validate` catches this. +- **Costume, animation, sound and sprite names are referenced by the `.spx` code.** `setCostume "萝卜"` + breaks the moment you rename that costume in `index.json`. Rename in both places or neither. +- **`builder-meta.json.type` must be `game`**; anything else is a hard import failure. Keep + `displayName` equal to the manifest's `projects[].name` — a stale one (the usual leftover from + duplicating a project) shows the wrong course number in the editor. +- **Length limits are server-enforced** and surface as an opaque `40001`: course title ≤200, course + prompt ≤4000, series title ≤200, series description ≤400, project name ≤100 and `^[\w-]+$`. +- **The output filename must end in `.xbcs.zip`.** The import file picker filters on that exact + suffix, so a `.zip` or `.xbcs` can't even be selected — an "upload problem" that never reaches any + code. + +For the course `prompt` itself — the ```jsonc config block, `opening`, `judge`/`complete`, `apis`, +`` — follow [docs/product/course-authoring.md](../../../docs/product/course-authoring.md). +That document is the authority; don't restate its rules here. + +## The edits that pass validation and still break the course + +`validate` reasons about the file. It cannot know whether a course still *works*, and the most +damaging edits are the ones that leave the archive perfectly well-formed. A course is three things +that have to agree — the config, the project the config describes, and the prose the learner reads — +and an edit request usually names only one of them. + +`complete.count` is the clearest case. It counts distinct output lines containing `complete.log` +within one run, so it is a claim about the project: setting it to 6 in a course whose stage holds +four carrots means the success dialog can never open, and the prose that says 「收集四个萝卜」 now +contradicts it. Nothing about that is detectable from the manifest. + +The same shape recurs elsewhere: narrowing `apis` hides a panel entry the reference answer still +uses; an `opening` spotlight targets a landmark the course's `hide` list just removed; renaming a +sprite orphans an `entrypoint` that names it in its path. + +So when an instruction changes one of the three, check the other two and say what you found. Carrying +out the edit and flagging the inconsistency is right; silently "fixing" the project or rewriting the +prose to match is not — which of the three was wrong is the author's call. + +## Diagnosing a package someone else built + +When a package fails to import, the useful question is not just "what is broken" but "what did they +actually change". Fetch or locate the last known-good export of the same series and compare: manifest +field by field, and each `.xbp`'s file map entry by entry. + +That separates the two kinds of difference, which want opposite responses. Intended edits — a new +thumbnail, a reworded prompt — you preserve. Packaging damage — directory entries, dropped UTF-8 +flags, `.DS_Store` — you discard wholesale by repacking. Without the comparison it is easy to +"repair" a package into something that quietly drops the author's work, or to preserve junk because +it looked deliberate. + +It also answers a question the author will ask: whether this round contained any real change at all. +A package whose only differences are three broken hand-edits and a bad repack has no content to +preserve, and saying so plainly is more useful than handing back a fixed archive. + +Know which failures are NOT the package's, and say so early instead of digging deeper into the +archive. The tells: the import succeeded but **every course misbehaves identically** (a bad package +breaks specifically — one course, one asset, one config), or the console shows a TypeError deep in +app code rather than an import error. That shape means the app↔backend contract changed underneath +the data — the backend serving fields the frontend doesn't expect, or omitting ones it requires — +and a `.xbcs` diff cannot explain it. Check the network response for one course against the +frontend's `Course` type before touching the package again; a package that `validate` passes and +that imported cleanly has done its job. + +Deeper structure (manifest schema field by field, `.xbp` anatomy, sprite `index.json` layout, the +full list of import failure paths) lives in [references/format.md](references/format.md). Read it +when an edit goes beyond swapping text or images. + +## Reading the validator + +Output is `ERROR` / `WARN` lines plus a one-line verdict, and the exit code is non-zero when there +is at least one error. + +- **ERROR** — the import will fail, or will succeed into something broken. Fix before handing over. +- **WARN** — legal but suspicious: a stale `displayName`, a thumbnail whose extension disagrees with + its actual bytes, an asset extension the app has no MIME for, a zero-byte asset. Judge each on its + merits; some are intentional (empty `main.spx` in a course with no starting code is normal). + +When a package fails to import and `validate` is clean, the remaining suspects are not in the file: + +- The person importing must be **signed in as the account that owns the series**. The importer looks + up each `projects[].name` under the *signed-in* user; a non-owner gets 31 fresh projects created + under their own account and then a 403 on the final series update. +- Import **overwrites** same-named projects under that account and forces them public, cutting a new + release for each. That is by design, and the confirm dialog is the only warning. +- It is serial: N projects × ~40 file uploads, no transaction and no rollback. A rate limit or a + network blip partway through leaves projects overwritten and courses orphaned. Retrying is safe + (it's idempotent per project), but a partial run is a real state to reason about. + +Say which of these you've ruled out rather than just reporting "the file is fine". + +## Verifying behavior, not just structure + +`validate` proves the package will import. It says nothing about whether a course *works* — whether +the stage lays out right, whether the code the learner is meant to write actually finishes, whether +`complete.log` / `complete.count` match what the project prints. + +For that, drive the real engine through the dev-only harness at `/devtools/course-runner` (registered +only under `import.meta.env.DEV`, so local dev server only): + +```js +await courseRunner.loadXbp('/0.xbp') // served from spx-gui/public/ +const r = await courseRunner.run({ code: { Lita: 'step 100\nturn Right' }, timeoutMs: 15000 }) +r.logs // [{ level: 'INFO', msg: '捡到萝卜 Radish', … }] +``` + +A page session only survives a handful of engine instances — reload between runs rather than +concluding the course broke. diff --git a/.claude/skills/xbcs-package/evals/evals.json b/.claude/skills/xbcs-package/evals/evals.json new file mode 100644 index 0000000000..8712c3b0e5 --- /dev/null +++ b/.claude/skills/xbcs-package/evals/evals.json @@ -0,0 +1,54 @@ +{ + "skill_name": "xbcs-package", + "notes": "Fixtures live in /Users/lita/lita-workdir/xbcs-package-workspace/fixtures. 'Code_ Lita (designer upload).xbcs.zip' is a 31-course package repacked the way a filesystem zip tool does it (directory entries in every .xbp, a stray .DS_Store) plus three hand-edit faults: course 3's thumbnail path points at a non-existent 'thumbnails/courses/2.PNG', course 5's entrypoint names a project absent from projects[], and course 7's jsonc config block has a syntax error. 'Code_ Lita.xbcs.zip' is the same series, clean.", + "evals": [ + { + "id": 0, + "name": "diagnose-and-fix-failed-upload", + "prompt": "设计师改了课程以后上传失败了,包在 /Users/lita/lita-workdir/xbcs-package-workspace/fixtures/Code_ Lita (designer upload).xbcs.zip 。帮我看看问题出在哪里。能修的话就修好,给我一个能直接上传的包,放到 OUTPUT_DIR 。", + "expected_output": "A diagnosis naming directory entries inside the .xbp payloads as the reason the import breaks (each becomes a bogus zero-byte project file), plus the three manifest faults, and a repacked .xbcs.zip in OUTPUT_DIR with all of them fixed and the course content otherwise untouched.", + "files": ["fixtures/Code_ Lita (designer upload).xbcs.zip"], + "assertions": [ + "Identifies directory entries inside the .xbp files as the cause of the failed upload", + "Output package exists in OUTPUT_DIR and its filename ends in .xbcs.zip", + "No .xbp in the output contains any directory entry", + "The stray projects/.DS_Store entry is gone from the output", + "Course 3's declared thumbnail path resolves to an entry that exists in the output", + "Course 5's entrypoint project appears in the manifest's projects[] list", + "Course 7's jsonc config block parses as JSON after comment stripping", + "The output still has 31 courses and 31 projects", + "Course prompts and titles are unchanged apart from course 7's syntax fix" + ] + }, + { + "id": 1, + "name": "edit-course-config-and-repack", + "prompt": "基于 /Users/lita/lita-workdir/xbcs-package-workspace/fixtures/Code_ Lita.xbcs.zip 改两处课程配置:第 13 课的 complete.count 改成 6;第 5 课的 apis 加上 turnTo(原来的 step 和 turn 要保留)。改完重新打包成 OUTPUT_DIR/Code_ Lita.xbcs.zip,我要上传。", + "expected_output": "A repacked archive where course 13's complete.count is 6 and course 5's apis contains step, turn and turnTo, with everything else — including all 31 project payloads — byte-for-byte unchanged and no directory entries introduced.", + "files": ["fixtures/Code_ Lita.xbcs.zip"], + "assertions": [ + "Output archive exists at OUTPUT_DIR/Code_ Lita.xbcs.zip", + "Course 13's config block has complete.count == 6", + "Course 5's apis list contains turnTo and still contains step and turn", + "No other course's jsonc config block changed", + "No .xbp in the output contains any directory entry", + "Every project payload's file set and bytes match the input package" + ] + }, + { + "id": 2, + "name": "replace-thumbnail-and-repack", + "prompt": "设计师重做了第 3 课的封面,新图在 /Users/lita/lita-workdir/xbcs-package-workspace/fixtures/course-3-new-cover.png 。把它换进 /Users/lita/lita-workdir/xbcs-package-workspace/fixtures/Code_ Lita.xbcs.zip 的第 3 课缩略图,重新打包到 OUTPUT_DIR/ 。", + "expected_output": "A repacked archive whose course-3 thumbnail is the new PNG, with the manifest's declared path and the entry's extension both reflecting PNG (the extension is what decides the uploaded MIME type, so leaving it as .jpeg mislabels the file). No dangling old thumbnail entry, nothing else changed.", + "files": ["fixtures/Code_ Lita.xbcs.zip", "fixtures/course-3-new-cover.png"], + "assertions": [ + "Output archive exists in OUTPUT_DIR with a .xbcs.zip filename", + "The entry at course 3's declared thumbnail path is byte-identical to course-3-new-cover.png", + "Course 3's declared thumbnail path ends in .png, matching the actual PNG bytes", + "The old thumbnails/courses/2.jpeg entry is not left in the archive unreferenced", + "No other course's thumbnail bytes changed", + "No .xbp in the output contains any directory entry" + ] + } + ] +} diff --git a/.claude/skills/xbcs-package/references/format.md b/.claude/skills/xbcs-package/references/format.md new file mode 100644 index 0000000000..107b62a292 --- /dev/null +++ b/.claude/skills/xbcs-package/references/format.md @@ -0,0 +1,190 @@ +# `.xbcs.zip` / `.xbp` reference + +Read this when an edit goes beyond swapping text or images — when you need to know exactly what a +field means, what the importer does with it, or how a project's assets are wired together. + +- [Archive layout](#archive-layout) +- [The manifest](#the-manifest) +- [Inside a `.xbp`](#inside-a-xbp) +- [Asset configs](#asset-configs) +- [What import actually does](#what-import-actually-does) +- [What export actually does](#what-export-actually-does) + +Source of truth, if this drifts: +[course-series-file.ts](../../../../spx-gui/src/components/course/management/course-series-file.ts) (import/export), +[xbp.ts](../../../../spx-gui/src/models/common/xbp.ts) (project payload), +[zip.ts](../../../../spx-gui/src/utils/zip.ts) (filename decoding). + +## Archive layout + +``` +course-series.json # the manifest +thumbnails/course-series. +thumbnails/courses/. # i = position in courses[] +projects/.xbp # j = position in projects[] +``` + +Indices in the file names are only conventional — the manifest's `path` fields are what the importer +reads, verbatim and case-sensitively. Nothing outside those declared paths is looked at, so extra +entries are inert (and a wrapping top-level folder is fatal: the manifest is no longer at the root). + +## The manifest + +```jsonc +{ + "format": "xbuilder-course-series", // both checked; the only friendly error the importer gives + "version": 1, + "courseSeries": { + "title": "Code: Lita", // <=200 + "description": "…", // <=400 + "thumbnail": { "path": "thumbnails/course-series.jpeg" } + }, + "courses": [{ + "title": "1. 你好,Lita", // <=200 + "entrypoint": "/editor/curator/Lita-Course-01/sprites/Lita/code", + "references": [], // [{ type: "project", fullName: "owner/name" }, …] + "prompt": "```jsonc\n…\n```\n\n## 目标\n…", // <=4000; see docs/product/course-authoring.md + "thumbnail": { "path": "thumbnails/courses/0.jpg" } + }], + "projects": [{ + "fullName": "curator/Lita-Course-01", // the exported source, used as a rewrite key + "name": "Lita-Course-01", // ^[\w-]+$, <=100; the name created on import + "path": "projects/0.xbp" + }] +} +``` + +`courseSeries.order` is deliberately not exported — sort order depends on the other series in the +target environment, so import keeps the local value. + +`courses` and `projects` are independent lists joined only through `entrypoint` / `references`. There +is no requirement that they be the same length or in the same order, but every course's entrypoint +project must appear in `projects[]`, otherwise the importer can't rewrite the owner and the course +ends up pointing at the export's original owner. That failure is silent at import time and shows up +as a 404 when a learner opens the course. + +`fullName` is a *lookup key*, not a destination: import maps `fullName` → whatever it actually created +(`/`) and rewrites entrypoints and references through that map. Only exact +`owner/name` segment matches are rewritten, so `curator/Lita-Course-1` is not touched by a mapping +for `curator/Lita-Course-01`. + +## Inside a `.xbp` + +A `.xbp` is a zip whose every entry becomes a project file, with two names treated specially: + +``` +builder-meta.json # { type, displayName, description, instructions, extraSettings } +builder-thumbnail. # optional; recognized by basename, becomes the project thumbnail +main.spx # stage code +.spx # per-sprite code +assets/index.json # stage config (backdrops, map, …) +assets/.png +assets/sprites//index.json +assets/sprites//.png|svg +assets/sounds//index.json +assets/sounds//.wav|mp3 +``` + +`type` must be `game`; a missing `type` defaults to `game` for backward compatibility, anything else +is a hard failure. `displayName` should equal the manifest's `projects[].name` — import uses +`serialized.metadata.displayName ?? project.name`, so a stale value survives and mislabels the +project in the editor. + +Everything else in the zip is copied verbatim into the project's file map, keyed by its zip path. +This is why directory entries are destructive: `assets/sprites/Lita/` becomes a project file at that +path, zero bytes, with `File.name === ''` (the segment after the final slash). See SKILL.md. + +## Asset configs + +The `.spx` code addresses assets **by name**, so names in these configs are part of the program. +`setCostume "萝卜"` and `animate "行走"` break if you rename the costume or animation without editing +the code too. + +**Sprite** — `assets/sprites//index.json`: + +```jsonc +{ + "costumes": [ + { "name": "kiko", "path": "kiko.png", + "x": 75, "y": 72, // pivot, in RAW image pixels + "bitmapResolution": 4 } // displayed size = raw / bitmapResolution (1 for SVG) + ], + "fAnimations": { + "行走": { "frameFrom": "…-1", "frameTo": "…-6", "frameFps": 10, + "onStart": { "play": "草地行走" } } + }, + "animBindings": { "step": "行走" }, // which animation an action plays + "heading": 90, + "rotationStyle": "normal", // none | normal | left-right + "faceRight": 0 +} +``` + +`frameFrom`/`frameTo` name a **contiguous range** of costumes in `costumes[]` order — the frames +between them are played in array order, so inserting a costume in the middle silently changes an +animation. + +**Every sprite needs at least one costume no animation references.** The editor's sprite model +pulls animation-referenced costumes out of the wearable-costume list (frames are not costumes +there); a sprite whose animations consume every costume loads with an empty costume list and +renders **nothing in edit mode** — while the engine does no such extraction, so the game and the +course-runner harness look perfectly fine. That split (runtime OK, editor invisible) is the +signature. `validate` checks this. + +`rotationStyle: "normal"` rotates the artwork with the heading, which is what top-down art drawn +**facing right** wants. Art drawn facing down looks wrong under any rotation style; the fix is the +artwork, not the config. + +**Sound** — `assets/sounds//index.json`: `{ "path": "水声.wav", … }`. + +**Stage** — `assets/index.json`: backdrops and map config, with `backdrops[].path` relative to +`assets/`. + +Paths in these configs resolve relative to the config's own directory (a leading `assets/` also +works). `xbcs.py validate` checks every one of them. + +## What import actually does + +Order matters because there is no transaction and no rollback: + +1. **For each project, serially**: load the `.xbp`; look up `/`; if it exists, + **overwrite it** (files, thumbnail, displayName) and force `visibility: public`; otherwise create + it. Then cut a project release. +2. **For each course, serially**: upload its thumbnail, rewrite `entrypoint` and `references` through + the project map, create the course. +3. Update the series to point at the new course IDs. +4. Delete the old courses. + +Consequences worth stating out loud when you hand a package over: + +- The importer must be **signed in as the account that owns the series and the projects**. A + non-owner silently creates a full parallel set of projects under their own account and then fails + on step 3 with a 403. +- Same-named projects are **overwritten and published**, and the confirm dialog is the only warning. +- A failure at any step leaves projects already overwritten and released, courses possibly created, + and the series still pointing at the old ones. Re-running is safe per project but the intermediate + state is real. +- It is N × ~40 serial file uploads. Rate limiting (`42900` / `42901`) is a plausible failure on a + large series and looks like a random mid-import death. + +Server-side limits surface as bare API codes: `40001` invalid args (the length limits above, or a +project name outside `^[\w-]+$`), `40300` forbidden, `40301` quota, `41300` content too large, +`41500` unsupported media type. + +The importer's only schema check is `format` + `version`. Everything else — missing `projects`, +`courses` not an array, a `thumbnail` that isn't an object — comes out as an unguarded `TypeError` +behind a generic "failed to import" toast. That is why `xbcs.py validate` checks these itself. + +## What export actually does + +Exports come from the **latest release** for public projects and the draft otherwise +(`preferPublishedContent = true`), so an exported archive is not necessarily what the curator last +saved. A referenced project that is private or belongs to someone else aborts the export with a +403/404. + +Thumbnails are named from the stored file's own name, which for storage keys usually has no +extension — so real exports frequently emit `.jpg` for what is actually PNG or WebP bytes. That is +where "extension disagrees with the actual image data" warnings come from; they are inherited, not +something you introduced, and the app tolerates them. + +Export requires every thumbnail to already be uploaded, and drops `courseSeries.order` by design. diff --git a/.claude/skills/xbcs-package/scripts/xbcs.py b/.claude/skills/xbcs-package/scripts/xbcs.py new file mode 100644 index 0000000000..a5d6730fbb --- /dev/null +++ b/.claude/skills/xbcs-package/scripts/xbcs.py @@ -0,0 +1,729 @@ +#!/usr/bin/env python3 +"""Unpack / inspect / validate / pack XBuilder course-series packages (`.xbcs.zip`). + +The importer takes an archive almost entirely at face value, so a malformed one fails mid-import +rather than being rejected up front. `validate` encodes what the importer actually requires; +`pack` writes archives the way the app's exporter does — file entries only, no directory entries, +because the `.xbp` loader turns every zip entry into a project file. + +Usage: + xbcs.py unpack + xbcs.py pack + xbcs.py validate + xbcs.py inspect +""" + +import json +import os +import re +import sys +import unicodedata +import zipfile +from urllib.parse import urlparse + +MANIFEST = "course-series.json" +FORMAT = "xbuilder-course-series" +VERSION = 1 + +# Mirrors ext2mime in spx-gui/src/utils/file.ts — anything else uploads with no MIME type. +KNOWN_EXTS = { + "png", "jpg", "jpeg", "gif", "svg", "bmp", "webp", "avif", + "mp3", "wav", "ogg", "webm", "json", "spx", "gmx", "md", "hash", +} + +LIMITS = { + "course_title": 200, + "course_prompt": 4000, + "series_title": 200, + "series_description": 400, + "project_name": 100, +} + +JUNK = re.compile(r"(^|/)(__MACOSX|\.DS_Store|\._)") +PROJECT_NAME = re.compile(r"^[\w-]+$") +LARGE_FILE = 8 * 1024 * 1024 +BIG_THUMBNAIL = 2 * 1024 * 1024 + +IMAGE_MAGIC = [ + (b"\x89PNG\r\n\x1a\n", "png"), + (b"\xff\xd8\xff", "jpg"), + (b"GIF87a", "gif"), + (b"GIF89a", "gif"), + (b"BM", "bmp"), +] + + +# --------------------------------------------------------------------------- reading + + +def read_archive(path): + """Return {entry_path: bytes} for an archive, plus the raw ZipInfo list.""" + with zipfile.ZipFile(path) as zf: + infos = zf.infolist() + data = {i.filename: zf.read(i) for i in infos if not i.filename.endswith("/")} + return data, infos + + +def read_dir(root): + """Return {entry_path: bytes} for an unpacked tree, with projects// re-zipped in memory.""" + manifest = json.loads(_read(os.path.join(root, MANIFEST))) + data = {MANIFEST: canonical_manifest_bytes(manifest)} + for thumb in thumbnail_paths(manifest): + p = os.path.join(root, thumb) + if os.path.isfile(p): + data[thumb] = _read(p) + for project in manifest.get("projects", []): + rel = project.get("path") + if not isinstance(rel, str): + continue + src = os.path.join(root, strip_ext(rel)) + if os.path.isdir(src): + data[rel] = zip_tree(src) + elif os.path.isfile(os.path.join(root, rel)): + data[rel] = _read(os.path.join(root, rel)) + return data, None + + +def load(target): + """Read either an archive or an unpacked directory into the same in-memory shape.""" + if os.path.isdir(target): + return read_dir(target) + return read_archive(target) + + +def _read(path): + with open(path, "rb") as f: + return f.read() + + +def strip_ext(path): + base, _ = os.path.splitext(path) + return base + + +def thumbnail_paths(manifest): + paths = [] + series_thumb = (manifest.get("courseSeries") or {}).get("thumbnail") or {} + if isinstance(series_thumb.get("path"), str): + paths.append(series_thumb["path"]) + for course in manifest.get("courses") or []: + thumb = course.get("thumbnail") or {} + if isinstance(thumb.get("path"), str): + paths.append(thumb["path"]) + return paths + + +def canonical_manifest_bytes(manifest): + return json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8") + + +# --------------------------------------------------------------------------- writing + + +def zip_bytes(entries): + """Zip an in-memory {path: bytes} map. File entries only, fixed timestamps. + + Directory entries are what break `.xbp` — the loader ingests each one as a zero-byte project + file with an empty name. Nothing here can produce one, which is the point of this helper. + """ + import io + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf: + for name in sorted(entries): + info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + info.external_attr = 0o644 << 16 + info.compress_type = zipfile.ZIP_DEFLATED + zf.writestr(info, entries[name]) + return buf.getvalue() + + +def zip_tree(root): + entries = {} + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d != "__MACOSX"] + for name in filenames: + if name == ".DS_Store" or name.startswith("._"): + continue + full = os.path.join(dirpath, name) + rel = os.path.relpath(full, root).replace(os.sep, "/") + entries[rel] = _read(full) + return zip_bytes(entries) + + +# --------------------------------------------------------------------------- commands + + +def cmd_unpack(archive, outdir): + data, infos = read_archive(archive) + if MANIFEST not in data: + wrapped = [i.filename for i in infos if i.filename.endswith("/" + MANIFEST)] + hint = f" (found it nested at {wrapped[0]} — the archive has a wrapping folder)" if wrapped else "" + die(f"missing {MANIFEST}{hint}") + manifest = json.loads(data[MANIFEST].decode("utf-8-sig")) + + os.makedirs(outdir, exist_ok=True) + write_file(os.path.join(outdir, MANIFEST), canonical_manifest_bytes(manifest)) + for thumb in thumbnail_paths(manifest): + if thumb in data: + write_file(os.path.join(outdir, thumb), data[thumb]) + + projects = 0 + for project in manifest.get("projects", []): + rel = project.get("path") + if not isinstance(rel, str) or rel not in data: + continue + dest = os.path.join(outdir, strip_ext(rel)) + explode(data[rel], dest) + projects += 1 + + print(f"unpacked {len(manifest.get('courses', []))} courses, {projects} projects -> {outdir}") + print(f"edit under {outdir}, then: xbcs.py pack {outdir} ") + + +def explode(xbp_bytes, dest): + import io + + with zipfile.ZipFile(io.BytesIO(xbp_bytes)) as zf: + for info in zf.infolist(): + if info.filename.endswith("/"): + continue + write_file(os.path.join(dest, *info.filename.split("/")), zf.read(info)) + + +def write_file(path, data): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as f: + f.write(data) + + +def cmd_pack(root, out): + if not out.endswith(".xbcs.zip"): + die(f"output must end in .xbcs.zip (the import file picker filters on it): {out}") + manifest = json.loads(_read(os.path.join(root, MANIFEST))) + entries = {MANIFEST: canonical_manifest_bytes(manifest)} + + for thumb in thumbnail_paths(manifest): + src = os.path.join(root, thumb) + if not os.path.isfile(src): + die(f"manifest declares {thumb} but it is not in {root}") + entries[thumb] = _read(src) + + for project in manifest.get("projects", []): + rel = project.get("path") + if not isinstance(rel, str): + die(f"project entry without a path: {project}") + src = os.path.join(root, strip_ext(rel)) + if os.path.isdir(src): + entries[rel] = zip_tree(src) + elif os.path.isfile(os.path.join(root, rel)): + entries[rel] = _read(os.path.join(root, rel)) + else: + die(f"manifest declares {rel} but neither {strip_ext(rel)}/ nor {rel} exists in {root}") + + with open(out, "wb") as f: + f.write(zip_bytes(entries)) + size = os.path.getsize(out) + print(f"packed {len(manifest.get('projects', []))} projects -> {out} ({size:,} bytes)") + print(f"now verify it: xbcs.py validate '{out}'") + + +def cmd_inspect(target): + data, _ = load(target) + manifest = json.loads(data[MANIFEST].decode("utf-8-sig")) + series = manifest.get("courseSeries") or {} + print(f"series : {series.get('title')!r} ({len(manifest.get('courses', []))} courses, " + f"{len(manifest.get('projects', []))} projects)") + print(f"{'#':>3} {pad('title', 26)} {pad('judge', 8)} {pad('complete', 16)} " + f"{pad('opening', 26)} apis") + for i, course in enumerate(manifest.get("courses", [])): + cfg = course_config(course.get("prompt", "")) or {} + complete = cfg.get("complete") or {} + complete_s = f"{complete.get('log')}×{complete.get('count')}" if complete else "" + opening = "+".join("/".join(sorted(s)) for s in (cfg.get("opening") or []) if isinstance(s, dict)) + print(f"{i + 1:>3} {pad(course.get('title', ''), 26)} {pad(cfg.get('judge') or '', 8)} " + f"{pad(complete_s, 16)} {pad(opening, 26)} {','.join(cfg.get('apis') or [])}") + + +def display_width(s): + """CJK titles are double-width, so len() misaligns every column in the inspect table.""" + return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in str(s)) + + +def pad(s, n): + s = str(s) + if display_width(s) > n: + kept = "" + for c in s: + if display_width(kept + c) > n - 1: + break + kept += c + s = kept + "…" + return s + " " * max(0, n - display_width(s)) + + +def course_config(prompt): + """Parse the leading ```jsonc block of a course prompt. None if absent or unparseable.""" + block = re.search(r"```jsonc\s*\n([\s\S]*?)\n```", prompt or "") + if block is None: + return None + try: + return json.loads(strip_jsonc(block.group(1))) + except json.JSONDecodeError: + return None + + +# --------------------------------------------------------------------------- validate + + +class Report: + """Collects findings and collapses repeats. + + One systemic fault (a repack, a bad asset convention) hits every project in the series. Printing + it 31 times buries the two hand-edit mistakes that are the actual news, so findings tagged with + the same `kind` print once with the affected locations listed after them. + """ + + def __init__(self): + self.findings = [] + + def error(self, msg, kind=None, where=None): + self.findings.append(("ERROR", kind, where, msg)) + + def warn(self, msg, kind=None, where=None): + self.findings.append(("WARN", kind, where, msg)) + + @property + def errors(self): + return [f for f in self.findings if f[0] == "ERROR"] + + def finish(self, target): + for level in ("ERROR", "WARN"): + for members in self._groups(level): + print(f"{level:<5} {members[0][1]}") + if len(members) > 1: + places = [w for w, _ in members if w] + shown = ", ".join(places[:6]) + more = f" (+{len(places) - 6} more)" if len(places) > 6 else "" + print(f" ↳ same in {len(members)} places: {shown}{more}") + + errors, warns = len(self.errors), len(self.findings) - len(self.errors) + name = os.path.basename(target.rstrip("/")) or target + if errors: + print(f"\n{errors} error(s), {warns} warning(s) — {name} will not import cleanly.") + return 1 + print(f"\n0 errors, {warns} warning(s) — {name} is structurally sound.") + print("If the import still fails, it isn't the file: check that whoever imports is signed in " + "as the account owning the series (a non-owner creates duplicate projects, then 403s), " + "and that a previous partial import didn't leave the series half-updated.") + return 0 + + def _groups(self, level): + groups, order = {}, [] + for lv, kind, where, msg in self.findings: + if lv != level: + continue + key = kind if kind is not None else ("~unique", len(order)) + if key not in groups: + groups[key] = [] + order.append(key) + groups[key].append((where, msg)) + return [groups[k] for k in order] + + +def cmd_validate(target): + r = Report() + is_archive = not os.path.isdir(target) + + if is_archive and not target.endswith(".xbcs.zip"): + r.error(f"filename must end in .xbcs.zip — the import file picker filters on that exact " + f"suffix, so {os.path.basename(target)} cannot even be selected") + + try: + data, infos = load(target) + except zipfile.BadZipFile as e: + print(f"ERROR not a readable zip: {e}") + return 1 + except FileNotFoundError as e: + print(f"ERROR {e}") + return 1 + + if MANIFEST not in data: + nested = [k for k in data if k.endswith("/" + MANIFEST)] + r.error(f"missing {MANIFEST}" + ( + f" — found nested at {nested[0]}, so the archive has a wrapping folder; " + f"re-zip from inside the directory" if nested else "")) + return r.finish(target) + + raw = data[MANIFEST] + if raw.startswith(b"\xef\xbb\xbf"): + r.warn(f"{MANIFEST} starts with a UTF-8 BOM (tolerated, but a sign of a hand edit)") + try: + manifest = json.loads(raw.decode("utf-8-sig")) + except json.JSONDecodeError as e: + r.error(f"{MANIFEST} is not valid JSON: {e}") + return r.finish(target) + + if manifest.get("format") != FORMAT or manifest.get("version") != VERSION: + r.error(f"format/version must be {FORMAT!r}/{VERSION}, got " + f"{manifest.get('format')!r}/{manifest.get('version')!r}") + for key in ("courseSeries", "courses", "projects"): + if key not in manifest: + r.error(f"{MANIFEST} has no {key!r} — the importer dereferences it unguarded and dies " + f"with an opaque TypeError") + if r.errors: + return r.finish(target) + + for name in data: + if JUNK.search(name): + r.error(f"junk entry {name} would be imported as a project/asset file", + kind="junk", where=name) + + if infos is not None: + outer_dirs = [i.filename for i in infos if i.filename.endswith("/")] + if outer_dirs: + r.warn(f"{len(outer_dirs)} directory entries in the outer zip " + f"(harmless — only declared paths are read — but a sign of a filesystem re-zip)") + + check_series(manifest, data, r) + project_names = check_projects(manifest, data, r) + check_courses(manifest, data, project_names, r) + return r.finish(target) + + +def check_series(manifest, data, r): + series = manifest.get("courseSeries") or {} + check_len(r, "series title", series.get("title", ""), LIMITS["series_title"]) + check_len(r, "series description", series.get("description", ""), LIMITS["series_description"]) + thumb = (series.get("thumbnail") or {}).get("path") + if not isinstance(thumb, str): + r.error("courseSeries.thumbnail.path is missing") + else: + check_thumbnail(r, thumb, data, "series thumbnail") + + +def check_projects(manifest, data, r): + """Validate every project payload. Returns {fullName: name} for entrypoint cross-checking.""" + import io + + full_names = {} + seen_names = {} + for project in manifest.get("projects") or []: + rel, name, full = project.get("path"), project.get("name"), project.get("fullName") + label = rel or name or repr(project) + + if not isinstance(name, str) or not PROJECT_NAME.match(name): + r.error(f"{label}: project name {name!r} must match ^[\\w-]+$ (the server rejects " + f"anything else with an opaque 40001)") + elif len(name) > LIMITS["project_name"]: + r.error(f"{label}: project name is {len(name)} chars (limit {LIMITS['project_name']})") + if isinstance(name, str): + if name in seen_names: + r.error(f"{label}: duplicate project name {name!r} (also {seen_names[name]}) — the " + f"second import overwrites the first") + seen_names[name] = label + if isinstance(full, str): + if len(full.split("/")) != 2 or "" in full.split("/"): + r.error(f"{label}: fullName {full!r} must be /") + full_names[full] = name + + if not isinstance(rel, str) or rel not in data: + r.error(f"{label}: declared path {rel!r} is not in the archive (lookup is verbatim and " + f"case-sensitive)") + continue + + try: + with zipfile.ZipFile(io.BytesIO(data[rel])) as zf: + infos = zf.infolist() + files = {i.filename: zf.read(i) for i in infos if not i.filename.endswith("/")} + except zipfile.BadZipFile as e: + r.error(f"{rel}: not a readable zip ({e})") + continue + + dirs = [i.filename for i in infos if i.filename.endswith("/")] + if dirs: + r.error(f"{rel}: {len(dirs)} directory entries (e.g. {dirs[:2]}) — the .xbp loader turns " + f"every zip entry into a project file, so each becomes a zero-byte file with an " + f"empty name that gets uploaded and indexed. Repack with `xbcs.py pack`, never " + f"with zip -r or a GUI archiver", kind="xbp-dirs", where=rel) + no_flag = [i.filename for i in infos + if not i.filename.isascii() and not (i.flag_bits & 0x800)] + if no_flag: + r.warn(f"{rel}: {len(no_flag)} non-ASCII names without the UTF-8 flag — the app forces " + f"UTF-8 so this is survivable, but it confirms a filesystem re-zip", + kind="xbp-utf8", where=rel) + + check_project_files(r, rel, name, files) + + for name in data: + if name.endswith(".xbp") and name not in {p.get("path") for p in manifest.get("projects") or []}: + r.warn(f"{name} is in the archive but not declared in projects[] — it will be ignored") + return full_names + + +def check_project_files(r, rel, project_name, files): + for junk in [k for k in files if JUNK.search(k)]: + r.error(f"{rel}: junk entry {junk} would become a project file", + kind="xbp-junk", where=f"{rel}!{junk}") + + if "builder-meta.json" not in files: + r.error(f"{rel}: no builder-meta.json") + else: + try: + meta = json.loads(files["builder-meta.json"].decode("utf-8-sig")) + except json.JSONDecodeError as e: + r.error(f"{rel}: builder-meta.json is not valid JSON ({e})") + meta = None + if meta is not None: + if meta.get("type", "game") != "game": + r.error(f"{rel}: project type {meta.get('type')!r} is not supported (must be 'game')") + if project_name and meta.get("displayName") != project_name: + r.warn(f"{rel}: displayName {meta.get('displayName')!r} != project name " + f"{project_name!r} — usually a leftover from duplicating a project; the " + f"editor will show the wrong course", kind="displayname", where=rel) + + for path, blob in files.items(): + ext = path.rsplit(".", 1)[-1].lower() if "." in path.rsplit("/", 1)[-1] else "" + if ext and ext not in KNOWN_EXTS: + r.warn(f"{rel}: {path} has extension .{ext}, which the app has no MIME type for", + kind=f"ext-{ext}", where=f"{rel}!{path}") + if len(blob) == 0 and not path.endswith(".spx"): + r.warn(f"{rel}: {path} is empty", kind="empty-file", where=f"{rel}!{path}") + if len(blob) > LARGE_FILE: + r.warn(f"{rel}: {path} is {len(blob):,} bytes — check it against the upload size limit", + kind="large-file", where=f"{rel}!{path}") + + check_asset_refs(r, rel, files) + + +def check_asset_refs(r, rel, files): + """An index.json pointing at a costume/sound that isn't in the package breaks the project.""" + for path, blob in files.items(): + if not path.endswith("index.json"): + continue + try: + config = json.loads(blob.decode("utf-8-sig")) + except json.JSONDecodeError as e: + r.error(f"{rel}: {path} is not valid JSON ({e})") + continue + directory = path[: path.rfind("/") + 1] + refs = [] + for key in ("costumes", "backdrops"): + for item in config.get(key) or []: + if isinstance(item, dict) and isinstance(item.get("path"), str): + refs.append(item["path"]) + if isinstance(config.get("path"), str): + refs.append(config["path"]) + for ref in refs: + if ref not in files and (directory + ref) not in files: + r.error(f"{rel}: {path} references {ref!r}, which is not in the package", + kind="asset-ref", where=f"{rel}!{path}") + + # Sprite configs only: the editor removes animation-referenced costumes from the wearable + # list (animation frames are not costumes there), so a sprite whose animations consume + # every costume renders NOTHING in edit mode — while the engine, which does no such + # extraction, renders it fine. The rabbit survived on a standalone `kiko.png`; hand-built + # sprites tend to forget the standalone costume. + if "/sprites/" in path and isinstance(config.get("costumes"), list): + names = [c.get("name") for c in config["costumes"] if isinstance(c, dict)] + consumed = set() + for anim in (config.get("fAnimations") or {}).values(): + if not isinstance(anim, dict): + continue + f, t = anim.get("frameFrom"), anim.get("frameTo") + if f in names and t in names: + consumed.update(names[names.index(f):names.index(t) + 1]) + if names and not [n for n in names if n not in consumed]: + r.error(f"{rel}: {path} has every costume consumed by fAnimations — the sprite " + f"will be INVISIBLE in the editor (the engine still renders it, so runtime " + f"tests pass). Add one costume no animation references, like the original " + f"sprites' standalone default costume", + kind="all-costumes-animated", where=f"{rel}!{path}") + + +def check_courses(manifest, data, project_names, r): + for i, course in enumerate(manifest.get("courses") or []): + label = f"course {i + 1} ({course.get('title')!r})" + check_len(r, f"{label} title", course.get("title", ""), LIMITS["course_title"], label="") + check_len(r, f"{label} prompt", course.get("prompt", ""), LIMITS["course_prompt"], label="") + + thumb = (course.get("thumbnail") or {}).get("path") + if not isinstance(thumb, str): + r.error(f"{label}: thumbnail.path is missing") + else: + check_thumbnail(r, thumb, data, label) + + entry = course.get("entrypoint") + parsed = parse_entrypoint(entry) if isinstance(entry, str) else None + if parsed is None: + r.error(f"{label}: entrypoint {entry!r} does not parse as /editor///…") + elif parsed not in project_names: + r.error(f"{label}: entrypoint project {parsed!r} is not in projects[], so the importer " + f"leaves the entrypoint pointing at the original owner and the course 404s at " + f"course-start time") + + for ref in course.get("references") or []: + if not isinstance(ref, dict) or ref.get("type") != "project": + continue + full = ref.get("fullName") + if not isinstance(full, str) or len(full.split("/")) != 2 or "" in full.split("/"): + r.error(f"{label}: reference fullName {full!r} must be / — the importer " + f"throws on anything else") + elif full not in project_names: + r.warn(f"{label}: reference {full!r} is not in projects[]; it will keep pointing at " + f"the original owner") + + check_course_config(r, label, course.get("prompt", "")) + + +def check_course_config(r, label, prompt): + block = re.search(r"```jsonc\s*\n([\s\S]*?)\n```", prompt) + if block is None: + r.warn(f"{label}: no ```jsonc config block — the course falls back to legacy " + f"copilot-driven setup") + return + try: + config = json.loads(strip_jsonc(block.group(1))) + except json.JSONDecodeError as e: + r.error(f"{label}: the jsonc config block does not parse ({e}); the frontend silently falls " + f"back to an empty config, so hide/apis/opening/judge are all lost") + return + + judge = config.get("judge", "code") + if judge not in ("code", "copilot"): + r.warn(f"{label}: judge {judge!r} is neither 'code' nor 'copilot'") + complete = config.get("complete") + if judge == "code" and isinstance(complete, dict): + if not complete.get("log"): + r.error(f"{label}: complete.log is empty, so completion can never be detected") + count = complete.get("count", 1) + if not isinstance(count, int) or count < 1: + r.error(f"{label}: complete.count {count!r} must be an integer >= 1") + for step in config.get("opening") or []: + if not isinstance(step, dict): + r.warn(f"{label}: opening step {step!r} is not an object and will be dropped") + elif not ({"prelude", "video", "spotlight"} & set(step)): + r.warn(f"{label}: opening step {list(step)} has no prelude/video/spotlight key and will " + f"be dropped") + + +def strip_jsonc(text): + """Drop // comments and trailing commas, leaving string contents alone.""" + out = [] + in_string = escaped = False + i = 0 + while i < len(text): + ch = text[i] + if in_string: + out.append(ch) + if escaped: + escaped = False + elif ch == "\\": + escaped = True + elif ch == '"': + in_string = False + i += 1 + continue + if ch == '"': + in_string = True + out.append(ch) + i += 1 + continue + if text.startswith("//", i): + while i < len(text) and text[i] != "\n": + i += 1 + continue + if text.startswith("/*", i): + end = text.find("*/", i + 2) + i = len(text) if end == -1 else end + 2 + continue + out.append(ch) + i += 1 + return re.sub(r",(\s*[}\]])", r"\1", "".join(out)) + + +def parse_entrypoint(value): + """Mirror the importer: only /editor///… is recognized and rewritten.""" + path = urlparse(value).path if "://" in value else urlparse(value).path + segments = [s for s in path.split("/") if s] + if len(segments) < 3 or segments[0] != "editor": + return None + return f"{segments[1]}/{segments[2]}" + + +def check_thumbnail(r, path, data, label): + if path not in data: + r.error(f"{label}: declared thumbnail {path!r} is not in the archive") + return + blob = data[path] + if len(blob) > BIG_THUMBNAIL: + r.warn(f"{label}: {path} is {len(blob):,} bytes for a card image — worth downscaling; the " + f"whole archive has to travel through the browser on both export and import", + kind="thumb-size", where=path) + ext = path.rsplit(".", 1)[-1].lower() + if ext not in KNOWN_EXTS: + r.warn(f"{label}: thumbnail extension .{ext} has no MIME mapping; it will upload untyped", + kind=f"thumb-ext-{ext}", where=path) + actual = sniff_image(blob) + if actual is None: + r.warn(f"{label}: {path} does not look like an image", kind="thumb-notimage", where=path) + elif actual != ext and not (actual == "jpg" and ext == "jpeg"): + r.warn(f"{label}: {path} is actually {actual.upper()} data — the extension decides the " + f"uploaded MIME type, so it will be served as the wrong type", + kind="thumb-ext", where=path) + + +def sniff_image(blob): + for magic, kind in IMAGE_MAGIC: + if blob.startswith(magic): + return kind + if blob[:4] == b"RIFF" and blob[8:12] == b"WEBP": + return "webp" + if blob[4:12] in (b"ftypavif", b"ftypavis"): + return "avif" + head = blob[:200].lstrip() + if head.startswith(b" limit: + r.error(f"{what} is {len(value)} chars, over the server limit of {limit} " + f"(surfaces as an opaque 40001)") + + +# --------------------------------------------------------------------------- entry + + +def die(msg): + print(f"ERROR {msg}", file=sys.stderr) + sys.exit(1) + + +def main(argv): + if len(argv) < 2: + print(__doc__) + return 2 + command, args = argv[1], argv[2:] + try: + if command == "unpack" and len(args) == 2: + cmd_unpack(*args) + elif command == "pack" and len(args) == 2: + cmd_pack(*args) + elif command == "validate" and len(args) == 1: + return cmd_validate(*args) + elif command == "inspect" and len(args) == 1: + cmd_inspect(*args) + else: + print(__doc__) + return 2 + except (json.JSONDecodeError, zipfile.BadZipFile, OSError) as e: + die(f"{type(e).__name__}: {e}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/.codex/skills/xbcs-package b/.codex/skills/xbcs-package new file mode 120000 index 0000000000..ca19adf37c --- /dev/null +++ b/.codex/skills/xbcs-package @@ -0,0 +1 @@ +../../.claude/skills/xbcs-package \ No newline at end of file diff --git a/.github/skills/xbcs-package b/.github/skills/xbcs-package new file mode 120000 index 0000000000..ca19adf37c --- /dev/null +++ b/.github/skills/xbcs-package @@ -0,0 +1 @@ +../../.claude/skills/xbcs-package \ No newline at end of file diff --git a/docs/develop/tutorial/code-lita-series.md b/docs/develop/tutorial/code-lita-series.md new file mode 100644 index 0000000000..6509160ff9 --- /dev/null +++ b/docs/develop/tutorial/code-lita-series.md @@ -0,0 +1,106 @@ +# Code: Lita —— 28 课的课程系列 + +一个系列,把初学者从「原来这里有人能帮我」一路带到独立写出一整个程序。 +每一课都是**小猫 Lita** 捡萝卜的关卡;游戏世界始终不变,所以每个新概念都出现在熟悉的背景之上。 + +## 设计规则 + +**每课最多引入一个新东西。** 大约三分之一的课程引入新知识,其余都是变体、组合与挑战。 +一个概念先被引入,再用镜像关卡练一遍,然后和之前学的组合起来,每隔几课来一次**从零开始的大挑战**—— +没有任何脚手架,逼你把学过的东西自己拼起来。 + +**轮换的不只是内容,还有形式。** 一门课打开时是三种形态之一: + +| 形态 | 编辑器打开时 | 用在哪些课 | +|---|---|---| +| **从零写** | 空的 | 1、4、6–10、12、16–18、20、22、24–26、28 | +| **补全脚手架** | 带 `// 该做什么呢` 注释的框架 | 14、15、21、23、27 | +| **改写起手码** | 已有代码(可能是故意写得不太对的) | 2、3、5、11、13、19 | + +**世界规则在它第一次起作用的地方教**,一句话讲完,之后不再重复: +碰到萝卜就捡起(第 2 课)、树挡路(第 5 课)、围栏挡路而石子路能走(第 7 课)、 +萝卜分生熟且生的收不了(第 15 课)、浇水能加快成熟(第 26 课)。 + +**完成与否由游戏结果判定,绝不比对代码。** 每一份提示词都明确写了这一点: +只要萝卜被收集了,这课就算过,不管代码写成什么样。 + +**先有动机,再给工具。** 先让人累,再给解药:第 10 课**故意**让学习者把同样两行写四遍, +第 19 课就用**同一个关卡**把 `repeat` 递给他。第 12 课让 Lita 像螃蟹一样横着滑过去, +第 13 课才给出 `turnTo` ——去修一件他自己已经觉得滑稽的事。 + +## 七个模块 + +| # | 课程 | 新知识点 | 关卡 | +|---|---|---|---| +| **一** | **认识环境** || +| 1 | 你好,Lita | 向 copilot 求助 | 1 个萝卜,不需要写代码 | +| 2 | 第一次运行 | 运行按钮;碰到即捡 | `step 160` 已写好 | +| **二** | **走与转** || +| 3 | 量一量 | 数字就是距离;**尺子** | 距离 233 —— 猜起来很痛苦 | +| 4 | 自己写第一行 | API 参考面板是本词典 | 距离 187,空编辑器 | +| 5 | 转个弯 | `turn Right`;树挡路 | 第一段给好,自己接上转弯 | +| 6 | 另一边 | —(镜像:`turn Left`) | 镜像关卡,从零写 | +| 7 | 绕过去 | —(**思维**:先规划路线) | 4 棵树组成的墙,U 形绕行 | +| 8 | 其实是数字 | **`Right` 一直都是 `90`**;尺子能量角度 | 斜方向的萝卜:`turn 63` | +| 9 | 负数的方向 | `Left` 就是 `-90` —— 角度可以是负的 | 另一侧斜方向:`turn -45` | +| 10 | 大挑战 I | —(step + turn 综合) | 4 个萝卜围成正方形;**故意写得很啰嗦** | +| **三** | **对象** || +| 11 | 叫它的名字 | `stepTo` —— 东西是有名字的 | 手算的角度差那么一点点 | +| 12 | 一个接一个 | —(强化) | 3 个萝卜;Lita 横着滑 | +| 13 | 转过身来 | `turnTo` | 治好螃蟹走路 | +| **四** | **条件** || +| 14 | 如果 | `if` 和 `==`;**每次运行朝向随机** | 代码要能应付作者也不知道的情况 | +| 15 | 要么这样,要么那样 | `if / else`;生熟规则;`IsMature()` | 两个萝卜随机一生一熟 | +| 16 | 大挑战 II | —(两个未知同时出现) | 朝向随机**且**生熟随机 | +| **五** | **数据** || +| 17 | 给数字起名字 | `var` | 三段等长的路;改一处全变 | +| 18 | 改变它 | 赋值(`=` 对照第 14 课的 `==`) | 递减的台阶:160/120/80/40 | +| **六** | **循环** || +| 19 | 重复的味道 | `repeat` | **和第 10 课同一个关卡**,重构 | +| 20 | 螺旋 | —(循环 × 角度 × 变量) | 向内收的螺旋 | +| 21 | 一筐萝卜 | 数组 + `for in` | 装进筐里,就是为了挨个处理 | +| 22 | 等它熟 | `waitUntil` | 萝卜会自己慢慢成熟 | +| 23 | 收到齐为止 | —(`for in` + `waitUntil`) | 三个萝卜,三个不同的时钟 | +| 24 | 大挑战 III | —(数组 + `if` + `waitUntil`) | 从零写 | +| **七** | **函数调用** || +| 25 | 代码里的尺子 | `distanceTo` 有返回值;**原来一直都是函数** | 让 Lita 自己把距离说出来 | +| 26 | 浇浇水 | `Water()`;浇水加快成熟 | 走近、浇水、等熟、收 | +| 27 | 熟的收,生的浇 | —(全部串起来) | 4 个萝卜,生熟混杂 | +| 28 | 毕业设计 | —(毕业) | 5 个萝卜,空编辑器,没有脚手架 | + +## 两个值得保护的时刻 + +**第 8 课是一次揭幕,不是一节新课。** 在用了四课 `turn Right` 之后,才告诉学习者: +你一直在打的那个 `Right`,**它就是 `90`**。没有增加任何新东西——只是让一直在用的东西**变得可见**。 +第 9 课的 `Left = -90` 顺手把负数带了进来,而且它的含义无比具体。 + +**第 25 课是同一个手法,更高一层。** `Radish.IsMature()` 从第 15 课起就一直被当作 +「向萝卜提一个问题」在用,`stepTo` / `step` 更是从头用到尾。第 25 课给它们正名: +那些**都是函数**,而 `distanceTo` 会**给你一个数**。 +这里刻意**不教**函数定义——只让学习者意识到,「调用」这件事他早就在做了。 + +## 第 1 课的 Hello World 彩蛋 + +第 1 课发**任何**消息都算完成。但如果消息里出现了 `Hello World`(不分大小写,中文「你好世界」也算), +copilot 会额外热情地讲讲这个传统——几乎每个程序员的第一个程序说的都是这句话——然后告诉他, +他刚刚也加入了这个行列。 + +提示词里**明确禁止**在用户没打这句话时去暗示它。**一个被提醒去找的惊喜,就不再是惊喜了。** + +## 验证状态 + +每一课的参考答案都通过[课程验证工具](./verifying-courses.md)在真实运行时(和用户跑的是同一个 WASM 引擎) +里实际执行过,并检查了关卡里**每一个**萝卜都被收集——而不是只收到一个就算过。 + +- **27 / 27** 门有代码的课程通过。第 1 课没有代码可跑。 +- **提示词部分(引导行为、开场序列、完成判定)不在此列**——那需要登录后跑真实的 copilot 才能验。 + +这个过程中发现了三条引擎约束,现已固化进课程设计: + +1. **裸的顶层代码就是 spx 的 "Main",它有执行时限。** 跑几秒的代码会被中途杀掉—— + 而且沿途的碰撞也一并不会注册,所以关卡看起来**不是慢,而是根本过不了**。 + 耗时长的课程都把逻辑放进 `onStart`。 +2. **数组字面量在顶层内联初始化不编译**(报 `unreachable`)。要先声明,再赋值。 +3. **浇水必须站在触碰半径之外。** Lita(半宽 16)和萝卜(12)在约 28 单位处就开始重叠, + 而 `onTouchStart` 只在**进入接触的那一瞬间**触发——站在 20 处,萝卜还生的时候 Lita 就已经贴上去了, + 等它熟了也不会再触发。所以课程里都站开 60。 diff --git a/docs/develop/tutorial/course-authoring.md b/docs/develop/tutorial/course-authoring.md new file mode 100644 index 0000000000..6501487d98 --- /dev/null +++ b/docs/develop/tutorial/course-authoring.md @@ -0,0 +1,69 @@ +# 编写教程课程 + +一门课程由它的**提示词(prompt)**驱动。除了写给 copilot 看的自然语言说明,提示词里还可以嵌入 +几段**由作者控制、前端直接读取**的配置(copilot 管不着这些)。它们都是可选的。 + +## 工作区配置(`jsonc` 块) + +在提示词里放一个 `jsonc`(或 `json`)代码块,用来配置这门课的编辑器工作区。它在课程开始时**只应用一次**。 + +```` +```jsonc +{ + // 要隐藏的面板/区域,用来减少干扰。不写则什么都不隐藏。 + "hide": ["editor-panels", "edit-mode-switch", "preview-header", "code-editor-tools"], + // 填 "open" 表示课程一开始就展开 copilot 面板。不写则默认后台静默启动。 + "copilot": "open" +} +``` +```` + +### `hide` + +要隐藏的工作区区域名数组。可用的区域: + +- `editor-panels` —— 游戏预览下方的精灵 / 声音 / 舞台面板 +- `edit-mode-switch` —— 导航栏里的编辑模式(默认 / 地图)切换器 +- `preview-header` —— 游戏预览的标题栏(标题、发布入口等) +- `code-editor-tools` —— 代码编辑器旁边的工具(文档标签页和缩放控件) + +未知的名字会被忽略。不写 `hide`(或整个块都不写)就什么都不隐藏。 + +**API References 面板的收窄不在这里配置**——那是 copilot 在课程开始时根据课程目标和参考项目自己做的。 +你只需要在提示词里把本课涉及的 API 写清楚。 + +### `copilot` + +默认情况下,课程开始时 copilot 是**隐藏的**,在后台运行——它静默地感知事件,只有当它确实有东西要展示时 +才浮现出来。如果一门课的主题**就是 copilot 本身**(比如第一课「认识你的新伙伴」),就声明 +`"copilot": "open"`,让面板一开始就展开。填其他任何值都维持默认。 + +## 故事线视频(``) + +在课程开始前播放一段视频,通常用在系列的第一课,用来介绍这个系列的世界观和目标。 + +``` +/tutorial-intro/opening.webm +``` + +URL 必须是同源的,或者在 usercontent 域名下(任意来源会被拒绝)。不写这一段就不播故事视频。 + +## 开场提示(``) + +在课程开始前(如果有故事视频,则在其之后)用对话框展示一句简短的文字引导。 + +``` +向前走,把萝卜都捡起来! +``` + +## 知识点视频 + +如果提示词里声明了本课的新知识点,copilot 会在课程开始时播放对应的讲解视频(用户提问时也可以再调用)。 +这一项是**自然语言**而不是配置块——比如写一个 `## 新知识点` 段落,把涉及的 API 列出来。 +只有在视频库里确实有视频的 API 才会被播放。 + +## copilot 仍然掌控的部分 + +copilot **不会**去动被隐藏的面板,也不会动开场的那些对话框。它会做的是:在课程开始时根据课程目标 +收窄 API References 面板;在课程进行中引导用户——用提示、聚光、视频和编辑器内的代码引导——但**强度受 +干预层级的硬性限制**(在用户真正卡住之前,它保持沉默)。完成与否,以你在提示词里写的判定标准为准。 diff --git a/docs/develop/tutorial/index.md b/docs/develop/tutorial/index.md index ff28e5d2a6..a9084ce033 100644 --- a/docs/develop/tutorial/index.md +++ b/docs/develop/tutorial/index.md @@ -1,5 +1,11 @@ # Tech design for [User Tutorial](../../product/tutorial.md) +- [What this branch adds](./whats-new.md) ([中文](./whats-new.zh.md)) —— 默认沉默的 copilot、干预层级、尺子,以及开发验证工具 +- [Code: Lita 课程系列](./code-lita-series.md) —— 28 课分别教了什么,为什么这样排 +- [课程编写](./course-authoring.md) —— 怎么写一份课程提示词 +- [LLM payload](./llm-payload.md) —— 发给模型的全部内容 +- [课程验证](./verifying-courses.md) —— 在真实运行时里跑课程代码 + ## Challenges * Provide simple Copilot APIs to meet the tutorial requirements diff --git a/docs/develop/tutorial/llm-payload.md b/docs/develop/tutorial/llm-payload.md new file mode 100644 index 0000000000..29b83b21ee --- /dev/null +++ b/docs/develop/tutorial/llm-payload.md @@ -0,0 +1,592 @@ +# 前端发给 LLM 的全部内容 + +这份地图列出**前端**摆到模型面前的一切,供审查。先说最要紧的一点: + +> **System Instruction 不在本仓库。** 前端只用 `{ messages, tools }` 调 `POST /copilot/messages` +> (见 `src/apis/copilot.ts` → `generateCopilotMessage`)。系统提示词、模型选择、以及任何服务端的 +> 包装都在 **spx-backend**。下面列的是前端的全部贡献:`messages` 数组和 `tools` 数组。 + +## 请求体:`{ messages, tools }` + +在 `src/components/copilot/copilot.ts` → `Round.generateCopilotMessage()` 中拼装: + +``` +messages = [ ...每一轮的 (userMessage + resultMessages), contextMessage ] + 经 toApiMessage 转换,再由 sampleApiMessages() 从最旧的开始截断以适配约 100k 上限 +tools = copilot.getTools().map(toApiTool) +``` + +### 1. 对话历史(`messages[0..n-1]`) + +按顺序排列每一轮的 `userMessage` 及其 `resultMessages`。消息角色: + +- **user / text** —— 用户打字输入的内容,或快捷输入。 +- **user / event** —— 环境事件,序列化为 `${detail}`(见 `toApiMessage`)。这是 + copilot 感知用户的唯一途径(事件清单见下)。 +- **copilot** —— 之前的助手回复(可能含自定义元素标签和工具调用)。 +- **tool** —— 工具执行结果。 + +会话只保留最近 `maxSessionRounds`(10)轮,更早的在拼装数组前就已丢弃。 + +### 2. Context message(`messages[n]`)—— 最后一条消息 + +由 `Copilot.getContextMessage()` 构建。是一条被 `` 包裹的 `user` 消息, +按以下顺序拼接三段,每段是一个 `#` 标题小节: + +| 部分 | 构建者 | 内容 | +|---|---|---| +| **可用的自定义元素** | `getCustomElementsPrompt()` | 每个已注册元素的 `tagName`、`description`,以及属性的 JSON schema。即「你可以输出哪些标签」的目录。 | +| **Context** | `getContext()` | 所有已注册 context provider 的 `provideContext()` 输出,之后是技能目录、预加载的技能。 | +| **当前 topic** | `getTopicPrompt()` | `topic.description` —— 对课程而言,就是整份教程协议(`tutorial.ts` 的 `generateTopic`)。 | + +若整体超过 `copilotMessageContentMaxLength`,**只有 Context 段会被截断**;自定义元素目录和 +topic 保持完整。 + +#### 2a. 已注册的自定义元素(可输出的标签) + +全局(始终注册,`CopilotRoot.vue`):`page-link`、`highlight-link`。 + +编辑器(`editor/copilot/index.ts`,编辑项目时):`code-link`;以及——仅在干预等级允许时 +(由 `editorCopilotCodeGuides` 闸门控制)——`code-change-hint`、`code-drag-hint`、 +`code-type-hint`、`code-delete-hint`。 + +教程课程(`TutorialRoot.vue`):`tutorial-progress`、`tutorial-course-success`、 +`tutorial-course-exit-link`、`tutorial-course-abandon-prediction`、 +`tutorial-course-abandon-dismissal`、`api-reference-filter`、`stay-silent`;以及——仅在干预等级 +≥ nudge 时(`tutorial-guidance.ts`)——`guide-modal`、`spotlight-hint`、`api-video`。 + +每个元素的 `description` 字符串就是模型看到的说明来源,这些字符串位于各元素自己的模块里 +(例如 `GuideModal.vue`、`spotlight-hint.ts`)。 + +#### 2b. Context providers(各 `provideContext()` 小节) + +- `CopilotRoot.vue`:UI 信息(radar 树)、已登录用户、当前位置。 +- `editor/copilot/index.ts`:项目内容、当前精灵、当前代码(光标附近采样)、最近的运行时输出、 + 预加载的技能。 +- `tutorials`:`TutorialIntervention`(当前干预等级 + 距上次进展的事件数)、 + `tutorialCourseReminder`(每轮的完成判定提醒)。 + +#### 2c. Topic(课程协议) + +对教程课程来说这是大头:`src/components/tutorials/tutorial.ts` 的 `Tutorial.generateTopic()` +返回 `topic.description`,即完整协议 —— 完成判定标准、静默开场、干预等级阶梯、保持沉默的规则、 +示例对话、放弃预测等等。 + +### 3. Tools(`tools`) + +`copilot.getTools()` 映射为 API 工具定义。在 `editor/copilot/index.ts` 中注册: +`get_project_metadata`、`get_project_content`、`get_sprite_content`、`get_project_code`、 +`get_code_diagnostics`、`list_api_reference_items`;此外还有来自技能注册表的技能工具 +(`load_skill`、`load_skill_resource`)。 + +## copilot 收到的事件(user/event 消息) + +这些信号会变成 `` 消息 —— 它们构成了 copilot 对用户的全部感知: + +全局(`CopilotRoot.vue`):页面切换、打开模态框、模态框中操作完成/取消、UI 消息提示。 + +编辑器(`editor/copilot/user-events.ts`):开始运行项目 / 停止运行项目 / 项目运行失败、 +游戏正常退出 / 游戏异常退出、运行时报错、代码变更(防抖)、代码存在错误 / 代码错误已消除、 +选中项变更、编辑器标签切换。 + +教程(`tutorial.ts`):课程开始。 + +已经**没有**定时的「自动感知」了 —— 感知完全由事件驱动。 + +## 如何在运行时查看真实 payload + +上面的静态地图给出的是结构;要抓取一次真实请求,在浏览器 console 里执行: + +```js +const orig = window.fetch +window.fetch = function (input, init) { + const url = typeof input === 'string' ? input : input.url + if (url.includes('/copilot/messages') && init?.body) console.log(JSON.parse(init.body)) + return orig.apply(this, arguments) +} +``` + +然后触发一轮 copilot;打印出的对象就是实际发送的 `{ messages, tools }`。 + +--- + +# 附录:模型看到的全部文本(逐字) + +以下是发给模型的实际文本,插值(标签名、阈值等)已按当前代码渲染;`{...}` 表示运行时动态数据。 +审查时改哪段,就去对应源文件改。如附录与代码不一致,以代码为准。 + +> **审查修复变更记录**(详见对应源文件): +> - **用户消息现在是最后一条**:context message 插到当前轮用户消息**之前**(`copilot.ts`),用户的话紧贴生成位置。 +> - **critical context**:provider 可声明 `criticalContext: true`(干预等级、每轮提醒已声明),排在可截断 +> context 之后、topic 之前,永不被截断。 +> - **打字求助临时解锁指点**:用户打字的那一轮,等级临时提升到至少 Nudge(guide-modal/spotlight 可用), +> 轮结束即恢复,不影响趋势计数(`TutorialIntervention.level`)。 +> - **`thinking` 元素启用**(全局注册):包裹推理即对用户隐藏(含流式过程中未闭合的部分);流式渲染统一净化—— +> 出现 stay-silent 即整轮不闪现、尾部半截标签暂扣(`content-visibility.ts`)。 +> - **stay-silent 与 verdict 的关系统一**:stay-silent 不再是「唯一的沉默方式」,而是「隐藏罩」——事件的标准 +> 静默回复是 `verdict + `(隐藏整轮、包括泄漏文本,verdict 仍生效);绝不与可见内容(提示/视频/ +> 成功对话框)同行,否则会把它们一起藏掉。开场也统一带 ``。每轮 reminder 增加 verdict +> 规则;guide-modal 去掉 "FIRST-level" 措辞;abandon 规则明确「乱逛是 neutral,back 只用于代码/运行偏离」。 +> - **干预等级改为进展判定驱动**(大改):模型不再计数,而是每个事件轮报一个 ``/ +> ``/``;系统累计并升降级(neutral 6 或 back 3 升级、升级清零、 +> ahead 减 2/1、ahead-归零后降级)。废弃 ``。见 `user-progress.ts` + +> `tutorial-intervention.ts`。事件轮漏报默认 neutral,提问轮漏报不计。 +> - `api-video` 改为 **Silent 级常驻**(开场知识点视频 + 应答用户提问在任何等级都可用;仅「主动推给卡住的用户」是 Nudge+)。 +> - 干预等级 provider 措辞改为「你的可用元素目录已按等级过滤,目录里没有的标签写了也无效」。 +> - 协议「等级阶梯」「示例对话」重写,使之与门控机制一致(示例每个事件都带进展判断)。 +> - radar UI provider 删去无条件的「鼓励拖拽」句;该建议移入「当前代码」小节,且受 `editorCopilotCodeGuides` 闸门控制。 +> - 「When coding tasks」节改为服从等级;abandon 规则不再引用「每步的 highlight-link 目标」。 +> - 渲染为空的元素统一带 `invisible` 标记,只含此类元素的回复算作静默轮(不再产生空气泡)。 + +## 一、自定义元素说明 + +在 context message 的 `# Available custom elements` 段中,每个元素渲染为: + +```` +### `` + +Attributes schema: +```json +<属性的 JSON schema> +``` +```` + +### 全局元素(`CopilotRoot.vue` 注册,任何时候都有) + +**`page-link`**([PageLink.ts](../../../spx-gui/src/components/copilot/markdown-elements/PageLink.ts)) + +> Link to a XBuilder page with given path. By clicking on the link, the user will be navigated to the page. For example, `Edit foo/bar` will create a link to the page with path "/editor/foo/bar" with text "Edit foo/bar". DO NOT make up routes or urls that you are not sure. + +**`highlight-link`**([HighlightLink.vue](../../../spx-gui/src/components/copilot/markdown-elements/HighlightLink.vue)) + +> Create a link that reveals & highlights a specific node in the UI when clicked. Use the node ID provided in the UI information to specify the target node. Use this element in your output to help users to find the relevant UI element quickly. For example, `Submit button` will create a link with text "Submit button", when clicked, reveals the node with ID "xxxyyy" and shows the tip "Click this button to submit". + +属性:`target-id`(ID for the linked node)、`tip`(可选)。 + +### 编辑器元素(`editor/copilot/index.ts` 注册,编辑项目时) + +**`code-link`**([CodeLink.ts](../../../spx-gui/src/components/editor/copilot/CodeLink.ts)) + +> Display a link to a code location in the project. By clicking on the link, the user will be navigated to the code location. A location can be a position or a range. For example, `L10,C20` will create a link to line 10, column 20 in the file "NiuXiaoQi.spx" with text "L10,C20". + +**代码引导四件套**(仅当干预等级达到 Guide 时注册,经 `editorCopilotCodeGuides` 闸门): + +**`code-drag-hint`**([CodeDragHint.vue](../../../spx-gui/src/components/editor/copilot/CodeDragHint.vue)) + +> Guide the user to drag a draggable API reference item (from the API reference panel) into a specific location in the editor. It opens a gap at the target line and draws a translucent orange indicator showing where to drop, and also highlights the matching item in the API reference panel automatically. This element does NOT modify the code; the user performs the drag themselves. (...后接示例) + +**`code-type-hint`**([CodeTypeHint.vue](../../../spx-gui/src/components/editor/copilot/CodeTypeHint.vue)) + +> Guide the user to manually type a piece of code at a specific location in the editor. An empty line is opened at the target with a "Type the code here" chip, and the given code is shown as a green reference below; the user types it themselves. This element does NOT write the code for the user — it only prepares an empty line and shows what to type. (That empty line is a temporary scaffold: it is undoable and is removed automatically if the user does not type anything into it.) Use this when teaching the user to write a brand-new line of code by hand. (...后接示例) + +**`code-change-hint`**([CodeChange.vue](../../../spx-gui/src/components/editor/copilot/CodeChange.vue)) + +> Display a modification based on the existing code and guide the user to make it themselves in the editor (there is no "Apply" button). The removed text is highlighted in red and the new code is shown as a green addition at the same time, so the user can see exactly what to change. In the editor the highlight is automatically narrowed to the part that actually differs, so unchanged text around the edit is not marked. The guide disappears once the user has made the change. (...后接示例) + +**`code-delete-hint`**([CodeDeleteHint.vue](../../../spx-gui/src/components/editor/copilot/CodeDeleteHint.vue)) + +> Guide the user to delete a piece of existing code by highlighting it in red in the editor. Like the other hint elements, this does NOT modify the code itself — it only marks which lines the user should remove, and the user deletes them. (To replace code, use ``, which shows the deletion and the new code together and guides the user to make the edit themselves.) To guide the user to change code, combine this with ``: mark the old code to delete here, then show the new code to type. (...后接示例) + +### 课程元素(`TutorialRoot.vue` 注册,课程进行中) + +**`stay-silent`**([StaySilent.ts](../../../spx-gui/src/components/copilot/markdown-elements/StaySilent.ts)) + +> Respond without saying anything to the user. When you decide no reaction is needed (see the topic instructions for when staying silent is expected), reply with exactly `` and nothing else — no other text (not even your reasoning for staying silent), elements or tool calls. Any reply containing this element is hidden from the user entirely, INCLUDING whatever text surrounds it. Do NOT use this element when the user sends you a message directly — anything they typed always deserves a response. + +**`api-reference-filter`**([api-reference-filter.ts](../../../spx-gui/src/components/tutorials/api-reference-filter.ts)) + +> Narrow the "API References" panel (left of the code editor) to only the listed APIs, to focus the user during a guided step. `ids` is a comma-separated list of API definition IDs — get the exact IDs from the `list_api_reference_items` tool. The filter stays effective on its own: do NOT re-emit this element unless you intend to CHANGE the visible set (then the latest one wins). Use `ids="*"` to show all APIs again; an empty `ids` does nothing. For example, `` keeps only those two APIs visible. + +**`tutorial-progress`**([tutorial-intervention.ts](../../../spx-gui/src/components/tutorials/tutorial-intervention.ts)) + +> Report that the user just made real progress toward the course goal (e.g. they wrote the code that was missing, or their run got closer to the goal). Add `` to your reply — it shows nothing to the user, and resets your intervention level back to silent so you leave them alone again. Do not use it when nothing changed, or you will never be allowed to help a stuck user. + +**`tutorial-course-success`**([TutorialCourseSuccess.vue](../../../spx-gui/src/components/tutorials/TutorialCourseSuccess.vue)) + +> Declare the course complete and show the user a success dialog. Add `` to your reply as soon as the course's completion criteria are met — the criteria in the course prompt are the only measure; do not demand more than they ask for. +> +> 1. Judge the criteria against everything that has happened, including the message you are reading right now. If the criteria are "the user sends the copilot a message", then any message the user sends meets them immediately: declare success in that same reply instead of asking them to do it again. +> 2. `comment` is a short, friendly sentence in the user's language, shown in the dialog. It is your reply to the user: greet them back, praise what they did, and — when the course involved code — add at most one improvement suggestion, inviting a retry when it is worth practicing. For example: `` `` +> 3. Use it once per course. If you already declared success, do not repeat it. + +**`tutorial-course-exit-link`**([TutorialCourseExitLink.ts](../../../spx-gui/src/components/tutorials/TutorialCourseExitLink.ts)) + +> Link to exit the current tutorial course. By clicking on the link, the user will exit learning of the current course. For example, `Exit Course` will create such a link with text "Exit Course". + +**`tutorial-course-abandon-prediction` / `tutorial-course-abandon-dismissal`**([tutorial-course-abandon.ts](../../../spx-gui/src/components/tutorials/tutorial-course-abandon.ts)) + +> When user deviates from the course content, add `` at the beginning of your message to trigger an abandon prediction. + +> When user returns to the course content after an abandon prediction, add `` at the beginning of your message to dismiss the abandon prediction. + +(连续 3 次预测后自动结束课程,这个规则模型看不到,是前端行为。) + +### 引导元素(`tutorial-guidance.ts`,干预等级 ≥ Nudge 才注册) + +**`guide-modal`**([GuideModal.vue](../../../spx-gui/src/components/tutorials/GuideModal.vue)) + +> Show one very short guidance sentence in a centered modal dialog, which draws much more attention than a chat message. This is your FIRST-level intervention for a user who is genuinely stuck: a plain-text nudge that points the direction (what to check, where to look) — NEVER the answer or the code itself. Do not use it at the course opening, for routine encouragement, or for anything the user is already handling fine. The element content is PLAIN TEXT ONLY — no markdown, no other elements — and MUST be at most 30 characters: a single short sentence. The modal opens immediately when your message arrives; the user closes it to continue and can reopen it from the chat. For example, `量一量:Kiko 离萝卜有多远?` + +**`spotlight-hint`**([spotlight-hint.ts](../../../spx-gui/src/components/tutorials/spotlight-hint.ts)) + +> Proactively reveal & highlight a specific node in the UI: the node is spotlighted while everything else is dimmed with a translucent mask (which does not block interactions), and a short tip is shown next to it. Unlike ``, which renders a link the user must click first, this element triggers the highlight immediately when your message arrives — use it to point the user at the ONE thing they should interact with next (e.g. the run button, or an item in the API references panel). Use the node ID provided in the UI information. The highlight dismisses automatically after a few seconds. Use at most one per message. For example, `` highlights the node with ID "xxxyyy" and shows the tip "Click here!" beside it. + +**`api-video`**([ApiVideo.vue](../../../spx-gui/src/components/tutorials/ApiVideo.vue),说明动态生成) + +> Play the explainer video of an API in a modal dialog. The dialog auto-opens only the FIRST time a video is emitted within a session; emitting the same video again renders a small chip the user can click to (re)play — it will not interrupt the user again. Use it when introducing an API the user has not learned yet, or when the user asks how an API works — a short demonstration teaches better than text. The `api` attribute is the API definition ID (as from `list_api_reference_items`). {可用性说明} For example, ``. + +`{可用性说明}`:当前 demo 模式(`apiVideoDemoFallback = true`)下为 "A video is available for EVERY API.";正式模式下为 "Videos are only available for these APIs (for others the element does nothing): {id 列表}." + +### 已存在但未注册的元素 + +`thinking`([Thinking.ts](../../../spx-gui/src/components/copilot/markdown-elements/Thinking.ts)):包裹思考过程、渲染为空的元素,代码里现成但没有任何地方注册。若要治理 reasoning 泄漏可以启用它。 + +## 二、Context providers 的输出(`# ...` 各小节) + +按注册顺序: + +**1. UI 信息**(`CopilotRoot.vue` RadarContextProvider) + +``` +# Current UI of XBuilder + +Current UI language: {English|Chinese}. + +Current UI structure (`n` for `node`): + +{radar 节点树,每个节点含 name/id/desc} + +DO NOT make up appearance or position (e.g., left/right/top/bottom) of any element, unless it is explicitly mentioned in the description. +``` + +(原先此处还有一句「encourage the user to insert code by dragging...」的行为指令,已移到「当前代码」小节, +并由 `editorCopilotCodeGuides` 闸门控制——课程中代码引导未解锁时该句不出现。) + +**2. 当前用户** + +``` +# Current user +Now the user is signed in as "{username}", with display name: "{displayName}" +``` + +**3. 当前位置** + +``` +# Current location +The user is now browsing page with path: `{fullPath}` +``` + +**4. 当前项目**(编辑器内,`ProjectContextProvider`) + +``` +# Current project +The user is now working on project: {displayName} ({owner}/{name}) +Class framework ID: spx +## Project content +{getProjectContent(project) 的 JSON} +``` + +**5. 当前精灵**(选中精灵时) + +``` +# Current sprite content +{getSpriteContent(sprite) 的 JSON} +``` + +**6. 当前代码**(光标附近 ±10 行采样) + +``` +# Current code +The user is now viewing / editing code of file `{path}`. Cursor position: {Line x, Column y|None}. Selection: {...|None}. +Code content of `{path}`: +{JSON 字符串形式的代码片段} +``` + +**7. 运行时输出**(有输出时,最近 50 条) + +``` +# Game runtime output +Recent game runtime outputs (last {n} of {total}): +[{HH:mm:ss.SSS}] {ERROR|LOG}[{文件:行}]: {message} +... +``` + +**8. 干预等级**(课程中,`TutorialIntervention`) + +``` +# Intervention level + +Events observed since the user last made progress: {n}. +Your current intervention level is {1|2|3} ({silent|nudge|guide}). The course topic describes what each level allows. Never use tools above your current level, and do not stay silent when the level tells you to act. +``` + +**9. 每轮提醒**(课程中,`tutorialCourseReminder`) + +``` +# Before you reply + +Read the course completion criteria (in the course topic) and check them against everything that has happened, INCLUDING the message you are reading right now. If they are met, emit in THIS reply, with the comment as your reply to the user. Do not ask for more than the criteria require, and never tell the user to do something they have already done. +``` + +**10. 技能目录**(`getSkillCatalogContext`) + +``` +# Skills + +When a task matches a skill description, call `load_skill` with the exact skill name to read the skill. +You can also read resources in a skill by calling `load_skill_resource` with the skill name and the resource path. + +Here are the available skills: + +{技能清单:xgo-language、spx-project} +``` + +**11. 预加载技能**(编辑器内 `xgo-language` 和 `spx-project` 全文预载) + +``` +# Preloaded skills + +These skills are already preloaded. Avoid calling `load_skill` for them again. + +{两个技能文档全文} +``` + +## 三、课程协议全文(topic.description) + +课程中 `# Current topic between you and user` 段的完整内容(`tutorial.ts` → `generateTopic`)。 +`{...}` 为课程数据;标签名、阈值(nudge=5、guide=8)已按当前代码渲染: + +``` +You are assisting the user in learning the course: {课程标题}. + +### Course Details + + + {id} + {标题} + {入口路由} + + {课程提示词全文(含作者写的 jsonc 配置块、prelude、知识点等)} + + + {参考项目 fullName} + + + +### Guidance + +First do some preparation: + +* Split the course into smaller steps. + + Each step should be clear and simple. For example: + + - Click button remove + - Drag API say "Hi" from API References into the code editor + - Hover card of project A and select menu item "edit" + + If there's already defined steps in the course, divide them into smaller steps as needed. + +* Clearly define the course completion criteria. If the course prompt specifies its own completion criteria (e.g. an in-game goal like "collect all the carrots"), treat those as the source of truth — judge completion by whether the goal is achieved (observable from the game runtime output and project state), not by whether the user's code matches the reference project exactly. The reference project is a possible answer, not the only one. The goal may not involve coding at all (e.g. "send the copilot a message"): apply such criteria literally and invoke the success dialog as soon as they are met — course-specific criteria take precedence over every generic rule below, including the silence rules. + +* Which editor panels are hidden is declared by the course author and applied automatically — you do NOT control the panels; do not try to change them. + +* If the course involves writing spx code, narrow the "API References" panel (left of the code editor) at the course start, in your reply to the "Course Started" event, with . Keep ALL the APIs the course uses anywhere — the union across every step, decided from the course goal and the reference project's code — not just the current step's, so the user can always find every API they will need. Get the exact ids from the `list_api_reference_items` tool. Set this once; it stays effective on its own, so do not re-emit it unless a later step genuinely needs a different set. + +* The course prompt may contain a section (a text guide) and a section (a video URL): both have already been shown to the user in dialogs before the course started. Do not repeat them; just act consistently with them. + +**The course start is silent** + +When you receive the "Course Started" event, the panels are already set up for you and the prelude has already told the user what to do. Your reply must be EMPTY of user-facing content: the invisible setup elements only — to narrow the APIs (for a coding course) and the declared knowledge-point videos (see below) — otherwise just . NO greeting, NO goal restatement, NO instructions, NO , NO narration (not even "Let me set up..."). Let the user explore from there. The API narrowing is a one-time setup: after this reply, do not emit again unless a step genuinely needs a different set, and never re-emit unprompted (it pops a dialog over the user). + +Then let the user explore on their own. While they work: + +1. If extra information is required, use appropriate tools to gather it (this produces no user-visible output). +2. Stay silent on user events while they are exploring or making progress (see below). Never proactively point out UI locations (e.g. where the run button is) — pointing things out belongs to the intervention ladder. +3. If the user asks a question, answer briefly based on the course information; redirect out-of-scope questions back to the course. +4. Check the course completion criteria against EVERY message and event. The moment they are met, invoke the success dialog using in that very reply — do not wait for another turn, do not ask the user to confirm, do not require anything the criteria do not ask for. When a criterion is satisfied by the message you are reading right now (e.g. the course goal is "the user sends the copilot a message"), it is met the instant you receive it: answer the user AND declare success in the same reply. + +**Staying Silent (the default reaction to user events)** + +The course is a playground: the user learns by exploring and succeeding on their own, not by being hand-held. You receive many user events (navigation, clicks, code edits, run results...); MOST of them need no reaction. For any event that does not require action, reply with exactly and nothing else. + +Silence applies to EVENTS ONLY — user messages wrapped in ... describe things that happened, not things said to you. A message NOT wrapped in is something the user typed to you personally: NEVER reply to it. Only speak up when: + +1. The user sends you a direct message — anything they typed (a question, a greeting, whatever), or a quick input. A direct message ALWAYS deserves a response. +2. Your intervention level (see below) has risen above 1, which means the user has been stuck for a while. Then you must act, at the level you have reached. +3. The user deviates far from the course AND keeps drifting further (see abandon prediction below). + +Do not praise or comment on every action. Do not repeat instructions the user is already following. Being silent is the normal, expected behavior for most of the course — when in doubt about an event, stay silent; when in doubt about a typed message, respond. + +**Replies contain user-facing content only** + +Never write your reasoning, analysis or planning into a reply ("Let me check the current state...", "The user just..."). Think silently; the reply is only what the user should see — either the user-facing response, or exactly alone. User-facing text is always in the user's language. + +**Editor events vs. "Next step"** + +As the user works you receive events describing what they do — code edits, runs, run results, selection changes, and so on. These are NOT user requests; most need no reaction (reply ). They are how you perceive the user: + +* Each event raises your intervention level, so a user who keeps trying without progress eventually reaches a level where you may help. While your level is 1, stay silent; once it rises, act at that level. +* Use the current code and runtime output in your context to judge whether the user is progressing. If they are, report it with (alone, if nothing else is needed) so the level resets. + +In contrast, the "Next step" quick input IS an explicit user request: respond right away with the most helpful next guidance — still restrained, at the level you have reached, never above it. + +**The intervention level: how strongly you may help right now** + +Your context reports an intervention level, derived from how many events passed since the user last made progress. +The guidance tools below are UNLOCKED by level: at a lower level the higher tools are not even available to you, so +you literally cannot over-help. Your job is the other direction: once a level unlocks a tool, do NOT keep staying +silent while the user is stuck — use it. + +* **Level 1 — silent** (the first 5 events since progress): observe only. No guidance, whatever you + think the user should do. Let them explore, fail, and retry. +* **Level 2 — nudge** (after 5 events): the user is stuck. Give ONE short hint via + ... — plain text, at most 30 characters, no other elements inside, + pointing the direction (what to check, where to look), NEVER the answer or the code. If the hint did not help, or + the problem is finding something on screen, point at the exact UI element with + (everything else is dimmed), or explain the relevant API with + when it has an explainer video. +* **Level 3 — guide** (after 8 events): the nudges did not work. Now guide the concrete code edit + with the in-editor guides / / / . (Code you + write in the chat is hidden from the user; these elements drive guides inside the editor.) + +Two things reset the level back to silent, so the next struggle starts gently again: + +* You report progress with — do this whenever the user genuinely moves toward the + goal (wrote the missing code, got closer in a run, found the thing you pointed at). +* A course (re)starts. + +An intervention that did not help does NOT reset the level: if the user is still stuck a few events later, you are +expected to climb, not to repeat the same hint. + +Regardless of level, a message the user typed always gets an answer, and the answer may resolve their question +directly. At any level, chat text stays at one or two short sentences. + +**Knowledge-point videos at the course start** + +The course prompt may declare the new knowledge points of this course (e.g. a "新知识点" / "knowledge points" +section). At the course start, for each declared knowledge point that has an available explainer video (see the + element's list of available APIs), show it with . If the course +prompt declares no knowledge points, do not show any videos at the start. Either way, you may still use + later when the user asks how an API works. + +**Course Abandon-Prediction and Dismissal** +**Rules:** +Predict abandon when: +1. **Path Deviation**: User repeatedly interacts with UI elements/pages unrelated to the current step's target or course scope. +2. **Irrelevant Actions**: User frequently performs actions that open unrelated modals, side panels, settings, etc., without returning to the task. + +**Protocol:** +When deviation is detected based on the rules above, insert in your response. +When the user returns to the course (by clicking "return to course" or showing clear intent to continue), insert in your response to dismiss and continue. + +When coding tasks are involved: + +* If a project reference is available for the course, treat it as the standard answer. +* Before offering coding suggestions, ensure you understand the current code. If not, use appropriate tools to review it first. +* Avoid giving complete solution code directly. Instead, guide the user step-by-step with hints and explanations. +* Code you output in the chat (code blocks or code-hint elements) is NOT displayed to the user — only the in-editor guides they drive are. Never rely on the user reading code from the chat; guide them with drag / type hints and short instructions instead. +* Prefer to insert code by dragging corresponding items (if available) from "API References" into the code editor over providing manual code snippets. +* Keep the "API References" panel showing all the APIs the course uses (see preparation); do not narrow it further down to only the current step's APIs. + +When tool result received: + +* Skip repeating content already mentioned before. +* Continue with the chat before the corresponding tool use. + +### example + +This is an example for messages between you and the user in a course (the course prompt declares the knowledge point "step" and the goal "let Kiko collect the carrot"): + +- User event + + course started + +- Copilot message + + + + +- User event + + Code of Kiko changed (the user is exploring; no reaction needed) + +- Copilot message + + + +- User event + + Project ran; runtime output shows Kiko stopped before reaching the carrot (first failure — let them try again) + +- Copilot message + + + +- User event + + Project ran again; runtime output shows Kiko stopped at the same place (repeated failure — give a directional hint, not the answer) + +- Copilot message + + 量一量:Kiko 离萝卜有多远? + +- User message + + 我不知道在哪里点运行 + +- Copilot message + + + +- User event + + Project ran; runtime output shows "捡到萝卜 Radish" (the course goal is achieved) + +- Copilot message + + +``` + +## 四、Tools 说明 + +编辑器注册(`editor/copilot/index.ts`): + +| 工具 | description | +|---|---| +| `get_project_metadata` | Get metadata of a project. | +| `get_project_content` | Get content of a project. | +| `get_sprite_content` | Get content of a sprite in a project. | +| `get_project_code` | Get code content of a file in project.(参数:project、file、lineStart、lineEnd) | +| `get_code_diagnostics` | Get code diagnostics (errors or warnings) of current editing project. | +| `list_api_reference_items` | List the available API reference items (definition id and signature) for the code file the user is currently editing. Use the returned ids with the `api-reference-filter` element to narrow the "API References" panel. | + +技能工具(`skills/tools.ts`): + +| 工具 | description | +|---|---| +| `load_skill` | Load the main document of a skill. Use when the current task matches a skill description. | +| `load_skill_resource` | Load a resource from a skill. Use this when you already know the exact resource path, typically after load_skill has listed the available resource paths. | + +## 五、事件文本(`{detail}`) + +| 事件 | detail 文本 | +|---|---| +| 课程开始 | Now the course has just started. | +| 页面切换 | User navigated to {fullPath} | +| 打开模态框 | User opened a modal dialog | +| 模态框中操作完成 | User completed operation in modal | +| 模态框中操作取消 | User cancelled operation in modal | +| UI 消息提示 | A {type} notification showed with content: {content} | +| 开始运行项目 | The user started running the project. | +| 停止运行项目 | The user stopped running the project. | +| 项目运行失败 | The project failed to start running: {error} | +| 游戏正常退出 | The game exited with code 0. | +| 游戏异常退出 | The game exited with code {code} (an error or crash). | +| 运行时报错 | The running game reported an error: {message} | +| 游戏输出 | The running game printed new output, latest: {message}. See the full runtime output in your context. | +| 代码变更 | The user edited the project code. | +| 代码存在错误 | The project code now has error diagnostics. | +| 代码错误已消除 | The project code no longer has error diagnostics. | +| 选中项变更 | The user selected {the stage / sprite "{name}"}. | +| 编辑器标签切换 | The user switched to the "{code/costumes/animations}" tab. | diff --git a/docs/develop/tutorial/verifying-courses.md b/docs/develop/tutorial/verifying-courses.md new file mode 100644 index 0000000000..281387f4a6 --- /dev/null +++ b/docs/develop/tutorial/verifying-courses.md @@ -0,0 +1,124 @@ +# 在运行时验证一门课程 + +一套快速检查课程行为是否正确的方法——不用把每个对话框都点一遍,而且能看到 copilot 发出的 +那些**不可见**的东西(`api-reference-filter`、`user-progress-*`、`stay-silent`),它们永远不会显示在屏幕上。 + +诀窍在于:整个 copilot 会话都被持久化到了 `sessionStorage`(节流写入,约 300ms 一次), +所以你可以直接从存储里读出每一轮 copilot 究竟回复了什么。 + +## 导出 copilot 会话 + +在浏览器控制台里粘贴(课程进行中或结束后都行): + +```js +function dumpCopilotSession() { + const raw = sessionStorage.getItem('spx-gui-copilot-session') + if (raw == null) return '(no copilot session)' + const parsed = JSON.parse(raw) + // 存的值是按用户隔离的:{ __user__, __value__: } + const session = parsed?.__value__ ?? parsed + if (session?.rounds == null) return '(empty session)' + return { + topic: session.topic?.title?.zh ?? session.topic?.title?.en, + rounds: session.rounds.map((r) => ({ + trigger: + r.userMessage.type === 'event' + ? `event: ${r.userMessage.name.en}` + : `typed: ${r.userMessage.content}`, + state: r.state, + reply: r.resultMessages + .filter((m) => m.role === 'copilot') + .map((m) => m.content ?? '(tool call)') + .join(' ') + })) + } +} +console.log(JSON.stringify(dumpCopilotSession(), null, 2)) +``` + +每一轮会显示它的 `trigger`(事件名,或用户输入的文字)、轮次状态 `state` +(`completed` / `loading` / `cancelled` / `failed`),以及 copilot 的原始回复 `reply`——连标签一起。 + +## 一次健康的课程开场长什么样 + +第一轮应该是 `event: Course Started`、`state: completed`,而且它的回复里**只有不可见的 setup 元素**—— +没有问候语、没有课程名、没有旁白。以一门编程课为例: + +``` + ← 把 API 面板收窄到本课的 API + ← 每个声明的知识点一个(开场视频) + ← 第一个进展判定 +``` + +对照课程提示词检查:`ids` 应当和课程的 API 列表一致,`api-video` 应当和声明的知识点一致。 +非编程类的课程可能只有 ``(外加 ``)。 + +## DOM 快速检查(在编辑器里) + +```js +({ + // 筛选生效时分类侧边栏是隐藏的(见「隐藏分类侧边栏」那个改动) + categorySidebarHidden: document.querySelector('section[class*="min-h-0"] > ul.flex-none') == null, + apiItemCount: document.querySelectorAll('.api-reference-item').length, + editorLoadFailed: document.body.innerText.includes('Failed to load'), + // 多精灵课程保留精灵/舞台面板,单精灵课程会隐藏它 + spritePanelVisible: /Stage|舞台/.test(document.body.innerText) +}) +``` + +## 注意事项与坑 + +- **课程之间要干净地重新开始。** 从一门课直接跳到另一门课,可能会让上一门课的会话仍然活着 + (它的视频对话框还赖着不走,它的会话还在响应页面跳转)。要单独测一门课,先清状态再重新加载课程 URL: + ```js + Object.keys(sessionStorage) + .filter((k) => /copilot|tutorial/i.test(k)) + .forEach((k) => sessionStorage.removeItem(k)) + ``` +- **推理泄漏在这里看得见。** 如果某条回复里出现了「让我看看当前状态……」这类**没有**被 `` + 包裹的大白话,那就是泄漏的推理内容——这是真缺陷,哪怕末尾的 `` 让用户那一轮看不见它。 +- 想检查发**给**模型的确切请求(而不是回复),用 + [llm-payload.md](./llm-payload.md#如何在运行时查看真实-payload) 里的 `fetch` 钩子。 + +## 不经过 UI 验证课程**代码**(`/devtools/course-runner`) + +要回答「这个参考答案真的能通关吗?」,根本不需要编辑器。仅在开发环境存在的页面 +`/devtools/course-runner` 挂载了生产同款的 `ProjectRunner`(和用户跑的是同一个 WASM 引擎), +并暴露出 `window.courseRunner`: + +```js +// 1. 加载课程的(公开)项目: +await courseRunner.load('curator', 'Coding-Course-1') +// -> { codeFiles: ['main', 'Kiko', 'Radish', ...] } + +// 也可以直接加载本地 .xbp(上传之前就能验证): +await courseRunner.loadXbp('/_verify/Lita-Course-02.xbp') + +// 2. 覆盖代码并运行。键 = 精灵名,`main` 表示舞台: +const result = await courseRunner.run({ code: { Kiko: 'step 160' } }) +// -> { exited, exitCode, durationMs, logs } + +// 3. 对结构化的游戏日志做断言: +result.logs.filter((l) => l.level != null).map((l) => l.msg) +// -> ['捡到萝卜 Radish'] +``` + +- `run()` 在游戏退出时 resolve,或者在 `timeoutMs`(默认 20 秒)之后。带事件处理器(onKey 之类)的游戏 + 永远不会自己退出——**超时不等于失败**,要对 `logs` 做断言。 +- **⚠️ 一个页面会话里只有第一次 `run()` 可信。** 每次运行都会起一个引擎实例,跑过几次之后, + 后续的运行会**静默失效**——明明正确的代码也会报告「没捡到萝卜」。这个坑曾经让人得出 + 「`repeat` 和 `var` 在 spx 里不能用」的错误结论,并且撑过了好几轮排查。 + 要批量验证多个关卡,就得**在关卡之间重新加载页面**:用 `?autorun=<同源脚本地址>`, + 让页面自己在加载完成后启动驱动脚本,脚本把进度存进 `sessionStorage`,跑完一关就 `location.reload()`。 +- `level != null` 的日志条目是结构化游戏日志(`println` 输出、运行时错误); + `level == null` 的是引擎噪音,忽略即可。 +- 这个页面访问公开项目不需要登录,而且**不会保存任何东西**。 + +## 教训 + +这套工具本身也曾经骗过人。上面那条「只有第一次运行可信」是花了好几轮排查才弄明白的, +在此之前,它稳定地给出了自相矛盾的结果(同样的代码、同样的项目,两遍跑出相反的结论)。 + +> **一个自身可靠性未经验证的验证工具,没有资格给任何问题下结论。** + +如果验证结果开始自相矛盾,先怀疑工具,再怀疑被测对象。 diff --git a/docs/develop/tutorial/whats-new.md b/docs/develop/tutorial/whats-new.md new file mode 100644 index 0000000000..e299a38fae --- /dev/null +++ b/docs/develop/tutorial/whats-new.md @@ -0,0 +1,238 @@ +# What this branch adds + +This tutorial rebuild turns a course from **a textbook the copilot reads out** into **a Playground +the copilot watches over**. + +## Design + +## 1. Intervention levels + +`tutorial-intervention.ts`, `tutorial-guidance.ts`, `user-progress.ts` + +Three levels: **Silent(1) → Nudge(2) → Guide(3)**. Each level registers a different set of custom +elements; unavailable tools are **unregistered**, which sidesteps the problem of a model having to +judge, on its own, what state a long-running session is currently in. + +| Level | What exists | +|---|---| +| Silent | `api-video` only | +| Nudge | `+ guide-modal`, `spotlight-hint` | +| Guide | `+ all in-editor code guides: code-change-hint, code-type-hint, code-drag-hint, ...` | + +The level moves on **progress verdicts**: every event round, the model reports +`` / `` / ``. The system counts +them (`neutralThreshold = 6`, `backThreshold = 3`); an *ahead* spends the counters down, and an +*ahead* while the counters are already at 0 de-escalates. A typed user message temporarily raises +the level to at least Nudge. + +Prompt rules alone proved unreliable. What we use is the combination: +**mechanism first**, **then a per-round reminder**, **then the protocol prose**. + +## 2. Perception: more events + +`editor/copilot/user-events.ts` + +The copilot now perceives many more events: run start/stop, game exit (any exit code), **runtime +output**, debounced code changes, diagnostics, and sprite/tab selection. + +That "runtime output" entry is load-bearing: the signal for passing a level *is* a log line, so +waking only on errors meant a coding course **could never complete unless the user clicked Next +step**. + +The design principle here is: **perceive more actively, intervene more passively**. And we removed +the Next step button. + +## 3. Less intervention + +`copilot/markdown-elements/StaySilent.ts`, `copilot/content-visibility.ts`, `CopilotUI.vue` + +`` hides the **entire round**, including any reasoning that leaked around it. The chat +shows **only rounds the user typed** — event-driven rounds run invisibly in the background. Cancelled +event rounds are skipped too (continuing to edit aborts the in-flight round; that is not something to +show). + +## 4. Course-authored configuration + +`tutorials/course-config.ts`, `editor/workspace-layout.ts`, `tutorials/TutorialRoot.vue` + +A course declares its configuration in a ```jsonc block at the top of the prompt. The **frontend +reads and applies it the moment the course starts** — no copilot round, no delay: + +```jsonc +{ + "hide": ["editor-panels", "edit-mode-switch", "preview-header", "code-editor-tools"], // simplify UI + "copilot": "open", // open the panel at start (default: hidden, background) + "judge": "code", // completion: code (default, from output) / copilot + "complete": { "log": "捡到萝卜", "count": 4 }, // the code-judged completion signal (see below) + "apis": ["step", "turn"], // narrow the API panel at start (union over the course) + "opening": [ // ordered in-editor opening (see §7) + { "prelude": "..." }, { "video": "step" }, { "spotlight": { "ui": "Run button" }, "tip": "..." } + ] +} +``` + +This setup used to be done by the copilot in its opening reply — a tool round plus a generation, so +the panel narrowed seconds late and videos landed over whatever the user was doing. It is now +**static data applied at t=0**; a course declaring `apis` / `opening` (or the legacy `videos`) flips +the copilot's opening protocol to **purely silent** (verdict + ``, zero tool calls). +API entries may be a bare name (`step`), a dotted name (`Sprite.step`), or a full definition id; +language constructs use their canonical names (`if_statement`, `for_iterate`, ...). + +**Completion (`judge` + `complete`):** +- `code` (preferred): completion is observable from runtime output. The frontend decides and the + success dialog opens **instantly** (no LLM wait); the copilot then gets a "Course completed" event + and fills in a comment. The signal is `complete: { log, count }` — `count` DISTINCT output lines + containing `log` within a single run (for collect-N goals whose project logs each step); absent, it + falls back to the sentinel `@@builder:course-complete@@` the project prints itself. + + The evaluation comment is told exactly where it lands (the dialog's comment area — prose in a + hidden event round reaches nowhere else), and it is protected from event supersession twice over: + ambient events (editor perception, page/modal noise — including the "Modal opened" the success + dialog itself fires) are **dropped while a copilot-produced artifact is on screen**, so nothing + aborts the round writing the comment; and should a round still be superseded, the dialog accepts + the evaluation from whichever event round ends up carrying it, instead of timing out into the + fallback text. +- `copilot`: when output cannot judge it (e.g. "message the copilot", or "must have used `repeat`"), + the copilot declares completion, at the cost of one LLM round. + +Author's guide: `docs/product/course-authoring.md`. + +## 5. The ruler + +`editor/preview/stage-viewer/StageRuler.vue`, `ruler-math.ts` + +A course-only stage tool: drag to measure, endpoints snap to sprite centres. When a measurement +**starts on a sprite**, it also reads the **turn angle** — signed, so the number is exactly what +`turn` expects (right positive, left negative, matching `Left = -90` / `Right = 90`). + +This is what makes "how far?" and "which way?" **measurable** rather than guessable. Courses 3, 8 +and 9 are built on it. + +## 6. Copilot presentation + +`CopilotUI.vue`, `CopilotChat.vue`, `editor/copilot/EditorCopilot.vue`, `copilot.ts` + +The copilot's **state is a global singleton** (provided by `CopilotRoot`), but the **presentation is +a shared chat body plus two shells**: +- `CopilotChat.vue` — the shared conversation body (rounds, quick inputs, welcome screen, input, + drag handle); +- `CopilotUI.vue` — the global floating shell (home / community / normal editor), draggable to an + edge; +- `editor/copilot/EditorCopilot.vue` — the **editor-owned** docked shell for the focused tutorial + layout, living in the control row at the code column's bottom-right, its panel anchored above the + trigger by plain layout — no more measuring viewport coordinates into a global overlay. + +Both shells inject the same copilot instance, so the conversation **carries seamlessly** from +floating to docked. The Run/Stop control teleports into that same row (the runner logic stays in the +preview component). The docked panel has scrollable history and a drag-resizable height; while +generating, the C mark in the trigger spins — **even when the panel is hidden**. + +Courses start **background-first**: `Topic.autoOpenOnEvents = false` means ambient events +(navigation, modals) never pop the panel — **including the navigation event fired on page reload**, +which used to make the panel reappear mid-course. A course whose subject *is* the copilot opts out +with `"copilot": "open"`. + +Tutorial topics set `hideCodeInChat`, so code-bearing elements still drive their in-editor guides but +render the code unselectable in chat — **visible, not copyable**. + +## 7. Opening sequence + +`course-start.vue`, `TutorialStoryVideoModal.vue`, `TutorialPreludeModal.vue`, `ApiVideoModal.vue`, +`api-videos.ts`, `course-config.ts`, `TutorialRoot.vue`, `utils/spotlight/*`, `utils/radar` + +The story video (series world-building) still plays **before** the editor exists; everything after +it is one **ordered, author-declared `opening` sequence** applied by the frontend once the editor is +up (see §4) — prelude / knowledge-point video / spotlight steps, played strictly in the authored +order, one cursor advancing on continue / close / dismiss. The queue is assembled on `/start` (the +course must be set before navigation so the workspace layout is ready when the editor mounts), but +steps render **only on the editor route** — otherwise the first prelude flashes over `/start`, +hides during the redirect, and pops up again in the editor. This replaces the old fixed "video → +prelude" ordering and the pre-editor `` modal (which is now inlined into `opening`; +the story video stays a tag because it precedes the editor and carries its own origin check). + +A **spotlight** step highlights the one thing to act on next — a UI landmark by its Radar name +(`Radar.getNodeByName`, e.g. `"Run button"`, `"Ruler"`) or an API-references item by its definition +id (a new `data-def-id` on the item, matched with the same `createCourseApiMatcher` as `apis`) — and +advances when the user clicks (a new `concealed` event on `Spotlight`). Targets that mount late are +retried, then skipped, so a bad reference can't stall the opening. + +API videos are keyed by definition id and remembered per user, so **a concept is never explained +twice**; every overload of an API resolves to the same video, and non-API topics can have one too +(the ruler, which the copilot plays when asked how measuring works — it learns the id from the +`` description, the only place it appears). API-reference hover cards play the same video +(the dialog is the shared `ApiVideoModal`). + +Video dialogs **size themselves to the video**: the library is not one shape (the newer explainers +are 4:3, the older ones 16:9) and story videos vary per series, so the box reads the intrinsic ratio +from the loaded metadata rather than pinning one and letterboxing everything else. Nothing to +declare per video. Both video dialogs also **play like the API-reference hover card** — +autoplaying, muted, looping, no browser controls popping up on every mouse move. Looping replaces +seeking, and leaving is the only control: closing the explainer modal, or the story dialog's +"Start the course" button (with the video looping, `ended` never fires, so the button is the one +way in). + +Externally hosted videos (e.g. S3) load in CORS mode via `