惊人的 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
请注意,allRequired
是 ajv-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。