2022年5月18日水曜日 ·14最短読了時間

ハイパーボリアの驚くべきシリアライゼーションとスキーマ

元々はtechblog.babyl.caに掲載されました。

過去2年間、私は毎週木曜日の夜にDiscordのテレポート魔法を通じて集結する勇敢な冒険者の一団に加わり、パルプ的なダンジョンズ&ドラゴンズのいとこであるAstonishing Swordsmen and Sorcerers of Hyperboreaの容赦のない世界で、恐ろしい死を遂げないように(半ば議論の余地はあるが)最善を尽くしています。ゲームは邪悪なダンジョンマスターであるGizmo Mathboyによって指揮されており、非常に楽しいものです。

しかし、ハイパーボリアの世界はモンスターに包囲されているだけではありません。いいえ。それはまた、ルール、統計、そして運命を決定づけるあらゆる種類のサイコロロールで満たされた領域でもあります。そして、それらの秘儀的な法則の多くを捉える中心は、このジャンルに精通しているすべての人にとって驚くべきことではないでしょうが、キャラクターシートです。

私たちは良い小さなオタクなので、通常はキャラクターシートを最新の状態に保つことに尽力しています。しかし、私たちは皆、失敗する生き物です。ミスが忍び寄ります。そこで私は考えました...きっとこれらのキャラクターシートでいくつかの検証を自動化する方法があるはずです。実際、私たちはすでにシートをYAMLドキュメントとして保持しています。JSONスキーマはドキュメントスキーマを定義するために完全に使用できます...きっとゲームのエキゾチックなロジックに対応するために、もう少しひねることができるでしょう?

答えは、もちろん、呪文が十分に暗ければすべてをひねることができるということです。このブログエントリとそれに関連するプロジェクトリポジトリは、(まだ)網羅的なソリューションではありませんが、JSONスキーマがもたらすことができる利点とエコシステムのツールを示すことを目的としています。

さあ...興味がありますか、仲間の冒険者たち?それでは、腰に帯を締め、剣を鞘に納め、私について来てください。JSONスキーマのジャングルへ!

準備

まず、このプロジェクトで使用するコアツールを紹介します。

JSONスキーマのすべてのことについては、ajv(およびCLIインタラクション用のajv-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]

うわー!検証が私たちに叫んでいます!タスクファイルで非常に冗長になるように設定したため、出力はここで省略されています。しかし、要点は明らかです。プレイヤー名が必要ですが、ありません。それでは、追加しましょう。

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によって利用可能になったカスタムキーワードであり、それを使用するには、タスクファイルの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

1つのサンプルでは本格的なテストにならない

これまでのところ、Vergをテスト対象として使用してきました。スキーマを微調整し、シートに対して実行し、シートを微調整し、すすぎ、泡立て、繰り返します。しかし、スキーマが複雑になるにつれて、おそらく小さなプロジェクトに実際のテストスイートを追加したいと思うでしょう。

1つの方法は、追加のコードが必要ないという魅力的な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});

タスクファイルに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を挿入して完了することもできますが、他の場所で再利用される可能性のあるリストであるため、独自のスキーマで定義し、キャラクターシートスキーマで参照すると効果的です。

追加の課題!ハイパーボリアでは、一般的なクラス、またはクラスとサブクラスを持つことができます。これは、次のように明示的にスキーマ化できます

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 }

基本的に(そして--dataフラグをajvに追加して、その機能を有効にするように指示すると)、{ $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    });

いつものように、-c ./src/sumOf.cjsを介して、その新しいコードをajvに含めるように指示する必要があります。しかし、それ以外は、おめでとうございます、私たちは新しいキーワードを持っています!

同じことの繰り返し

これで、必要なツールのほとんどが揃いました。あとはクランクを回すだけです。

経験値?体力ポイントとほぼ同じロジックです。

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があり、それをどのように処理するかをよく知っていることを覚えておく必要があります。