2022 年 5 月 18 日,星期三 ·14分钟阅读

惊人的 Hyperborea 序列化与模式

最初发布在 techblog.babyl.ca 上。

在过去的两年里,我加入了一个由勇敢的冒险家组成的团队,他们每周四晚上通过 Discord 的传送魔法聚在一起,半开玩笑地尽力避免在《惊人的 Hyperborea 剑士和巫师》(一个《龙与地下城》的粗俗表亲)这个残酷无情的世界中遭遇可怕的死亡。游戏由邪恶的地下城策划者 Gizmo Mathboy 主导,它非常有趣。

但 Hyperborea 的世界不仅受到怪物的围攻。不,它也是一个充满了规则、统计数据和各种决定命运的掷骰子的领域。而捕捉到这些神秘法则的中心点——对于所有熟悉这个类型的人来说并不奇怪——就是角色卡。

作为一群好学生,我们通常会尽力保持角色卡的最新状态。但我们都是易犯错误的生物;错误会潜入其中。这让我思考……肯定有办法自动执行这些角色卡上的某些验证。事实上,我们已经将角色卡保存在 YAML 文档中。 JSON 模式 可以完全用来定义文档模式……当然,它可以被扭曲一点以适应游戏的奇特逻辑吗?

答案是,当然,只要咒语足够黑暗,任何东西都可以被扭曲。这篇博客文章及其相关的 项目存储库,虽然不是一个完整的解决方案(至少现在不是),但旨在展示 JSON 模式可以带来的好处,以及生态系统中的工具。

所以……感兴趣吗,各位冒险家?那就系好腰带,收好刀剑,跟我来:让我们进入 JSON 模式丛林吧!

准备工作

首先,让我们介绍一下我将在这个项目中使用的核心工具。

对于所有 JSON 模式的相关事宜,我们将使用 ajv(以及 ajv-cli 用于 CLI 交互)。它是一个快速、健壮的 JSON 模式规范实现,具有许多附加功能,而且它提供了一个简单的机制来添加自定义验证关键字,我们很快就会利用它。

由于我们将进行很多命令行操作,所以我将引入 Task,一个基于 YAML 的任务运行器——基本上是 Makefile,但将疯狂的基于空格的语法替换成了……另一种疯狂的基于空格的语法,我比较习惯。

顺便说一句,本文中我将讨论的所有代码的最终形式都在 这个仓库 中。

JSON 最糟糕

好吧,这有点过分。JSON 是一种很棒的序列化格式,但手动编辑它非常痛苦。但这并不是什么大问题,因为 JSON 模式有点名不副实:目标文档和模式本身最终都只是普通的旧数据结构——JSON 碰巧是它的典型序列化方式。好吧,典型就典型吧,我们将 YAML 作为我们的源代码。为了方便后面的其他步骤,我们将使用 [transerialize][] 将这些 YAML 文档转换为 JSON。

1# in Taskfile.yml
2tasks:
3    schemas: fd -e yml -p ./schemas-yaml -x task schema SCHEMA='{}'
4
5    schema:
6        vars:
7            DEST:
8                sh: echo {{.SCHEMA}} | perl -pe's/ya?ml/json/g'
9        sources: ["{{.SCHEMA}}"]
10        generates: ["{{.DEST}}"]
11        cmds: transerialize {{.SCHEMA}} {{.DEST}}

哦,对了,task 在循环方面不幸地很笨拙,所以我正在使用 fd 和重新进入来处理所有单独的模式转换。

设置验证流程

在我们对模式本身疯狂操作之前,我们需要弄清楚如何调用它。为此,让我们以最无聊、最简化的方式播种我们的模式和示例文档。

1# file: schemas-yaml/character.yml
2$id: https://hyperboria.babyl.ca/character.json
3title: Hyperboria character sheet
4type: object
1# file: samples/verg.yml
2
3# Verg is my character, and always ready to face danger,
4# so it makes sense that he'd be volunteering there
5name: Verg-La

