3.3 原型的可分享与可回放

把「种子 + 整段策略序列」编码成可回放的链接片段 #s=301&seq=...,页面读取 location.hash 后逐字重现同一局。本节给出编解码契约、页面侧 replay() 的三步、以及回放一致性的断言:Python 侧重跑与 Node 构建期 digest 逐字相同,再编码必须无损。

3.3 原型的可分享与可回放

《引擎实现与体验打磨》 那套交付的是能跑起来的引擎——构建产物、平台包、性能预算;本节交付的是能贴出去的一局:一段链接。《小游戏开发实战》的最小可玩原型 讨论过原型该做多小;本节补上它没回答的那一半——原型做小之后,怎么把它准确地递给别人。

3.2 里我们已经能断言一局的结果,但那个结果还在本机。截图能证明「我看过这一局」,证明不了「你也能看到同一局」。把种子与整段策略序列编码进链接片段,才能让「同一局」变成可传递的输入。

分享的是链接,不是截图

一张截图包含的信息是不可复现的:它记录了某一帧的样子,却没有记录到达那一帧的路径。别人看到截图只能问「你怎么打的」,而答案通常说不清。链接片段记录的是输入——种子决定洗牌,序列决定每一步操作;有了输入,同一份内核必然给出同一个局面。这就是 3.1 为什么必须内联、3.2 为什么必须确定性:没有确定性,回放无从谈起。

链接片段的契约

契约落在 config/gd_proto_03_share_link.json,页面与 S 层脚本共用同一份——它只声明三样东西:hashPrefix 是 #、pair 是 &、sep 是 -,外加一个默认种子 301。

片段成分含义例
#前缀,走 hash 不触发导航#
s=<seed>洗牌种子(整数)s=301
&键值对分隔符&
seq=<cmd-cmd-...>整段策略序列,- 连接seq=p0-p0-p0-p0-e-...

完整片段 80 字符,形如 #s=301&seq=p0-p0-p0-p0-e-p0-...-e。选 hash 而不是查询串,是因为 hash 变化不会触发页面重新加载——回放时页面不会闪一下。

页面侧:replay() 的三步

页面里的 replay() 故意绕远路:先把自己生成的链接写回 location.hash,再从 location.hash 读回来。如果只在内存里传对象,编解码里的 bug 永远不会暴露;绕这一圈,等于每次回放都顺带做一次「分享—接收」的端到端往返。

三步是:shareLink(seed, seq) 编码 → 赋给 location.hash → specFromHash(location.hash) 解码 → runSpec 跑一局 → 返回 digest。回放逻辑与 3.1 的构建脚本是同一份文件(同一个 file: 清单,本节关注 replay() 与 specFromHash() 那两段):

// file: web/build_proto03.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/build_proto03.mjs
//
// 3.1 规格内联 / 3.3 可回放:把 config/play_spec.json 与 core/src/ashdeck/ 的五个确定性内核
// 模块塞进**一个**自包含 HTML(out/proto03.html)——无构建工具、无网络、无 <script src>,
// 拷走一个文件就能玩。页面暴露 globalThis.__ashdeck:
//   run(seq, override) -> digest(与 S 层 Python / P 层 TS 逐字相同)
//   replay()           -> 写回 location.hash 再读回来,逐字重现同一局,返回 digest
//   shareLink()        -> 链接片段 #s=<seed>&seq=<cmd-cmd-...>;snapshot();canvasDrawn
// 擦类型复用 web/pack.mjs 的 bundle(),本章不重写任何擦除逻辑;构建期顺手在 Node 侧跑一遍
// 内核,把 baseline / share 两条 digest 与链接片段落进 out/proto03_build.json。

import { bundle, page, writePage, inlineConfig } from "./pack.mjs";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { runSpec } from "../core/src/ashdeck/combat.ts";
import { digestSnap } from "../core/src/ashdeck/digest.ts";

const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); // $HOME/.gdbook/ashdeck
// 手工拓扑序:先声明后使用。去掉跨文件 import 后同一作用域内不得重名。
const MODULES = ["core/src/ashdeck/types.ts", "core/src/ashdeck/rng.ts",
  "core/src/ashdeck/cards.ts", "core/src/ashdeck/combat.ts", "core/src/ashdeck/digest.ts"];
const spec = JSON.parse(readFileSync(join(ROOT, "config/play_spec.json"), "utf8"));
const LINK = JSON.parse(readFileSync(join(ROOT, "config/gd_proto_03_share_link.json"), "utf8"));
const core = bundle(ROOT, MODULES);
const OUT = join(ROOT, "out", "proto03.html");

