🔖

ゲーム開発における複合的なクエストの開始条件を表現するためにZodのRecursive Typeを利用してみる

2024/12/18に公開

RPGなどのゲームにおいて条件を満たしたら何かができるというような条件判定を行うことがしばしばあります。例えばクエストをクリアするためにはほかのクエストをクリアしてなければならないなどです。

そうしたときに条件を以下のようなExpression(式)を組み合わせたJSONで記述できるとうれしいです。

export const quests: Quest[] = [
  // ...
  {
    id: 3,
    name: "宝がある廃墟の炭鉱の島を攻略しろ",
    clearCondition: {
      kind: AND_KIND, // argumentsの条件をすべて満たしているのならばクリアできる。
      arguments: [
        {
          kind: IS_QUEST_SUCCEEDED_KIND, // questId: 1 をすでにクリアしているか判定
          questId: 1,
        },
        {
          kind: IS_QUEST_SUCCEEDED_KIND, // questId: 2 をすでにクリアしているか判定
          questId: 2,
        },
      ],
    },
  },
];

こういう書き方をするときに以下の条件を満たせているとよりうれしいです。

  • Expression(式)は再帰的に記述可能でAndやOrの条件をnestして記述できる
  • JSONはtypescriptの型やValidatorによりチェックされており、エディター上やWeb APIでも間違いに気が付くことができる

以下のように、Zodの再帰的なschema定義(Recursive types)により以上を実現できます。

// src/schema/conditionExpression.ts

import { z } from "zod";
import { isQuestSucceededSchema } from "./conditionExpressions/isQuestSucceeded";

export const AND_KIND = "and";

// 再帰して参照されるもの以外のschema定義
export const AndBaseSchema = z.object({
  kind: z.enum([AND_KIND]),
});

// 手動で定義したAND条件のExpressionの型
export type And = z.infer<typeof AndBaseSchema> & {
  arguments: Condition[];
};

// 再帰的なスキーマ定義のためにz.lazyを使用して、AndBaseSchemaにargumentsを追加します。手動で定義した型からschemaの型を定義します。
export const AndSchema: z.ZodType<And> = AndBaseSchema.extend({
  arguments: z.lazy(() => ConditionSchema.array()),
});

// 条件判定のExpressionのschemaは`AND条件`が入ることも `特定のクエストをクリアしたことがあるかという条件` が入ることもあるのでunionにしています。
export const ConditionSchema = z.union([AndSchema, isQuestSucceededSchema]);

export type Condition = z.infer<typeof ConditionSchema>;

ソースコードは以下にあります。licenseはcc0です。
https://github.com/rmake/zod-recursive-type-expression-experimental

以下詳細を説明していきます。

例題

ポストアポカリプスの世界で、普段は廃墟の中から使えるものを探しているスカベンジャー様御一行は、その経験を買われて廃墟にある宝を探すQuestを請け負った。宝はこの付近にある多くの島の廃墟のどこかにあるらしい。しかし正確な場所をまだ特定できておらず、また海を越えるための船もまだない状態である。
まず以下の二つのQuestをクリアすることにより

  • 廃墟ビルで宝のありかの座標を探せ
  • 廃墟となった港で船を手に入れろ

その後、以下のクエストをクリアすることができる。

  • 宝がある廃墟の炭鉱の島を攻略しろ

最初の二つのクエストはどちらから攻略してもよい。

実装

Partyのschemaは以下のように定義します

// src/schema/party.ts
import { z } from "zod";

export const PartySchema = z.object({
  id: z.number(),
  name: z.string(),
  clearedQuests: z.array(z.number()),
});

export type Party = z.infer<typeof PartySchema>;

データとしては以下のとおりです。

// src/db/conditionExpression/parties.ts
import { Party } from "../../schema/party";

export const party: Party = {
  id: 1,
  name: "スカベンジャー様御一行",
  clearedQuests: [],
};

Questのschemaは以下のとおりである。clearConditionがクリアしたQuestのIDの配列です。
ConditionExpressionSchemaが条件のExpressionのSchemaです。

// src/schema/quest.ts
import { z } from "zod";
import { ConditionSchema } from "./conditionExpression";