我们有一个模式,我们有一个文档,验证它的直截了当的方法是执行以下操作。

1⥼ ajv validate -s schemas-yaml/character.yml -d samples/verg.yml
2samples/verg.yml valid

太好了。现在我们只需要在 Taskfile 中稍微正式化一下,我们就可以开始了。

1# file: Taskfile.yml
2# in the tasks
3validate:
4    silent: true
5    cmds:
6        - |
7            ajv validate  \\
8                --all-errors \\
9                --errors=json \\
10                --verbose \\
11                -s schemas-yaml/character.yml \\
12                -d {{.CLI_ARGS}}

开始构建模式

为了热身,让我们从一些简单的字段开始。一个角色显然有姓名和玩家。

1# file: schemas-yaml/character.json
2$id: https://hyperboria.babyl.ca/character.json
3title: Hyperboria character sheet
4type: object
5additionalProperties: false
6required:
7    - name
8    - player
9properties:
10    name: &string
11        type: string
12    player: *string

这里没什么特别的,除了 YAML 锚点和别名,因为我是一个懒惰的家伙。

1⥼ task validate -- samples/verg.yml
2samples/verg.yml invalid
3[
4    ...
5    "message": "must have required property 'player'",
6    ...
7]

哇!验证在向我们大喊!这里省略了输出,因为我在 taskfile 中将其配置为超详细模式。但要点很清楚:我们应该有一个玩家姓名,但我们没有。所以让我们添加它。

1# file: samples/verg.yml
2name: Verg-La
3player: Yanick

在添加了玩家姓名后,一切都恢复正常。

1⥼ task validate -- samples/verg.yml
2samples/verg.yml valid

添加属性和定义

接下来是核心属性!所有属性都遵循相同的规则(1 到 20 之间的数字)。复制粘贴所有属性的模式将是不礼貌的。使用前面部分中的锚点是一种选择,但在这种情况下,最好使用模式定义,使事情更正式一些。

1# file: schemas-yaml/character.yml
2# only showing deltas
3required:
4    # ...
5    - statistics
6properties:
7    # ...
8    statistics:
9        type: object
10        allRequired: true
11        properties:
12            strength: &stat
13                $ref: "#/$defs/statistic"
14            dexterity: *stat
15            constitution: *stat
16            intelligence: *stat
17            wisdom: *stat
18            charisma: *stat
19$defs:
20    statistic:
21        type: number
22        minimum: 1
23        maximum: 20

请注意,allRequiredajv-keywords 提供的自定义关键字,为了使用它,我们需要在 taskfile 中修改对 ajv validate 的调用

1# file: Taskfile.yml
2validate:
3    silent: true
4    cmds:
5        - |
6            ajv validate \\
7                --all-errors \\
8                --errors=json \\
9                --verbose \\
10                -c ajv-keywords \\
11                -s schemas-yaml/character.yml \\
12                -d {{.CLI_ARGS}}

为了符合模式,我们也需要将属性添加到我们的示例角色中

1# file: samples/verg.yml
2statistics:
3    strength: 11
4    dexterity: 13
5    constitution: 10
6    intelligence: 18
7    wisdom: 15
8    charisma: 11

我们检查一下,是的,我们的角色卡仍然有效。

1⥼ task validate -- samples/verg.yml
2samples/verg.yml valid

一个样本不足以进行严肃的测试

到目前为止,我们一直使用 Verg 作为测试对象。我们调整模式,将其与角色卡进行比较,调整角色卡,反复重复这个过程。但随着模式变得越来越复杂,我们可能想要为这个小项目添加一个真正的测试套件。

一种方法是使用 ajv test,它具有不需要任何额外代码的优势。

