本文档是 puzzle-framework 的项目基石文档,供开发者与 AI 编码代理阅读。 前半部分是对参考项目 mijie 的架构解析(力求"吃透"),后半部分是本框架的设计草案。 文档处于草稿状态,标注「待定」的条目均有待拍板。
puzzle-framework 是一个解谜游戏(Puzzle Hunt)框架,灵感与大部分设计来源于 YouXam/mijie。三个核心设计目标:
- 沿袭 mijie:UI 风格、题面渲染方案(mdvc)、流程图、排行榜、公告、提交记录等体验与 mijie 保持一致;大部分技术细节直接继承。
- 更灵活的机制:在题目配置、解锁条件、分值、提示等机制上提供比 mijie 更灵活的方案(见「机制增强方向」)。
- 完全运行在 Cloudflare 上:单个 Worker + Cloudflare 全家桶(D1 / Durable Objects / R2 / Workers AI / Turnstile / Static Assets),一次
wrangler deploy完成部署,以极低开销长期留存,供后人重复体验。
以下决策已拍板,是实现的唯一依据。下一节「新架构的设想」为原始设计输入,若与本节冲突,以本节为准。
| # | 决策点 | 结论 |
|---|---|---|
| 1 | 解锁模型 | 手动点击「解锁」+ 持久化 unlocked_at,不可逆;时间类条件因此成为"解锁窗口" |
| 2 | 得分判定 | ctx.award(id) 原语 + 声明式 scores[].when 兜底评估;score_events 幂等(不可重复、不可撤销) |
| 3 | 得分条件可见性 | 全部可见(任务清单),scores[].desc 必填 |
| 4 | gameover | 机制全部删除(配置项、ejs 通关文本、页面、路由、users 字段) |
| 5 | AI 能力 | 整体移除(Context 无 ai,不配置 Workers AI binding;glot/runCode 属代码执行,保留待定) |
| 6 | pid 大小写 | 不强制转换,全链路大小写敏感;构建期对仅大小写不同的 pid 告警 |
| 7 | label | 新增 label?: string,兼任题目列表排序键与幽灵行标识,全行显示 |
| 8 | noPrize | 不实现;QQ 号仅供管理员在用户列表人工核对 |
| 9 | 管理员隐藏 | 沿用 mijie hidden 机制:排行榜/通过率的排除口径只看 hidden,与 admin 身份无关;管理员在获得权限时 hidden 默认置 1(首个注册管理员、被授予 admin 时),可在用户列表手动取消 |
| 10 | 回顾模式 | 由 vars.REVIEW_MODE("true"/"1")开启:全局比赛起止时间不生效(题目内 time 条件保留原语义);关闭排行榜/通过率与 rank 广播;玩家可自行重置游戏进度(POST /api/reset-progress,清空 records/score_events/problem_state/game_storage 并归零 users 冗余字段,账号保留);公开 GET /api/mode 供前端感知。账号生命周期:不再提供玩家自助删号,改由 Cron Trigger 每日清理 180 天未登录的非管理员账号(users.last_login_at,存量以 created_at 为宽限起点) |
每个(玩家,题目)对上有四个相互独立的状态,这是架构的轴心:
- 可见性
visible | ghost | hidden:派生量,由accessible配置 +(解锁态,访问态)计算,不存储; - 解锁
unlocked_at:持久化,玩家手动点击、条件校验通过后写入; - 通关
passed_at:持久化,checker 返回 true 或ctx.pass()时写入; - 得分
score:持久化,由score_events幂等累加,可正可负,与通关无关。
访问控制规则:unlocked 是访问的必要条件(未解锁一律 404);visible 只影响列表展示,不影响 URL 访问(潜伏题的入口就是 URL)。
- 排序:有 label 的题按自然序(数字感知,
Intl.Collator(..., { numeric: true }))在前;无 label 的按 pid 字典序在后。 - 显示:可见行 =
label + 名称;幽灵行 = 仅label(无 label 回退???);隐藏行不出现。 - 校验:无硬性长度限制(约定两位以内);无 label 的题构建期告警「将按 pid 排在列表末尾」。
- JWT 不内嵌 gameprocess:进度在 D1,token 仅含
{sub, admin},用户摘要走GET /api/me; - 插件无
points字段:玩家得分 =score_events之和,"通关得分"用 scores 表达; - 改密路由移入登录后(mijie 在 JWT 校验前,行为等价但位置更合理);
- AI / noPrize / gameover / 流程图(Graph 页与
next/first配置)全部移除; gameProcess.passed由Record<pid, points>改为ReadonlySet<pid>,移植 mijie 题目时Object.hasOwn(passed, pid)需改为passed.has(pid)。
本节为原始设计输入,实现以「设计决策定稿」为准。
接下来是我关于新谜题框架的设想:
题目之间不再定义顺序,所以将弃用原有的路线图,代之以单纯的题目列表。
玩家开始关卡一般通过题目列表进入,通过关卡后也一律回到题目列表。
题目通过配置特定条件来解锁。
题目分为“已解锁”和“未解锁”两种状态。未解锁的题目即使通过 URL 来访问,也会返回 404 ,效果上如同这道题目不存在。
为了描述解锁状态和解锁条件,题目需增加一个配置项 unlock。它可以是 true 值,表示本题自动解锁;或者是一个自定义的校验函数,返回 boolean 值表示玩家是否可以解锁。原有的以定义题目顺序组织谜题和进行解锁的方法不再使用,也不再定义“第一道题”。
未解锁的题目如果处于可见态或幽灵态,将在题目列表中呈现它的解锁条件。解锁条件满足后,可在题目列表中将其解锁。
常见的解锁条件包含以下几类,必须满足本题列出的所有条件,才可以解锁:
- 通过特定的关卡
- 通过某一个特定的关卡(类似于旧架构的
next逻辑) - 在某个集合中,通过特定数量的前置关卡(类似于不得已而设置的
Entrance的逻辑)
- 通过某一个特定的关卡(类似于旧架构的
- 达到特定的分数
- 总分达到特定分数
- 某个关卡达到特定分数
- 某些关卡的分数之和达到特定分数
- 服务端时间
- 只在奇数分钟满足/只在偶数分钟满足
- 只在特定时间之前开放/只在特定时间之后开放
可见状态说明了一道题目是否在题目列表中可见,分为以下三种状态:
- 可见态。玩家能看到题目的名字,也能查看解锁条件。如果已解锁,还可以通过题目列表访问题目。
- 幽灵态。玩家看不到题目的名字,只能查看解锁条件,不可以通过题目列表访问题目。
- 隐藏态。在题目列表中不出现,也不会留下任何线索,玩家不会知道这道题目的存在。
为了描述题目在何种情况下可见性如何,题目需增加一个配置项 accessible,它是一个自定义的函数,表示题目的可见性。
- 永远可见。它会一直显示在题目列表中。
- 不可见。它不会显示在题目列表中。
- 空悬。如果这道题目已解锁,那么它将处于可见态;如果这道题目未解锁,那么它将处于幽灵态。
- 潜伏。它不会一开始就显示在题目列表中,处于隐藏态。不过,一旦玩家访问过本题,它就会变为可见态。
从实践上讲,一般只有第一题是永远可见,其后的题目多为空悬。不过,如果想要跟据玩家的进度或其它条件,来逐步披露后续题目的存在,也可以写一个更复杂的函数来描述其可见性(不可见或空悬)。至于潜伏题,一般只用在玩家必须通过 URL 才能找到的题目。
另外,“已解锁”是可以“访问”的必要条件,因为未解锁的题目只会返回 404。因此,一道题如果同时配置成潜伏和未解锁,那么它就没有意义了。
在旧架构中,通关和分数是高度相关的两件事,玩家会在通关后得到一个确定的分数(也可能由管理员手动打分)。而新架构将对其进行大刀阔斧的变更:
- 取消管理员手动打分的功能,相关逻辑不再保留。
- 通关不会直接获得任何分数(除非通关本身也在得分条件之中),二者没有必然联系。一个玩家可能已通关但没有获得任何分数,也可能获得了满分但未通关。
- 通关的判定方法和原架构相仿。
- 得分的计算方法与任务清单相似。每道题目都可以配置一系列得分条件(函数),每个条件都有自己的分值。当玩家达到了条件时,即可得到相应的分数,并更新总分。举例来说:
- 在
CountLightsOut中,如果把所有灯全都打开了,获得 10 分。(这和通关条件完全背道而驰,但依然有一定的挑战难度,所以给分) - 在
CAPTCHA中,如果通过bug通关,获得 10 分。(这是通关的充分而不必要条件,可以当作是鼓励玩家通过这条路线通关) - 在
CAPTCHA中,如果通过resistor通关,获得 15 分。 - 在
CAPTCHA中,如果通过chordate通关,获得 -5 分。(也就意味着,在某些情况下,玩家是会扣分的) - 在
BesiegeWithoutAssault中,如果敌军累计走过的路线长度达到 60 ,获得 5 分 (这是通关的必要而不充分条件,可以理解为阶段性的奖励。即使玩家没有通过本关,也可以由此得到一些分数) - 在
CatchGlowworm中,如果抓到萤火虫时你离原点的距离不超过 5,获得 10 分(这个条件不仅是通关的充分条件,而且比简单的通关还要高,可以理解为附加的挑战)
- 在
- 得分判定和通关判定是一起进行的,所以可能需要对架构做一番调整。
- 得分判定的条件达成后,即得到相应的分数,这一变化不可重复,不可撤销,也与通关状态无关。
添加一个 QQ 号字段(可不填),并注明这是核验领奖者身份的必须信息。
在账户设置中也可以更改 QQ 号。
按照通关题数降序、分数降序、最近有效提交时间升序排序,全部相同者排名相同。
最近有效提交指的是,更新了题数或分数的提交(无论分数是增加了还是减少了)。
管理员应默认隐藏。
用户信息中显示 QQ 号。原架构中使用的“学号”弃用。
应将通过题目数和分数都显示出来。
mijie(谜界)是为解谜游戏设计的网络平台,最初为 2023 年北邮百团大战「哈士奇再现」开发。以下为对其 main 分支的完整剖析。
| 层 | 技术 |
|---|---|
| 后端运行时 | Bun(bun run --hot src/index.ts 开发) |
| Web 框架 | Koa(koa-router / koa-bodyparser / koa-compose / koa-send) |
| 数据库 | MongoDB(官方驱动,聚合管线大量使用) |
| 认证 | jsonwebtoken(JWT,1 天过期) |
| 实时推送 | Ably(pub/sub,频道:notice、rank) |
| 人机验证 | Cloudflare Turnstile(可选,secret 为空则不启用) |
| AI 能力 | Cloudflare AI REST(优先,多 key 轮询)或智谱 AI glm-4-flash |
| 代码执行 | glot.io API(runCode / glot) |
| 模板 | ejs(仅用于游戏结束文本渲染) |
| 压缩 | brotli(题目附件静态预压缩) |
| 前端 | Vue 3 + Vite + vue-router + TailwindCSS + DaisyUI |
| 题面渲染 | mdvc(Markdown + Vue SFC 混合,markdown-it + KaTeX + highlight.js) |
| 流程图 | mermaid(vue-mermaid 封装) |
| 部署 | Caddy 反代:/api/* → 后端,/* → 前端 dist(SPA fallback) |
mijie/
├── src/ # 后端(TypeScript,Bun 运行)
│ ├── index.ts # 入口:Koa 装配、静态服务、MongoDB 连接
│ ├── auth.ts # 注册/登录/改密 + JWT 中间件 + 管理员路由 + gameConfig
│ ├── game.ts # 核心:插件加载/校验、题目路由、提交判定、通过率、记录
│ ├── gameprocess.ts # GameProcess(通关进度)与 GameStorage(每用户每题 KV)
│ ├── pluginServer.ts # 交互题事件分发器(on / adminOn / handle / adminHandle)
│ ├── rank.ts # 排行榜聚合 + 内存缓存 + Ably 广播
│ ├── publish.ts # Ably 发布封装
│ ├── ai.ts # CloudflareAI / ZhipuAI 统一接口
│ ├── turnstile.ts # Turnstile siteverify
│ ├── compress.ts # brotli 预压缩
│ ├── types.ts # Plugin / Context / createPlugin 类型定义(题目 API 的源头)
│ └── games/glot.ts # glot.io 代码执行
├── game/ # 题目目录:每题一个文件夹,内含 index.ts + 题面/资产
├── frontend/ # Vue 3 SPA
│ └── src/
│ ├── pages/ # 17 个页面(见「前端架构」)
│ ├── components/ # Problem/Mdv/Markdown/NextList/FileList/...
│ ├── tools/ # api 封装、bus(用户状态)、subscribe(Ably)、keys
│ └── constants.js # 每场比赛要改的:title、school_id
└── Caddyfile, dev.kdl # 反代配置、zellij 开发布局
- 以
/api开头的路径剥离前缀进入 API 路由;其余路径尝试发静态文件,404 则回退index.html(SPA)。 - 公开路由:
GET /ping、GET /keys(下发 Ably public key 与 Turnstile site key)。 authRoutes:注册/登录/改密(注意:改密在 JWT 校验之前,靠请求体里的旧密码验证);随后挂 JWT 中间件check_auth。check_auth:校验 Bearer JWT,把username / gameprocess / admin挂到ctx.state,并构造GameStorage。响应阶段若 gameprocess 有变化则重新签发 JWT(响应体带token字段,前端 api 封装自动写回 localStorage)。afterAuthRoutes/game(db)/amdinRoutes(db)依次挂载。
公开(无需登录):
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/ping |
健康检查 |
| GET | /api/keys |
{ ably, turnstile } 公钥下发 |
| POST | /api/register |
注册(Turnstile 必验;第一个注册用户 admin=2) |
| POST | /api/login |
登录,签发 JWT(payload 含 gameprocess/admin/studentID) |
| POST | /api/change-password |
凭旧密码改密 |
| GET | /api/game-config/:option |
读比赛配置(gameover 除外,需登录) |
登录后:
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/game-config/gameover |
ejs 渲染通关文本(变量:username / passed / plugins) |
| POST | /api/change-school-id |
补填学号 |
| GET | /api/rank |
排行榜(内存缓存) |
| GET | /api/start |
开始游戏,返回 first 题目 pid;受 startTime 限制 |
| GET | /api/problem |
当前用户已通过的题目及其 next(流程图数据源) |
| GET | /api/problem/:name |
题目详情(题面/分值/通过率/附件/inputs);?simple 仅元信息 |
| POST | /api/problem/:name |
提交答案 → checker 判定(核心路由) |
| POST | /api/problem/:name/server |
交互题事件分发;?admin=true 走 adminOn;__admin_bypass 直接通过 |
| GET | /api/problemManual/:name |
手动评分题:拉取管理员给的分数并判通过 |
| GET | /api/skipProblem/:name |
已通过的题重新查看 after_solve 题面 |
| GET | /api/submitted_problems |
当前用户(或管理员视角全部)各题提交次数 |
| GET | /api/record |
提交记录,分页(page/size≤200),可按 pid/passed 过滤 |
| GET | /api/notice |
公告列表 |
| GET | /api/hint/:uid |
按 uid 取提示(需满足前置) |
| GET | /api/file/:name/:path* |
题目文件服务(include/exclude 白名单 + brotli) |
管理员(admin >= 1,部分操作要求 admin >= 2):
| 方法 | 路径 | 说明 |
|---|---|---|
| GET/PUT | /api/game-config |
读写比赛配置(起止时间/规则/通关文本/关于) |
| POST/DELETE | /api/notice[/:id] |
发布/删除公告(发布时 Ably 广播) |
| GET | /api/users |
用户列表(adminRank) |
| PUT | /api/user |
设置 admin/banned/hidden/remark(改 admin 需 admin≥2) |
| GET | /api/recalculate |
重算排行榜与全部通过率 |
| POST | /api/cleanRecords |
清空所有提交记录 |
| GET | /api/problemList |
全部题目元信息 |
| POST | /api/record/:name |
手动给某用户某题打分(manualScores 流程) |
- 前置校验
checkPre:由所有题目的next反向构建前置依赖表pluginPre;访问某题要求其前置集合与用户 passed 有交集(任一前置通过即可,haveCommonKeyValuePair)。不满足时对普通用户返回 404(隐藏存在性)。没有被任何next指向的题目可直接通过 URL 访问,但不出现在流程图中。 - 通过率:
records聚合——按用户分组取max(passed),联表 users 排除 hidden/banned,通过人数/提交过该题的人数。结果缓存在problems集合与内存 Map;管理员改用户状态后需/api/recalculate。 - 排行榜:users 聚合排序
points desc, passed desc, lastPassed asc, username asc;内存缓存 +update_cnt防并发写脏;变更时 Ablyrank频道广播 uuid。无 studentID 的用户标记noPrize。 - 限流 TaskManager:内存计数,3 分钟窗口内同一用户提交 10 次后强制要求 Turnstile,验证通过清零。
- gameStorage:每用户每题一个 KV 命名空间,落在
users.gamestorage.<pid>;读取后内存修改,save()时整体覆写该题的存储对象。 - JWT 滚动续签:请求处理中 gameprocess 变更(通过题目)→ 响应携带新 token,前端透明更新。
- manualScores:现场赛模式,题目没有 checker,管理员在后台给用户打分,前端轮询
problemManual领取结果。
users:{ username, password(明文!), studentID?, admin: 0|1|2, banned?, hidden?, remark?, gameprocess: { [pid]: points }, gamestorage: { [pid]: { [key]: any } }, points, passed, lastPassed, gameover }points / passed / lastPassed是通过题目时用聚合管线($objectToArray+$reduce+$merge)重算并回写的冗余字段,服务排行榜。
records:{ username, pid, ans?, server?, manualScores?, time, name, msg, content?, passed, points, gameover }—— 全量提交流水(含失败)。config:{ name: 'game-config', startTime, endTime, gamerule, gameover, about }。notices:{ content, time, author }。problems:{ pid, percent }—— 通过率缓存。banned:{ username, banned, time }—— 封禁名单(启动时载入内存)。
每题一个文件夹 game/<folder>/,必须含 index.ts(或 index.js),默认导出 createPlugin({...})。createPlugin 是带泛型的类型推导函数(src/types.ts),根据 inputs 的类型自动推导 checker 的入参类型。
启动时 Plugins.loadPlugins() 扫描 game/ 下所有文件夹,动态 import() 并逐项校验(不满足则拒绝加载并打印原因):
checker必须是函数(或省略但满足 manualScores/server/inputs===false 之一);name、pid必填;points为非负数;description.before_solve必须提供mdv/md/content之一;md引用的文件必须存在(加载时读入content);next/hints元素结构完整;server必须是函数且初始化不抛错。
加载副作用:pid 统一转小写;record===false 的题进入 hiddenRecord(普通用户查不到其提交记录);字符串形式的 checker 转为相等判定函数;hints 注册进全局 uid 表;first 题记录为起始题。
| 字段 | 类型 | 说明 |
|---|---|---|
pid |
string | 全局唯一标识,用于 URL 与数据库;加载时转小写 |
name |
string | 显示名 |
description |
object | 题面,见 4.3 |
points |
number | 通过得分(Infinity 显示为 ∞) |
checker |
fn | string | 判题函数;字符串等价于全等比较。inputs===false 时不可设置 |
inputs |
true | false | {name, placeholder}[] |
单输入框 / 无输入(交互题)/ 多输入框(checker 收到对象) |
server |
fn | 交互题逻辑:server(app) { app.on(event, (data, ctx) => ...) } |
next |
{pid, description?}[] |
通过后解锁的题(流程图边 + 前置依赖来源) |
first |
boolean | 「开始游戏」直接跳转此题 |
gameover |
boolean | 通过后显示通关文本、置游戏状态为通关 |
captcha |
boolean | false 时该题提交永不触发 Turnstile |
record |
boolean | false 时对普通用户隐藏提交记录(管理员可见,admin≥2 可见计数) |
showPercent |
boolean | false 时不显示通过率(默认显示) |
manualScores |
boolean | 现场赛手动评分模式 |
hints |
{uid, content}[] |
提示;/hint?uid= 跳转对应题并展示;仅存浏览器 localStorage |
files |
{filename, info?}[] |
可下载附件(相对于题目文件夹) |
三个阶段:before_solve(解题前,必填)、after_solve(通过后,可选)、admin(仅管理员可见的调试面板,可选)。每种题面三种形态互斥:
content:内联 Markdown 字符串;md:Markdown 文件路径(相对于题目文件夹,加载时读入内存);mdv:{ main, include?, exclude? }—— Markdown-Vue 混合组件(mdvc),.md为主文件可嵌 Vue SFC;include/exclude是 minimatch 白/黑名单,控制/api/file/:pid/*允许拉取哪些源文件。
前端 Problem.vue:content → Markdown.vue 静态渲染;否则 Mdv.vue 用 mdvc 运行时拉取源文件动态编译为异步组件。mdv 组件挂载时注入 api 函数(admin 组件注入 admin_api),前端 api(event, data) → POST /api/problem/:pid/server → 后端 PluginServer 分发到 app.on(event) 注册的处理器。
type Context = {
username: string;
gameProcess: GameProcess; // { passed: Record<pid, points>, gameover }
gameStorage: { get/set/delete/clear }; // 每用户每题 KV
msg(str): void; // 反馈信息(可多次调用,拼 \n)
content(str): void; // 重设题面内容
glot, runCode; // glot.io 代码执行
jwt; ai(inputs); // JWT 库;AI 对话
}ServerContext(server 事件)把 msg/content 换成 pass(msg?) / nopass(msg?) —— 调用即判定本次事件通过/未通过题目。
- 静态答案题:
checker: "flag{...}"或函数比对; - 动态判分题:checker 内用
gameStorage存状态、ctx.ai调 AI(如 EncryptedDialog 的字符置换对话); - 纯交互题:
inputs: false+server(如 CountLightsOut 熄灯、BesiegeWithoutAssault 围而不攻); - Meta 题:
Entrance—— server 事件里检查gameProcess.passed中其他 6 题过了 5 题则ctx.pass()(这是 mijie 用现有机制 hack 出"解锁条件"的典型例子,本框架要把它变成一等能力); - 手动评分题:
manualScores: true(现场赛)。
- 页面(vue-router,均懒加载):Home / Start / Game/:pid / Graph(mermaid 流程图)/ Rank / Record/:pid? / Notice / About / Gamerule / Gameover / Login / Register / Settings / Admin / UsersList / Hint / 404。
admin路由 meta 守卫。 - 状态:
tools/bus.js从 localStorage 的 JWT 解码出响应式user(username/gameprocess/admin/points/gameover),无服务端状态库。 - API 封装
tools/api:fetch 包装,自动带 token;响应里有token就换新、message/error弹通知、action:'logout'踢回登录页。 - 实时:
tools/subscribe.js用/api/keys拿到的 Ably key 订阅notice/rank,到达后刷新对应页面。 - 多场比赛隔离:
constants.js的title写入 localStorage,发现 title 变了就清空全部缓存(所以不同比赛 title 必须不同)。 - UI:Tailwind + DaisyUI 组件、FontAwesome 图标、nprogress 顶部进度条、KaTeX 数学、highlight.js 代码高亮、github-markdown-css 暗色题面。
- 密码明文存储 —— 必须改为加盐哈希。
gameprocess.ts的GameProcess没有pass() / setGameover() / changed,但game.ts/auth.ts在调用 —— TS 迁移未完成的残留(package.json的start还指向不存在的src/index.js)。- TaskManager 限流、排行榜缓存、banned 名单都在单进程内存,多实例部署即失效。
gameStorage.save()整体覆写单题存储,并发提交会互相覆盖。- 解锁只支持"任一前置通过",meta 题只能靠 server 事件 hack(见 4.5-4)。
- 通过率/排行榜的重算逻辑在
POST /problem与insertRecord里重复实现了两遍,口径还有细微差别(一处没排除 hidden/banned)。
| mijie | puzzle-framework | 状态 |
|---|---|---|
| Bun + Koa | Workers + Hono | 已定 |
| MongoDB | D1 (SQLite) | 已定,schema 见第 8 节 |
| users.gamestorage 内嵌 | D1 game_storage 表(请求开始时按 (user,pid) 预取为内存快照,save() 批量回写) |
已定 |
| Ably | Durable Objects + WebSocket(notice/rank 频道) |
已定,P4 实施 |
| 内存排行榜缓存 | D1 冗余字段(users.total_points/passed_count/last_progress_at)直查 | 已定 |
| jsonwebtoken | jose(WebCrypto);JWT 仅含 {sub, admin},不再内嵌进度 |
已定 |
| 明文密码 | PBKDF2(WebCrypto)加盐哈希 | 已定 |
| TaskManager 内存限流 | Workers Rate Limiting binding,超限回退 Turnstile | 已定。 |
| Cloudflare AI / 智谱 | 移除(决策 5) | 已定 |
| glot.io | 保留(fetch 直连),ctx.glot/runCode 签名不变 |
待定,P4 前拍板 |
fs 扫描 game/ 动态 import |
构建时代码生成清单(scripts/gen-manifest.mjs 扫描 game/,生成显式 import 的 src/plugins/manifest.generated.ts) |
已定。import.meta.glob 在 vitest(vite) 下可用但 wrangler 的 esbuild 管线不支持,故弃用 |
/api/file + fs + brotli |
文本资产 import.meta.glob(..., { as: 'raw' }) 打包;大二进制附件放 R2 |
已定,P4 实施 |
| Caddy + 静态目录 | Workers Static Assets + SPA fallback,/api/* run_worker_first |
已定 |
| ejs | 移除(gameover 已删,无模板需求) | 已定 |
见 migrations/0001_init.sql:
users(id, username UNIQUE, password_hash, salt, qq?, admin, banned, hidden, remark?, total_points, passed_count, last_progress_at, created_at)total_points / passed_count / last_progress_at为排行榜冗余字段;last_progress_at仅在"有效提交"(题数或分数发生变化,无论增减)时更新。
problem_state(username, pid, unlocked_at?, visited_at?, passed_at?, score, PK(username,pid))—— 四维度的持久化载体。score_events(username, pid, score_id, points, awarded_at, PK(username,pid,score_id))—— award 幂等的真理之源;只允许INSERT OR IGNORE。game_storage(username, pid, key, value_json, PK(username,pid,key))。records(id, username, pid, ans_json?, server, passed, gained_points, msg?, content?, created_at)——gained_points为本次提交带来的分数变化(含负)。config(key PK, value);notices(id, content, author, created_at)。
写入不变式(单个 D1 batch 内完成):award 仅在实际插入 score_events 行时联动 problem_state.score += points、users.total_points += points、users.last_progress_at = now;首次通关联动 passed_at、passed_count += 1、last_progress_at。
并发说明:同一玩家两次并发提交存在极小重复计分窗口(与 mijie 同级风险),v1 接受;v2 可将 award/pass 收敛进每用户 Durable Object 串行化。
game/<folder>/index.ts默认导出createPlugin({...});pnpm gen(dev/test/deploy 自动触发)运行scripts/gen-manifest.mjs扫描game/,生成src/plugins/manifest.generated.ts(题目模块显式 import +.md等文本资产内联为字符串表),wrangler 按普通模块打包。新增/删除/重命名题目后无需手动操作,但注意 wrangler dev 不会监听game/新目录,需重启 dev。- 配置面(最终):
pid, name, label?, unlock(必填), accessible?(默认 'suspended'), description{before_solve, after_solve?, admin?}, scores?[{id, desc, points, when?, renotify?}], checker?/inputs?/server?, files?, captcha?, record?, showPercent?。 - 得分再次达成通知:条件已入账后再次达成时默认静默(不重复计分);仅当该条件
renotify: true时,接口返回reAchieved,前端弹「再次达成【…】」。createAward与evalDeclarativeScores两处均以renotify === true为守卫。 - 构建期校验:unlock 必填;pid 唯一(仅大小写不同告警);label 缺失告警;
accessible==='lurking'搭配非true的 unlock 告警(无意义组合);scores.id 题内唯一、desc 必填;unlock 引用的 pid 必须存在于注册表;unlock 条件的形状经validateConditionShape统一校验。 UnlockDesc:解锁条件的desc除静态字符串外,可给同步函数(ctx: UnlockContext, nameOf) => string(动态展示进度,如`总分达到 5 分(当前 ${ctx.totalPoints} 分)`);函数抛错时回退自动生成文案(custom 兜底「(描述生成失败)」),不会打挂列表。- desc 可见性掩码(防泄名):
evalUnlock生成/渲染文案时,引用的 pid 按当前玩家对其可见性掩码——visible 显名、ghost 显label(无 label 回退???)、hidden 显???;函数 desc 收到的nameOf同为掩码版。掩码随进度动态变化(解锁后由 label 变真名)。 Context = { username, gameProcess, gameStorage, msg, content, award, glot, runCode, jwt }(无ai);ServerContext将msg/content换为pass/nopass,其余相同。gameProcess:{ passed: ReadonlySet<string>, scores: ReadonlyMap<string,number>, totalPoints, passedCount }。gameStorage接口与 mijie 相同(get/set/delete/clear,同步读内存快照)。- 重试契约:玩家点击「再试一次」时,前端强制重挂载题面组件(
Game.vue对<Problem>施加自增:key),mdv 交互组件会重新执行 setup 并重新调用init事件。交互题的init应视为重置入口(覆写 gameStorage 为全新状态),这是框架级契约,不依赖玩家手动刷新页面。 AccessContext在UnlockContext之上附加unlocked/visited/unlockedPids/visitedPids/met(cond)(跨题快照 + 条件求值函数糖),供 accessible 求值使用。
公开:GET /api/ping · GET /api/keys(turnstile site key)· GET /api/mode(回顾模式开关)· POST /api/register · POST /api/login · GET /api/game-config/:option。
登录后:
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/me |
当前用户摘要(username/admin/qq/通关数/总分) |
| POST | /api/change-password |
凭旧密码改密 |
| POST | /api/change-qq |
设置/修改 QQ |
| POST | /api/reset-progress |
重置自己的游戏进度(仅回顾模式;清空 records/score_events/problem_state/game_storage,保留账号) |
| GET | /api/problems |
题目列表:可见性、解锁条件逐项 met、canUnlock、我的得分/通过标记 |
| POST | /api/problems/:pid/unlock |
服务端重估条件,全满足则持久化解锁 |
| GET | /api/problem/:pid |
题目详情(未解锁 404)+ 得分任务清单;潜伏题在此写 visited_at |
| POST | /api/problem/:pid |
提交答案(checker + award 流水线) |
| POST | /api/problem/:pid/server |
交互事件(award 流水线) |
| GET | /api/rank |
passed_count desc, total_points desc, last_progress_at asc;并列同名次 |
| GET | /api/record |
提交记录(分页/过滤,沿袭) |
| GET | /api/submitted_problems |
各题提交次数(沿袭) |
| GET | /api/notice · GET /api/file/:pid/* |
沿袭 |
管理员:game-config(无 gameover 字段)、notice 增删、users(显示 QQ)、user 状态修改(admin/banned/hidden/remark;授 admin 时 hidden 默认置 1,决策 9)、recalculate、cleanRecords、problemList。
删除:/start、/problemManual、/game-config/gameover、POST /record/:name、原 /problem(流程图数据源)、Graph/Start/Gameover 三个前端页面。
puzzle-framework/
├── src/
│ ├── index.ts # Hono 入口:公开路由 → requireAuth → 认证路由
│ ├── env.ts # Env / Variables 类型
│ ├── types.ts # createPlugin / Context / UnlockCondition 等
│ ├── plugins/
│ │ ├── registry.ts # 注册表 + 构建期校验(含 hiddenRecord/server 实例化)
│ │ ├── server.ts # PluginServer 事件分发器
│ │ └── manifest.generated.ts # 代码生成,请勿手改
│ ├── domain/ # unlock.ts(条件求值+desc 生成)/ visibility.ts / sort.ts
│ ├── routes/ # auth.ts(注册/登录/me/改密/QQ)/ game.ts(列表/解锁/详情/提交/事件/rank/记录)/ admin.ts(管理后台)/ file.ts(资产服务)
│ ├── lib/ # jwt / password / auth 中间件 / config / state(UserState+GameStorage)/ pipeline(award 幂等)/ context / glot / turnstile / publish / playable
│ └── durable/ # RealtimeHub:单例 DO + WebSocket 广播(notice/rank),RPC publish
├── game/ # 题目(每题一文件夹 + index.ts)
├── frontend/ # Vue 3 SPA(自 mijie 改造)
├── migrations/ # D1 迁移
├── scripts/gen-manifest.mjs # 题目清单代码生成器
├── wrangler.jsonc
└── AGENTS.md
pnpm install && pnpm --dir frontend install --frozen-lockfile
pnpm dev # wrangler dev(Worker + 静态资产,:8787)
pnpm --dir frontend dev # vite dev(:5173,代理 /api → :8787)
pnpm build # 前端构建 → frontend/dist
pnpm deploy # gen + 前端构建 + wrangler deploy(首次需先创建 D1 并填入 database_id)首个注册用户自动成为超级管理员(admin=2)——沿用 mijie 约定。
- 全 TypeScript;后端禁止 Node API,一切存储走 CF 绑定;时间条件按
vars.TIMEZONE(默认Asia/Shanghai)求值。 game/下只允许依赖src/types。- 提交前必须
npx tsc --noEmit与pnpm --dir frontend build通过。(注:Vitest 测试设施已应所有者要求移除,pnpm test不复存在) - 密钥走
wrangler secret(JWT_SECRET、TURNSTILE_SECRET、GLOT_IO_API_KEY),非密变量写wrangler.jsonc的vars。 - frontend 是独立 pnpm 项目(非 workspace 成员),依赖必须用
pnpm --dir frontend install --frozen-lockfile按 lockfile 精确安装;升级 mermaid 等重依赖前必须验证构建(新版本曾导致vite build内存溢出)。
- P0 骨架:wrangler.jsonc、Hono 入口、D1 migrations、frontend 拷入并跑通
- P1 域模型:types.ts、插件注册表(glob + 校验)、可见性/解锁求值器、单测
- P2 流水线:auth(jose/PBKDF2)、/api/me、problems 列表 + unlock 路由、提交/事件流水线(award 幂等)、rank、record/submitted_problems/notice 只读路由、glot 与 ctx.jwt
- P3 前端核心:Problems.vue、Game.vue 任务清单、Rank.vue、QQ、删除 Graph/Start/Gameover;bus.js 改由 /api/me 水合;弃用 mijie 的客户端 encryptPassword(密码经 TLS 传输、服务端 PBKDF2)
- P4 周边:公告 + RealtimeHub DO(WebSocket 广播)、admin 后台、file 服务(rawAssets + R2 回退)、Rate Limiting + Turnstile 回退
- P5 移植验证:3 道 mijie 题(digitalcircuit 静态 checker+gameStorage、countlightsout server+全亮 award、besiegewithoutassault mdv 交互+60 阶段分+passCount 解锁)
- P6 交付:build + deploy 全流程验证(已上线)、README 部署指南
- P7 回顾模式:
vars.REVIEW_MODE开关;解除全局比赛时间限制、关闭排行榜/通过率与 rank 广播、玩家可自行重置游戏进度;Cron Trigger 每日清理 180 天未登录的非管理员账号
提交策略(次数限制/冷却/答案归一化)、生命周期 hooks(onPass/onUnlock)、多比赛并存、score 条件的 secret 逃门、每用户 DO 串行化计分。