const script = core + `

const SPEC = ` + inlineConfig(spec) + `;
const LINK = ` + inlineConfig(LINK) + `;
const cv = document.getElementById("cv"), ctx = cv.getContext("2d");
let snap = null;

// 链接片段:#s=<seed>&seq=<cmd-cmd-...>。分隔符取自 config,编码/解码全整数、无浮点。
function parseHash(h) {
  const parts = {};
  for (const kv of String(h || "").replace(LINK.hashPrefix, "").split(LINK.pair)) {
    const i = kv.indexOf("=");
    if (i > 0) parts[kv.slice(0, i)] = kv.slice(i + 1);
  }
  return parts;
}
function shareLink(seed, seq) {
  return LINK.hashPrefix + "s=" + seed + LINK.pair + "seq=" + seq.join(LINK.sep);
}
function specFromHash(h) {
  const p = parseHash(h), sp = JSON.parse(JSON.stringify(SPEC));
  if (p.s) sp.seed = Number(p.s);
  if (p.seq) sp.strategy = String(p.seq).split(LINK.sep);
  return sp;
}
function bar(x, y, w, h, num, den, color) {
  ctx.fillStyle = "#334155";
  ctx.fillRect(x, y, w, h);
  ctx.fillStyle = color;
  ctx.fillRect(x, y, Math.floor((w * num) / den), h);
}
function render(s, tag) {
  const W = cv.width, H = cv.height, p = SPEC.params;
  ctx.fillStyle = "#111827";
  ctx.fillRect(0, 0, W, H);
  ctx.font = "13px system-ui, sans-serif";
  bar(20, 24, W - 40, 16, s.enemyHp, p.enemyHp, "#f87171");
  bar(20, H - 40, W - 40, 16, s.playerHp, p.playerHp, "#34d399");
  ctx.fillStyle = "#e2e8f0";
  ctx.fillText("敌方 HP " + s.enemyHp + " / " + p.enemyHp, 20, 60);
  ctx.fillText("手牌 " + s.hand.join(" · ") + "   回合 " + s.turn + "   能量 " + s.energy, 20, 92);
  ctx.fillText("我方 HP " + s.playerHp + " / " + p.playerHp, 20, H - 48);
  ctx.fillText(tag + "   " + (s.won ? "胜" : "未胜"), 20, H - 16);
  document.getElementById("out").textContent = digestSnap(s);
  document.getElementById("link").textContent = shareLink(SPEC.seed, SPEC.strategy);
}

globalThis.__ashdeck = {
  spec: SPEC,
  run(seq, override) {
    const sp = JSON.parse(JSON.stringify(SPEC));
    if (seq) sp.strategy = seq;
    if (override) for (const k of Object.keys(override)) sp.params[k] = override[k];
    snap = runSpec(sp);
    render(snap, "默认局");
    return digestSnap(snap);
  },
  replay() {
    location.hash = shareLink(LINK.seed, SPEC.strategy);
    snap = runSpec(specFromHash(location.hash));
    render(snap, "回放局 s=" + LINK.seed);
    return digestSnap(snap);
  },
  shareLink() { return shareLink(LINK.seed, SPEC.strategy); },
  snapshot() { return snap; },
  canvasDrawn: false,
};

globalThis.__ashdeck.run();
// 冷启动首个 page 的 canvas 在首帧合成前读像素恒为空白;推迟到两次 rAF 之后再置真。
requestAnimationFrame(() => requestAnimationFrame(() => {
  globalThis.__ashdeck.canvasDrawn = true;
}));
`;

const body = `<h1>《灰烬牌库》· 可玩原型</h1>
<div class="sub">一个自包含 HTML:规格 + 内核内联,无构建工具、无网络、无 &lt;script src&gt;</div>
<canvas id="cv" width="640" height="360"></canvas>
<div class="card"><div class="k">digest(应与 S 层 Python / P 层 TS 逐字相同)</div><div id="out" class="k">—</div></div>
<div class="card"><div class="k">可回放链接片段(种子 + 整段策略序列)</div><div id="link" class="k">—</div></div>`;

const html = page({ title: "《灰烬牌库》· 第 3 章可玩原型",
  subtitle: "规格内联 + 无头真玩 + 可回放链接", body, script });
const bytes = writePage(OUT, html);

const shareSpec = JSON.parse(JSON.stringify(spec));
shareSpec.seed = LINK.seed;
const shareLinkStr = LINK.hashPrefix + "s=" + LINK.seed + LINK.pair + "seq=" + spec.strategy.join(LINK.sep);
const nodeDigest = digestSnap(runSpec(spec));
const nodeShareDigest = digestSnap(runSpec(shareSpec));
mkdirSync(join(ROOT, "out"), { recursive: true });
writeFileSync(join(ROOT, "out", "proto03_build.json"),
  JSON.stringify({ spec: spec.id, modules: MODULES.length, kernelChars: core.length,
    page: "out/proto03.html", pageBytes: bytes, seed: LINK.seed,
    strategyLength: spec.strategy.length, shareLink: shareLinkStr,
    nodeDigest, nodeShareDigest }, null, 2) + "\n");