1⥼ ajv test -c ajv-keywords \\
2    -s schemas-yaml/character.yml \\
3    -d samples/verg.yml \\
4    --valid
5samples/verg.yml passed test
6# bad-verg.yml is like verg.yml, but missing the player name
7⥼ ajv test -c ajv-keywords \\
8    -s schemas-yaml/character.yml \\
9    -d samples/bad-verg.yml \\
10    --invalid
11samples/bad-verg.yml passed test

但它在简单性方面存在缺陷,在模块化方面也不足。这些模式会变得更加复杂,并且针对它们的某些部分进行测试会很有用。因此,我们将使用 [vitest][],使用传统的单元测试。

例如,让我们测试一下属性。

1// file: src/statistics.test.js
2import { test, expect } from "vitest";
3
4import Ajv from "ajv";
5
6import characterSchema from "../schemas-json/character.json";
7
8const ajv = new Ajv();
9// we just care about the statistic schema here, so that's what
10// we take
11const validate = ajv.compile(characterSchema.$defs.statistic);
12
13test("good statistic", () => {
14    expect(validate(12)).toBeTruthy();
15    expect(validate.errors).toBeNull();
16});
17
18test("bad statistic", () => {
19    expect(validate(21)).toBeFalsy();
20    expect(validate.errors[0]).toMatchObject({
21        message: "must be <= 20",
22    });
23});

我们在 taskfile 中添加一个 test 任务

1# file: Taskfile.yml
2test:
3    deps: [schemas]
4    cmds:
5        - vitest run

就这样,我们有了测试。

1⥼ task test
2task: [schemas] fd -e yml -p ./schemas-yaml -x task schema SCHEMA='{}'
3task: [schema] transerialize schemas-yaml/test.yml schemas-json/test.json
4task: [schema] transerialize schemas-yaml/character.yml schemas-json/character.json
5task: [test] vitest run
6
7 RUN  v0.10.0 /home/yanick/work/javascript/hyperboria-character-sheet
8
9 √ src/statistics.test.js (2)
10
11Test Files  1 passed (1)
12     Tests  2 passed (2)
13      Time  1.41s (in thread 5ms, 28114.49%)

更多模式!

下一步是角色职业。虽然我们可以直接在主模式中添加一个 enum 并将其称为完成,但这是一个可能在其他地方重复使用的列表,因此将其定义在自己的模式中,并在角色卡模式中引用它可能会有所回报。

额外的挑战!在 Hyperborea 中,你可以拥有一个通用职业,或者一个职业和一个子职业。这可以用模式明确地描述,例如

1oneOf:
2    - enum: [ magician, figher ]
3    - type: object
4      properties:
5        generic: { const: fighter }
6        subclass: { enum: [ barbarian, warlock, ... ] }
7    ...

但这会导致大量重复的键入。相反,如果稍微不那么 JSON 模式化,那么我们的源代码会更紧凑一些。比如,像这样

1$id: https://hyperboria.babyl.ca/classes.json
2title: Classes of characters for Hyperborea
3$defs:
4    fighter:
5        - barbarian
6        - berserker
7        - cataphract
8        - hunstman
9        - paladin
10        - ranger
11        - warlock
12    magician: [cryomancer, illusionist, necromancer, pyromancer, witch]

然后让一个小脚本在我们将 YAML 转换为 JSON 时处理数据。幸运的是(真是幸运!),transerialize 确实允许在处理过程中插入一个转换脚本。因此,我们可以将我们的 taskfile 模式任务更改为

1schema:
2    vars:
3        TRANSFORM:
4            sh: |
5                echo {{.SCHEMA}} | \\
6                    perl -lnE's/yml$/pl/; s/^/.\//; say if -f $_'
7        DEST:
8            sh: echo {{.SCHEMA}} | perl -pe's/ya?ml/json/g'
9    cmds:
10        - transerialize {{.SCHEMA}} {{.TRANSFORM}} {{.DEST}}

然后我们插入一个看起来像这样的转换脚本

