TypeScript npm workspaces でゲームロジックを UI から完全分離する

TypeScript npm workspaces でゲームロジックを UI から完全分離する 概要 モダンなフロントエンド開発、特にゲーム開発において、「ロジック」と「表示(UI)」の分離は永遠の課題です。React や Vue などのフレームワークにロジックが密結合してしまうと、テストが困難になり、将来的に別のプラットフォーム(例えば Web から React Native や CLI ツールへ)に展開する際の大きな障害となります。 本記事では、TypeScript npm workspaces を活用して、ゲームロジックを独立したパッケージ (packages/core) として切り出し、React UI (apps/client) から完全に分離する設計手法を解説します。また、外部 I/O や非決定的な処理(乱数など)を抽象化する Port / Adapter パターンについても触れます。 課題:なぜロジックが UI に染み出すのか? 多くのプロジェクトでは、気づかないうちにロジックが React コンポーネントや Hooks の中に漏れ出していきます。 // 密結合な例 const PlayerStats = () => { const [hp, setHp] = useState(100); const handleAttack = () => { // UI の中で計算ロジックが動いている const damage = Math.floor(Math.random() * 10) + 5; setHp(prev => Math.max(0, prev - damage)); }; return <button onClick={handleAttack}>攻撃を受ける</button>; }; このような設計には以下の課題があります: テストの困難さ: Math.random() が直接使われているため、結果が不安定でユニットテストが書きにくい。 再利用性の欠如: この「ダメージ計算ロジック」を、サーバーサイドや別の UI フレームワークで使い回すことができない。 依存の混入: ロジックを動かすために React の実行環境(レンダリングサイクル)が必要になる。 設計:npm workspaces による物理的隔離 ロジックを「物理的に」隔離するために、以下の monorepo 構成を採用します。 ...

March 28, 2026 · 3 min

AI コーディングエージェントを飼い慣らす:最強の「AGENTS.md」の書き方

AI コーディングエージェントを飼い慣らす:最強の「AGENTS.md」の書き方 近年、Gemini、Claude、Aider といった「自律型 AI コーディングエージェント」が開発現場に浸透しつつあります。彼らは指示一つでファイル構成を理解し、コードを書き、テストを実行して修正まで行います。 しかし、エージェントを使い始めた多くのエンジニアが直面するのが、**「AI エージェントの暴走」**です。 型定義を面倒くさがって any を連発する プロジェクト独自のディレクトリ構造を無視して勝手に utils/ を作る vitest --watch などの終了しないコマンドを叩いてフリーズする 指示していないリファクタリングを始めて関係ないファイルを壊す これらの問題を防ぎ、エージェントを「熟練のペアプロ相手」に変える魔法のファイル、それが AGENTS.md です。本記事では、AI エージェントに守らせるべきルールを定義する AGENTS.md の書き方と、その設計思想を徹底解説します。 なぜ AGENTS.md が必要なのか AI エージェントは、人間が数年かけて培った「プロジェクトの阿吽の呼吸」を知りません。 エージェントに与えられるコンテキスト(ファイル内容や履歴)は有限であり、その中で彼らは「最も確率的に正しそうな推論」を行います。その結果、彼らはしばしば**「最も楽な道(=技術的負債を生む道)」**を選んでしまいます。 AGENTS.md をプロジェクトのルートに配置する理由は、**「エージェントの推論空間に強制的な制約(ガードレール)を設けるため」**です。 なぜその設計にしたか:外部メモリとしての役割 エージェントは毎回ゼロベースで思考するわけではありませんが、多くのファイルを見すぎることで逆に重要なルールを見失う(ロスト・イン・ザ・ミドル現象)ことがあります。AGENTS.md という明確なルールブックを定義し、エージェントの起動時やタスク開始時に必ず読み込ませることで、思考のブレを最小限に抑えることができます。 AGENTS.md に書くべき内容の 4 つの分類 効果的な AGENTS.md は、以下の 4 つのセクションで構成するのがベストプラティスです。 禁止事項 (Prohibitions): 致命的なエラーや環境のハングを防ぐ 命名規則・コーディング基準 (Standards): コードの品質を一定に保つ アーキテクチャ原則 (Architecture): システムの整合性を維持する テスト・検証方針 (Testing): 修正の正しさを担保する それぞれのセクションについて、具体的な記述例を見ていきましょう。 1. 禁止事項 (Prohibitions) エージェントが最もやりがちな「環境破壊」を防ぐための最重要セクションです。 ルール 理由(なぜその設計にするか) インタラクティブコマンドの禁止 npm init や git commit (メッセージ入力待ち) など、ユーザー入力を待つコマンドは CLI エージェントをフリーズさせます。 Watch Mode の禁止 vitest --watch 等はプロセスが終了しないため、エージェントが「完了」を検知できなくなります。 any 型の原則禁止 AI は型の整合性を取るのが面倒になると any で逃げようとします。これは長期的な保守性を著しく低下させます。 勝手な依存関係の追加禁止 package.json を書き換えて新しいライブラリを入れる行為は、セキュリティやビルドサイズに影響するため、人間の許可を必須にします。 実例コード ## 禁止事項 - **コマンド実行**: - `vi`, `nano`, `top` などのインタラクティブなコマンドは実行しない。 - `npm start`, `vitest --watch` などの終了しないプロセスは背景実行 (`&`) するか、単発実行モードを使用すること。 - **TypeScript**: - `any` 型の使用は厳禁。どうしても必要な場合はコメントで理由を明記すること。 - **Git**: - ユーザーの明示的な指示なしに `git commit` や `git push` を行わない。 2. 命名規則・コーディング基準 (Standards) プロジェクトの「見た目」と「一貫性」を守るためのルールです。ここを疎かにすると、エージェントは自分の得意なスタイル(多くの場合、学習データで最も多いスタイル)で書き始めてしまいます。 ...