console.log("modules=" + MODULES.length + " kernelChars=" + core.length);
console.log("built " + OUT + "  " + bytes + " B");
console.log("SHARE_LINK=" + shareLinkStr);
console.log("NODE_DIGEST=" + nodeDigest);
console.log("NODE_SHARE_DIGEST=" + nodeShareDigest);

W 实跑:让验证器走回放路径

共享验证器支持 --run <方法名>,让它调用 replay() 而不是 run();期望值取 3.1 构建期落下的 NODE_SHARE_DIGEST:

# snippet: 验证器两次独立加载都走 replay(),期望值取自构建期的 NODE_SHARE_DIGEST
NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_mech.mjs \
  --page out/proto03.html --run replay \
  --expect-digest '{"block":5,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":0,"hand":["jab","strike","jab"],"over":true,"played":7,"playerHp":48,"turn":3,"won":true}'
=== 通用断言 ===
  ok   两遍独立加载 digest 逐字一致(确定性)
  ok   canvas 已绘制(230400 px >= 1)
  ok   digest 与 --expect-digest 一致
PAGE_DIGEST={"block":5,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":0,"hand":["jab","strike","jab"],"over":true,"played":7,"playerHp":48,"turn":3,"won":true}
OK W 层通过

PAGE_DIGEST 与构建期的 NODE_SHARE_DIGEST 一字不差。注意它和 3.2 的默认局 digest 不同(turn 4→3、played 9→7、block 0→5)——种子从 20261014 换成 301,洗牌顺序变了,同一段 25 条命令走上了另一条路径,仍然打赢。这说明链接片段真的在起作用,而不是碰巧复现了默认局。

S 层:回放一致性的三条断言

sim/gd_proto_03_replay.py 在 Python 侧独立解码同一段链接,重跑一遍,并对编解码本身做无损检查:

#!/usr/bin/env python3
# file: sim/gd_proto_03_replay.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_03_replay.py
"""3.3 原型的可分享与可回放(S 层):链接片段 `#s=<seed>&seq=<cmd-cmd-...>` 的逐字重现。

分享一段原型,分享的不是截图,是**一段可回放的链接**。断言三条:
  1) **可解码**:从 out/proto03_build.json 取页面生成的链接片段,按契约解出 seed 与整段策略序列;
  2) **可重现**:用解出来的 (seed, 序列) 在 Python 侧重跑,digest 必须与构建期 Node 侧落下的
     nodeShareDigest **逐字相同**(同算法同种子的自实现 PRNG 是唯一保证);
  3) **可再编码**:把解出来的值重新编码,必须与原字符串**逐字相同**(编解码无损,否则分享出去的
     链接会漂移)。

契约取自 config/gd_proto_03_share_link.json:hashPrefix / pair / sep。全整数、无浮点、无 random。
产出 out/gd_proto_03_replay.json。
"""
from __future__ import annotations

import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402


def parse_hash(fragment: str, link: dict) -> dict:
    """镜像页面的 parseHash:去掉前缀、按 pair 切键值、按 sep 切策略序列。"""
    parts: dict = {}
    for kv in fragment.replace(link["hashPrefix"], "").split(link["pair"]):
        i = kv.find("=")
        if i > 0:
            parts[kv[:i]] = kv[i + 1:]
    if "seq" in parts:
        parts["seq"] = parts["seq"].split(link["sep"])
    return parts


def encode_hash(seed: int, seq: list, link: dict) -> str:
    return (link["hashPrefix"] + "s=" + str(seed) + link["pair"]
            + "seq=" + link["sep"].join(seq))


def main() -> int:
    bpath = os.path.join(OUT, "proto03_build.json")
    if not os.path.exists(bpath):
        print("缺 out/proto03_build.json:请先跑 web/build_proto03.mjs", file=sys.stderr)
        return 2
    with open(bpath, encoding="utf-8") as f:
        build = json.load(f)
    link = json.load(open(os.path.join(ROOT, "config", "gd_proto_03_share_link.json"), encoding="utf-8"))

    fragment = build["shareLink"]
    parts = parse_hash(fragment, link)
    seed = int(parts["s"])
    seq = list(parts["seq"])
    spec = ashlib.load_spec()
    replay_spec = json.loads(json.dumps(spec))
    replay_spec["seed"] = seed
    replay_spec["strategy"] = seq
    py_digest = ashlib.digest(ashlib.run_spec(replay_spec, seq))
    reencoded = encode_hash(seed, seq, link)

    checks = {
        "decoded": "s" in parts and "seq" in parts,
        "seedMatchesConfig": seed == link["seed"],
        "seqLengthMatches": len(seq) == build["strategyLength"],
        "replayDigestMatchesNode": py_digest == build["nodeShareDigest"],
        "reencodeLossless": reencoded == fragment,
    }
    bad = [k for k, v in checks.items() if not v]
    report = {
        "id": "gd_proto_03_replay", "fragment": fragment, "fragmentChars": len(fragment),
        "seed": seed, "strategyLength": len(seq), "commands": seq,
        "pythonDigest": py_digest, "nodeShareDigest": build["nodeShareDigest"],
        "reencoded": reencoded, "contract": {"hashPrefix": link["hashPrefix"],
                                             "pair": link["pair"], "sep": link["sep"]},
        "checks": checks, "fail": len(bad),
        "note": "回放一致性的判据是逐字相同;不一致说明编解码或 PRNG 有一侧漂了。",
    }
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "gd_proto_03_replay.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print("=== 3.3 链接片段的回放一致性断言 ===")
    print(f"片段({report['fragmentChars']} 字符):{fragment}")
    print(f"解出 seed={seed} 序列长度={len(seq)} 前 6 步={'-'.join(seq[:6])}")
    print(f"Python 回放 digest      {py_digest}")
    print(f"Node 构建期 share digest {build['nodeShareDigest']}")
    print(f"逐字相同={checks['replayDigestMatchesNode']} 再编码无损={checks['reencodeLossless']}")
    print(f"fail={len(bad)}" + ("" if not bad else f"  {bad}"))
    print("wrote out/gd_proto_03_replay.json")
    return 1 if bad else 0


if __name__ == "__main__":
    raise SystemExit(main())
=== 3.3 链接片段的回放一致性断言 ===
片段(80 字符):#s=301&seq=p0-p0-p0-p0-e-p0-p0-p0-p0-e-p0-p0-p0-p0-e-p0-p0-p0-p0-e-p0-p0-p0-p0-e
解出 seed=301 序列长度=25 前 6 步=p0-p0-p0-p0-e-p0
Python 回放 digest      {"block":5,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":0,"hand":["jab","strike","jab"],"over":true,"played":7,"playerHp":48,"turn":3,"won":true}
Node 构建期 share digest {"block":5,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":0,"hand":["jab","strike","jab"],"over":true,"played":7,"playerHp":48,"turn":3,"won":true}
逐字相同=True 再编码无损=True
fail=0
wrote out/gd_proto_03_replay.json

判读:三条断言各自的失效信号

断言验的是失效意味着
可解码契约两侧一致页面改了分隔符而脚本没跟,链接在接收端解不开
可重现PRNG 与规则同源同一段链接在不同语言下跑出不同局面,回放失效
再编码无损编解码互为逆运算链接转发几次就漂了,分享链条会断

第三条最容易被忽略:它不检查任何游戏逻辑,只检查「解出来再编回去还是不是原来那串」。少了它,一个把空序列解成 [""] 的小 bug 可以长期潜伏——因为回放当前这一局看起来完全正常。80 字符的片段短到可以直接贴在聊天窗口里,也正因为它短,任何一处漂移都会立刻被发现。口径再声明一次:本节比较的都是代理指标(proxy)——回合数、出牌数、格挡值这些从快照里读出来的整数,只用于相对比较(默认局 vs 回放局),禁绝对化;「同一段链接在任何宿主上都重现同一局」是可断言的机制事实,而「这一局更好玩」不是本节能说的话。

本节实跑清单

命令产物实测
NODE_PATH=… node web/verify_mech.mjs --page out/proto03.html --run replay --expect-digest '…'回放断言3 断言全绿
/opt/homebrew/bin/python3 sim/gd_proto_03_replay.pyout/gd_proto_03_replay.json5 断言全绿,再编码无损

第 3 章到此收口:规格内联成一个文件(3.1)、在无头浏览器里真玩并断言(3.2)、把一局编码成可回放的链接(3.3)。下一章换一个方向——不再问「原型怎么跑」,而是问「先验什么假设、采多少样本、拿什么做决策」。

阅读导航:上一节:3.2 浏览器里的真玩与断言 · 下一节:4.1 测试的目标与假设 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「gamedesign」更多文章

  1. 附录 A 品类参数表、机制距离矩阵与数据集索引
  2. 9.3 自走棋的涌现
  3. 9.2 自走棋的经济与平衡