Skip to content

小游戏回放数据格式 v1 ​

状态:已实现。网站和离线版新回放统一保存为 v1,读取时兼容 v0;IndexedDB 自动升级,后端历史数据已完成压缩迁移。

本文规范本地回放及持久化使用的 v1 JSON 载荷,并规定其 7z 存储容器。独立录像下载及文件扩展名暂不定义。JSON 文本使用 UTF-8;对象字段顺序和空白不影响含义,元组字段顺序和事件排列顺序必须保留。

1. 载荷与外层成绩 ​

游戏类型、模式、成绩和完成时间继续由外层记录提供,不重复写入回放:

信息IndexedDB后端
游戏与模式game、mode由 ranking_key 确定
总用时,整数毫秒scoreMsscore
完成时间playedAt,Unix 毫秒achieved_at,服务端时间
所有游戏回放replay,7z ArrayBufferreplay_archive BinaryField,7z 字节
漏雷各局数据解压后 JSON 的 rounds解压后 JSON 的 rounds
回放格式版本解压后 JSON 的 version解压后 JSON 的 version

棋盘行列数由外层模式确定,不从压缩字符串或题目数组猜测尺寸。所有棋盘坐标均为从 0 开始的 [row, column],表示格子索引;鼠标事件仍使用像素坐标 x, y。

只持久化成功局。失败局和未完成就重开的对局不上传、不写入本地历史。前端运行时可以用同样的事件表示法回放失败局。

存储容器 ​

每份回放保存为独立、未加密的 7z 归档,恰好包含一个名为 replay.json 的普通文件,内容是本文定义的 UTF-8 JSON。使用 LZMA2,当前写入参数为级别 1、1 MiB 字典,不跨回放压缩。JSON 内的 version 继续标识载荷格式,7z 封装不改变其含义。

前端使用 7z-wasm 编解码,IndexedDB 只保存原始 ArrayBuffer,不转 Base64;离线包内联压缩器 Wasm,不依赖联网加载。后端使用 py7zr,BinaryField 保存相同容器的字节,不进入 Redis。

上传接口仍接收表单中的 v1 JSON 字符串,后端在新建或改善成绩时压缩后写入。存储读取顺序为:7z 解包 → UTF-8 JSON 解析 → 识别 version → 解码棋盘和事件。

版本识别 ​

在上表约定的位置读取 version,以该字段识别回放格式,不通过棋盘或事件数组的形状猜测版本:

version解释
字段不存在,或整数 0v0,即此前的格式:二维数组棋盘、对象形式的鼠标事件;漏雷目标编码在棋盘的唯一 10 中
整数 1本文定义的 v1 格式
其他非负整数未支持的版本,报告并停止解析,不回退为 v0

字段存在时必须是非负整数;null、字符串、布尔值等均属于格式错误,不视为字段缺失。现有未带 version 的数据统一识别为 v0,再校验 v0 所需字段;新写入数据统一显式保存 version: 1。

左键训练与标双训练 ​

ts
interface TrainingRecordingV1 {
    version: 1;
    cellSize: number | null;
    initialBoard: string | [row: number, column: number, mineDirection: number][];
    events: [dt: number, x: number, y: number, action: number][];
    specialEvents: [dt: number, index: number, type: number][];
}
字段约定
version固定为整数 1,标识本次回放格式;旧数据缺少此字段时按 v0 读取
cellSize记录使用的格子边长,单位为 CSS 像素,为正整数或 null;新录制在开始计时的事件上确定并固定,v0 迁移填 null,读取 null 时按固定参考值 32 解释
initialBoard左键训练为雷位 Base64 字符串;标双训练为题目数组,每项保存题目中心坐标与雷方向,详见第 2 节;具体表示由外层游戏类型确定
events从计时开始到结束的普通鼠标事件,按实际处理顺序排列;每项恰好四个整数,含义见第 3 节
specialEvents失焦、取消等特殊事件,包含时间差、插入索引和类型;没有特殊事件时保存空数组,详见第 3.5 节

格式版本、IndexedDB 数据库版本和榜单 key 中的规则版本分别维护。当前 IndexedDB 版本为 3,支持从版本 1 的 v0 数据及版本 2 的 v1 JSON 数据升级到 7z 存储;榜单规则和 key 不变。未来改变元组字段、单位、编码或已有枚举含义时,必须增加格式版本,不能直接改变 v1 的解释。

多指训练 ​

