第08章 - 六个模板总览与pattern
8.1 模板是引擎原语:一张表看清六个模板
模板决定「画布长什么样、工具有哪些、判定怎么做」,内容 JSON 决定「这一关的具体参数」。这条边界的硬要求是数据完备:同一个模板要能承载编辑器生成的新关卡,不需要为每关写新代码。
| 模板 | 画布 | 典型工具 | 判定核心 | 主线 |
|---|---|---|---|---|
pattern |
序列带(Phaser) | pattern + construct | 规则补全 + 换表征迁移门 | 结构 |
rule |
网格地图(Phaser) | rule + counter | 程序执行 + 变体泛化 | 算法 |
construct |
无(纯工具台) | construct +(按关)counter | 约束满足 + 多解 + 不可能证书 | 结构 / 算法 / 建模 |
simulate |
无(指标表) | knob + probe | 预算内指标 + 换种子稳健 | 建模 |
experiment |
无(数据面板) | probe +(按关)knob / construct / rule | 大样本胜率 / 模型 + 证据充分 | 数据 / 算法 |
argue |
无(棋盘由工具渲染) | counter + probe + 论证板 | 证书可检验 + 边界解释 | 边界 |
「画布」一列只有两格是 Phaser:pattern 与 rule。这条不是文档口径,而是单测钉住的——games/forge/src/forge.test.ts 用 const canvasTemplates = new Set(['pattern', 'rule']) 逐关比对 manifest.hasCanvas,模板形态一改就红。
模板入口的形状因此分成两类。有画布的两个模板真正创建/销毁 Phaser 实例:
// packages/templates/src/pattern/index.ts(节选)
export function definePatternLevel(manifest: LevelManifest): LevelModule {
let game: { destroy(removeCanvas: boolean): void; loop: { sleep(): void; wake(): void } } | null = null;
return {
manifest,
createRuntime: (detail, opts) => createPatternRuntime(detail, opts),
async mount(ctx, runtime) {
if (!ctx.canvasHost) return;
game = createPhaserGame(ctx.canvasHost, {
designSize: manifest.designSize,
scenes: [createPatternScene(ctx, runtime)],
});
},
async unmount() { game?.destroy(true); game = null; },
pause() { game?.loop.sleep(); },
resume() { game?.loop.wake(); },
};
}
无画布的四个模板把 mount / unmount / pause / resume 一律写成空实现,并逐条注释说明「为什么可以空」:simulate/index.ts 里的原话是「交互全在 ToolDock(KnobTool + MetricsTool)里,宿主把工具区拉满宽度即可,所以 mount 故意什么都不挂,只保留 LevelModule 的形状」。
六个模板共用一套契约(第 4 章讲过 LevelModule),但实现路径分两条:
| 自管运行时 | 走共享适配器 | |
|---|---|---|
| 谁 | pattern(判定带大量 pattern 专属状态与预览) |
rule、construct、simulate、experiment、argue |
| 写法 | 自己实现 dispatch / snapshot / subscribe |
只写 RuntimeAdapter,由 createRuntimeWithAdapter 包壳 |
| 换表征门 | 在模板 runtime 的 submit 里手接 |
共享运行时统一处理(transferVariants 可选) |
共享适配器的接口把「模板要写什么」压到了九个方法:
// packages/templates/src/shared/runtime.ts(节选)
export interface RuntimeAdapter<S> {
initialState(detail: LevelDetail, now: number): S;
/** 只处理工具动作;run/submit/reset 由共享运行时统一处理 */
apply(state: S, action: LevelAction): AdapterResult<S>;
artifact(state: S): unknown | null;
checkpoints(state: S, ctx: TemplateCheckpointContext): Record<string, boolean>;
toolState(state: S): Partial<ToolStateMap>;
validate(detail: LevelDetail, variant: RuntimeVariant, artifact: unknown): Verdict;
actionsFor(detail: LevelDetail, variant: RuntimeVariant, artifact: unknown): LevelAction[];
// …artifactSummaries / 可选的 argumentState / transferVariants / transferFailureClass
}
「只处理工具动作;run/submit/reset 由共享运行时统一处理」这句话很重要:三个宿主动作的判定顺序(无产物 → 缺证据 / 有产物先结构校验 → 再缺证据 → 最后迁移门)只写一遍,走适配器的五个模板不会各写一套;pattern 因为要多维护一份「预览」状态而自管运行时,但 submit 里的判定顺序与共享路径同语义(第 8.4 节)。
六个模板的失败类也集中在各自 logic.ts 的 FAILURE_CLASSES 常量里,内容必须为可达失败类提供反馈(第 6 章的 content:check 会查)。
8.2 pattern 模板:schema 只有两个对象
pattern 模板最薄,脚本 schema 只有两个对象:
// packages/templates/src/pattern/schema.ts(全文)
import { z } from 'zod';
export const patternScriptSchema = z.object({
scenario: z.object({
kind: z.literal('path'),
length: z.number().int().min(3).max(24),
visible: z.array(z.string().min(1).nullable()).min(3).max(24),
startIndex: z.number().int().min(0).max(12).default(0),
symbols: z.record(z.string(), z.string()).optional(),
}),
pattern: z.object({
allowedPeriods: z.array(z.number().int().min(2).max(6)).min(1),
allowedTransforms: z.array(z.enum(['none', 'mirror', 'rotate', 'shift', 'reflect'])).min(1),
ambiguous: z.boolean().default(false),
}),
});
export type PatternScript = z.infer<typeof patternScriptSchema>;
export const patternArtifactSchema = z.object({
patternRule: z.object({
tokens: z.array(z.string().min(1)).min(2).max(6),
period: z.number().int().min(2).max(6),
transform: z.enum(['none', 'mirror', 'rotate', 'shift', 'reflect']),
}),
repair: z.array(z.object({ index: z.number().int().min(0), token: z.string().min(1) })),
});
scenario.visible用null表示缺口,缺口位置就是玩法本身(12 格里有 3 个 null);allowedPeriods与allowedTransforms是「这一关允许的规律空间」,玩家声明超出范围的规则会被validateRule判pattern-rule-invalid;ambiguous是本关是否接受多种读法(对称墙那关同时接受「重复」与「镜像」两种规则);scenario.symbols是可选字段——它就是换表征迁移门的入口,第 8.6 节展开。
产物 schema(patternArtifactSchema)不属于内容,而是给测试与审计用的:solutions / negativeSamples 里的 artifact 必须能被它描述。
8.3 logic:三分支判定,每种失败都有名字
packages/templates/src/pattern/logic.ts 里的 validateArtifact 是 pattern 的判定核心。它按「产物里有什么」分成三条路径:
// packages/templates/src/pattern/logic.ts(节选)
export function validateArtifact(
script: PatternScript,
variant: PatternVariant,
artifact: unknown,
): Verdict {
const applied = applyVariant(script, variant);
const symbols = symbolsOf(variant);
const relabeled = Object.keys(symbols).length > 0;
const world = toWorld(applied, symbols);
const artifacts = summarizeArtifact(artifact);
const candidate = artifact as { patternRule?: { tokens?: unknown }; repair?: unknown } | null | undefined;
const hasRule = Boolean(candidate?.patternRule && Array.isArray(candidate.patternRule.tokens));
const hasRepair = Array.isArray(candidate?.repair);
// 既没声明规律、也没补块:这是"空产物",要报明确的结构失败类(A1b),不能走缺证据分支。
if (!hasRule && !hasRepair) {
return {
passed: false,
stamps: [],
failure: { class: 'pattern-rule-invalid', variantId: variant.id, causes: [] },
missingEvidence: [],
artifacts,
};
}
// …
}
三条路径对应三种玩家行为:
| 产物状态 | 走哪条判定 | 典型失败类 |
|---|---|---|
| 没有规律也没有补块 | 直接判空产物 | pattern-rule-invalid |
有规律(patternRule) |
validatePattern(world, artifact) |
pattern-cycle-wrong / pattern-repair-incomplete / pattern-mismatch |
| 只有补块 | validateRepairOnly(script, repair) |
pattern-repair-incomplete / pattern-mismatch / pattern-cycle-wrong |
第二条路径是主判定,实现在纯逻辑包 @mmf/toolkit/pattern 里,四步顺序固定:
// packages/toolkit/src/pattern/index.ts(节选)
export function validatePattern(world: PatternWorld, artifact: unknown): PatternValidation {
// 1) 规则是否合法(tokens 2–6、period 与 tokens 等长且在 allowedPeriods 内、transform 合法)
const ruleError = validateRule(world, rule);
if (ruleError) return { passed: false, failureClass: ruleError };
// 2) 规则必须在每一个可见格上成立
for (let i = 0; i < world.length; i += 1) {
const visible = world.visible[i];
if (visible === null || visible === undefined) continue;
if (tokenAt(rule, i) !== visible) return { passed: false, failureClass: 'pattern-cycle-wrong', conflictIndex: i };
}
// 3) 缺口必须全部补上
if (missing.length > 0) return { passed: false, failureClass: 'pattern-repair-incomplete', missingIndices: missing };
// 4) 补的块必须与规则一致
if (mismatch.length > 0) return { passed: false, failureClass: 'pattern-mismatch', mismatchIndices: mismatch };
return { passed: true };
}
第 2 步返回的是 conflictIndex——第一个冲突格的位置,它会被翻成 FailureCause 的 {kind: 'cell', ref: '5'},画布据此把那一格画成裂纹。这就是「可观察后果」的实现方式:失败不是一个布尔值,而是「从哪一格开始断的」。
「规律在某一格应该是什么」由 tokenAt 算,五种变换各有公式:
// packages/toolkit/src/pattern/index.ts(节选)
/** 规则在第 index 位应该生成的 token。index 从 0 开始。 */
export function tokenAt(rule: PatternRule, index: number): string {
const period = rule.period > 0 ? rule.period : rule.tokens.length;
if (rule.transform === 'reflect') {
const cycle = period * 2;
const position = ((index % cycle) + cycle) % cycle;
return rule.tokens[position < period ? position : cycle - 1 - position] ?? '';
}
const i = ((index % period) + period) % period;
switch (rule.transform) {
case 'mirror': return rule.tokens[(period - 1 - i) % period] ?? '';
case 'rotate': return rule.tokens[(i + 1) % period] ?? '';
case 'shift': return rule.tokens[(i + period - 1) % period] ?? '';
case 'none':
default: return rule.tokens[i] ?? '';
}
}
reflect 与其他四种不在一个量级上:none/mirror/rotate/shift 都是「周期内平移」,reflect 是周期翻倍后的往返——它让 ABC 读成 ABCCBA,正是「对称补墙」那关需要的读法。负数取模的 ((x % n) + n) % n 不是洁癖:shift 会算出 i + period - 1,不这么写会得到负下标。
第三条路径「只填不声明」值得单独说,因为它是一个真实的学习路径,而不是宽松判定:
/**
* 「只填不声明」:没有 patternRule 时,用可见格推出的候选规律检验补的块。
* 判据和带规律的判法同源:填法必须能被本关允许的某条规律完整解释,
* 所以它不是放宽判定,而是允许玩家把「声明」省掉。
*/
export function validateRepairOnly(script: PatternScript, repair: unknown): { … } {
const world = toWorld(script);
// 1) 缺口必须补齐
if (missing.length > 0) return { passed: false, failureClass: 'pattern-repair-incomplete', missingIndices: missing };
// 2) 补法必须能被某条「从可见格推出来」的候选规律解释
for (const rule of candidateRules(script)) {
if ([...repairs].every(([index, token]) => tokenAt(rule, index) === token)) return { passed: true, rule };
}
// 3) 连候选规律都没有:按「规则和带子对不上」报,别退化成空判定
return { passed: false, failureClass: 'pattern-cycle-wrong' };
}
注意「候选规律只由可见格推出」:candidateRules 遍历 allowedPeriods,用带子前 period 个可见格组成候选,再用 explainsVisible 过滤掉与任何可见格冲突的候选。这条约束保证了「只填不声明」不会变成「随便填都能过」——填法仍然要符合一条能解释整条带子的规律。
8.4 runtime:四个工具动作 + 三个宿主动作
createPatternRuntime 是少数自管运行时的模板(不走共享适配器)。它处理的动作可以分成两栏:
| 动作 | 状态影响 | 证据相位 |
|---|---|---|
pattern/inspect |
记录被点过的格、相位转 observing |
observe |
pattern/setCycle |
写入 rule、committed = false、清掉旧预览 |
hypothesize |
pattern/preview |
生成 preview(预测格),不参与判定 |
observe |
pattern/commit |
committed = true、checkpoints.hypothesis = true |
hypothesize |
construct/place |
在缺口写块、刷新 fill / repair 检查点 |
construct |
construct/remove |
删块、刷新检查点 | construct |
run / submit / reset |
宿主动作:判定、出 Verdict、重置 | run / result |
两个动作的细节值得抄下来。第一,pattern/commit 会先做一次规则合法性校验,失败不写状态:
case 'pattern/commit': {
if (!state.rule) return fail('先拼出一个重复单元。');
const error = validateRule(world, state.rule);
if (error) {
emit({ phase: 'hypothesize', tool: 'pattern', action: action.type, outcome: 'fail', failureClass: error });
notify();
return fail('这条规律不在这一关允许的范围里。');
}
state.committed = true;
// 提交后画布要让位给「哪块放错了」的即时提示,预览 ghost 先收起。
state.preview = undefined;
state.checkpoints.hypothesis = true;
// …usage + emit + notify
}
第二,pattern/preview 与「已提交」的关系很微妙:预览的规则与已提交规则一致时不能打掉 committed,否则玩家「先点用这条规律、再点预览一下」会把提交状态弄丢,之后的 run/submit 全部失败:
case 'pattern/preview': {
// rule 来自 action.payload 的 cycle/transform(省略这三行)
const sameAsCommitted = state.committed && state.rule
&& patternRuleKey(state.rule) === patternRuleKey(rule);
state.rule = rule;
// 预览不是提交:只有预览的规则与已提交的规则不一致时,才让旧提交失效。
if (!sameAsCommitted) state.committed = false;
state.previewSeq += 1;
state.preview = buildPreview(script, state.rule, opts.now(), state.previewSeq);
}
preview 的全部意义是「看得见」:它被写进快照的 toolState.pattern.preview,画布据此把预测格画成半透明 ghost。为了让它「连点也能重跑」,buildPreview 带一个单调递增的 seq,并带 ruleKey——周期或变换一改,旧预览立刻作废。ruleKey 的定义只有一行,但注释说明了为什么 tokens 顺序与变换都要算进去:
/** 规则指纹:tokens 顺序与变换都要算进去,否则换变换后旧预览会被误认成同一条规则。 */
export function patternRuleKey(rule: Pick<PatternRule, 'tokens' | 'transform'>): string {
return rule.tokens.join('') + ':' + rule.transform;
}
toolState.pattern 的类型有一处刻意的「越界」:game-kit 的公共 PatternToolState 不含 preview(公共类型不该为一个模板扩字段),模板侧用结构化子类型 PatternToolStateView = PatternToolState & { preview?: PatternPreview } 在读取时窄化回来。
submit 的判定顺序与共享运行时一致:先看有没有产物 → 没有就报「还差一个可以提交的结构」或缺失证据;有产物先跑结构校验(真实错误优先于缺步骤提示);结构通过但证据检查点没走完,返回「缺失证据」;最后才是迁移门。
8.5 scene:渲染一条带子,只在签名变化时重画
pattern/scene.ts 是这个模板唯一碰 Phaser 的文件(736 行)。它做四件事:画带子、处理指针、画 ghost/裂纹、把通过状态画成「小人走过去」。
布局是纯常量,住在 layout.ts 里,不 import Phaser——e2e 或后续真实指针测试可以安全引用。两样东西:DESIGN = { width: 900, height: 560 }(横向工坊台,手机竖屏时画布在上、工具台在下)与 PATH_LAYOUT(startX: 70、y: 320、cellW: 56、cellH: 76、gap: 8、headerH: 84)。
渲染入口只做一件事:先比签名,没变就返回。render(snapshot) 的头三行是 const signature = this.signatureOf(snapshot); if (signature === this.lastSignature) return; this.lastSignature = signature;,之后才 killTweensOf 清空容器并逐格重画。签名把「会影响画面的一切」拼成一个字符串,包括相位、revision、观察过的格、规则、补块、最近一次运行结果、失败原因,以及预览:
private signatureOf(snapshot: RunSnapshot): string {
const pattern = patternStateOf(snapshot);
const preview = this.activePreview(pattern);
return [
snapshot.phase,
String(snapshot.revision),
(pattern?.inspected ?? []).join(','),
pattern?.cycle?.join('') ?? '',
pattern?.committed ? 'committed' : 'open',
pieces.map((piece) => `${piece.x}:${piece.shape}`).join(','),
snapshot.lastRun?.id ?? '',
causes.map((cause) => `${cause.kind}:${cause.ref}`).join(';'),
// 预览也要进 signature:ghost 与「连点重新跑一遍」都靠它触发重绘。
preview ? preview.ruleKey + '@' + preview.seq : '',
].join('|');
}
为什么需要这么细的签名:工具面板每点一次都派发一个动作,画布每收到一次 notify 就会调用 render。没有签名比对,「点一下候选规律」会连带来一次整条带子的重画(含十几次文本对象创建);有了签名,只有真正影响画面的变化才重画。
画面上的三类「可观察后果」:
| 玩家做了什么 | 画布怎么表现 | 数据来源 |
|---|---|---|
| 点了一格 | 该格出现观察环 | snapshot.toolState.pattern.inspected |
| 补错了一块 | 裂纹/红圈,但不写进 verdict | mismatchedPlacements(committedRule, pieces) |
| 点「预览一下」 | 空格上出现半透明 ghost,冲突格标出不一致 | pattern.preview + sameTokenShape |
第二行有一条重要纪律:已提交规律下放错的块只做即时提示,不能写进判定。玩家先放错再改是允许的学习过程,画布若因此报错,就变成了「边填边判」,把探索杀死了。真正判定只在 submit 时发生。
mismatchedPlacements 用「形状族」比较而不是字符串相等:construct 传进来的 shape 可能是 token(A),也可能是调色板的形状名(round),两者等价。实现就是 sameTokenShape(a, b) => shapeFamily(a) === shapeFamily(b),注释写明「放错提示按族比较,避免把 A 与 round 之间换个说法就误报」。
8.6 换表征迁移门:scenario.symbols 是真门,不是装饰
pattern 有一道其他模板没有的门:同一抽象规律换个表示之后仍然成立。
内容侧的表达方式是给变体加 scenario.symbols:
{
"id": "v-relabel-shapes",
"axis": "representation",
"patch": {
"scenario": { "symbols": { "A": "round", "B": "square" } }
}
}
运行时不需要区分「哪些变体是迁移门」——transferVariants() 的判据就是「带 symbols 的变体」:
/**
* 迁移门:内容里所有带 `scenario.symbols` 的变体都是换表征变体,逐条要过。
* 返回空数组表示这一关没有迁移门(template verifier 的可选约定)。
*/
export function transferVariants(detail: LevelDetail): PatternVariant[] {
const variants = Array.isArray(detail.variants) ? detail.variants : [];
return variants
.filter((variant) => Object.keys(symbolsOf(variant)).length > 0)
.map((variant) => ({ id: variant.id, axis: variant.axis, patch: { ...variant.patch } }));
}
判定时,带子与产物都要按映射翻译一遍再比较。翻译器只做字符串替换,映射里没有的 token 原样保留:
/** 把带子上的 token 按映射改名;映射里没有的 token 原样保留,不静默丢信息。 */
export function relabelVisible(visible: readonly (string | null)[], symbols: PatternSymbolMap): (string | null)[] {
if (Object.keys(symbols).length === 0) return [...visible];
return visible.map((token) => (
token === null || token === undefined ? null : (symbols[token] ?? token)
));
}
与之配套的 relabelArtifact 对产物做同一件事:把 patternRule.tokens 与 repair[].token 逐个替换——玩家提交时用原始字母,迁移门里再换算比对。
真正的门在 validateArtifact 里:只补块、不声明规律的产物在迁移门直接失败,且失败类固定为 pattern-transfer-failed:
// 迁移门只认「随映射走」的产物:只填不声明的 repair-only 是把答案抄在旧表征上(空壳),
// 换个表示以后没有规律可翻译,必须报 pattern-transfer-failed,而不是悄悄当成通过。
if (relabeled && !hasRule) {
return {
passed: false,
stamps: [],
failure: {
class: 'pattern-transfer-failed',
variantId: variant.id,
causes: [{ kind: 'variant', ref: variant.id, messageKey: 'level.pattern.transfer.message' }],
},
missingEvidence: [],
artifacts,
};
}
这条规则解释了一个设计取舍:为什么允许「只填不声明」通过母本?因为它是一个真实的学习阶梯(孩子先看出怎么填,才说得出规律)。但它过不了迁移门——换个表示以后,没有规律就没有可翻译的东西,只能算把答案抄在旧表征上。
提交时,runtime 逐条跑迁移变体,第一条失败即门失败,并把结果写进 Verdict.variantResults(完成面板的「变体通行证」就是读它):
/** 逐条跑换表征变体;和 shared/runtime 的 transferCheck 同语义:第一条失败即门失败。 */
function transferCheck(artifact: unknown): { passed: boolean; results: { id: string; passed: boolean }[]; failure?: Verdict } {
const results: { id: string; passed: boolean }[] = [];
for (const variant of gateVariants) {
const verdict = validateArtifact(script, variant, artifact);
results.push({ id: variant.id, passed: verdict.passed });
if (!verdict.passed) return { passed: false, results, failure: verdict };
}
return { passed: true, results };
}
通过的关卡会多一枚 transfer 印章——四枚思想印章(constraint / simple / transfer / argument)里最能说明「抽象可以搬运」的一枚:不只是约束满足,换个表示仍然成立。
8.7 两个真实关卡:基础路径与对称补墙
pattern 只有两关,但恰好覆盖了「基础」与「进阶」两端,且都不是给代码写的,而是给内容写的——两关的关卡模块各 15 行,只声明 manifest:
// games/forge/src/levels/k-structure-pattern-path.ts(全文,省略 import)
const manifest: LevelManifest = {
id: 'k-structure-pattern-path',
band: 'k',
thread: 'structure',
template: 'pattern',
designSize: DESIGN,
estimatedMinutes: 5,
hasCanvas: true,
};
export default definePatternLevel(manifest);
g13-structure-symmetry 与它逐字段只差 id / band / estimatedMinutes。两关的差异全在 JSON 里:
k-structure-pattern-path 模式修补匠 |
g13-structure-symmetry 对称补墙 |
|
|---|---|---|
| 学段 / 时长 | K / 5 分钟 | G1–3 / 6 分钟 |
| 带子 | 12 格,缺口在第 6–8 格 | 6 格,缺口在第 3–5 格 |
| 可见序列 | ABBABB???ABB |
ABC??? |
| 允许周期 | 2 / 3 / 4 | 3 / 6 |
| 允许变换 | none / mirror / shift |
none / reflect |
| 黄金解 | ABB 循环 + BBA 平移(两种读法) | 重复 ABC 与镜像 ABCCBA(两种读法) |
| 迁移门 | A→round/B→square、A→red/B→blue |
A/B/C → round/square/triangle、→ red/blue/green |
两关的共通结构是「两个有效解 + 若干负例 + 两个迁移变体」:模式修补匠是 3 条负例,对称墙是 4 条,负例分别覆盖不同失败类。对称墙那关有一条 n-repeat:expectFailure 写 pattern-mismatch,patternRule 是 {tokens:["A","B","C"], period:3, transform:"none"},而 repair 补的是第 3–5 格的 C B A。按 none 变换,第 3 格该是 A 而不是 C,所以判 pattern-mismatch——它测的是「规则与补块不一致」这条路径,而不是「补错格子」。
对称墙的reflect 读法值得算一遍:规则 tokens: [A,B,C]、period: 3、transform: 'reflect',tokenAt 里 cycle = 6:
| index | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
position |
0 | 1 | 2 | 3 | 4 | 5 |
取 tokens[position < 3 ? position : 6-1-position] |
A | B | C | C | B | A |
所以「ABC???」按镜像补成 C B A,与黄金解 s-mirror-cba 的 repair 完全一致。这也说明了为什么 content 里两个解的 repair 不同却都通过:ambiguous 字段只声明在 patternScriptSchema 里,当前判定逻辑并不读它——两种解各自在自己的规则下自洽,就都算对;真正限制读法的是 allowedPeriods / allowedTransforms 这两个「规则空间」字段(none 与 reflect 才算,rotate 不在名单里)。
对称墙的反馈表还展示了「失败类 → 人话」的写法:pattern-mismatch 的 messageKey 是「补的块和你的对称规则不一致。」,hintKey 是「按镜像规则,第 4 格应该和第 3 格一样。」,focus 是 repair。
focus 是给画布的指令:repair / conflict-cell / missing-cells / strip / variant / pattern-card,告诉界面「这条提示该聚焦到哪里」。于是同一套失败类在不同关卡可以有完全不同的提示语,代码不用改。
8.8 小结
- 六个模板 = 两种形态(Phaser 画布 / 纯工具台)× 两条实现路径(自管运行时 / 共享适配器);
hasCanvas只有 pattern 与 rule 为真,并由forge.test.ts钉住。 - 模板只决定「怎么判定」,内容决定「判定什么」:两关 pattern 的模块各 15 行,差异全在 JSON。
patternScriptSchema只有scenario与pattern两个对象;visible里的null就是缺口,symbols是迁移门开关。- 判定三分支:空产物 / 有规律 / 只补块;四步顺序是「规则合法 → 与可见格一致 → 缺口补齐 → 补块与规则一致」,每种失败都有独立失败类与定位索引。
tokenAt支持none/mirror/rotate/shift/reflect五种变换,reflect是周期翻倍的往返,对称墙靠它读成ABCCBA。- runtime 的
preview只是「看得见的预测」:不进判定、改规则即作废、连点靠seq重跑;committed不能被「预览同一条规则」误打掉。 - 场景渲染先比 signature 再重画;放错块只画裂纹不进 verdict,判定只在
submit发生。 - 换表征门是真门:带
scenario.symbols的变体逐条要过,只补块不声明规律会落pattern-transfer-failed,通过多一枚transfer印章。
下一章拆 rule 模板:程序块、执行轨迹与「同一程序要在变体地图上照样成立」。