1# file: schemas-yaml/classes.pl
2sub {
3    my $schema = $_->{oneOf} = [];
4
5    push @$schema, { enum => [ keys $_->{'$defs'}->%* ] };
6
7    for my $generic ( keys $_->{'$defs'}->%* ) {
8        push @$schema, {
9            type => 'object',
10            properties => {
11                generic => { const => $generic },
12                subclass => { enum => $_->{'$defs'}{$generic} }
13            }
14        }
15    }
16
17    return $_;
18}

有了它,输出模式会膨胀到我们想要的样子。我们既可以吃美味的蛋糕,也可以吃大块松软的蛋糕。真棒!

所以剩下的就是将这些模式链接在一起。我们从角色模式中引用职业模式

1# file: schemas-yaml/character.yml
2required:
3    # ...
4    - class
5properties:
6    # ...
7    class: { $ref: "/classes.json" }

我们还需要告诉 ajv 新模式的存在

1validate:
2    silent: true
3    cmds:
4        - |
5            ajv validate \\
6                --all-errors \\
7                --errors=json \\
8                --verbose \\
9                -c ajv-keywords \\
10                -r schemas-json/classes.json \\
11                -s schemas-json/character.json \\
12                -d {{.CLI_ARGS}}

最后,我们将 Verg 的职业添加到他的角色卡中

1# file: samples/verg.yml
2class:
3  generic: magician
4  subclass: cryomancer

就这样,Verg(以及我们的角色模式)变得非常优雅。

引用模式的其他部分

到目前为止,我们可以设置角色表模式,以确保我们拥有想要的所有字段,以及想要的数据类型和值。但我们还希望做的一件事是验证属性之间的关系。

例如,角色有一个健康值。每次角色升级时,玩家掷骰子并相应地增加健康值。正如你所想,忘记获得这个奖励可能会导致致命错误,因此最好确保这种情况永远不会发生。

我们将通过JSON 指针和 avj 的 $data 的魔力来实现,如下所示

1# file: schemas-yaml/character.yml
2level: { type: number, minimum: 1 }
3health:
4    type: object
5    required: [ max ]
6    properties:
7        max: { type: number }
8        current: { type: number }
9        log:
10            type: array
11            description: history of health rolls
12            items: { type: number }
13            minItems: { $data: /level }
14            maxItems: { $data: /level }

基本上(一旦我们在 ajv 中添加 --data 标志来告诉它启用该功能),任何对 { $data: '/path/to/another/value/in/the/schema' } 的提及将被替换为该 JSON 指针在被验证的文档中解析到的值。这不是 JSON 模式本身的一部分,但它是一种非常有用的方式来将模式和被验证的文档互连。

不过需要注意的是:我说“任何对 $data 的提及”,但这是言过其实了。在某些情况下,$data 字段不会被解析。如果你要使用该功能,请确保留出几分钟时间阅读 AJV 的相关文档。相信我,这将为你节省一些“究竟是怎么回事”的时刻。

自定义关键字

在上一节中,我们检查了健康值的掷骰次数是否等于角色的等级。这已经是一种进步了。但接下来的逻辑步骤是确保这些掷骰的总和等于我们的最大生命值。我们需要类似以下的东西

1# file: schemas-yaml/character.yml
2health:
3    type: object
4    properties:
5        max:
6            type: number
7            sumOf: { list: { $data: 1/log } }
8        log:
9            type: array
10            items: { type: number }

这就是自定义关键字发挥作用的地方。AJV 允许我们使用新关键字来扩展 JSON 模式词汇表。

定义该自定义关键字有几种方法。我选择的方法是将其定义为一个 JavaScript 函数(这里变得更复杂了一些,因为我们在内部处理 JSON 指针)