由外层 game: 'multi-touch' 或 multi-touch-* 榜单选择此结构,与鼠标录像的 v1 结构独立,不改变已有四元组的含义。多指训练没有 v0 格式。

多指成功局按此格式压缩保存到 IndexedDB,网站用户手动上传时提交相同 JSON,后端压缩写入 BinaryField;回放不进入 Redis。

ts
interface TouchRecordingV1 {
    version: 1;
    cellSize: number | null;
    initialBoard: string;
    events: [dt: number, dx: number, dy: number, contact: number, action: number][];
}

cellSize 和 Base64 initialBoard 的含义与左键训练相同。各触点事件合并为一个有序数组,时间和像素坐标均相对于数组中上一条事件差分;第一条相对于零,坐标为绝对值。差值保留正负,不使用 ZigZag。contact 不差分:每次按下分配新编号,从 0 连续递增;编号代表一次触摸,浏览器复用 pointerId 后再次按下也分配新编号。

action含义
0按下,创建触点并尝试打开当前位置
1移动,只更新该触点位置
2抬起,移除该触点,不开格
3取消,移除该触点,不开格

移动、抬起和取消必须引用仍处于按下状态的触点。失焦、隐藏页面等全局取消按当前触点各写一条 3,因此无需 specialEvents。计时从第一条按下开始;同一毫秒的事件保持数组顺序。结束后不再录制,末尾允许仍有触点按住。读取时分别还原各触点的轨迹和当前位置标记,只根据按下事件重建棋盘,不显示格子按下预览。

例如先后按下两个手指、移动第一个手指、抬起第二个手指:

json
[[0,16,16,0,0],[10,32,0,1,0],[5,-30,2,0,1],[5,30,-2,1,2]]

添加此游戏不改变 IndexedDB 的表结构和版本,已有鼠标及漏雷回放不需要迁移。

高级统一使用 16×30 的棋盘及事件坐标。纵向仅在显示时转置棋盘和轨迹、在输入时交换横纵坐标;方向保存在用户本地设置中,不写入回放,也不改变榜单 key。切换方向保留当前局及回放内容。

漏雷训练 ​

漏雷不记录鼠标事件,每局保存雷位字符串、目标格坐标和用时:

ts
interface MissingCellRecordingV1 {
    version: 1;
    rounds: { board: string; target: [row: number, column: number]; timeMs: number }[];
}

rounds 按实际游玩顺序排列,成功局固定为 10 项。board 只编码雷位;target 单独保存唯一未打开的安全格坐标,不得指向雷;已打开数字由雷位重算。timeMs 是该局用时,为非负整数毫秒,各局用时之和等于外层总成绩。漏雷没有依赖格子大小的轨迹,因此不保存 cellSize。整个 {version, rounds} 对象压缩后写入 IndexedDB 条目的 replay。

2. 棋盘编码 ​

2.1 左键与漏雷的 Base64 雷位 ​

每个格子只占一位:雷为 1,非雷为 0。使用 RFC 4648 第 4 节 的标准 Base64 字母表,字符索引为 0..63:

text
ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/

从棋盘左上角开始按行排列,每行从左到右,行末直接衔接下一行;每 6 个格子对应一个字符,组内先出现的格子占高位:

text
i = row * columns + column
characterIndex = floor(i / 6)
bitPosition = 5 - i % 6

value[j] = sum(mine[6 * j + k] * 2 ** (5 - k), k = 0..5)
board[j] = alphabet[value[j]]

最后不足 6 个格子时,在低位补 0,这些补位不代表实际格子。字符串长度固定为 ceil(rows * columns / 6),不保存 = 填充字符、空格或换行;末尾补位必须为 0。

级别行 × 列实际格子数雷位字符串长度
初级8 × 86411
中级16 × 1625643
高级16 × 3048080

例如一组格子的雷位为 1, 0, 1, 0, 0, 1,得到二进制 101001,值为 41,对应字符 p。连续 6 个非雷对应 A,连续 6 个雷对应 /。

实现可将按行排列的雷位按高位优先打包成字节,再复用现有 Base64 编解码器;当前三种棋盘的格子数都是 8 的倍数,编码后去掉末尾 = 即得到上述表示。调用需要填充的解码器时,可临时补回 =;持久化值仍不带填充。

2.2 标双题目数组 ​

initialBoard 是题目数组,每项恰好三个整数:

text
[row, column, mineDirection]