March 28, 2026 · 2 min

EmacsでGoのLsp補完が死んだときの調査記録

症状 Emacsで .go ファイルを開くと以下のログが無限ループする。 [eglot] Asking EGLOT (mydns/(go-ts-mode go-mod-ts-mode)) politely to terminate [jsonrpc] Server exited with status 2 [eglot] Reconnected! [eglot] Connected! Server 'gopls' now managing '(go-ts-mode go-mod-ts-mode)' buffers in project 'mydns'. [jsonrpc] (warning) Sentinel for EGLOT (...) still hasn't run, deleting it! [jsonrpc] Server exited with status 9 [eglot] Reconnected! [2 times] Error running timer: (error "Selecting deleted buffer") status 9 は SIGKILL。gopls が起動→即死→reconnect を繰り返し、補完が一切効かない状態。 fmt. と打っても候補が出ない、もしくは関係のないゴミ候補が出る。手動で M-x completion-at-point を叩いても No match。 ...

March 22, 2026 · 2 min

テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その6

はじめに Part5でUIまで実装が終わった。機能として動くものはできた。テストも15件、全パス。 ここで一度立ち止まって、なぜこの設計になったのかを振り返る。 正直に言う。最初の要件はこうだった。 「クリーンアーキテクチャっぽく、テスタブルにしたい」 それだけだった。クリーンアーキテクチャを理解して設計したわけじゃない。 結果的にクリーンアーキテクチャを遂行したのは生成AIだ。 そしてPart3以降、自分はほとんど手を動かさなくなった。 設計ドキュメントをAIに渡したら、実装サイクルがほぼ自動で回るようになったからだ。 途中でこう思った。 「俺いる必要なくね?」 この記事はその問いに向き合いながら、設計を改めて言語化したものだ。 このシリーズ自体がTDDだった 書き終えてから気づいたことがある。 このシリーズのサイクルはこうだった。 AIに実装させる ↓ 動かす・読む・会話する(認識のズレを検出) ↓ ズレを言語化してAIにフィードバック ↓ 納得したら記事にする ソフトウェアのTDDは「Red → Green → Refactor」だけど、 自分がやっていたのはこれだ。 Red → 認識がズレていると感じる Green → 会話して納得する Refactor → 記事として言語化する 人間がテストケースになっていた。 TDDが「仕様をテストで表現する」なら、自分がやっていたのは「理解をフィードバックループで検証する」だ。構造は同じだと思う。 誤っていた認識たち 振り返ると、理解がズレていた箇所がいくつかあった。 「PolicyはVOと1-1になる」と思っていた 最初、ReadingStatusPolicyがReadingStatusに対応しているのを見て、PolicyはVOと対になるものだと思っていた。 違う。今回たまたま1-1になっているだけだ。 Policyの本質は複数のEntityやVOをまたいだ条件判定だ。たとえば「同じ本に同じユーザーが2回レビューできない」というルールは、Book・User・Review[]をまたぐ。これをPolicyに出す。 VOに閉じるルールならVOに書けばいい。Entityをまたぐ判定が必要になったときにPolicyの出番だ。 「Domainは外部を知らない」という捉え方が逆だった 「DomainはDBを知らない」という言い方をよくするが、正確には逆だ。 外部がDomainを知っている。 向きの問題だ。Domainが何かを避けているのではなく、依存の矢印がすべてDomainに向かって刺さっている。 Route Handler ↓ Service ↓ Repository interface ↓ Domain(Entity / ValueObject / Policy) この向きがわかって初めて、Serviceの役割も見えた。 Serviceが何をするのかわかっていなかった 依存の向きが腑に落ちるまで、Serviceが何者なのかずっと曖昧だった。 答えはシンプルだった。フローだけ持つ接着剤。 async startReading(id: string): Promise<Book> { const book = await this.repo.findById(id); // 取得 if (!book) throw new NotFoundError(...); book.changeStatus(ReadingStatus.Reading); // Entityのルールに従う await this.repo.save(book); // 保存 return book; } ServiceはDomainのルールを自分で判定しない。 canTransitionを自分で呼ばない。book.changeStatus()に委ねるだけだ。 判定はEntity・VO・Policyが持っている。Serviceはその結果を使ってフローを組む。 ...

March 5, 2026 · 2 min

テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その5

はじめに Part4 でカスタム例外クラスと残りのエンドポイントを実装し、APIが完成した。 Part5では src/app/page.tsx に簡易UIを実装する。バックエンドのロジックやテストには一切触れない。 フロントエンドからAPIを叩いて動くものを作るだけだ。 実装方針 page.tsx をClient Componentにする。 Server Componentでfetchする方法もあるが、ボタン操作のたびにstateを更新して再描画する必要がある。 今回のUIは「ボタンを押す → APIを叩く → 一覧を再取得して画面を更新する」という流れが中心なので、 useState + useEffect で管理するClient Componentのほうがシンプルだ。 "use client"; ファイル先頭にこの1行を追加する。 実装するUI 本の追加フォーム(id / title / isbn) 本棚(Unread / Reading / Completed のグループ表示) 各本へのアクションボタン Unread → 「読み始める」ボタン Reading → 評価入力(1〜5)+「読了にする」ボタン 全ステータス → 削除ボタン 型定義 APIレスポンスの型を定義する。 type BookStatus = "Unread" | "Reading" | "Completed"; type Book = { id: string; title: string; isbn: string; status: BookStatus; rating: number | null; }; バックエンドの ReadingStatus は as const で定義した文字列リテラルなので、そのまま使える。 データ取得 async function fetchBooks() { try { const res = await fetch("/api/books"); if (!res.ok) throw new Error("取得失敗"); const data: Book[] = await res.json(); setBooks(data); } catch (e) { setError(e instanceof Error ? e.message : "不明なエラー"); } finally { setLoading(false); } } useEffect(() => { fetchBooks(); }, []); fetchBooks は追加・ステータス変更・削除の後にも呼ぶ。 サーバーの状態を正として再取得するシンプルな方針だ。 楽観的更新(Optimistic Update)は今回やらない。 ...

March 5, 2026 · 2 min

テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その4

はじめに Part1 でValueObject・Policy・Entityを作り、Part2 でServiceをDI+Mockテストで固めた。 Part3 でPrismaを繋ぎ、GET /api/books と POST /api/books を動かした。 Part4では残りのエンドポイントを実装する。やることは3つ。 カスタム例外クラスの導入 PATCH /api/books/:id/start と PATCH /api/books/:id/complete の実装 DELETE /api/books/:id の実装 そして「エラー種別ごとにHTTPステータスを整理する」という設計判断を掘り下げる。 また、Part3でPrisma v7特有のセットアップをしたが、v6以前と何が変わったのかをここで整理しておく。 0. Prisma v7で何が変わったか Part3でPrismaをセットアップしたとき、v6以前と比べていくつか「見慣れない書き方」が必要だった。 v7は破壊的変更が多く、ネット上のv6時代の記事を参考にすると詰まる箇所がある。 ここで整理しておく。 参考: Upgrade to Prisma ORM 7 | Prisma Documentation generator の変更 // ❌ v6以前 generator client { provider = "prisma-client-js" } // ✅ v7 generator client { provider = "prisma-client" output = "../src/generated/prisma" } v7では prisma-client-js が廃止され prisma-client に変わった。 Rustベースのエンジンを廃止しTypeScriptネイティブになったことに伴う変更だ。 また output が必須になり、node_modules への自動生成はなくなった。 ...

March 5, 2026 · 6 min

テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その3

はじめに Part1 でValueObject・Policy・Entityを作り、Part2 でServiceをDI+Mockテストで固めた。 累計13テスト、全パスの状態だ。 Part3ではいよいよDBを繋ぐ。やることは3つ。 Prismaセットアップ+スキーマ定義 PrismaBookRepository 実装(ドメインオブジェクトへの変換) Route HandlerでDIを組み立てる そして最後に「テストを書かない層を意図的に決める」という話をする。 1. Prismaセットアップ npm install prisma @prisma/client npx prisma init --datasource-provider sqlite prisma/schema.prisma に Bookモデルを定義する。 generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } model Book { id String @id title String isbn String status String rating Int? } User と Review はまだDBに持たない。Book の CRUD が動けば Part3 のゴール。 なぜ status を String で持つのか Prismaは SQLiteで enum をネイティブサポートしていない。 そのため status String で持ち、取り出し時に as ReadingStatus でキャストする。 ...