1// file: src/sumOf.cjs
2
3const _ = require("lodash");
4const ptr = require("json-pointer");
5
6function resolvePointer(data, rootPath, relativePath) {
7    if (relativePath[0] === "/") return ptr.get(data, relativePath);
8
9    const m = relativePath.match(/^(\d+)(.*)/);
10    relativePath = m[2];
11    for (let i = 0; i < parseInt(m[1]); i++) {
12        rootPath = rootPath.replace(/\/[^\/]+$/, "");
13    }
14
15    return ptr.get(data, rootPath + relativePath);
16}
17
18module.exports = (ajv) =>
19    ajv.addKeyword({
20        keyword: "sumOf",
21        $data: true,
22        errors: true,
23        validate: function validate(
24            { list, map },
25            total,
26            _parent,
27            { rootData, instancePath }
28        ) {
29            if (list.$data)
30                list = resolvePointer(rootData, instancePath, list.$data);
31
32            if (map) data = _.map(data, map);
33
34            if (_.sum(list) === total) return true;
35
36            validate.errors = [
37                {
38                    keyword: "sumOf",
39                    message: "should add up to sum total",
40                    params: {
41                        list,
42                    },
43                },
44            ];
45
46            return false;
47        },
48    });

像往常一样,我们必须告诉 ajv 通过 -c ./src/sumOf.cjs 包含这一新代码。除此之外,恭喜你,我们拥有一个新的关键字!

更多相同的内容

现在我们拥有了大部分想要的工具,剩下的就是转动曲柄。

经验值?与生命值相同的逻辑

1# file: schemas-yaml/character.yml
2experience:
3    type: object
4    properties:
5        total:
6            type: number
7            sumOf:
8                list: { $data: '1/log' }
9                map: amount
10        log:
11            type: array
12        items:
13            type: object
14            properties:
15                date: *string
16                amount: *number
17                notes: *string

其他基本属性很简单

1# file: schemas-yaml/character.yml
2gender: *string
3age: *number
4height: *string
5appearance: *string
6alignment: *string

基于列表的字段?我们已经做过

1# file: schemas-yaml/character.yml
2  race: { $ref: /races.json }
3  languages:
4    type: array
5    minItems: 1
6    items:
7      $ref: /languages.json

法术只属于魔法师吗?没问题。

1# file: schemas-yaml/character.yml
2type: object
3properties:
4    # ...
5    spells:
6      type: array
7      items: { $ref: /spells.json }
8      maxSpells:
9        class: { $data: /class }
10        level: { $data: /level }

有了新的关键字 maxSpells

1// file: src/maxSpells.cjs
2
3const _ = require("lodash");
4const resolvePointer = require('./resolvePointer.cjs');
5
6module.exports = (ajv) =>
7    ajv.addKeyword({
8        keyword: "maxSpells",
9        validate: function validate(
10            schema,
11            data,
12            _parent,
13            { rootData, instancePath }
14        ) {
15            if (schema.class.$data) {
16                schema.class = resolvePointer(
17                    rootData, instancePath, schema.class.$data
18                );
19            }
20
21            if( schema.class !== 'magician'
22                && schema.class?.generic !== 'magician'
23                && data.length ) {
24                validate.errors = [
25                    {
26                        message: "non-magician can't have spells",
27                    },
28                ];
29                return false;
30            }
31
32            return true;
33        },
34        $data: true,
35        errors: true,
36    });

装备?切!当然可以。

1# file: schemas-yaml/character.yml
2properties:
3    # ...
4    gear: { $ref: '#/$defs/gear' }
5$defs:
6  gear:
7    type: array
8    items:
9      oneOf:
10        - *string
11        - type: object
12          properties:
13            desc:
14              type: string
15              description: description of the equipment
16            qty:
17              type: number
18              description: |
19                quantity of the item in the
20                character's possession
21          required: [ desc ]
22          additionalProperties: false
23          examples:
24            - { desc: 'lamp oil', qty: 2 }

到目前为止,你应该明白了。许多约束可以通过原始的 JSON 模式关键字来表达。对于更奇怪的东西,可以添加新的关键字。对于任何难于键入的内容,我们必须记住,在它的底层是 JSON,我们非常了解如何修改 JSON。