row, column 是数字 1 的中心坐标,使用格子索引,不使用像素;雷位由中心加方向偏移得到。方向编号沿用现有相邻格的逐行遍历顺序,跳过中心:

mineDirection方向行偏移列偏移
0左上-1-1
1上-10
2右上-11
3左0-1
4右01
5左下1-1
6下10
7右下11

例如 [3, 5, 2] 表示题目中心位于第 3 行、第 5 列,雷位于 (2, 6)。每题固定一个雷,数字固定为 1,不重复保存数字或其余安全格。初级固定 4 项,中级固定 15 项;题目的完整 3×3 区域不得越界或相互重叠。

写入时按中心的 row、column 升序排列题目,使同一个局面有固定表示;数组顺序不表示玩家解题顺序。

2.3 漏雷目标坐标 ​

每局的 board 使用第 2.1 节的雷位编码,另用 target: [row, column] 保存目标格。目标坐标必须在棋盘内,且对应雷位为 0。题目顺序和用时只保存在外层 rounds,不写入雷位字符串。

2.4 运行时棋盘重建 ​

存储层使用上述压缩表示,解码后恢复项目已有的二维主棋盘矩阵:

数值左键 initialBoard标双 initialBoard漏雷每局 board
0不使用题目之外的空白区域已打开的空白格
1..8不使用只使用 1,表示题目中心已打开的相邻雷数
10尚未点击的安全格尚未打开的安全邻格唯一未打开的安全目标格
16显示的雷显示的雷隐藏雷,回溯显示时转换为 10

左键按雷位恢复 16 和 10;标双先填充全盘 0,再按每题恢复中心 1、雷 16 和其余邻格 10;漏雷由雷位计算安全格的相邻雷数,再将雷设为 16、目标格设为 10。展示漏雷局面时将雷的 16 转为盖住的 10,目标高亮使用单独保存的坐标。

初始棋盘保留完整雷位,不保存随机种子或相邻雷数。旗、错旗、爆雷和操作后的棋盘均由规则重建;不持久化逐次操作的快照、剩余数量或分析结果。

3. 鼠标事件元组 ​

text
[dt, x, y, action]
下标字段首个事件后续事件
0dt从开始计时到该事件的整数毫秒数,正常新录像为 0与前一个事件的时间差,非负整数毫秒
1x相对棋盘左上角的绝对横坐标,整数像素,可为负相对前一个事件的横坐标差值,有符号整数
2y相对棋盘左上角的绝对纵坐标,整数像素,可为负相对前一个事件的纵坐标差值,有符号整数
3action输入事件编号同左

差分始终以普通事件数组中的前一个元素为基准,包含移动及无效果的按键,不以最后一次有效游戏操作为基准。特殊事件单独保存,不参与普通事件的时间或坐标差分。允许 dt = 0,同一毫秒内的事件仍按数组顺序处理,不能按时间去重或重新排序。

3.1 时间 ​

使用游戏的单调时钟,单位为毫秒;Unix 完成时间不参与事件计时。先量化累计时间,再计算差值:

text
t[i] = max(0, round(now[i] - startedAt))
dt[0] = t[0]
dt[i] = t[i] - t[i - 1]       // i > 0

这里的 round 对非负数采用 floor(value + 0.5),前后端不得分别舍入每段时间差。迁移时旧的 timeMs 已经量化,直接做差。

  • 左键训练:首次左键弹起并触发操作时开始;该弹起事件记为 t = 0,此前的按下和观察移动不记录,因此首次点击没有可配对的计时内按下事件。
  • 标双训练:首次有效插旗时开始;该右键按下事件记为 t = 0。
  • 开始计时后,移动、按下和弹起进入 events,取消及重置进入 specialEvents,包括无效果的游戏操作;结束事件必须包含在内,结束后不再追加。
  • 允许空事件数组,表示没有可回放输入;正常完成的新录像应含开始和结束所需事件。不能仅因空数组就在迁移中丢弃历史记录。

两组事件各自累加本数组的 dt,得到相对于同一个计时起点的时间;总用时使用外层成绩。保留现有成功成绩至少为 1ms 的规则,因此不能强制用最后一个事件的时间覆盖总成绩。

3.2 像素坐标与格子大小 ​

以棋盘内容左上角为 (0, 0),x 向右增加,y 向下增加。坐标使用参考格子大小对应的逻辑 CSS 像素,不包含计数器、外框、页面滚动偏移,也不乘 devicePixelRatio。