March 4, 2026 · 4 min

テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その2

前回のおさらい その1では ValueObject・Policy・Entity を作った。ポイントは「フレームワークを一切使わないので、テストがただの関数呼び出しになる」という体験だった。 今回はいよいよ Service 層に入る。ここが設計の核心だ。 複数のクラスをまたぐフローを、DI(依存性の注入)を使って DB から切り離す。 今回追加したもの src/ domain/ entity/ User.ts # 追加 Review.ts # 追加 valueobject/ UserName.ts # 追加 ReviewComment.ts # 追加 repository/ BookRepository.ts # 追加(Interface) service/ BookShelfService.ts # 追加 User と Review を追加する まず Entity の準備。今回は単純なので ValueObject から作る。 UserName export class UserName { constructor(public readonly value: string) { if (!value || value.trim().length === 0) { throw new Error("UserNameは空にできない"); } if (value.length > 50) { throw new Error("UserNameは50文字以内"); } } } trim() してから空チェックしているのがポイント。スペースだけのユーザー名を弾く。 ReviewComment export class ReviewComment { constructor(public readonly value: string) { if (!value || value.trim().length === 0) { throw new Error("ReviewCommentは空にできない"); } if (value.length > 1000) { throw new Error("ReviewCommentは1000文字以内"); } } } User Entity import { UserName } from "../valueobject/UserName"; export class User { constructor( public readonly id: string, public readonly name: UserName, ) {} } User 自体はシンプルだ。ロジックがない。ビジネスルールが増えれば changeXxx() メソッドが生える設計だが、今は id と name を持つだけでいい。 ...