export const QuestSchema = z.object({
  id: z.number(),
  name: z.string(),
  clearCondition: ConditionSchema.optional(),
});

export type Quest = z.infer<typeof QuestSchema>;

以下がQuestのデータです。clearConditionを階層構造になっているObjectで表現しています。

// src/db/conditionExpression/quests.ts
import { AND_KIND } from "../../schema/conditionExpression";
import { IS_QUEST_SUCCEEDED_KIND } from "../../schema/conditionExpressions/isQuestSucceeded";
import { Quest } from "../../schema/quest";

export const quests: Quest[] = [
  {
    id: 1,
    name: "廃墟ビルで宝のありかの座標を探せ",
  },
  {
    id: 2,
    name: "廃墟となった港で船を手に入れろ",
  },
  {
    id: 3,
    name: "宝がある廃墟の炭鉱の島を攻略しろ",
    clearCondition: {
      kind: AND_KIND,
      arguments: [
        {
          kind: IS_QUEST_SUCCEEDED_KIND,
          questId: 1,
        },
        {
          kind: IS_QUEST_SUCCEEDED_KIND,
          questId: 2,
        },
      ],
    },
  },
];

ANDの引数にQuestId: 1とQuestId: 2をクリアしたことがあるという条件が渡されています。

Expressionの実装

特定のクエストをクリアしたことがあるかという条件は以下のようなExpressionで表現しています。

// src/schema/conditionExpressions/isQuestSucceeded.ts
import { z } from "zod";

export const IS_QUEST_SUCCEEDED_KIND = "isQuestSucceeded";

export const isQuestSucceededSchema = z.object({
  kind: z.literal(IS_QUEST_SUCCEEDED_KIND),
  questId: z.number(),
});

export type IsQuestSucceeded = z.infer<typeof isQuestSucceededSchema>;

ANDの条件と、条件のExpressionのSchemaであるConditionSchemaは、以下のように定義しています。

// src/schema/conditionExpression.ts

import { z } from "zod";
import { isQuestSucceededSchema } from "./conditionExpressions/isQuestSucceeded";

export const AND_KIND = "and";

// 再帰して参照されるもの以外のschema定義
export const AndBaseSchema = z.object({
  kind: z.enum([AND_KIND]),
});

// 手動で定義したAND条件のExpressionの型
export type And = z.infer<typeof AndBaseSchema> & {
  arguments: Condition[];
};

// 再帰的なスキーマ定義のためにz.lazyを使用して、AndBaseSchemaにargumentsを追加します。手動で定義した型からschemaの型を定義します。
export const AndSchema: z.ZodType<And> = AndBaseSchema.extend({
  arguments: z.lazy(() => ConditionSchema.array()),
});

// 条件判定のExpressionのschemaは`AND条件`が入ることも `特定のクエストをクリアしたことがあるかという条件` が入ることもあるのでunionにしています。
export const ConditionSchema = z.union([AndSchema, isQuestSucceededSchema]);

export type Condition = z.infer<typeof ConditionSchema>;

ConditionSchemaはANDの条件とQuestをクリアしたことがあるかどうかの条件の判定のunionのschemaとしており、条件式を表現できるschemaとして定義しています。
基本的な考え方はZodのRecursive typesの考え方の通りです。
ConditionSchemaaがAndSchemaを介して再帰的に自分自身を参照していることに気を付けてください。
AndSchemaの型がz.lazyにより型推論ができなくなるため、手動で定義したAndをZodのschemaの型としています。
z.lazyを使う理由はAndSchemaの初期化をする時点ではConditionSchemaが初期化されてないので、遅延評価する必要があると理解しています。
z.ZodType<And>を指定しない場合はAndSchemaの型がanyになってしまいます。

const AndSchema: any

評価の実装

expressionの評価を行う関数evaluatorは以下のように実装しています

// src/db/conditionExpression/index.ts
import { AND_KIND, Condition } from "../../schema/conditionExpression";
import { IS_QUEST_SUCCEEDED_KIND } from "../../schema/conditionExpressions/isQuestSucceeded";
import { Party } from "../../schema/party";
import { andEvaluator } from "./evaluators/andEvaluator";
import { isQuestSucceededEvaluator } from "./evaluators/isQuestSucceededEvaluator";