cellSize 为 null 时表示原录制尺寸未知,固定使用 32 作为参考值;该默认值不随设置页当前格子大小变化。字段仍必须存在,缺失或其他非法值不按 null 处理。后续公式统一使用有效参考值:

text
referenceCellSize = cellSize === null ? 32 : cellSize

设事件发生时显示的格子大小为 displayCellSize,指针在该显示棋盘上的逻辑像素位置为 displayX, displayY:

text
x[i] = floor(displayX / displayCellSize * referenceCellSize)
y[i] = floor(displayY / displayCellSize * referenceCellSize)

页面发生 CSS 缩放时,应先按棋盘实际显示矩形换算回逻辑像素。整局使用同一个 referenceCellSize 参考系;游玩途中修改格子大小不重开、不改变元数据,后续事件换算到原参考系。坐标先向下取整,再做差分,避免把格子右边缘的点击四舍五入到相邻格。

不裁剪棋盘外坐标,负数和超出棋盘宽高的坐标均可保留。回放根据坐标是否在棋盘范围内判断命中,不保存浏览器遮挡和滚动裁剪信息。

回放时还原像素坐标,再转换为现有渲染器需要的格子单位:

text
column = x / referenceCellSize
row = y / referenceCellSize

游戏操作格索引使用 floor(column)、floor(row)。绘图可使用当前显示格子大小重新缩放,回放不要求恢复录制时的显示尺寸。像素量化会舍去不足一个参考像素的小数位置,不改变所属格子。

3.3 坐标差分 ​

首个事件的 x, y 直接写入;后续事件分别保存相对前一个事件的 dx, dy,以 JSON 有符号整数表示,不进行 ZigZag 转换。

text
storedX[0] = x[0]
storedY[0] = y[0]

storedX[i] = x[i] - x[i - 1]       // i > 0
storedY[i] = y[i] - y[i - 1]

x[0] = storedX[0]
y[0] = storedY[0]
x[i] = x[i - 1] + storedX[i]       // i > 0
y[i] = y[i - 1] + storedY[i]

静止时差值为 0,向左或向上移动时对应差值为负数。存储值、还原坐标、时间累计值及计算中间值都必须位于 JavaScript 安全整数范围内。

3.4 输入事件编号 ​

action输入事件现有名称
0鼠标移动mv
1左键按下lc
2左键弹起lr
3右键按下rc
4右键弹起rr

触摸操作继续由输入适配层转换为左键事件,不新增触摸专用编号。取消和重置不占用普通事件编号,统一进入特殊事件数组。

3.5 特殊事件与插入索引 ​

specialEvents 的每项恰好三个非负整数:

text
[dt, index, type]
字段含义
dt相对上一个特殊事件的时间差,整数毫秒;首项为从开始计时到该特殊事件的时间,不相对普通事件计算
index该事件在普通事件数组中的插入索引,从 0 开始,范围为 0..events.length
type特殊事件类型;v1 仅定义 0,表示取消并重置输入状态

index = i 表示在 events[i] 之前执行;index = 0 表示在首个普通事件之前执行;index = events.length 表示在最后一个普通事件之后执行。索引始终针对原始 events,不因插入其他特殊事件而增加。同一插入索引可出现多个特殊事件,按 specialEvents 中的顺序执行,不去重。

特殊事件按实际发生顺序记录,其 index 必须单调不减。设该事件累积时间为 s,插入索引为 i,普通事件累积时间为 t:存在前一个普通事件时,必须满足 t[i - 1] <= s;存在下一个普通事件时,必须满足 s <= t[i]。同一毫秒内的先后关系由插入索引和数组顺序确定,不能仅按时间合并两组事件。

失焦、页面隐藏、触摸取消或按键状态不一致统一记录为类型 0。执行时重置自动机、清除按下预览及未配对的按下时间,不触发左键、插旗或双键操作。特殊事件不保存坐标,沿用前一个普通事件的位置,不改变普通事件的差分基准;首个普通事件之前没有已知鼠标位置。具体重置原因不单独保存。

3.6 派生状态与起始事件 ​

