第10章 - 进度存档与复习调度

10.1 progress 包的位置

packages/progress 是「让孩子记住」这条主线的数据层:存档结构、星星、复习计划。它由两个文件组成,全是纯 TypeScript:

packages/progress/src/
├─ manager.ts     # ProgressManager:存档的读写、通关记录、到期查询
├─ review.ts      # 复习调度:简化版 SM-2(间隔递增,答错降级)
├─ review.test.ts # 9 项单测
└─ index.ts

没有 Phaser、没有 DOM、没有 Date.now()(时钟由构造函数注入)—— 它满足「逻辑与渲染分离」铁律的最严格标准,因此可以在 Node 里被完整单测。ESLint 对这类文件有专门规则:packages/progress/src/** 与 **/logic/** 里禁用 Math.random 与 Date.now。

10.2 存档的数据结构

// packages/game-kit/src/contract.ts —— 类型定义在契约层
export const SAVE_SCHEMA_VERSION = 1;

export interface SaveData {
  schemaVersion: number;
  allusions: Record<string, AllusionProgress>;
  updatedAt: number;
}

export interface AllusionProgress {
  allusionId: string;
  stars: 0 | 1 | 2 | 3;
  completions: number;
  lastPlayedAt: number | null;
  review: ReviewState;
}

export interface ReviewState {
  level: number;                       // 当前复习等级(对应 1/3/7/21/60 天)
  dueAt: number | null;                // 下次到期时间
  lastGrade: 0 | 1 | 2 | 3 | null;
  reviews: number;                     // 累计复习次数
}

几个设计点:

  • 整份存档是一个键(save):MVP 无后端、无并发写入,一个对象简单可靠;
  • schemaVersion 从第一天就有:注释写得很直白 ——「现在写迁移函数几乎零成本,等有用户数据了再补就是灾难」;
  • stars 与 review 分离:星星是给孩子的(只增不减),复习等级是给调度器的(可升可降),两个关注点不混在一个数字里。

10.3 迁移:不对就安全降级

/**
 * 存档迁移:现在写几乎零成本,等有用户数据了再补就是灾难。
 * 老存档(无 schemaVersion)一律降级为空档,避免脏数据炸掉游戏。
 */
export function migrate(raw: unknown, now: number): SaveData {
  if (!raw || typeof raw !== 'object') return emptySave(now);
  const data = raw as Partial<SaveData>;
  if (data.schemaVersion !== SAVE_SCHEMA_VERSION) return emptySave(now);
  if (!data.allusions || typeof data.allusions !== 'object') return emptySave(now);
  return {
    schemaVersion: SAVE_SCHEMA_VERSION,
    allusions: data.allusions,
    updatedAt: typeof data.updatedAt === 'number' ? data.updatedAt : now,
  };
}

当前策略是「版本不匹配 → 空档」。对一个还在早期、用户数据即玩即有的儿童游戏来说这是正确的取舍:丢失一局进度的代价远小于让脏数据把游戏卡死。等真的要保数据时,这里替换为逐版本迁移函数即可,读路径(store.load() → migrate())已经固定。

10.4 ProgressManager:存档的唯一入口

export class ProgressManager {
  private readonly store: ProgressStore;
  private readonly clock: () => number;
  private data: SaveData;

  constructor(store: ProgressStore, clock: () => number, now = clock()) { ... }

  async init(): Promise<SaveData> {
    this.data = migrate(await this.store.load(), this.clock());
    return this.data;
  }

  snapshot(): SaveData { return this.data; }

  get(allusionId: string): AllusionProgress {
    return this.data.allusions[allusionId] ?? emptyProgress(allusionId);
  }

  /** 通关:记录星星(只增不减,对小朋友永远正向)与复习计划 */
  async recordCompletion(allusionId, stars, grade): Promise<AllusionProgress> {
    const now = this.clock();
    const prev = this.get(allusionId);
    const next: AllusionProgress = {
      ...prev,
      stars: Math.max(prev.stars, stars) as 0 | 1 | 2 | 3,   // ★ 只增不减
      completions: prev.completions + 1,
      lastPlayedAt: now,
      review: nextReview(prev.review, grade, now),
    };
    this.data = { ...this.data, allusions: { ...this.data.allusions, [allusionId]: next }, updatedAt: now };
    await this.store.save(this.data);
    return next;
  }

  /** 复习关卡的评分(不影响星星) */
  async recordReview(allusionId: string, grade): Promise<AllusionProgress> { /* 同上,不动 stars */ }

  /** 到期待复习的典故(历史长卷 / 复习中心的输入) */
  due(allusionIds: string[]): string[] {
    const now = this.clock();
    return allusionIds.filter((id) => {
      const p = this.data.allusions[id];
      return p !== undefined && p.review.dueAt !== null && p.review.dueAt <= now;
    });
  }
}
  • 不可变更新(每次 recordCompletion 生成新对象再保存):与 Vue 的响应式刷新天然合拍,store 里快照一换,视图自动更新;
  • stars: Math.max(...):重玩不会掉星。对小朋友永远正向;
  • due() 是查询而非副作用:给「复习中心 / 图鉴到期标记」用,当前还没有消费方(见 10.7)。