export const evaluator = ({
  conditionExpression,
  party,
}: {
  conditionExpression?: Condition;
  party: Party;
}): boolean => {
  if (!conditionExpression) return true;

  switch (conditionExpression.kind) { // どのExpressionかで分岐
    case IS_QUEST_SUCCEEDED_KIND:
      return isQuestSucceededEvaluator({ conditionExpression, party });
    case AND_KIND:
      return andEvaluator({ conditionExpression, party });
    default:
      const _: never = conditionExpression;  // Expressionを追加したがvalidatorの追加が漏れていた時にtranspile時にエラーが出るように
      throw "unknown condition expression";
  }
};

switch (conditionExpression.kind) によってそれぞれのkindごとにconditionExpressionがどの型かが決まります。そのためクエストをクリアしているかの判定とANDの判定に渡す引数もエラーなく決定できます。isQuestSucceededEvaluatorとandEvaluatorの実装は以下の通りです。

// src/db/conditionExpression/evaluators/isQuestSucceededEvaluator.ts
import { IsQuestSucceeded } from "../../../schema/conditionExpressions/isQuestSucceeded";
import { Party } from "../../../schema/party";

// 対象パーティがquestIdをクリアしたことがあるかを返す
export const isQuestSucceededEvaluator = ({
  conditionExpression: { questId },
  party,
}: {
  conditionExpression: IsQuestSucceeded;
  party: Party;
}) => {
  return party.clearedQuests.includes(questId);
};
// src/db/conditionExpression/evaluators/andEvaluator.ts
import { evaluator } from "..";
import { And } from "../../../schema/conditionExpression";
import { Party } from "../../../schema/party";

// 再帰的に引数の条件を評価して、すべての条件がtrueかどうかを返す
export const andEvaluator = ({
  conditionExpression,
  party,
}: {
  conditionExpression: And;
  party: Party;
}) => {
  return conditionExpression.arguments?.every((argument) =>
    evaluator({ conditionExpression: argument, party })
  );
};

ここまで定義した内容をもとに、以下の検証用のスクリプトを実行すると

// src/index.ts
import { party } from "./db/conditionExpression/parties";
import { quests } from "./db/conditionExpression/quests";
import { evaluator } from "./db/conditionExpression";
import { Party } from "./schema/party";
import { ConditionSchema } from "./schema/conditionExpression";
import { IS_QUEST_SUCCEEDED_KIND } from "./schema/conditionExpressions/isQuestSucceeded";

const logParty = (party: Party) => {
  console.log(
    `パーティ ${party.id}:${party.name}, クリア済み: ${JSON.stringify(
      party.clearedQuests
    )}`
  );
};

const logClearableQuests = (party: Party) => {
  console.log("クリア可能");
  quests.forEach((quest) => {
    console.log(
      `  ${quest.id}:${quest.name} ${evaluator({
        conditionExpression: quest.clearCondition,
        party,
      })}`
    );
  });
};

console.log("JSONの構造チェック");
console.log(ConditionSchema.parse(quests[2].clearCondition));
try {
  ConditionSchema.parse({
    ...quests[2].clearCondition,
    ...{ kind: IS_QUEST_SUCCEEDED_KIND },
  });
} catch (e) {
  console.log(e);
}

console.log("まだ何もクリアしてない状態");
logParty(party);
logClearableQuests(party);
console.log("");

console.log("1番目のクエストのみクリア");
party.clearedQuests = [1];
logParty(party);
logClearableQuests(party);
console.log("");

console.log("2番目のクエストのみクリア");
party.clearedQuests = [2];
logParty(party);
logClearableQuests(party);
console.log("");

console.log("1, 2番目両方のクエストクリア");
party.clearedQuests = [1, 2];
logParty(party);
logClearableQuests(party);
console.log("");

以下の出力がなされます。

JSONの構造チェック
{
  kind: 'and',
  arguments: [
    { kind: 'isQuestSucceeded', questId: 1 },
    { kind: 'isQuestSucceeded', questId: 2 }
  ]
}
ZodError ...