March 4, 2026 · 4 min

テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その1

なぜこの記事を書いたか 動くものをまず作って、そこにテストを後付けしようとした。しかし、できなかった。 具体的にはこういう壁にぶつかった。 ModelなどのDB接続の切り替えがうまくいかない Controllerをテストしようにもいろいろおかしくなる 悩んだ結果、気づいたことがある。そもそもそんなものは単体テストじゃない。 ロジックをControllerやModelに全部書いていたのが悪かった。 「じゃあ最初からそう書けばいいじゃないか」という話だが、それが難しい。雑魚プログラマにいきなりクリーンな設計はできない。 なので発想を逆にした。テスト前提でコードを書く。 テストが書けない場所にはロジックを書かない。それを体で覚えるために、今回のハンズオンを始めた。 方針 生成AIに実装ヒントと次のステップを教えてもらいながら、コードは自分で書く。 正直、コード自体はAIに書かせてもいいと思っている。ただ、設計の考え方は頭に入れる必要があるので、写経しながら理解を深めている。 テストする場所の原則はシンプルだ。 フレームワークで用意された便利クラスはテストしない DBやAPIなど外界と接する場所はテストしない 自分で書いた純粋なTS部分だけをテストする 作るもの 読書レビューサイト。Todoアプリはビジネスロジックがほぼ存在しないのでテストの練習に向いていない。読書レビューサイトなら本の状態遷移(積読→読中→読了)などのルールが自然に生まれるので、テストの旨味がある。 技術スタック Next.js SQLite + Prisma vitest ディレクトリ構成 src/ domain/ entity/ valueobject/ policy/ service/ repository/ この構成の考え方は以下の通り。 層 役割 entity 概念そのもの(Book, User) valueobject 値の制約を持つクラス(ISBN, Rating) policy ビジネスルールを切り出したクラス service 複数クラスをまたぐフロー repository DBとのアダプタ(副作用をここに閉じ込める) repositoryはCI4でいうModelに近い立ち位置だ。 ただし決定的な違いがある。CI4のModelはActiveRecordパターンでクラス自身がDBを知っているため切り離せないが、repositoryはInterfaceと実装を分けることで差し替え可能にする。これがDI(依存性の注入)の肝で、テスト時にMockに差し替えられる。 vitestのセットアップ npm install -D vitest @vitejs/plugin-react vite-tsconfig-paths npm install -D @testing-library/react @testing-library/jest-dom vitest.config.ts import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' import tsconfigPaths from 'vite-tsconfig-paths' export default defineConfig({ plugins: [react(), tsconfigPaths()], test: { environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], }, }) package.jsonのscriptsに追加。 ...