按键状态、操作意图和按下预览不持久化,由现有 ClassicMouseAutomaton、事件坐标及当时棋盘重建。回放时先让自动机处理普通事件,再根据坐标范围将产生的操作意图交给游戏规则;无效果的操作仍留在事件序列中。

  • 左键训练的首个普通事件是开始计时的 lr。重放前将自动机置于左键按住状态,再处理该事件;这一步只补足已知初态,不生成计时内的 lc,因此首次点击仍不参与按下时长统计。
  • 标双训练从自动机空闲状态开始,首个普通事件为开始计时的 rc,按普通规则产生插旗操作。
  • 重置后继续从空闲状态处理后续普通事件;棋盘外的事件仍更新按键状态,但不产生棋盘操作或按下预览。

预览遵守现有公共规则:单键只排除旗格;双键预览中心及周围 3×3,旗格保持凸起。完整自动机状态用于区分普通单键按住与双键触发后剩余按键按住的情况。

4. 前后端共用编码样例 ​

以下仅为事件片段,不代表完整成功局。cellSize = 32,坐标均为解码后的参考像素。

左键、移动和取消 ​

序号累计时间坐标 (x, y)输入状态/意图/预览存储元组
00(16, 16)lruu/left/无[0, 16, 16, 2]
110(48, 16)lcdu/无/单格[10, 32, 0, 1]
225(45, 18)mvdu/无/单格[15, -3, 2, 0]
325(45, 18)lruu/left/无[0, 0, 0, 2]

若在 30ms 发生取消,另存:

json
{"specialEvents": [[30, 4, 0]]}

这里 4 表示普通事件数组的末尾插入位置。若取消发生在 25ms 的移动之后、弹起之前,则改存:

json
{"specialEvents": [[25, 3, 0]]}

此时先处理普通事件 2,再重置,最后处理普通事件 3;重置后的 lr 不产生左键操作。两种情况下普通事件的时间和坐标差分都不改变,状态/意图/预览列仅用于说明推导结果,不属于存储字段。

插旗后双键 ​

序号累计时间坐标 (x, y)输入状态/意图/预览存储元组
00(16, 16)rcud/right/无[0, 16, 16, 3]
110(16, 16)rruu/无/无[10, 0, 0, 4]
230(48, 48)lcdu/无/单格[20, 32, 32, 1]
340(48, 48)rcdd/无/3×3[10, 0, 0, 3]
450(48, 48)lrud/chord/无[10, 0, 0, 2]

若该双键成功完成一道题,三个统计时间点分别为 0ms、30ms、50ms,堆叠条形底段为 30ms、顶段为 20ms。失败和无效果双键仍保留在事件序列中,但不生成成功标双统计。

5. 解码与回放 ​

  1. 从外层确定游戏和模式,按 version 识别格式;字段缺失时按 v0 读取,按第 6 节转换后进入 v1 解码流程。
  2. 鼠标回放分别累加两组事件的时间差,还原普通事件坐标,再按特殊事件的插入索引合并;多指回放累加单一事件流并跟踪各触点。使用解码后的序列索引定位运行时快照和同毫秒内的操作,不改写持久化的插入索引。
  3. 按第 2 节将 initialBoard 解码为二维矩阵,再创建游戏控制器。鼠标按第 3.6 节补足初态,顺序处理普通输入与特殊重置,推导操作意图并执行游戏规则;多指仅在按下时尝试开格。复用现有自动机和左键/标双规则,不引入 ms-toollib。漏雷单独解码每局雷位并结合目标坐标恢复局面。
  4. 一次性重建所有棋盘快照、剩余计数和成功标双时间点;这些数据只在内存中使用。
  5. 由解码后的轨迹和时间计算移速、点击速度及按下时长;按外层成绩控制播放末端。

识别版本后,该版本必需字段缺失、非法枚举、负时间差、应为整数的字段含非整数值、安全整数溢出,特殊事件插入索引越界/递减或与时间顺序冲突,以及雷位字符串长度/字符/补位错误、越界坐标、目标指向雷或无法重建的标双题目布局,均属于格式错误。读取器应报告并停止解析该记录,不将缺失数据默认为零;version 缺失按第 1 节视为 v0,cellSize: null 按第 3.2 节使用参考值 32。未知 version 不按旧格式猜测。此规则服务于数据读取和迁移,不新增成绩真实性、操作合法性或作弊检测。

6. v0 到 v1 迁移 ​

6.1 共同转换规则 ​

v0 左键/标双回放为 {initialBoard, events},棋盘为二维数组,事件是包含 timeMs、row、column、action、state、pressed、trigger、cancelled 的对象。v0 漏雷回放为 {rounds},每局是 {board: number[][], timeMs}。version 缺失或显式为整数 0 时均按 v0 校验并转换;空鼠标事件数组也应转换。

