Claude Codeでセーブデータを設計 — 旧版との互換をテストで守る
ゲームのセーブをバージョン付きJSONにし、移行関数と旧セーブの固定fixtureをClaude Codeに書かせる手順です。fixtureの凍結、スキル化、Stopフックでの検証までを載せます。
ゲームを更新したら、遊んでいた人のセーブが読めなくなった。この事故は、アイテムの持ち方を1回変えただけで起きます。防ぐ方法は2つに絞れます。セーブに schemaVersion を持たせることと、過去の形式のセーブを動かないファイルとしてテストに残すことです。
Claude Codeに任せやすいのは、この2つ目の作業です。移行関数の下書きも、旧形式のサンプル作りも、テストの追加も、Claudeが得意とする反復作業にあたります。一方で、「テストが通った」と報告させるだけでは足りません。何を凍結し、どこまで任せるかを先に決めておく必要があります。
この記事では、TypeScript製のゲームを例に、セーブ形式の設計から回帰テスト、権限とスキルの設定までを順に組みます。
壊れるのは「読み込み」ではなく「古い形式を新しい型として読む」瞬間
セーブの互換が壊れる原因は、ほぼ同じ形に集まります。
- 項目名を変えた(
itemsをinventoryにした) - 型を変えた(文字列の配列をオブジェクトの配列にした)
- 項目を足したのに、旧セーブには値がない
- 逆に、新しいセーブを古い実行ファイルで開いた
最後だけは性質が違います。古い実行ファイルは、新しい形式を知りません。読み込みを拒否してメッセージを出すしかなく、無理に読むと壊れた状態で上書きしてしまいます。
そこで形式は、中身を包む外枠を作って決めます。
セーブ形式で先に決める3点
バージョン番号を必ず持つ
schemaVersionを最上位に置く整数にします。最初期のセーブには番号がないので、無ければ1とみなす規則も決めます。移行は1段ずつ
「v1からv2」「v2からv3」のように隣の版へ変える関数だけを書き、読み込み時に現行版まで順に適用します。v1からv3への直通は作りません。
新しい版は読まない
現行より大きい
schemaVersionは、例外にして上書きを止めます。
移行チェーンの実装を書かせる
下は、v1(items が文字列の配列)からv3(個数付きの inventory と maxHp)までを扱う最小の実装です。Claudeに最初から書かせる場合も、この形に寄せるよう指示すると、後の差分が読みやすくなります。
export const CURRENT_VERSION = 3;
export type SaveV3 = {
schemaVersion: 3;
player: { name: string; hp: number; maxHp: number };
inventory: { id: string; count: number }[];
flags: Record<string, boolean>;
};
type Migration = (data: any) => any;
// キー n は「版 n から n+1 への変換」
const migrations: Record<number, Migration> = {
1: (d) => ({
...d,
schemaVersion: 2,
inventory: Object.entries(
(d.items as string[]).reduce<Record<string, number>>((acc, id) => {
acc[id] = (acc[id] ?? 0) + 1;
return acc;
}, {}),
).map(([id, count]) => ({ id, count })),
items: undefined,
}),
2: (d) => ({
...d,
schemaVersion: 3,
player: { ...d.player, maxHp: d.player.maxHp ?? d.player.hp },
flags: d.flags ?? {},
}),
};
export function loadSave(raw: string): SaveV3 {
let data = JSON.parse(raw);
const version = data.schemaVersion ?? 1;
if (version > CURRENT_VERSION) {
throw new Error(`新しいバージョン(v${version})のセーブは読めません`);
}
for (let v = version; v < CURRENT_VERSION; v++) {
const step = migrations[v];
if (!step) throw new Error(`v${v} からの移行関数がありません`);
data = step(data);
}
return JSON.parse(JSON.stringify(data)) as SaveV3;
}最後の JSON.parse(JSON.stringify(...)) は、items: undefined のように値が空のキーを落とすための一手間です。
手元でこの実装に、schemaVersion のない旧セーブ {"player":{"name":"Aki","hp":12},"items":["potion","potion","key"]} を通すと、次の結果になります。
{"player":{"name":"Aki","hp":12,"maxHp":12},"schemaVersion":3,"inventory":[{"id":"potion","count":2},{"id":"key","count":1}],"flags":{}}重複していた potion は個数2にまとまり、maxHp は旧セーブの現在HPで補われます。この補い方は設計判断です。「最大HPを現在HPと同じにする」で正しいかどうかは、ゲームの仕様を知る人が決めます。Claudeに任せるなら、その判断を指示に書いておきます。
同じセーブが版を上がると
v1(版番号なし)
{
"player": { "name": "Aki", "hp": 12 },
"items": ["potion", "potion", "key"]
}v3(現行)
{
"schemaVersion": 3,
"player": { "name": "Aki", "hp": 12, "maxHp": 12 },
"inventory": [
{ "id": "potion", "count": 2 },
{ "id": "key", "count": 1 }
],
"flags": {}
}旧セーブをfixtureとして凍結する
回帰テストの中心は、過去の版で実際に書き出されたセーブです。ここで次の順序を守ります。
fixtureを先に置く手順
- 1
旧版のセーブを取り出す
過去のリリースを動かして書き出したファイル、または配布済みのセーブをコピーします。Claudeに「旧形式のサンプルを作って」と頼むと、今の型に引きずられた形になりやすいので、実物を使います。
- 2
凍結ディレクトリに置く
tests/fixtures/saves/frozen/v1-basic.jsonのように、版とシナリオを名前にします。以後、人間以外は書き換えません。 - 3
期待値を別に持つ
移行後の期待値を
expected/に置き、テストが両者を突き合わせます。 - 4
新しい版を足すたびに fixture を追加する
現行版のセーブを
frozen/にコピーしてから、形式を変えます。
テストは、凍結ディレクトリのファイルを全部読む形にします。Vitestでの例です。
import { readdirSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { loadSave, CURRENT_VERSION } from "../src/save";
const dir = "tests/fixtures/saves/frozen";
describe("旧セーブの互換", () => {
for (const file of readdirSync(dir)) {
it(`${file} が現行版に移行できる`, () => {
const save = loadSave(readFileSync(`${dir}/${file}`, "utf8"));
const expected = JSON.parse(
readFileSync(`tests/fixtures/saves/expected/${file}`, "utf8"),
);
expect(save.schemaVersion).toBe(CURRENT_VERSION);
expect(save).toEqual(expected);
});
}
it("新しい版のセーブは拒否する", () => {
expect(() => loadSave('{"schemaVersion": 99}')).toThrow();
});
});ディレクトリを走査する作りなので、fixtureを足すだけでテストが増えます。Claudeが「テストを足し忘れた」状態になりにくい構成です。
凍結を権限で守る
fixtureが凍結であることは、指示文に書くだけでは守られません。Claudeがテストを通すために、期待値や旧セーブの側を書き換えてしまうことがあるからです。そこで権限の拒否ルールを使います。
{
"permissions": {
"deny": [
"Edit(/tests/fixtures/saves/frozen/**)",
"Edit(/tests/fixtures/saves/expected/**)"
]
}
}これを .claude/settings.json に置きます。Edit(...) のルールは、ファイルを編集するすべての組み込みツールに適用されます。書き込み用のツール名で Write(...) と書いても、パスの規則としては一致しないので、パスで縛るときは Edit を使います。パスの先頭の / は、プロジェクト設定ではプロジェクトの作業ディレクトリからの位置を指します。
この拒否には副作用があります。Claudeは新しいfixtureも置けなくなります。旧セーブの取り出しは前節のとおり人間の作業とし、期待値ファイルは人間がレビューして置きます。Claudeに任せるのは、移行関数とテストコード、そして現行版の動作確認です。設定ファイル全体の考え方はsettings.json完全ガイドにまとめています。
手順はスキル、事実はCLAUDE.mdに分ける
セーブ形式を変える作業は、毎回同じ手順を踏みます。ここはCLAUDE.mdに長く書かず、スキルに切り出すほうが向いています。CLAUDE.mdには「毎回持っていてほしい事実」だけを置く、という使い分けが公式の説明にあります。複数手順にわたる内容や一部の作業でしか使わない内容は、スキルかパス指定のルールに移す扱いです。
CLAUDE.mdには、たとえば次の3行で足ります。
## セーブデータ
- セーブ形式は src/save.ts。schemaVersion は整数で、移行は隣の版への関数だけ
- 検証コマンド: npx vitest run tests/save
- tests/fixtures/saves/frozen と expected は編集しない(人間が置く)検証コマンドを書く理由も、公式のベストプラクティスにあります。リポジトリにテストなどの確認手段があるなら、CLAUDE.mdの最も有用な1行はそれを実行するコマンドだ、という説明です。
手順の側は、スキルにします。.claude/skills/save-version-bump/SKILL.md の例です。
---
description: セーブ形式の版を1つ上げる。形式変更・項目追加のときに使う
disable-model-invocation: true
---
1. 現行版のセーブを tests/fixtures/saves/frozen/ にコピーする案を提示する(置くのは人間)
2. src/save.ts の CURRENT_VERSION を +1 し、型を更新する
3. migrations に「旧現行版 → 新現行版」の関数を1つだけ足す。既存の関数は書き換えない
4. 補う値の既定値は理由を一言添えてユーザーに確認する
5. npx vitest run tests/save を実行し、出力をそのまま報告するdisable-model-invocation: true を付けると、Claudeが自分の判断でこのスキルを起動しません。/save-version-bump と打ったときだけ走ります。副作用のある作業や、タイミングを自分で決めたい作業向けの設定です。形式を変えるのは「今やる」と人間が決めたときだけなので、この設定が合います。
「通りました」を証拠で受け取る
Claudeが「テストは通りました」と言うだけでは、確認になりません。ベストプラクティスにも、実行したコマンドとその出力を求めるよう書かれています。「すべて通過」とだけ聞いても得られる情報が少なく、出力を読むほうが再実行より速いという理由です。
強制したいなら、Stopフックがあります。Claudeがターンを終えようとするたびにスクリプトを走らせ、終了コード2を返すと、止まらずに作業へ戻らせる仕組みです。指示文の「必ずテストする」が多くの場合は守られても毎回ではないのに対し、フックはハーネス側で強制します。
まず .claude/settings.json に、Stopイベントでスクリプトを呼ぶ設定を足します。
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/save-tests.sh"
}
]
}
]
}
}スクリプトは .claude/hooks/save-tests.sh に置き、chmod +x で実行権限を付けます。
#!/bin/bash
INPUT=$(cat)
# 一度戻らせた後の再発火なら、ここで止まることを許す
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
if ! OUTPUT=$(npx vitest run tests/save 2>&1); then
echo "セーブ互換テストが失敗しています。直してから終えてください:" >&2
echo "$OUTPUT" | tail -n 30 >&2
exit 2
fi
exit 0終了コード2のとき、stderrに書いた内容がClaudeへの指示として返ります。stop_hook_active の確認は、フックが無限に作業へ戻し続けるのを防ぐ定石です。公式の説明では、フックが連続8回止めてもClaudeのツール呼び出しが挟まらなければ、Claude Code側がフックを上書きして止めます。テストが直らないまま8回戻され続ける状況は、この確認で避けられます。
もうひとつ、別の目でレビューさせる手もあります。/code-review は、会話を見ていない新しいサブエージェントで差分を見ます。移行関数の既定値の妥当性は、書いた本人より外から見たほうが気づきやすい論点です。API側の互換性を契約テストで守る話は、Pactのコントラクトテストの記事が同じ発想で書かれています。
落とし穴
落とし穴
過去の移行関数を「整理」してしまう
v1からv2の関数は、v1のセーブが残る限り消せません。リファクタリングの流れでClaudeが関数をまとめると、凍結fixtureのテストが落ちます。それは正しい失敗なので、テストではなくまとめた側を戻します。スキルの手順に「既存の関数は書き換えない」と入れてあるのはこのためです。
ほかに、設計時に決めておくと楽になる点を挙げます。
- 日時や乱数をfixtureに入れない:保存日時のような値は、期待値の側で固定した値に揃えます。テストごとに値が変わると、凍結の意味がなくなります
- 読み込み失敗でも既存のファイルは消さない:
JSON.parseが失敗したときに空のセーブで上書きすると、直せたはずのデータを失います。保存は、一時ファイルに書いてから置き換える形が安全です - 版を上げない変更の線引き:項目を足すだけで既定値が自明なら、版を上げずに読み込み時に補う選択もあります。ただし、判断は人間が持ちます
データベースのスキーマ移行にも同じ「隣の版へ1段ずつ」の考え方があります。往復検証の進め方はPrismaスキーマの設計の記事が参考になります。
まとめ
セーブの互換は、版番号、1段ずつの移行関数、凍結した旧セーブの3つで守れます。Claude Codeに渡す範囲は、移行関数とテストコードの実装までです。fixtureと期待値は拒否ルールで人間の領分に残し、版を上げる手順はスキルに閉じ込めます。補う既定値の妥当性は、ゲームを知る人が決めます。特に凍結fixtureは、後から「この旧セーブはもう不要では」と消したくなる場面が来ます。プレイヤーの手元にその形式が残っている限り消せない、と最初にチームで決めておくと迷いません。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。