March 4, 2026 · 2 min

AVL木と赤黒木をPythonで実装して比較する

はじめに 動かしながらゼロから学ぶLinuxカーネルの教科書 第2版 上記の技術書を読んでいてLinuxスケジューラの話が出てきた。CFSもEEVDFも赤黒木を使っている。赤黒木とは何かを調べていくうちにAVL木との比較が面白かったので、両方Pythonで実装してベンチマークを取った。 実装コードはClaude (Anthropic) により生成。リポジトリは以下。 https://github.com/wasuken/avl-rb-tree 自己平衡二分探索木とは まず前提として、ただの二分探索木には問題がある。 挿入順: 1, 2, 3, 4, 5 1 \ 2 \ 3 \ 4 ← 一直線になる 挿入順によっては木が一直線になり、検索がO(n)に劣化する。これを解決するのが自己平衡二分探索木で、挿入・削除のたびに自動でバランスを取り直す。AVL木と赤黒木はどちらもこのカテゴリに属する。 AVL木 1962年にソ連の数学者Adelson-VelskyとLandis(AVLの名前の由来)が考案した世界初の自己平衡二分探索木。 バランスの管理方法 各ノードに高さ(height)を持たせ、左右の高さの差(バランス係数)が常に1以内になるよう管理する。 def _balance_factor(self, node): return self._height(node.left) - self._height(node.right) 差が2以上になったら回転で修正する。 回転 回転は「親子関係を1段入れ替えるだけ」の操作。二分探索木の順序を壊さずに形だけ変える。 rotate_right(y): y x / \ / \ x C → A y / \ / \ A t2 t2 C t2 は回転で行き場を失う孫ノード。二分探索木の順序的に x < t2 < y が保証されているので、yの左に付け替えるだけでよい。 バランス崩れのパターンは4つ(LL, RR, LR, RL)で、それぞれ1〜2回の回転で解消できる。 挿入・削除 挿入・削除のたびに _rebalance が呼ばれ、高さの更新とバランスチェックが走る。 ...

March 1, 2026 · 2 min