· engineering· 约 24 分钟精读
Qubitlab Unlock Gate Topology Chapter Star Thresholds — Technical Analysis | KeyRotate Technology
QubitLab 的 96 关不是一条 1 到 96 的数组,而是 8 章 × 12 关、由 64 个基础关与 32 个插入关拼成的课程顺序。本文按源码核对章节星门、关卡集合、Pro 判定与每日挑战侧门,解释"买了 Pro 仍锁关"与"零颗星却能玩第 17 关"为何会同时成立。
一台没有插卡的 iPad 上,两个提问先后出现。第一个来自家长:已经买了 Pro,为什么第五章还是灰的。第二个来自孩子:一颗星都没有,为什么每日挑战让他玩了第 17 关。
两个提问都指向同一份进度数据,却给出了相反的印象:付费没有打开全部,免费却打开了一部分。QubitLab 是一套完全离线的教育沙盒,没有账号、没有服务器、没有遥测,进度只存在本机的 LabProgress 结构里。也正因为没有服务端兜底,解锁这件事必须由客户端把每一道判断写清楚。
本文只核对一件事:一关能不能被点开,究竟由哪些数据、哪几个谓词决定。结论先说——决定它的不是”有没有付费”,而是三个账本与两道闸门的合取;付费只抵掉其中一项。
左右滑动查看完整图表
一、目录不是 1 到 96
OdysseyCatalog.totalLevels 写的是 96,但屏幕上并不存在”第 1 关到第 96 关”这样一条连续编号。目录是 8 个章节,每章 12 关;每一章的 12 个原始 id 并不是连续的。
| 章 | 显示关号 | 原始 id(8 个基础 + 4 个插入) | 星门 |
|---|---|---|---|
| 第 1 章 | 1–12 | 1–8,65,66,77,78 | 0 |
| 第 2 章 | 13–24 | 9–16,79–82 | 24 |
| 第 3 章 | 25–36 | 17–24,67,68,83,84 | 48 |
| 第 4 章 | 37–48 | 25–32,69,70,85,86 | 72 |
| 第 5 章 | 49–60 | 33–40,71,72,87,88 | 96 |
| 第 6 章 | 61–72 | 41–48,73,74,89,90 | 120 |
| 第 7 章 | 73–84 | 49–56,91–94 | 144 |
| 第 8 章 | 85–96 | 57–64,75,76,95,96 | 168 |
这张表可以直接从模型里读出来。chapters 是八个 PuzzleChapter,每个 chapter 的 levels 数组按屏幕顺序排列;orderedLevels 把它们摊平成一条课程序列。原始 id 只作为存储键与文件名使用,界面上的”LVL n”由 curriculumNumber(forLevelId:) 换算,取的是该关在这条序列里的下标加一。
插入的痕迹很清楚:1–64 在每一章里正好 8 个,65–96 在每一章里正好 4 个,且全部排在章末。代码注释也把后者称为”bridging levels”,并特意写明”第 1 章是 1–8、65、66、77、78,所以 id 顺序不是屏幕顺序”。工程仓库里留存的早期构建截图显示的是”48 个谜题、144 颗星”,第 1 章星数为”0/24”——按每关 3 星折算,那一版每章 8 关。把这两组证据放在一起,可以确定的是一次按章扩容:章节骨架先按 8 关设计(1–64 正好每章 8 个),后来每章补进 4 个衔接关(65–96 正好每章 4 个),屏幕顺序才变成今天的 12 关一章、全书 96 关。ContentView 里那句”Campaign Mode - 48 Levels”的注释没有跟着改,是这次扩容留下的旧标记。
这个结构直接决定了解锁代码不能写 id + 1。第 8 关的下一关是 65,不是 9;第 78 关的下一关才是第 2 章的首关(原始 id 9)。nextLevel(after:) 因此在 orderedLevels 里找下标,而不是在 id 上做加法。只要有人图省事写一次加法,章节边界就会断。
二、三份账本
LabProgress 里有三个与解锁相关的容器,职责并不相同。
| 字段 | 类型 | 初值 | 记录什么 |
|---|---|---|---|
levelStars | [Int: Int] | 空 | 每关的历史最好星级,总和上限 288 |
unlockedLevels | Set<Int> | [1] | 被判定为可进入的关卡 id,是许可,不是完成 |
levelQuizMastered | Set<Int>? | 空 | 哪几关的前置概念问答答对过 |
第一份是成绩账。completeLevel 只在新的星级高于旧值时写入,重玩不会把三颗星降成两颗。totalStars 就是这张表的值求和,总进度条与家长报告读它;章节胶囊另按本章 12 关单独求和。
第二份是许可账。它记录的是”这一关曾经被允许进入”,既不代表玩过,也不代表拿到星。初始值只有 1;每次通关时,completeLevel 把课程顺序里的下一关插进来。它只增不减,重置进度时整体归零。
第三份是问答账。它是可选类型,缺键时解码为 nil,再由 recordLevelQuizMastery 在第一次答对时初始化。可选是这类后加字段最直接的写法,因为 Swift 合成的解码器不会使用声明处的默认值——非可选字段一旦缺键,整个 LabProgress 解码失败。
三份账本被打包成一个 JSON,存在 @AppStorage("qubitlab_progress_data") 里。ContentView.loadProgress() 的写法是 guard let decoded = try? JSONDecoder().decode(...) else return。解码失败不抛错、不提示、不备份,内存里留下一个全新的空进度;此后的第一次写入会把这份空进度存回去。这是一个很安静的失败面:用户看到的是”星星没了”,代码里没有任何一行日志。unlockedLevels 是非可选的,levelQuizMastered 是可选的——同一张表里两种字段,容错能力并不一样。
三、第一道闸门:章节星门
OdysseyCatalog.requiredStars(forChapter:) 是一个八分支的 switch,返回 0、24、48、72、96、120、144、168。写成公式就是:
isChapterUnlocked 的判断顺序是固定的:第 1 章直接放行;累计星数小于门槛则返回否;再看这一章是否免费,或者用户是否已购 Pro。三步的次序有意义——星数检查在 Pro 检查之前,所以 Pro 不能跳过星门;免费章节的检查在星数之后,所以第 2 章虽然免费,仍然要 24 颗星。
这里有一处注释与实现的口径差。第 2 章的分支注释写的是”24 stars in Chapter 1”,读起来像是”第 1 章内累计 24 星”;实现用的是 progress.totalStars,也就是全部章节的星级总和。平时两者差别不大,因为玩家基本按顺序推进;但一旦有跨章获得的星星——每日挑战就是——“某一章内的星数”与”全局总星数”就会分叉。注释描述的是一个按章统计的量,代码算的是一个全局量。
四、星门为什么总是”刚好”够
每关最多 3 星,每章 12 关,满星上限是 36。星门每一步是 24,恰好等于 12 关各拿 2 星。这不是近似,是精确相等:24 = 2 × 12。
再看星级是怎么发出来的。LevelPlayView.triggerWin 只有两个分支:问答答对给 3 星,没答对给 2 星。completeLevel 再把”实验星”截到最多 2 星,第三颗星只留给 levelQuizMastered 里已经记录过的那一关。因此,任何一次正常通关,最低也是 2 星;现有唯一的调用点只会传 2 或 3,1 星这个值不会被写出。界面仍然画三个星槽,parMoves 与 maxMoves 也仍然写着”1–2 星”的档位,但发星函数不接受 1。
把这两件事放在一起:推进一关至少得 2 星,推进下一章要求前章 12 关各 2 星。于是线性游玩时,玩家打完第 1 章恰好 24 星,正好推开第 2 章;打完第 2 章恰好 48 星,正好推开第 3 章。星门一步也不多、一步也不少。它看起来是”星级奖励”,实际是一张用星星计量的章节完成度检查:只要按顺序把前一章打完,门一定开;只要少打一关,门一定不开,除非在别的关多拿几颗星补上。
这也解释了为什么”1 星”的缺失没有在正常流程里暴露。星门是按 2 星地板校准的,而发星函数的下限也是 2 星,两个常量彼此吻合,谁也没有留余量。若哪天发星逻辑改成允许 1 星,星门会立刻从”必然通过”变成”必须补星”,而补星的唯一来源是把旧关重玩到 3 星。
五、第二道闸门:关卡门
章节开了,不等于章里每一关都能点。isLevelUnlocked 在章节判定之后还有四步:
- 第 1 关永远放行;
- 所属章节必须已解锁;
- 关卡必须免费,或者用户已购 Pro;
- 该关是本章第一关,或者它的 id 已经在
unlockedLevels里。
第三条与第四条的次序值得注意。“本章第一关直接可玩”这条豁免排在 Pro 检查之后,所以第 1、2 章的第一关对免费用户开放,第 3 章以后的第一关仍然要 Pro。章节门开了、星数够了,第一关也不是无条件可进。
completeLevel 在发完星之后做一件事:把 nextLevel(after:) 的结果插进 unlockedLevels。第 78 关通关,插入的是第 9 关;第 96 关通关,nextLevel 返回 nil,什么也不插。它不插入”所有前序关”,也不检查下一关属于哪一章。于是集合里可能出现一个属于未解锁章节的 id——那是一份休眠的许可,等星门打开才生效。
这套写法有一个明确的边界:集合只向前补一格,不做前缀回填。正常线性游玩时这不是问题,因为每一关都是上一关解锁的;但只要存在第二个入口,集合就可能出现”后面的关已获准、前面的关还没获准”的稀疏状态。
六、Pro 只抵一张门票
isPro 在关卡门里只参与第三条:它把”关卡必须免费”这一项短路为真。章节星门仍然要过,unlockedLevels 仍然要命中,第一关豁免仍然按章生效。买断解锁的是付费墙,不是课程顺序。
按线性通关、每关 2 星计算,Pro 用户在几个节点上真正能点开的关数是:
| 累计星数 | 已解锁章节 | 可点开的关数 |
|---|---|---|
| 0 | 第 1 章 | 1 |
| 24 | 第 1–2 章 | 13 |
| 48 | 第 1–3 章 | 25 |
| 72 | 第 1–4 章 | 37 |
| 96 | 第 1–5 章 | 49 |
这张表的读法是:每开一章,只多出”上一章剩下的关卡 + 新章的第一关”。从 24 到 48 星,可点关数从 13 涨到 25,多出来的 12 关是第 2 章剩下的 11 关,加上第 3 章的第一关。刚购买、零星的用户,实际能进的仍然只有第 1 关。
同一屏上因此会出现两句互相拆台的话。购买后,计数行右侧的文案走 odyssey.allUnlocked 分支,写”全部 96 关已解锁”;同一张卡片左下角的计数走 unlockedCount,写”已解锁 1 / 96 关”。前者是购买态文案,后者是判定结果。两者都没有说谎,只是说的不是同一件事:一个说”付费墙已移除”,一个说”当前谓词为真的关卡有几关”。产品把前者写成了后者。
这不是权益判定出错。Transaction.currentEntitlements 的核对、isPro 的取值都没有问题;出问题的是文案把”移除付费墙”翻译成了”全部解锁”。在离线应用里,这句话没有服务端可以纠正,用户只能靠数锁头自己发现。
七、侧门:每日挑战
AchievementCatalog.todayTrial() 用一年中的第几天对 28 取模,从一个固定的 28 关循环里取当天关卡。这个循环的 id 是 1 到 28:第 1–2 章的 16 个基础关(id 1–16),加上第 3–4 章的 12 个基础关(id 17–28);两章的衔接关(65、66、77–82)一个也没有进入循环,第 5 章以后(id 33 起)更没有。
DailyChallengeView 拿到这个 id 后,直接构造 LevelPlayView(level:progress:),没有调用 isLevelUnlocked,也没有检查 isPro。而 LevelPlayView 的通关路径与正常关卡完全一样:triggerWin 发星,completeLevel 写 levelStars、写 levelQuizMastered(如果答对)、插入下一关。
于是免费用户在一个 28 天的循环里,有 12 天可以玩到第 3、4 章的 Pro 关卡,并把这些星写进同一份 levelStars。星是全局求和的,所以这些星会参与章节星门:一个没打完第 1 章的免费用户,可能靠每日挑战先把总星数堆到 24,从而打开第 2 章;也可能堆到 48,让第 3 章的星门条件成立——但第 3 章是 Pro,isFree 为假,仍然进不去。星门被满足了,门票仍然没有。
地图上的显示会因此出现三种口径同时成立的一格:卡片右上角是 PRO 徽章,左下角三颗星里有亮星,状态文字写”已点亮”或”满星”。点它,走的是 isProLocked 分支,弹家长门,再到付费墙。徽章没有说谎,星星也没有说谎,两个谓词各画各的。
更细的一处是 unlockedLevels 的稀疏化。假设某个免费用户在每日挑战里通关了第 27 关(第 4 章),completeLevel 会插入第 28 关。此后他靠每日挑战与免费关把总星数堆到 72,第 4 章打开;地图上第 25 关作为本章第一关可点,第 27 关有星,第 28 关已在集合里可点,而第 26 关从未被解锁。顺序变成了”26 锁、27 有星、28 可点”。这不是数据损坏,是”只插下一关、不做前缀回填”与”存在第二个入口”共同产生的合法状态。
每日挑战覆盖 28 关,占 96 关的 29.2%。它是一条没有写进任何文档的旁路:产品上它叫”每日特训”,状态机上它是第三个入口,而且这个入口不过闸门。
八、两个不随账本走的开关
isTeacherDemoMode 不在 LabProgress 里,它是 LanguageController 的一个 @Published 布尔量,值直接落 UserDefaults。视图层在三处把它或进判定:总览卡片的已解锁计数、关卡卡片的 isUnlocked、通关后”下一关”的续跳。模型层的 isChapterUnlocked 与 isLevelUnlocked 并不知道这个开关。
结果是同一屏上的两套结论:计数行因为或进了演示态,显示”已解锁 96 / 96 关”;章节头的锁横幅调用的是没有或进演示态的 isChapterUnlocked,仍然写”收集 168 颗星星解锁第 8 章”。前者的谓词为真,后者的谓词为假,两个都对,只是不是同一个谓词。
另一个开关是 -uiTestWalkthrough。它在模型内部直接返回真,两个闸门都短路。这是为自动化走查留的启动参数,但它写在发布代码里,不是 #if DEBUG 包起来的。它不影响正常用户,但它说明这套判定至少有四个输入:进度、权益、演示态、启动参数。
设置页的”重置进度”执行的是 progress = LabProgress()。这一行会把星级、许可集合、问答记录、勋章、连续天数、学生姓名一并回到默认值;Pro 权益与演示态不在这个结构里,不会被清。于是重置之后,如果演示态还开着,首页仍然显示 96 / 96,关卡仍然能点——账本清零了,开关没有。
九、计数器与五条可迁移规则
总览卡片上的”已解锁 N / 96 关”统计的是 isLevelUnlocked 为真的关卡数,不是玩过的关卡数。每开一章,本章第一关会立刻被计入,即使用户一次也没进过。Pro 用户在 24 星时看到 13,是 12 个已玩关加 1 个”本章首关”,不是 13 个已访问关。
同一屏还有另外两个分母:总进度条用 totalStars / 288,章节胶囊用 chapterStars / 36,计数行用”许可关数 / 96”。三个分母,三种范围,都是计算出来的,都没有存储。它们不会互相校验,所以任何一个谓词改了,另外两个不会自动跟着改。
把这套结构收拢,可以提炼出五条规则,适用于任何端侧离线课程或合规台账的解锁设计。
第一,进度主键用课程顺序,不用原始 id。插入新内容时,id 是历史遗留,顺序才是产品语义;nextLevel 必须查顺序表,不能做加法。
第二,闸门收敛到一个纯函数,所有入口共用。地图、每日挑战、续跳、批量入口都调同一个谓词;第二个入口一旦绕过它,集合就会出现无法用界面解释的状态。
第三,文案由判定函数生成,不写死”全部解锁”。购买态、演示态、星门态是三个不同的谓词,文案必须说明自己对应哪一个;否则用户会在同一屏上读到两句相反的话。
第四,侧入口要么过闸门,要么写独立账本。每日挑战如果定位是”体验”,就不该把星写进主账本;如果要把星写进主账本,就必须过同一道闸门。两者只能选一个。
第五,账本字段用可选类型加显式迁移,解码失败不静默覆盖。levelQuizMastered 的可选写法是对的;unlockedLevels 的非可选写法把”缺键”变成了”整份进度解码失败”,而 try? 又把这个失败变成了静默归零。更稳的做法是给每个后加字段一个 decodeIfPresent 的回退,并在解码失败时保留原始字节、不要用空结构覆盖。
十、能力边界
这套进度系统的能力边界需要说清楚。星级是本机学习进度的记录,不是防作弊凭据:JSON 存在应用沙盒里,越狱设备或备份文件都可以改;进度文件没有签名链,也没有服务端校验。重置是不可逆的,代码里没有撤销,也没有重置前的自动快照。允许集合只增不减,跨设备不同步,换机通常只能靠系统备份整体搬迁。
行业侧,2026 年 11 月 5 日有两项量子计算性能测试评估类国家标准正式实施。那类标准衡量的是量子计算系统的性能测试与评估方法,对象是硬件与系统;教育沙盒里的 288 颗星是学习进度,不是任何意义上的性能指标。把两者放在一张表里比较,是范畴错误;QubitLab 也从未把自己定位成测试或基准工具。
回到开头那两个提问。买了 Pro 仍锁关,是因为付费只短路了 isFree 一项,星门与许可集合还在;零颗星却能玩第 17 关,是因为每日挑战是第三个入口,没有过闸门。两个现象都不神秘,它们是同一条判定链上两个不同位置的缺口。
一关能不能打开,最终由三个账本与两道闸门的合取决定。把这句话写进代码,也写进文案,锁与不锁才不会互相矛盾。
参考资料
- [1] Apple. AppStorage. appstorage
- [2] Apple. Encoding and Decoding Custom Types. encoding-and-decoding
- [3] Apple. Set. set
- [4] Apple. Dictionary. dictionary
- [5] Apple. ProgressView. progressview
- [6] Next Generation Science Standards. nextgenscience
- [7] 国家标准化管理委员会. sac