まだ何もクリアしてない状態
パーティ 1:スカベンジャー様御一行, クリア済み: []
クリア可能
  1:廃墟ビルで宝のありかの座標を探せ true
  2:廃墟となった港で船を手に入れろ true
  3:宝がある廃墟の炭鉱の島を攻略しろ false

1番目のクエストのみクリア
パーティ 1:スカベンジャー様御一行, クリア済み: [1]
クリア可能
  1:廃墟ビルで宝のありかの座標を探せ true
  2:廃墟となった港で船を手に入れろ true
  3:宝がある廃墟の炭鉱の島を攻略しろ false

2番目のクエストのみクリア
パーティ 1:スカベンジャー様御一行, クリア済み: [2]
クリア可能
  1:廃墟ビルで宝のありかの座標を探せ true
  2:廃墟となった港で船を手に入れろ true
  3:宝がある廃墟の炭鉱の島を攻略しろ false

1, 2番目両方のクエストクリア
パーティ 1:スカベンジャー様御一行, クリア済み: [1,2]
クリア可能
  1:廃墟ビルで宝のありかの座標を探せ true
  2:廃墟となった港で船を手に入れろ true
  3:宝がある廃墟の炭鉱の島を攻略しろ true

上にある通り、実際にQuestの前提条件をAND条件で表現できていることが確認できました。

まとめ

今回、Zodを利用した再帰的なSchema定義によりゲーム内の複雑な条件式を型安全に表現、評価する方法を紹介しました。これにより、開発時のコード補完や型チェックが効くようになり、バグの減少や開発効率の向上が期待できます。さらに、この方法を応用すれば、ORやNOTなどの他の論理演算子にも対応可能であり、柔軟な条件設定が可能となります。
拡張に当たっては、isQuestSucceededSchemaと同じようにConditionSchemaのunionに新しいleafとなるschemaを追加していけば対応できます。また、ORやNOT条件についてもANDと同じように再帰的なschema定義と、ConditionSchemaのunionへの追加で実装できます。

もし気になった方は、実際に動かしてコードの理解を深めたり、ORやほかの条件などを追加してみてください。

気が付いたこと

この機能を作ろうとしていた時に、以下のようなコードが問題を起こしていました。

export const QuestSchema = z.object({
  id: z.number(),
  name: z.string(),
  clearCondition: ConditionSchema.optional(),
});

export const QuestIdSchema = QuestSchema.shape.id;

QuestIdのみのschemaを定義しようとしてQuestSchemaからid用のschemaを取得していたのですが、
isQuestSucceededSchemaのQuestIdの型として使用した場合に、

export const isQuestSucceededSchema = z.object({
  expressionKind: z.literal(IS_QUEST_SUCCEEDED_KIND),
  questId: QuestIdSchema,
});

以下のようなエラーになってしまいました。

型 'ZodObject<extendShape<{ kind: ZodEnum<["and"]>; }, { arguments: ZodLazy<any>; }>, "strip", ZodTypeAny, { kind: "and"; arguments?: any; }, { kind: "and"; arguments?: any; }>' を型 'ZodType<And, ZodTypeDef, And>' に割り当てることはできません。
  プロパティ '_type' の型に互換性がありません。
    型 '{ kind: "and"; arguments?: any; }' を型 'And' に割り当てることはできません。
      型 '{ kind: "and"; arguments?: any; }' を型 '{ arguments: any[]; }' に割り当てることはできません。
        プロパティ 'arguments' は型 '{ kind: "and"; arguments?: any; }' では省略可能ですが、型 '{ arguments: any[]; }' では必須です。ts(2322)

おそらく循環参照で型を決められずにoptionalの型が出てきてしまったのが問題のようです。
もしQuestIdSchemaを定義して再利用したい場合は以下のようにすることで循環参照を回避するのが望ましいと気が付きました。

export const QuestIdSchema = z.number();

export const QuestSchema = z.object({
  id: QuestIdSchema,
  name: z.string(),
  clearCondition: ConditionSchema.optional(),
});

参考資料

Discussion