根据外层游戏与模式转换棋盘:

  • 左键:将旧 initialBoard 中的 16 编为雷位 1,其余安全格编为 0,得到 Base64 字符串。
  • 标双:遍历旧 initialBoard 中的数字 1,提取每个中心和其八邻格中唯一的 16,转换为方向编号,按中心坐标排序后生成题目数组。
  • 漏雷:每局将旧 board 中的 16 编为雷位 1,其他格子编为 0;提取唯一的 10 作为 target,原样保留该局 timeMs。数字不再存储,解码时由雷位重算。

旧鼠标坐标单位为格,未保存录制时格子大小,无法恢复当时的实际像素尺寸。左键/标双迁移后保存 cellSize: null,按默认参考值 32 将旧格子坐标转换为像素坐标。先按原顺序分离普通事件和取消事件,再分别计算差分:

text
normal = old.events.filter(event => !event.cancelled)
x[i] = floor(normal[i].column * 32)
y[i] = floor(normal[i].row * 32)
dt[0] = normal[0].timeMs
dt[i] = normal[i].timeMs - normal[i - 1].timeMs

普通事件按第 3 节保存有符号坐标差和 action 编号。旧 cancelled: true 转为类型 0 的特殊事件;其插入索引为遍历到它时已经保留的普通事件数量。特殊事件的 dt 只相对上一个特殊事件计算,首项使用原 timeMs。不能先对全部旧事件计算差分再删除取消事件,否则会丢失时间间隔。

旧 state、pressed、trigger 不再持久化,读取时按自动机和坐标重建;不额外记录旧 DOM 命中信息。棋盘内容和合并后的输入顺序不变,只改变表示方式。

cellSize: null 保留原尺寸未知的含义,32px 只是解码使用的统一参考系,不意味着旧录像当时使用了 32px。迁移保留原格子位置,轨迹舍去不足 1/32 格的小数;后续仍可按任意显示尺寸缩放。

棋盘、事件或漏雷各局全部转换成功后,统一添加 version: 1,不写入半转换记录。已符合 v1 的回放重复迁移时不再编码;未知版本和损坏数据必须保留原值并报告。

6.2 前端 IndexedDB ​

复用现有 idb,将 saolei-minigame 升为版本 3,兼容版本 1 的 v0 和版本 2 的 v1 JSON。打开升级事务前先初始化压缩器,在事务中遍历 results:读取并转换各游戏载荷,将 7z ArrayBuffer 写入统一的 replay 字段;旧漏雷条目顶层的 rounds、version 移入压缩载荷。保留 id、playedAt、scoreMs、game、mode,以及 playedAt、scoreMs 两个索引。

首次安装直接创建新版库。单条回放转换失败时,记录警告并将其原始 JSON 整体压缩为 7z,保留条目及全部原字段,继续迁移其他记录;不补写 version: 1,后续读取仍执行正常格式校验并报告解析错误。左键/标双保留原 replay 对象,漏雷保留原 rounds 及存在的 version。压缩或数据库操作失败时,中止事务并保留旧数据,不通过删库重建或清空历史来处理。旧页面继续在收到版本变更时关闭连接,避免阻塞升级。版本升级使用 IndexedDB 的 upgradeneeded 机制。

有旧记录需要转换时,网站和离线版显示“正在优化录像格式,请不要关闭页面”,并按已处理/总条数显示进度;升级事务提交或失败回滚后关闭弹窗。开始压缩前先让浏览器绘制提示,等待期间保持数据库请求活跃,避免升级事务提前结束。空库及已升级的库不显示此提示。

6.3 后端存储 ​

后端历史数据已完成 v1 转换及 7z 压缩,回放仅保存在 replay_archive BinaryField。旧 replay JSONField、一次性迁移命令及其转换代码已移除。迁移文件继续由 Git 忽略,部署脚本自动生成并执行删除旧列的结构迁移;保留二进制回放、玩家、榜单、成绩、达成时间及主键。在 back_end/saolei 目录手动部署时执行:

bash
python manage.py makemigrations
python manage.py migrate

提交接口仍接收 JSON 对象字符串,新建或改善成绩时直接压缩存储。回放不进入 Redis,结构迁移不需要重建排行榜缓存。前端 IndexedDB 中的旧数据仍按第 6.2 节升级,保留 v0 读取兼容。