10.5 复习调度:简化版 SM-2

export const DAY_MS = 86_400_000;
/** 简化版 SM-2:间隔递增,答错降级 */
export const REVIEW_INTERVALS_DAYS: readonly number[] = [1, 3, 7, 21, 60];

export function nextReview(state: ReviewState, grade: 0 | 1 | 2 | 3, now: number): ReviewState {
  const maxLevel = REVIEW_INTERVALS_DAYS.length - 1;
  const level = grade >= 2 ? Math.min(state.level + 1, maxLevel) : Math.max(state.level - 1, 0);
  const days = REVIEW_INTERVALS_DAYS[level] ?? 1;
  return { level, dueAt: now + days * DAY_MS, lastGrade: grade, reviews: state.reviews + 1 };
}

/**
 * 答对率 → 0..3 级,用于安排下次复习间隔。
 * 门槛刻意放宽(60% 即 2 级):这是给小朋友的,宁可多复习一次,也不要打击信心。
 */
export function gradeFromScore(correct: number, total: number): 0 | 1 | 2 | 3 {
  if (total <= 0) return 0;
  const ratio = correct / total;
  if (ratio >= 1) return 3;
  if (ratio >= 0.6) return 2;
  if (ratio >= 0.3) return 1;
  return 0;
}

调度规则一句话概括:答得不错就升级、间隔变长;答得不好就降级、很快再见。

  • 间隔梯度 1 / 3 / 7 / 21 / 60 天,覆盖「当天记住 → 长期记忆」的爬升路径;
  • grade >= 2 才升级(60% 答对率即达标)—— 门槛是被一次真实复盘调宽的:原本 70% 才给 2 级,被判定「宁可多复习一次也不要打击信心」;
  • 首次通关就会写入 dueAt(1 天后),所以「明天再来看看」从第一局就成立。

单测覆盖了这些规则(review.test.ts,9 项),包括升级、降级、封顶、下限、gradeFromScore 的分档边界。

10.6 构造注入:测试友好的具体做法

constructor(store: ProgressStore, clock: () => number, now = clock()) { ... }
  • 传 ProgressStore(而不是具体 IndexedDB 实现)→ 测试里传一个内存假实现;
  • 传 clock: () => number → 测试里控制时间流逝,断言「7 天后到期」不需要真的等;
  • 外壳里的实例化(shell/progress.ts):
export const progressManager = new ProgressManager(platform.storage, () => platform.now());

这条「构造函数注入 + 逻辑层禁 Date.now」的组合,是全仓「可复现测试」的通用范式,模板层的 session / logic 模块同样遵守。

10.7 与 UI 的接线现状(如实说明)

当前版本的接线是「星星与图鉴全通,复习链路半通」:

能力 状态
通关 → 星星落盘、图鉴出卡、刷新不丢 ✅ 已接线(level:complete → recordCompletion)
复习计划写入(dueAt) ✅ 已接线(grade 暂用星星值代替)
review 评级来自答对率 ⚠️ 未接线 —— PlayView 里是 recordCompletion(gameId, stars, stars),第二个参数是临时代替
due() 到期查询 ⚠️ 已实现、无消费方(复习中心未做)
createQuizScene 复习问答 ⚠️ 模板已实现、无生产入口
recordReview ⚠️ 已实现、无消费方

原因在第 7 章已经说明:quiz 模板没接的原因不是技术,而是产品选择 ——「每局结束都考三题」与「少写字、多动手」冲突,更合适的入口是到期复习:典故卡到了复习时间,从图鉴点进去做一次小测验。届时要做三件事:

  1. 图鉴里给 due() 命中的典故加「该复习了」标记;
  2. 用一个 ReviewView(或在 PlayView 加 ?review=1 分支)挂载 createQuizScene;
  3. 小测验结束用 gradeFromScore(correct, total) 计算评级,调 recordReview(而不是 recordCompletion)。

数据结构、模板、调度函数都已就位,这条链路接起来不涉及任何架构改动 —— 这是「先做数据模型、后做界面」的典型收益。

10.8 替换为云存档

ProgressManager 依赖的是 ProgressStore 接口(load / save 两个方法)。要做云存档(阶段 3 的可选项):

  1. 写一个 createCloudProgressStore(),在 load 时「本地优先 + 远端合并」、save 时本地写完后异步上行;
  2. 在外壳 shell/progress.ts 里把 platform.storage 换成它;
  3. 游戏包、模板、契约全部零改动。

这也是第 1 章「无后端是 MVP 决策,不是终局」的具体出口。

10.9 小结

  • 存档结构从第一天带 schemaVersion 与 migrate(),坏档一律安全降级;
  • ProgressManager 是唯一入口:不可变更新、星星只增不减、到期查询无副作用;
  • 复习调度是简化版 SM-2(1/3/7/21/60 天),评级门槛刻意宽松;
  • 复习的「数据 + 模板 + 调度」已完整,缺的是 UI 入口,接入路径清晰且无需架构改动。

下一章讲双语:界面、内容、语音三条链路如何在构建期被强制完整。