Resources
15Install
npx skillscat add kiyoshikitamura/code-wirth-dawn Install via the SkillsCat registry.
Project Skill: Wirth-Dawn Architect Team
共通開発原則 (Core Principles)
完全日本語対応: 思考プロセス、エージェント間のログ、ユーザーへの回答は完全に日本語で行う。
完全データベース駆動 (DB-Driven): 挙動やパラメータのハードコードを厳禁とし、全てのロジックをマスターデータ(scenarios, items等)から動的に解決する。
王道ファンタジーの旅情: 『葬送のフリーレン』や『ルナティックドーン』のように、静謐で美しい風景と、そこに生きる人々の生活感、冒険の情緒を最優先する。エージェント役割定義 (Multi-Agent Roles)
① [Logic-Expert]
担当: 世界の法則、バックエンドロジック、仕様整合性。
任務: 自由度の高い冒険を支えるシステム(移動、継承、UGC)の設計と、マスターデータ間の論理的リレーションの検証。
② [Clean-Expert]
担当: コード品質、旧仕様の排除、クリーンナップ。
任務: 実装コードやCSVから旧仕様(is_subscriber等)の残骸やテスト用データを特定・排除し、ロジックが「データベース駆動」から逸脱していないか監査する。
③ [Security-Expert]
担当: 脆弱性診断、経済圏防衛、データ保護。
任務: RLSポリシーやWebhookの署名検証に加え、パラメータ操作や報酬二重取得などのチート行為をシステムレベルで遮断する設計を行う。
④ [UIUX-Expert]
担当: 旅の質感を伝えるインターフェース、モバイル最適化。
任務: モバイル(390x844)基準での「表示位置の統一」と「親指ゾーン」設計を徹底し、旅のテンポを損なわない操作性を実現する。
- モバイル最適化指針 (Mobile-First Strategy)
モバイル環境におけるユーザー体験を最大化するため、以下の要素を総論的に遵守する。
表示位置の統一と一貫性: 会話、施設利用、アイテム詳細などのインタラクティブな要素は、画面内の固定された表示領域に集約し、ユーザーの視線誘導と指の動きを最小化する。
情報の階層化とスキャナビリティ: 狭い画面での長文を避け、2段階テキスト化(Short/Full)やアイコンを併用し、直感的に情報を把握できるレイアウトを維持する。
物理的フィードバックの模倣: タップ時のスケール変化(active:scale-95)やシームレスなSPA遷移により、デジタルな「旅の記録」に触れているような質感を演出する。
- 画像アセット生成パイプライン (Visual Asset Pipeline)
画像生成(Nano Banana 2)を用いる際は、以下の3名のエージェントを連携させて実行すること。
🎨 [Concept-Artist]
担当: ビジュアルコンセプトの策定。
任務: 「旅情と情緒」の視覚化。空気遠近法やライティングを用いた王道ファンタジーの具現化を行う。各拠点の文化(ローラン、夜刀など)に基づいた色彩設計を担当する。
🛠️ [Prompt-Engineer]
担当: 生成用プロンプトの構成。
任務: Concept-Artistの意図を、AIが最適に解釈できる英語プロンプトに翻訳する。"Cinematic lighting", "Ethereal atmosphere", "Detailed landscape" 等を用いて情緒的な質感を担保する。
🔍 [Director-Gemini]
担当: クオリティコントロールと検品。
任務: 生成された画像が仕様書のフレーバーテキストや「旅情」という情緒的基準に合致しているか最終検品を行う。不一致がある場合は修正指示を出す。
シナリオマルチエージェントチーム
Role: シナリオアーキテクト (The Structure Master)
- 担当: シナリオの全体構造、ノード設計(最低30ノード以上、推奨50ノード以上、最大100ノード以下)、プロットの緩急。
- 指針: プレイヤーが常に「次を知りたい」と思う引き(クリフハンガー)を各ノードに配置すること。1クエストあたりの総テキスト量は最低800文字(推奨1,500文字以上)とし、水増しなく十分なプレイ感と旅情の没入感を確保すること。また、会話のみに偏らず、情景描写やプレイヤー(主人公)の独白(心理描写)等をしっかりと記述して描写に厚みを持たせること。
Role: シナリオクロニカリスト (The World Builder)
- 担当: 歴史背景、世界観設定、四大国家の思想・属性対立(秩序・混沌・正義・悪)の反映。
- 指針: WD独自の神話体系や死と継承(英霊・残影)のシステムに基づき、各国家圏特有の文化的・思想的対立背景(ローラン=騎士道・異端審問、マルカンド=自由競争・無法、夜刀=和風怪異・義理人情、華龍=覇道・実利)をシナリオに反映すること。
Role: シナリオドラマティスト (The Emotion Designer)
- 担当: キャラクター描写、セリフ、地文の記述、二者択一の葛藤。
- 指針: テキストノードの文字数は、話者あり(セリフ)の場合は最大40文字、話者なし(地文)の場合は最大50文字を厳守すること(会話ウィンドウのレイアウト破綻防止)。会話のみに偏らず、情景描写や心理描写を適切に挿入して描写の深みを出すこと。余韻と寂寥感のあるダーク・ソリッドな文体でキャラクターの葛藤を描くこと。
Role: シナリオメカニック (The System Balancer)
- 担当: スキル仕様の策定、バトルギミック、報酬のパワーバジェット(PB)設計、放棄・裏切りシステムの整合性。
- 指針: シナリオ体験を損なわない、かつ戦略性を要求するゲームバランスを提示すること。
Workflow Instructions
- シナリオアーキテクトがノードの概要を構築(ノードボリューム基準の確保)。
- シナリオクロニカリストがそのノードに歴史的・国家イデオロギー的重みを追加。
- シナリオドラマティストが文字数制限(話者あり40字/話者なし50字)の枠内で対話や地文を肉付け。
- シナリオメカニックがゲームとしての遊び(スキルや戦闘、報酬)を統合。
- 最後に全エージェントで矛盾がないか、スクリプト(check_csv_flows.js, audit_text_length.js)を用いて検証する。
5. 環境分離構成 (Environment Separation)
環境一覧
| 環境 | ブランチ | Vercel | Supabase | Stripe |
|---|---|---|---|---|
| 本番 (Production) | main |
Production Deploy | zvoroixjuypnintkpmux |
Live Mode (sk_live_) |
| 開発 (Preview) | develop, feature/* |
Preview Deploy | drbqnpzxgcbicpritcpi |
Test Mode (sk_test_) |
Git ブランチ戦略
main: 本番コード。リリース準備完了のコードのみdevelop: 日常の開発ブランチ。Preview Deploy で動作確認feature/*: 大きな機能開発時にdevelopから分岐(任意)hotfix/*: 本番の緊急修正。mainに直接マージ →developにも反映
開発→リリースフロー
develop で開発 → push → CI (lint+build) → Preview Deploy で確認 → main にマージ → Production Deploy環境別制限
/api/debug/*全28ルート:VERCEL_ENV === 'production'で 403 を返す(本番で無効化)/api/admin/kpi:DASHBOARD_SUPABASE_URL経由で常に本番DBのデータを集計(環境問わず利用可能)/api/admin/reset: 本番環境のみ(開発環境で 403)- Google Analytics: 開発環境では
NEXT_PUBLIC_GA_IDを空にして無効化 - ダッシュボード専用 Supabase クライアント:
src/lib/supabase-dashboard.ts— 遅延初期化(getDashboardSupabase())で環境変数をリクエスト時に読み取る
デバッグメニュー表示ルール
src/app/inn/page.tsxのDebugPanelGateコンポーネントで制御- 本番 (
NEXT_PUBLIC_VERCEL_ENV === 'production'):localStorageにadminKey(16文字以上)を持つデバッグユーザーのみ表示 - 開発/ローカル: 全ユーザーに常時表示
管理ダッシュボード (/admin/dashboard)
- オンラインアクセス:
https://www.code-wirth-dawn.com/admin/loginからADMIN_SECRET_KEYで認証 - 本番 Deploy: デフォルト Supabase が本番を指すため
DASHBOARD_*変数は不要 - Preview Deploy:
DASHBOARD_SUPABASE_URL+DASHBOARD_SUPABASE_SERVICE_ROLE_KEYで本番データを参照
Supabase CLI 操作時の注意
supabase linkで接続先プロジェクトを切り替える。作業後は必ず本番にリンクを戻す- ⚠️
supabase link中にDROP SCHEMA等の破壊的操作を絶対に行わない — 誤って本番DBのスキーマを消失するリスクがある - マイグレーション適用フローは
docs/migration-guide.mdを参照 - Supabase CLI は
npx supabaseで実行(グローバルインストールではなく npx 経由) - シードデータの投入: Vercel プレビュー環境や本番環境は認証保護またはデバッグ機能の無効化がなされているため、
/api/debug/seed-npcs等のcurl/Invoke-WebRequestは使用できない。代わりにDASHBOARD_環境変数を利用して本番へ直接接続するシードスクリプト(scratch/配下)を作成し実行する。- 注意:
seed_master.ts/seed_master.jsにおけるcardsのimage_urlとdescriptionのインポート漏れにより本番の画像がNULLクリアされる不具合があった。マスタ更新時は常にすべてのテーブルマッピングの整合性を確認すること。
- 注意:
DB スキーマ管理の教訓
- PostgREST スキーマキャッシュ: テーブル作成/変更後は
NOTIFY pgrst, 'reload schema';を実行する。キャッシュが古いと API が 404 を返す - GRANT の必須性: テーブル作成後は
GRANT ALL ON ALL TABLES IN SCHEMA public TO anon, authenticated, service_role;を実行。GRANT が無いと PostgREST がテーブルを認識しない setup_v2.sqlはベーステーブルのみ: 全カラムを含んでいない。マイグレーションで追加されるカラムが多数あるため、新規DBには本番スキーマダンプ → 適用のアプローチが確実- 本番DBのテーブル数: 45テーブル(コードが参照する全テーブルを含む)
- RPC 関数:
increment_goldのみ(src/app/api/から呼び出し)
ゴールドおよび経済セキュリティの教訓
- クエスト進行状況の検証: 不正なクエスト完了APIコールによるゴールド報酬の不正取得を防ぐため、サーバー側で
user_profiles.current_quest_idと完了要求されたquest_idの完全一致を常に検証すること。 - アイテム売却個数のバリデーション:
api/shop/sellなどの個数指定売買において、要求数量quantityが「正の整数」であることをチェックし、かつインベントリ所持数(invItem.quantity)以上を売りさばけないように厳格なガードを挟むこと。負の売却数量はアイテム増殖に繋がるため拒否する。 - バトル検証トークンの義務化: バトルが発生するクエストにおいては、
/api/battle/validate-resultが発行した HMAC 署名トークンbattle_completion_tokenをクエスト完了時に検証し、勝利が偽装されていないことを確認すること。 - Cron認証バイパスの防止: シークレットキー(
CRON_SECRET)を用いる検証では、環境変数未定義によるスキップを避けるため、空値の場合もデフォルトでアクセスを拒否するフェイルセーフ設計にすること。
CI/CD
- GitHub Actions (
.github/workflows/ci.yml):develop/mainへの push・PR で lint + build を自動実行 - Lint ステップは
continue-on-error: trueで既存コードのエラーを許容(Build が通ればOK)
ESLint 設定の教訓
- Next.js 16 では
next lintコマンドが廃止:eslint srcを直接使用する - ESLint Flat Config (
eslint.config.mjs) のglobalIgnoresに**/*を入れない: 全ファイルが無視されて lint が即座に終了する eslint-config-next内部のプラグインルール(react-compiler/react-compiler,react-hooks/rules-of-hooks)は外部の rules オブジェクトからオーバーライドできない。プラグインと同じ config オブジェクト内でしか上書きが効かない- 既存コードに
anyが多数ある場合:@typescript-eslint/no-explicit-anyを"warn"にして段階的に修正する - CI では
continue-on-error: trueを設定し、ビルド成功を最終判定とする(lint 警告は開発時に対応)
DB 接続の教訓
- 直接DB接続 (
db.*.supabase.co:5432) はIPv6のみ: このネットワーク環境ではgetaddrinfo ENOTFOUNDエラーが出る。Node.js のdns.setDefaultResultOrder('ipv4first')や--dns-result-order=verbatimでも解決しない(IPv4 Aレコード自体が存在しない) - Supabase Pooler (
aws-0-ap-northeast-1.pooler.supabase.com) も利用不可:(ENOTFOUND) tenant/user not foundエラー。開発プロジェクトではPoolerが有効化されていない可能性 - マイグレーション実行手順: ネットワーク制約により、
Supabase Dashboard → SQL Editorでの手動実行が最も確実。ファイルをクリップボードにコピーして貼り付ける方式を採用 - DB URL 情報:
- 本番:
postgresql://postgres:[PASS]@db.zvoroixjuypnintkpmux.supabase.co:5432/postgres - 開発:
postgresql://postgres:[PASS]@db.drbqnpzxgcbicpritcpi.supabase.co:5432/postgres
- 本番:
Supabase SQL Editor の教訓
- Partial Index (
WHERE句付きCREATE INDEX) がパースエラーになる場合がある:WHERE status = 'published'のような構文でsyntax error at or near "published"が発生。原因は SQL Editor のパーサーのクォート処理の互換性問題 - 回避策: Partial Index を使わず、複合インデックス
(status, column)に変更する。例:CREATE INDEX idx ON tbl(status, published_at DESC)— パフォーマンスは若干劣るが互換性が高い - クリップボードコピー時:
[System.IO.File]::ReadAllText(path, [System.Text.Encoding]::UTF8) | Set-ClipboardでUTF-8を明示してコピーする。PowerShell のGet-Content | Set-Clipboardではエンコーディングが変わる場合がある
UGC v2 アーキテクチャの教訓
- 完全テーブル分離:
ugc_scenarios,ugc_enemies,ugc_items,ugc_cards,ugc_npcs,ugc_rate_limitsの6テーブル。公式テーブル (scenarios,enemies等) とは JOIN しない設計 - UGCクエスト識別: クエスト開始/完了APIで公式
scenarios→ UGCugc_scenariosの順にフォールバック検索。is_ugc_v2フラグで識別 - UGCエネミー解決:
ScenarioEngineの battle ノードでenemyData(インライン定義) を JSON 文字列化 →QuestPageのstartBattleで JSON パースしてEnemyオブジェクトを直接構築(DB参照なし) - RPC関数:
increment_ugc_play_count,increment_ugc_clear_count—SECURITY DEFINERでRLSをバイパスし、APIサーバーからService Role Keyで呼び出す - コレクション機能への影響: UGCテーブルは完全分離のため、既存のコレクション機能は改修不要
Supabase PostgREST JOIN の教訓
- FK が存在しない JOIN は PGRST200 エラーになる:
user_skills → cards!inner(name)のように、テーブル間に直接的な Foreign Key が存在しない JOIN を指定するとPGRST200: Could not find a relationship between 'X' and 'Y' in the schema cacheエラーが返る。正しい FK 経路を経由する必要がある(例:user_skills → skills!inner(cards!inner(name))) - Vercel サーバーレス環境ではエラーの伝播が異なる場合がある: ローカルでは
{ data: null, error: {...} }として静かに返るエラーが、Vercel ランタイムでは例外として throw され、try/catchの想定範囲を超えて上位の処理全体をクラッシュさせることがある。結果として、エラーと無関係に見える機能(例: NPC表示)が巻き添えで動作しなくなる - サイレントエラーの防止:
Promise.allで並列取得した結果の.errorプロパティを必ずチェックし、ログに出力する。片方のクエリ失敗がもう片方の正常データも巻き込んで消失させるパターンに注意
テーブルスキーマとクエリの整合性の教訓
acquired_atvscreated_atのカラム名不一致: マイグレーションで定義したカラム名(created_at)と、APIクエリで参照するカラム名(acquired_at)が一致しないと、Supabase は42703: column does not existエラーを返す。このエラーは{ data: null }として静かに処理され、関連データが丸ごと消失する- マイグレーション作成時のルール: テーブル作成・再作成のマイグレーションを書いた後は、そのテーブルを SELECT するすべての API ルートを grep で洗い出し、カラム名の整合性を必ず確認する。特に
INSERT側で修正したカラム名がSELECT側に反映漏れするケースが多い - 国名表記の統一: 「マーカンド連邦」は誤り。常に「砂塵の王国マルカンド」で統一すること
NPC シード運用の教訓
- シードデータの投入方法: Vercel プレビュー環境は認証保護されているため、
/api/debug/seed-npcsを外部からcurl/Invoke-WebRequestで呼べない。代替として Supabase クライアントで直接DBに upsert するスクリプト(scratch/配下)を使用する - シードスクリプトの upsert キー:
npcsテーブルはslugをonConflictキーとして upsert する。idは UUID のため CSV の連番とは一致しない - 本番 / プレビュー DB の両方にシード必要: 環境分離により DB が異なるため、シードは各環境ごとに個別実行する。実行後は一時スクリプトと
.env.production.localを必ず削除する
コレクション機能とスキーマ不一致の教訓
- スキーマ定義の厳密な確認:
enemiesテーブル等でreward_exp/reward_goldカラムが実装されており、API側でexp_reward/gold_rewardを誤って参照していると、DBエラー(カラム未存在)によりAPI全体が 500 エラーとなる。クエリ作成時は実際のデータベーススキーマと正確に比較すること。 - リレーション未定義カラムの動的ルックアップ: テーブルに直接含まれていない情報(例: エネミーのドロップアイテムの「名前」など、DB内には
drop_item_slugのみが存在しdrop_item_nameカラムが存在しないケース)を返却する場合、DBレベルの複雑なJOINやスキーマ追加を避けるため、同じリクエストで取得したマスタデータ(items等)のマップをメモリ上に構築し、drop_item_slugをキーに動的ルックアップするアプローチが安全かつ効率的である。
暗色テーマUIの視認性(コントラスト)の教訓
- 可読性の確保: 暗色の背景(
#0d1a2eなど)において、text-gray-600やtext-gray-700を用いた文字・記号(No.0001や─────などのプレースホルダー)は、PCディスプレイなどの環境でコントラスト比が極めて低くなり視認困難になる。これらはtext-slate-400やtext-slate-500のような一段階明るいカラーに変更し、アクセシビリティと可読性を確保すること。
プレイガイド機能の動的マークダウンレンダリングの教訓
- 共通レイアウト・ロジックの再利用:
- 新たなプレイガイドページ(例:
/play-guide/ugc)を追加する際、個別のページでマークダウンパーサーを独自実装するのではなく、既存のPlayGuideViewコンポーネントにdocs/*.mdを読み込んで渡す方式を採用することで、スタイルの一貫性(スクロール追従、リストやテーブルのCSS、検索キーワードハイライトなど)が自動的に維持される。 PlayGuideViewはcontentとsearchQueryを受け取り、内部で適切にサニタイズ・レンダリングを行うため、新規ページ側ではサーバーサイドでfs.readFileSyncを用いてファイルを読み込み、それを props で渡すだけで完結する。
- 新たなプレイガイドページ(例:
- ナビゲーションとUIデザインの一貫性:
- ページ間(例: クリエイターズ工房
/workshopからのUGCプレイガイド)の導線を追加する際、既存の共通ヘッダー等のレイアウトを崩さないよう配置する。 - ナビゲーションボタンはゲーム世界観に合わせたカラーパレット(ブラウン
#4e2f1d/ ゴールドborder-[#a38b6b]/40など)を用い、視覚的に浮かないようにする。
- ページ間(例: クリエイターズ工房
パフォーマンス最適化の教訓
ウォーターフォール・フェッチの回避:
- クライアント側で複数のAPIを逐次呼び出す(
await fetchA(); await fetchB(); await fetchC();)パターンは、各リクエストのRTTが累積し深刻な遅延を招く。独立したデータ取得はPromise.allで並列化するか、サーバーサイド統合API(/api/init-page等)で1リクエストにまとめること。 - 特にInnページのような多数のデータソースを必要とするページでは、統合APIパターンが最も効果的。
- クライアント側で複数のAPIを逐次呼び出す(
コード分割(Code Splitting)の徹底:
- モーダルやダイアログ等、ユーザー操作時にのみ表示されるコンポーネントは、
next/dynamic+{ ssr: false }で遅延ロードすること。静的importは初期バンドルサイズを不必要に肥大化させる。 - デバッグ用パネル(QuestTestPanel等)は本番ビルドに含めないか、
next/dynamicで完全に分離すること。
- モーダルやダイアログ等、ユーザー操作時にのみ表示されるコンポーネントは、
APIルートの認証パターン:
- すべてのユーザー固有データを扱うAPIルートでは、
createAuthClient(req)でJWTからユーザーIDを取得すること。リクエストBody/クエリパラメータからのユーザーID受け取りは認証として無効であり、他ユーザーのデータ操作を許してしまう。 - APIルート内でのDB操作は、
select('*')を避け、必要なカラムのみを明示的にselect('col1, col2, col3')で取得すること。特にscript_data等のJSON blobカラムを含むテーブルでは転送量に大きな差が出る。
- すべてのユーザー固有データを扱うAPIルートでは、
N+1クエリの回避:
- ループ内での個別DB更新(
for (const item of items) { await db.update(...).eq('id', item.id); })は、.in('id', ids)を用いたバッチ更新に置き換えること。
- ループ内での個別DB更新(
Google認証後のセッション確立遅延とAPI認証の教訓 (v27.3)
- 非同期セッション確立のタイムラグとクライアントガードの設計: Google OAuth認証直後、SDK G認可コード(
code)をセッションに交換しlocalStorageへ永続化するまでには微小な遅延がある。この過渡期にクライアント側ガード(useAuthGuard)でsupabase.auth.getUser()(Authサーバーへの通信検証) を実行すると、競合により一時的に検証に失敗し、タイトル画面へ誤リダイレクトされる原因となる。 - クライアント側ガードには getAuthToken を使用:
useAuthGuardのようなフロントエンド用の遷移ガードは、生のsupabase.auth.getSession()を直接呼ぶと、App Router移行期等のハイドレーション時にlocalStorage同期が間に合わず一時的にnullを返す競合が起きる。APIコールと完全に一元化されたgetAuthToken()を呼び出すことで、メモリ内の有効なセッション(JWTキャッシュ)を直接参照して競合を防ぐ設計にすること。 - 生History操作と popstate の競合対策: Next.js App Router環境において、生の
window.history.replaceState等でURL表示を書き換えた直後にrouter.push等のSPA遷移を行うと、ルーターの内部同期の副作用によりpopstateイベントが意図せず自動誤発火される問題がある。popstateリスナー側では、単にイベント発火を検知するだけでなく、現在のブラウザ of パス(window.location.pathname)を必ず確認し、ゲーム内ページに留まっている場合は処理をスキップする等の防御コードを入れること。 - サーバーサイドでの明示的トークン検証: サーバーサイドAPI(
/api/init-page等)でcreateAuthClientを用いる際、supabase.auth.getUser(token)のようにAuthorizationヘッダーから取得したトークンを明示的に引数に渡して呼び出すこと。引数なしで呼び出すと、サーバーレス環境 of メモリ内にセッションが存在しないため、認証を誤判定することがある。 - 認証エラー判定の緩和: 認証確立の過渡期に発生する、動作に影響のない警告エラーなどでAPIを遮断してタイトルに戻してしまわないよう、認証可否の最終判断は
!user(ユーザーの存在有無)のみを基準とすること。 - 複数外部キー(FK)関係があるテーブルの JOIN クエリの注意点 (PGRST201):
同じ2つのテーブル間に複数の外部キー(リレーション)が存在する場合(例:user_profilesからlocationsへのcurrent_location_idとprevious_location_idの2つのFK関係)、Supabase (PostgREST) を用いてselect('..., locations(...)')のように単純に結合を指定すると、どの外部キーを使用すればよいか曖昧なため、エラーPGRST201: Could not embed because more than one relationship was foundが発生する。
この曖昧さを回避するためには、クエリでselect('..., locations:locations!fk_current_location(columns)')のように、使用する外部キー名(例:!fk_current_location)を明示的に指定してリレーションを指定すること。
また、.single()などの単一クエリ結果を取得する際、例外が直接スローされず{ data: null, error: {...} }の形でエラーが返ってくるため、API側で例外キャッチ(try-catch)されずに200 OKで{ profile: null }が返り、結果としてクライアント側が意図しないリダイレクト(一瞬表示されてタイトルに戻る)などの原因となるため注意が必要である。 - 外部キー制立の不備による JOIN エラーと環境依存の例外スロー (PGRST200):
テーブル間に適切な外部キー制約が定義されていない(または型不整合などにより制約の作成に失敗している)場合、PostgREST を介した結合クエリ(例:equipped_itemsからitemsへの結合)はPGRST200エラーになる.
ローカル環境では単に空データが返されるだけのサイレントエラーとなるが、Vercelサーバーレス環境(本番やプレビュー環境)ではこの PostgREST エラーが例外(Exception)として直接スローされ、API全体が500エラーでクラッシュするという重大な環境依存挙動がある.
スキーマキャッシュで解決できない結合エラーを避けるため、必要な結合は外部キーが正しく定義されているテーブル(例:inventory)に代替し、クエリ側でis_equipped = trueのフィルターをかけるなど、スキーマの安全性と堅牢性を重視した設計とすること. - 世界情勢履歴における複数外部キー関係下でのPostgREST JOIN解決 (PGRST201):
world_states_historyからlocationsへの結合を行う際、外部キーがworld_states_history_location_id_fkeyとworld_states_history_location_name_fkeyの2つ存在するため、曖昧さを回避するためにはlocations!world_states_history_location_id_fkeyのように外部キー名を明示的に指定して JOIN を行う必要がある.
これを怠ると、ローカル環境では問題なくとも Vercel 環境では API 500 クラッシュを誘発するため、スキーマ変更後はすべてのリレーション結合に細心の注意を払うこと. - OAuth後のセッション遅延を考慮したデータフェッチ側のリトライ:
Google OAuthから戻った直後のSPA遷移時、セッション確立タイムラグによりgetAuthToken()が一時的にnullを返す過渡期がある.
画面ガード(useAuthGuard)だけではなく、データをロードする側(loadInitData等)においても、初回トークン取得がnullの場合は即座に API を叩いて 401(未認証)で自爆するのを防ぐため、1000ms 程度の待機時間を設けてトークンを再取得(リトライ)する設計を徹底すること. - リレーション結合時の不存在カラム参照エラー (42703) とエラーマスキング:
PostgREST クエリにおいて、複数外部キー関係などの曖昧リレーションエラー(PGRST201)が発生している場合、結合自体の解決に失敗するため、結合テーブル内の「存在しないカラム(例:region)」を参照しようとした際のエラー42703 (column does not exist)が裏に隠蔽(マスキング)される.
曖昧エラーを解消して結合関係を正常に定義した直後、今度はカラム不存在エラーが表面化し、Vercel 等のサーバーレス環境下で 500 クラッシュを誘発するため、結合定義の修正後はクエリで指定しているすべてのカラムが対象テーブルに実際に存在するかどうかを必ず物理スキーマと照合すること.
UGC 簡易クエストビルダーの設計判断
- ステップ式ウィザードへの完全移行: モバイルでの操作性と学習コストを考慮し、SVGベースのビジュアルフローエディタから「4ステップ構成のステップ式ウィザードUI」へ完全移行。SVG描画や複雑なピンチ/パン、ドラッグ&ドロップ操作を排除し、通常のスクロールと直感的なリストフォーム入力に簡素化。
- 既存APIの完全再利用: 簡易ビルダーで作成されたクエストは、クライアントサイドで UGC JSON テンプレートに変換し、既存の
/api/ugc/v2/importエンドポイントに送信。専用の新規APIは不要。ugc_scenarios.source_format = 'builder'で識別。 - プリセット方式のエネミー・報酬: モバイルUIの複雑さを制御するため、エネミーは24種のプリセット(4カテゴリ×6種)+レベルスケーリング、報酬は12種の公式アイテムプリセットから選択する方式を採用。フルカスタムはテンプレート方式で対応。
- リスト形式によるフロー構築: ノードはリスト形式で順次追加・編集・削除し、並べ替え(ドラッグ&ドロップまたはボタン移動)を行う。分岐はテキストノードの選択肢パラメータとして「分岐先ノードID」をドロップダウン指定することで表現し、リスト上でインデントして視覚化する。
- フロー構造制約: テンプレート方式の自由度(100ノード、5選択肢、自由グラフ)に対し、ビルダーは最大20ノード、2択、分岐深度1段階に制限。これにより初心者でもフロー全体を把握しやすくなる。
クリエイターズ工房のPCレイアウト保護の設計判断
- モバイル模擬枠によるPC表示崩れの防止: クリエイターズ工房(
/workshop)全体をPCなどの広画面で見た際、ボタンの巨大化や入力項目の引き伸ばしを防ぐため、最大幅450pxのコンテナ(max-w-[450px] mx-auto)に制限し、画面中央に上下左右中央寄せで配置する。 - ゲーム共通デザイン言語の維持: モバイル未対応時の外枠として、アンティーク調のウッドブラウン境界線(
md:border-4 md:border-[#3e2723] md:rounded-[24px])と陰影(shadow-2xl)を適用し、PC上でもゲームアプリをプレイしているかのような旅情と情緒を演出する。 - ヘッダー/フッターの固定とコンテンツ独立スクロール: コンテナ内でスクロールを完結させるため、Top Barやタブ、ビルダーのナビゲーション(戻る・次へ)は
shrink-0で固定し、コンテンツ領域のみをflex-1 overflow-y-auto no-scrollbarに設定してスクロール制御する。 - タブバーの左右スクロール矢印ボタン: 450px幅制限に伴い見切れてしまうタブ項目(バランス計算など)へPCやモバイル環境から確実にアクセスできるよう、タブバーの左右に滑らかな横スクロールを実行する矢印ボタン(Chevron)を配置する。
ユーザー自己紹介と他ユーザー簡易プロフィール表示の教訓
イベントバブリングの抑止とアバター個別アクション:
リストの行全体をタップすると詳細画面が開くUI(例:酒場などのシャドウリストや噂話のタイムライン)において、アバターアイコンのみに個別のアクション(簡易プロフィールポップアップ表示など)を割り当てる場合は、アバター画像要素のonClickハンドラで必ずe.stopPropagation()を呼び出す。これを怠ると、イベントがバブリングして親の行タップイベントが発火し、意図せず詳細モーダルが同時に開いてしまう。自己紹介データの同期とNGワードチェック:
他ユーザーの簡易プロフィールに自己紹介を表示するためには、シャドウデータを解決する API やサービス(ShadowSummaryの返却時など)において、user_profiles.introductionを結合してクライアントへ返すようにする。また、自己紹介の更新 API (PATCH /api/character/introduction) では、不正な入力を防ぐための最大文字数バリデーション(30文字制限)およびcheckNgWordを用いたNGワードフィルターの適用を徹底し、コミュニティにおける健全性をシステムレベルで担保する。チュートリアルスライド表示と世界情勢変化ポップアップの設計方針:
user_profilesテーブルにis_tutorial_completed(BOOLEAN, DEFAULT FALSE) カラムを追加し、初回キャラクター作成時のチュートリアル表示制御に使用する。- 世界情勢変化が起きた際、
gougaiEvents(未読の世界履歴)を検知して、まずWorldChangedModalをポップアップ表示する。 - 新聞風号外モーダル(
ChronicleModal)内の「Gougai」という英語表記は非推奨であり、日本語の「号外」のみに変更すること。 - スライド切り替えなどの UI アクションにおいて、
soundManagerによる SE 再生(se_clickやse_quest_acceptなど)を適切に実行すること。 - ポップアップの表示順は
TutorialModal(チュートリアル)が最優先され、次にWorldChangedModal(情勢変化)が優先されるように制御すること。
API認証およびハイブリッドキャッシュの教訓 (v4.5)
- API 認証ヘッダーの漏れによるサイレント失敗:
クライアントサイドからサーバーAPI(/api/battle/startや/api/gossipなど)を呼び出す際、Authorization ヘッダー(JWT トークン)の付与漏れがあると、サーバー側で 401 Unauthorized となりDB書き込み等が実行されません。クライアント側が API のレスポンスを待たずに画面遷移(楽観的処理)を行う場合、ゲーム自体は正常に進行しているように見えてしまうため、裏での図鑑登録などがサイレントにスキップされてしまい不具合の発見が遅れる原因となります。APIコールの際は常にgetAuthHeaders()を併用してトークンを正しく伝播させる必要があります。 - ランキングキャッシュと自己データリアルタイム反映の両立:
ランキング等の重いクエリにおいて、サーバー負荷を抑えるためにキャッシュ(15分〜6時間など)を導入している場合でも、ログインユーザー自身のスコアと順位については、サーバー側でDBのリアルタイム値に上書きし、リストへのマージ、再ソート、および再ランク(順位の振り直し)を行ってからクライアントに返却することで、DB負荷の抑制と「自分の行動が即時ランキングに反映される」という優れたユーザー体験を両立できます。 - サーバーレス環境における
getUser()の明示的トークン指定:
サーバーサイド API において、createAuthClientを用いた Supabase クライアントでclient.auth.getUser()を引数なしで実行すると、サーバーレス環境(Vercel 等)ではメモリ内のセッション状態が維持されないため、認証検証に失敗して 401 Unauthorized を返す原因となります。これを防ぐため、サーバー側でもreq.headersから Authorization ヘッダーの JWT トークン文字列を抽出し、明示的にclient.auth.getUser(token)のように引数にトークンを渡して検証を行う必要があります。
宿屋休息・統合タイムラインと所在地・コレクション表記統一の教訓 (v4.6)
- 宿屋休息のトースト通知順の整理と統合:
楽観的UI更新の時点では個別の「HPが全快した」といった先行トーストは出さず、APIが完了した段階で「✨ 休息完了:HPが全快しました(宿泊費: XXX G / X日経過)」という1つの統合されたトースト通知で全快および宿泊完了を報告することで、ユーザーへの表示順(HP全快→宿泊中ロード→宿泊完了)のバグ・混乱を完全に防止し、クリーンな体験を提供する。 - ヒストリータイムラインの所在地とクエスト制御:
個人履歴(レベルアップや加齢など)の所在地は「旅の途中」で固定するが、クエスト関連ログについては「依頼を受けた(受注または完了した)土地」の地名を表示する。データベースのインサートトリガー(insert_quest_activity_logs_triggerやinsert_completed_quests_trigger)内で、ユーザーの profile のcurrent_location_idおよび locations テーブルから現在地を自動解決してuser_chroniclesのlocation_id/location_nameに保存することで、将来的なクエストログも常に正しい所在地が記録される。 - 図鑑・魔物図鑑の「コレクション:」への統合:
「図鑑登録:」および「魔物図鑑登録:」は「コレクション:」という表記へ統一する。過去データ(移行データ)の年代記タイトル接頭辞変更に加え、インサートトリガー内での文字列生成時に'コレクション: ' || COALESCE(...)として保存し、さらにフロントエンドUI (HistoryArchiveModal.tsx) での表示時にも正規表現(/^図鑑登録[::]\s*/gおよび/^魔物図鑑登録[::]\s*/g)を用いて置換 normalization を施すことで、表記ゆれのない統一されたコレクション表現を実現する。
クエスト履歴重複クリアバグとビューのユニーク化 (v4.7)
- 一元化履歴とユニーク制約のトレードオフ:
タイムライン(「私の歩み」)としての役割を持つuser_chroniclesに対して、クエスト完了チェック(.maybeSingle())の重複エラーを防止する目的で(user_id, scenario_id)のユニークインデックス制約をかけてしまうと、繰り返し可能クエストやUGCクエストを2回目以降にクリアした際にインサートが失敗(無視)されるバグを招く。 - 解決策としてのビューのユニーク化 (DISTINCT ON):
タイムライン(履歴)への記録は重複(何度クリアしたか)を許容して制約を外し、前提クエスト判定などで重複エラーを避けたいビュー側(user_completed_quests)でDISTINCT ON (user_id, scenario_id)を適用して最新の1件だけを返すように定義することで、履歴の連続記録とプログラムの動作整合性を両立できる. - INSTEAD OF TRIGGER のカラム検証:
既存テーブルをビューへ移行してINSTEAD OF INSERTトリガーを定義する際、INSERT文のカラム順序と VALUES に渡す変数の順序の不一致(例: UUIDカラムにTEXT値を誤挿入するなど)は、データベースレベルでのサイレントな挿入失敗やロールバックを招く。スキーマ変更・トリガー再定義の際は厳格なカラムマッピング確認が必須である。
拠点ロード時間・施設モーダル遷移ゼロ化(追加改修・v27.4)
- 不要な 1000ms 待機の排除と OAuth 限定化:
アノニマスログインなどのゲストセッションや、通常の初期マウント時における認証トークン検証でトークンが無い場合に、無条件に1000msのsetTimeout待機をかけてしまうと、ログイン完了までに毎回 1 秒以上の不要な遅延が発生する。この遅延は、Google OAuth からのリダイレクトバック時(URL にcode=が含まれる場合)のみに厳密に制限し、不要なウェイトを排除すること。また、OAuth 処理時に動作しないクライアント側ガード(useAuthGuard)内の待機処理は完全に削除すること。 - フルリロードによるキャッシュ消失の防止:
Next.js の Web アプリケーションにおいて、施設等からリダイレクトさせる際にwindow.location.href = '/inn'を用いると、画面全体のフルリロードが走り、Zustand のインメモリキャッシュがすべて破棄される。これにより、その後の遷移時に不要な初期ローディング(「拠点情報を取得中...」など)が再発生してしまう。すべての内部遷移にはuseRouterを用いて Next.js のrouter.push('/inn')(クライアントサイドルーティング)を徹底すること。 - Zustand ハイドレーション完了のリアクティブ検知:
Zustand の永続化データ(localStorage)は、マウント時の初期レンダリング直後にはハイドレーションがまだ完了していないため、getState()でデータを取得すると初期値のnullになる。これにより、ローカルキャッシュが存在していてもマウント第一フレームでローディング状態(loading = true)に誤って初期化されてしまう。これを防ぐため、Zustand ストアのハイドレーション完了フラグ(_hasHydrated)をリアクティブに監視し、キャッシュが存在していれば即座にloading = falseに切り替えて描画を開始するuseEffectを導入すること。 - sessionStorage を用いた同期的なキャッシュ復元の徹底:
モーダル(酒場TavernModalやギルドQuestBoardModal)を開く際、Zustand 上にキャッシュが無い場合でも、sessionStorageのキャッシュから同期的にデータを復元して即座にsetLoading(false)(0ms描画)を実行し、その後にバックグラウンドで非同期通信と最新化を行うこと。非同期のPromise.allなどの API 完了(ネットワークRTT)を待ってからsetLoading(false)を行うと、数秒の待機ラグがユーザーに表示されてしまうため、同期的キャッシュ復元と非同期バックグラウンド更新(SWRパターン)を切り離して実装すること。
バトル中の AP 同期不整合バグ(400 Bad Request)の解消(追加改修・v28.0)
- ターン終了時の AP 同期:
クライアント側でターンが終了した際(runNpcPhaseが呼ばれた際)、サーバーのバトルセッションに対してもaction_type: 'end_turn'のアクションをPOST /api/battle/action経由で送信して同期する。これにより、サーバー側のplayer_state.current_apが自然回復(+5)され、ターン経過によって AP が不整合を起こし、次のターンでのカード使用時に400 Bad Request(AP不足)エラーが発生する問題を解消する。 - AP 回復スキル(瞑想等)のサーバー側処理:
瞑想スキル(effect_id: 'ap_recover')など、使用時に AP を回復させる特殊なカード効果について、サーバー側のアクションハンドラ(/api/battle/action/route.ts)内でも同等の AP 回復処理(+3、上限15)を実行する。これにより、カード使用の度にサーバーとクライアントの AP を常に完全に同期させる。
UGCアップロード・Xシェア・バトル連打防止の教訓 (追加改修・v28.5)
- UGCアセットアップロードでのバケット分離と自動生成:
UGC用の画像・音声ファイルをアップロードする際、ファイル形式に応じてugc-imagesとugc-audioの2つのバケットに分離して管理すること。また、バケットが存在しない場合にはAPI側でpublic: trueかつ適切な MIME タイプ・容量制限(画像 2MB / 音声 10MB)を設定して自動作成を行うことで、ストレージの安全性と効率性を確保する。 - Xシェア時のゲームURL自動付与とハッシュタグ統一:
X(旧Twitter)へのシェア機能において、表記(#WirthDawn #CWD)を一貫させること。また、固有のシェアURLが無い場合でも、クライアント側で自動的にwindow.location.originをフォールバックとして付与し、常にOGPカードが正しく表示されるように制御する。 - バトル中のスキルカード連打(重複発動)防止:
APIリクエストの多重送信を防ぐため、カード発動処理(API送信中)はローカル状態isActioning等のフラグを用いて追加のカード選択や「NEXT」ボタン押下を即座にブロックし、処理完了後にロックを解除する防御的設計を徹底すること。
クエスト中ブラウザバック抑止とUGCアセット削除・容量取得の教訓 (追加改修・v29.0)
- ブラウザバック抑止と安全な離脱(放棄)の両立:
Next.js App Router 環境下でのクエスト進行中において、単純なbeforeunloadでは SPA 遷移によるブラウザバックを検知できない。マウント時にwindow.history.pushState(null, '', window.location.href)でダミースタックをプッシュし、popstateイベントリスナーでそれを捕捉する。ブラウザバックを検知した際はダイアログを表示し、ユーザーが承認した場合はhandleGiveUp(true)を非同期で実行し安全に離脱(放棄)させ、キャンセルした場合は再度pushStateを積んでクエストを維持する。結果画面の表示中など、すでにクエストが完了している場合はブラウザバックをブロックしないようresultOverlayの表示状態をuseRefを介してリアクティブに判定・スキップする。 - UGCアセット削除APIにおける所有権の厳格な検証:
/api/ugc/uploadにDELETEハンドラを追加する際、要求されたアセットのURLからバケット名(ugc-images/ugc-audio)とファイル名を抽出する。不正なアセット削除を防ぐため、ファイル名が自身のuserId_で開始しているかを厳格に検証(プレフィックスチェック)した上で、Supabase Storage から削除を実行する。 - UGCストレージ使用量(容量上限)の動的集計とUI表示:
/api/ugc/v2/usageのレスポンスにstorage: { used: number, limit: number }を追加する。supabaseServer(adminClient) を用いてugc-imagesおよびugc-audioバケット内から本人のファイルをスキャンし、ファイルサイズを合算して使用容量(used)を算出する。上限値(limit)はUGC_STORAGE_LIMITS定数に基づいて Tier 別に自動決定する。UI側(WorkshopStatusPanel)では、バイト単位の数値をMB単位に適切に換算(.toFixed(1) + 'MB')してUsageBarで表示する。
拠点UI・バトルボタン配置・Workshop容量表示・UGCパスの修正教訓 (追加改修・v30.0)
- 条件付きメインビジュアルアクション非表示:
名もなき旅人の拠所 (isHub === true) のような特別拠点では、ワールドマップ遷移(コンパスアイコン)などの一部機能ボタンは不要となるため、!isHubガードを用いて条件付きでレンダリングから完全に排除し、ユーザーがワールドマップへ遷移できないよう安全に制限する。 - バトルアクションボタンの常に中央寄せ固定 (grid-cols-3レイアウト):
ボタンの有無によってNEXTボタンなどの中心軸がずれるのを防ぐためには、コンテナをgrid grid-cols-3にし、各要素をflex justify-start,flex justify-center,flex justify-endに格納する。これにより、両サイドのボタンが条件付き非表示になっても中央のNEXTボタンは常に完全な中央配置を維持する。 - モバイル表示崩れの解決に向けた進捗インジケーター排除:
狭い幅(モバイルのマルチカラム配置等)において、プログレスバーのような長尺要素は改行崩れ(レイアウトバグ)の直接的原因となる。進捗バーを削除し、ラベルと数値を横並びにしてボーダーで囲むバッジ形式 (flex items-center justify-between) に簡素化することで、デザインの洗練さを保ちつつモバイル環境における耐久性を確保する。 - UGCアセットの
ugc://プレフィックス移行とプレビュー用ダブルURL設計:
ユーザーがアセットパスとしてコピー・使用するのはugc://プレフィックスのパスである。しかし、ブラウザが標準の<img>や<audio>タグでメディアをレンダリング(プレビュー)するためには、https://で始まる絶対パスが必要である。このため、UI側ではuploadedUrl(コピー/削除用のugc://...形式) とpreviewUrl(表示・再生用のhttps://...形式) の二つの状態を保持する設計を行う。また、APIのDELETEハンドラは、URL文字列からファイル名とバケット名を抽出する際、https://形式とugc://形式の両方を解析できるようにフォールバックロジックを構成することで、安全なリソース削除処理を実現する。
Workshop Status Panelのモバイル最適化レイアウト再構築の教訓 (追加改修・v30.1)
- UI要素の再配置とモバイル画面の耐久性向上:
- モバイルの狭い幅(特に390px以下)において、2列2段のステータスグリッドの横幅を最大限確保するため、右端にあった所持ゴールド、枠追加、更新ボタンなどを左側の「Premium」などのTierバッジの下に縦方向に並び替える再配置を行った。
- 左端のカラムに縦並びで「Tierバッジ」「所持ゴールド」「枠追加と更新ボタン(横並び)」をまとめ、右端のカラム(
flex-1)に4つのステータスバッジのグリッドを寄せることで、ステータスバッジそれぞれが利用できる幅が格段に広がり、モバイルでのテキスト折り返しや枠崩れを根本から解決した。
UGCステータスのリアルタイム同期とカスタムイベントの教訓 (追加改修・v30.2)
- 状態の疎結合なリアルタイム更新(CustomEventの活用):
- ステータスバー (
WorkshopStatusPanel) と、他の操作パネル(アセットアップローダー、作品一覧、インポーター、簡易作成)は親子関係を持たない、あるいは深くネストされた兄弟関係にあるため、状態のバケツリレーやグローバルストアの過剰な肥大化を避けるためにCustomEventを活用した。 - アセットのアップロード・削除・インポート・新規作成・公開・取り下げ・ドラフト削除の成功時に、
window.dispatchEvent(new CustomEvent('ugc-status-updated'))を発火。 WorkshopStatusPanel側でuseEffectを用いてこのイベントをリッスンし、イベント発火時にサイレントアップデート (fetchUsage(true)) をトリガーすることで、UI全体で手動リロードを挟むことなく、下書き数や素材容量などの数値が100%動的に追従するクリーンなUXを実現した。
- ステータスバー (
Workshop Status Panelレイアウトの上段・下段分割設計の教訓 (追加改修・v30.3)
- 情報の配置分離とスキャナビリティ向上(上段・下段分割):
- 横方向のレイアウトがごちゃつくのを防ぐため、コンテナを
flex flex-col gap-2の縦積み構造とし、情報を明確に上段・下段に分離した。 - 上段(Row 1):
flex justify-betweenにより、左端に「Tierバッジ」、右端に「ゴールド+枠追加ボタン+更新ボタン」をまとめ、視認性の高いヘッダー行として整理。 - 下段(Row 2): ステータスバッジの 2x2 グリッド(
w-full)をそのまま下に改行して配置し、各バッジの横幅を大きく確保。 - これにより、限られた幅(モバイル等)でも視認性が最大限向上し、UI要素同士が重なったり配置が煩雑に見えたりする問題を根本から解決した。
- 横方向のレイアウトがごちゃつくのを防ぐため、コンテナを
システムメンテナンス機能の実装仕様と運用手順(追加改修・v31.0)
- ミドルウェアによる一元遮断設計:
src/middleware.tsにおいて、静的アセットや/maintenance画面自体を除くすべてのリクエストを捕捉し、データベース(system_settingsテーブルのmaintenanceキー)の定義に基づいてメンテナンス判定を実施する。- ページ要求(HTML要求)はお知らせ画面
/maintenanceへリダイレクトし、API要求(/api/*)は503 Service Unavailable(JSON形式) を返却する。
- 強制フラグと予約日時のハイブリッド判定:
- メンテナンス状態は、
force_maintenance = trueまたは、現在時刻がstart_atからend_atの日時範囲内にある場合にアクティブとなる(事前予約・自動インが可能)。
- メンテナンス状態は、
- 開発者用管理者バイパス(Cookie & クエリ):
- メンテナンス中であっても、
admin_bypass_keyに一致する Cookie (maintenance_bypass) または URL パラメータ(?bypass=SECRET)を持つリクエストは制限を免除し、デバッグ・検証を可能にする。 - クエリパラメータ
?bypass=SECRETで一度アクセスすると、ミドルウェアが自動で Cookie をセットしてリダイレクトする設計とし、デバッグ操作性を向上。
- メンテナンス中であっても、
- 手動イン・解除および予約の手順:
- 即時メンテナンスの開始: 管理API(
POST /api/admin/maintenance)へヘッダーAuthorization: Bearer <ADMIN_SECRET_KEY>を付与し、ボディに{ "force_maintenance": true }を送信してDBの値を更新する。 - 即時メンテナンスの解除: 同管理APIへ
{ "force_maintenance": false, "start_at": null, "end_at": null }を送信する。 - メンテナンス予約: 同管理APIへ開始/終了日時を含めて
{ "force_maintenance": false, "start_at": "2026-06-10T16:00:00.000Z", "end_at": "2026-06-10T18:00:00.000Z" }を送信する。 - 本番検証: メンテナンス中に開発者が動作確認を行う際は、ブラウザで
https://www.code-wirth-dawn.com/?bypass=<admin_bypass_key>にアクセスしてバイパスセッションを確立する。
- 即時メンテナンスの開始: 管理API(
影のロイヤリティ支払アトミック化とDB側ランキング集計の教訓(追加改修・v32.0)
- ロイヤリティ支払いの二重支払い防止(TOCTOU対策):
- プレイヤー同士の同時雇用や連打によるロイヤリティ日額制限(CAP)のすり抜けを防ぐため、Node.js側での複数DBクエリによる「SELECT → 計算 → UPDATE / UPSERT」のフローを完全に廃止。
- Postgresのトランザクションおよび行レベルロック(
ON CONFLICTを用いたダミー更新による排他ロック)を適用したprocess_royalty_payoutRPCを一元呼出する設計に変更。これにより、連打・同時リクエストが発生してもトランザクションが直列化され、日額CAPを超える不正還元ゴールドは1Gたりとも発生しない。
- Node.jsインメモリ集計の排除とDB窓関数(ROW_NUMBER)への移行:
- 全ユーザーの名声やアライメントポイント差分を Node.js メモリ上に展開して
Array.prototype.sort()でソートする方式は、レコード数増加時に PostgREST のデフォルト取得制限(1,000行)に衝突し、かつメモリオーバーヘッド・タイムアウトを招く。 aggregate_reputation_ranking()およびaggregate_alignment_ranking(p_cycle_started_at)RPCをデータベース側(plpgsql)に定義し、ROW_NUMBER() OVER (...)窓関数を使用して集計からキャッシュテーブル(ranking_reputation_cache,ranking_alignment_cache)の更新までをミリ秒単位で処理する。
- 全ユーザーの名声やアライメントポイント差分を Node.js メモリ上に展開して
- オンデマンド集計の廃止とCron移行:
- ランキングAPI (
/api/ranking) 呼び出し時にキャッシュの鮮度を検証して自動集計(オンデマンド集計)を行うと、一般ユーザーのアクセスがボトルネックになり、API遅延や重複集計負荷(ロック競合)を招く。 - 集計処理はすべて
/api/cron/daily-updateエンドポイント(6時間ごとのVercel Cron)内でRPCを順次実行し、キャッシュテーブルを書き換える方式に一元化。APIルート側はキャッシュテーブルからselectするのみの極軽量な読み取り処理に簡素化することで、APIの応答速度と信頼性を向上させた。
- ランキングAPI (
- アライメントサイクル基準の動的解決:
- `daily-update` Cron内でアライメントランキングを集計する際、アライメント増分の基準時刻(`cycle_started_at`)には、直前に `updateWorldSimulation` によって更新された `world_states.updated_at` の最新タイムスタンプを動的に取得して引き渡す。これにより、世界シミュレーションのアップデートサイクルとランキング増分サイクルが100%同期する。
サブスクリプション無料トライアル判定とプラン同期の教訓(追加改修・v32.1)
- 無料トライアル期間中の Weekly ボーナス不正取得(エクスプロイト)防止:
- サブスク加入時(無料トライアル開始時)から `subscription_tier` が Basic/Premium となるため、そのまま Weekly ゴールドボーナスの判定にかかると、課金未発生であるトライアル期間中にゴールドを得てから即時解約するエクスプロイトが可能になってしまう。
- 解決策として、`user_profiles` テーブルに `subscription_status`(Stripeの `status`: `trialing` / `active`等)カラムを追加。Stripe Webhook にてこの状態を同期し、Weekly ゴールドボーナス付与処理時に `subscription_status = 'active'` である(トライアル中ではない)ユーザーのみに限定して付与するガードを実装する。
- Stripe Customer Portal 経由のプラン変更同期:
- ユーザーが Stripe ポータル上でプランを変更(Basic ⇔ Premium)した際に発生する `customer.subscription.updated` Webhook イベントを正しくハンドリングする。
- 受信時に `subscription` オブジェクトから最新の Price ID とステータスを解決し、DB 側の `subscription_tier` と `subscription_status` を同期・更新することで、Stripeポータル操作に伴うデータ不整合を防止する。
- Weekly ゴールドボーナス付与のアトミック化:
- 6時間おきの Cron 内での Weekly ゴールド付与について、並行リクエスト発生時における二重付与(ゴールドの二重獲得)を防ぐため、
last_weekly_bonus_atの検証およびゴールド加算、付与日時の更新を一連の PostgreSQL トランザクションとして処理するアトミックな SQL 関数(process_weekly_gold_bonus())へ移行した。
- 6時間おきの Cron 内での Weekly ゴールド付与について、並行リクエスト発生時における二重付与(ゴールドの二重獲得)を防ぐため、
世界アライメントの減衰・月次リセットおよび拠点初期値・ビジュアルの教訓(追加改修・v32.2)
- アライメントスコアの永続的累積による膠着化・バグ(Friction逆戻り)の防止:
- 各拠点の
order_score,chaos_score等のアライメントスコアは永続的に蓄積される設計になっており、プレイヤーのアクションにより 100 を超えると、摩擦計算(Math.abs(100 - currentVal))により摩擦が逆に跳ね上がって「目標繁栄度が常に崩壊(Lv1)に固定される」深刻なバグ(仕様の穴)が存在した。 - Friction計算の100キャップ: 摩擦計算時の属性値を
Math.min(100, currentVal)でキャップし、100超のスコアでの摩擦の逆戻り(不一致判定)を根本から防止。 - 6時間ごとの20%減衰:
updateWorldSimulationの開始時に、全拠点一律で各アライメントスコアに0.8を乗算し四捨五入する(下限は初期値10で保護)減衰処理を導入。アライメントを一定の平衡状態で維持させつつ、プレイヤーの継続的な活動(祈り等)がリアルタイムに反映されるようにした。 - 1ヶ月ごとの月次リセット: シミュレーション更新時に前回の更新時刻と現在の時刻の「月」を比較し、月が切り替わった最初のタイミングで全アライメントスコアを
50に自動初期化する。これにより、長期的な偏りの固定を防ぎ、シーズン的な新鮮さを提供。
- 各拠点の
- 古い6時間放置リセットロジック of the system の廃止:
worldStateReset.tsで行われていた「6時間放置でスコアを 0 にクリアする」極端な古いロジック(一部拠点だけが極端になりやすかった)を廃止し、上記の20%減衰に一本化した。互換性のために元の関数は空(ダミー)にスタブ化した。
- 拠点初期値の「停滞 (Lv3)」引き上げ:
- 首都とハブを除く一般拠点の初期繁栄レベルを
3(停滞 / Stagnant) に引き上げ、DB上の既存レコードにも一括アップデートを適用した。これにより、ゲーム開始時に世界全体が過度に崩壊した状態から始まるのを防いだ。
- 首都とハブを除く一般拠点の初期繁栄レベルを
- 衰退(Lv2)時のキービジュアル通常画像化:
- 繁栄度が
2(衰退)の拠点において、UIバッジ等の表示は「衰退」を据え置きつつ、背景画像(stateSuffix = "normal")および Vignette 演出は通常(Lv3)と同じ表示にするようMainVisualArea.tsxを修正。荒廃表示(_ruined.png)および不気味な赤いビネット演出を1(崩壊)のみに限定した。
- 繁栄度が
- 世界変遷(シミュレーション更新)のJST時間同期とリリース前ガード:
- 世界シミュレーションの更新(
updateWorldSimulationバッチ処理)の実行時間を、日本標準時(JST)の 6:00, 12:00, 18:00, 24:00 (0:00) に統一。 - これに伴い、Vercel Cron(UTC基準)の実行スケジュールを
"0 3,9,15,21 * * *"(UTC 3:00, 9:00, 15:00, 21:00) へ同期設定。 - ゲームリリース前における自動情勢変化を防ぐため、2026年6月15日 12:00 JST より前に実行された場合は処理をスキップして初期状態を維持するガードロジックを
world-simulation.tsの先頭に導入。
- 世界シミュレーションの更新(
従量課金メニュー追加と初回トライアル制限・タイミング適正化の教訓(追加改修・v32.3)
- 従量課金パッケージ(30,000G / 950 JPY)の追加:
AccountSettingsModal.tsx(設定画面)およびPurchaseConfirmModal.tsx(特商法最終確認モーダル)に「スタンダードパック(30,000G / 950円税込)」の価格定義および購入ボタンを新設。- バックエンド
checkout/route.tsのGOLD_PACKAGESに連動する環境変数STRIPE_PRICE_ID_GOLD_30Kを追加し、都度払いとして Stripe Checkout Session を作成。
- Weeklyボーナスの即時付与とタイミング適正化(初日➔7日後):
- 新規に Basic/Premium サブスクリプションの課金(支払)が開始された時点(Stripe Webhook の
checkout.session.completedにてactiveになった際、またはcustomer.subscription.updatedにてtrialing→activeに切り替わった際)に、データベースのlast_weekly_bonus_atを一旦nullにリセットした上でアトミックな SQL 関数process_weekly_gold_bonus()を即時実行。 - これにより、本契約(有料)開始の初日にその週のボーナスが即時でユーザーに付与され、同時に
last_weekly_bonus_atが現在時刻(NOW())に設定されます。 - 次回の付与は
daily-updateの判定ロジックに従い、初日決済からちょうど7日後(および以降7日おき)に実行され、「契約初日に初回ボーナス獲得 ➔ 7日後に2回目」という健全かつリアルタイムな体験を提供します。
- 新規に Basic/Premium サブスクリプションの課金(支払)が開始された時点(Stripe Webhook の
- 初回無料7日間トライアルの1回制限化:
user_profilesテーブルにhas_used_trial(トライアル消費済み)カラムを追加。- ユーザーが一度でもトライアル契約(status =
trialing)または通常契約(status =active)を開始した時点で、Stripe Webhook にてhas_used_trial = trueに更新。 - サブスクリプション Checkout Session 発行前に
user_profiles.has_used_trialを検証し、すでに消費済みの場合は Stripe のtrial_period_daysオプションを指定せずに Checkout Session を作成(即時課金開始)。
世界アライメント初期値50化(30時間崩壊推移)と中立ハブ除外の教訓(追加改修・v32.4)
世界アライメント初期値 50 における無操作時の崩壊推移:
- プレイヤーが少ないリリース初期等に世界が即座に崩壊状態からスタートするのを防ぐため、アライメントスコアの初期値(および月次リセット時の初期化値)を
10から50に引き上げた。 - アライメントスコアが
50の場合、プレイヤーのアクションが無い状態で6時間ごとの20%減衰(下限10)が働くと、以下のように 30時間(シミュレーション更新5サイクル) で世界全体がレベル1(崩壊状態)に到達する。- 0h (初期状態): 属性値 50 (摩擦 50) ➔ 首都: 4 (Prosperous), 一般: 3 (Stagnant)
- 6h (1回目更新): 属性値 40 (摩擦 60 / 目標 Lv2) ➔ 首都: 3 (Stagnant), 一般: 2 (Declining)
- 12h (2回目更新): 属性値 32 (摩擦 68 / 目標 Lv2) ➔ 首都: 2 (Declining), 一般: 2 (Declining)
- 18h (3回目更新): 属性値 26 (摩擦 74 / 目標 Lv2) ➔ 首都: 2 (Declining), 一般: 2 (Declining)
- 24h (4回目更新): 属性値 21 (摩擦 79 / 目標 Lv2) ➔ 首都: 2 (Declining), 一般: 2 (Declining)
- 30h (5回目更新): 属性値 17 (摩擦 83 / 目標 Lv1) ➔ 首都: 1 (Ruined), 一般: 1 (Ruined)
- プレイヤーが少ないリリース初期等に世界が即座に崩壊状態からスタートするのを防ぐため、アライメントスコアの初期値(および月次リセット時の初期化値)を
覇権計算および領土割り当てからの中立ハブ除外:
- 永続的な中立ハブ拠点である「名もなき旅人の拠所」が他国の領土ドラフト(territory draft)に割り当てられたり、覇権(コントロール割合)チャートの分母に含まれて割合を歪めるバグを防止するため、計算から除外した。
world-simulation.tsの領土ドラフト対象数を 21 から 20(Hub を除く)に変更し、Hub は常に controlling_nation ='Neutral'として固定。/api/world/hegemonyの総数計算(totalCount)から controlling_nation ='Neutral'である Hub を除外。これにより、覇権バーは 4 大国家の支配拠点数のみ(分母20)で正確に 100% となるように算出される。
中立ハブ拠点の「繁栄(Prosperous / Lv4)」固定とCron更新処理からの完全除外:
- 中立ハブ拠点の状態は常に「繁栄 (Prosperous) / 繁栄度レベル4」で固定とし、アライメントスコアも
50に固定される。 - 6時間ごとのシミュレーション更新(
updateWorldSimulation)時に、中立ハブはアライメントスコアの20%減衰や月次リセット処理からスキップされる。 - さらに、シミュレーション処理内でハブ拠点のデータに意図しない変更(他国への帰属やレベルの変動など)が検出された場合に備え、自動的に
controlling_nation = 'Neutral',status = 'Prosperous',prosperity_level = 4, 各アライメントスコアを50へと強制リセット・上書きして保護するガードロジックを実装し、Cronのシミュレーション影響から完全に隔離した。
- 中立ハブ拠点の状態は常に「繁栄 (Prosperous) / 繁栄度レベル4」で固定とし、アライメントスコアも
クエスト進行ロックとギルドでの再開・放棄(ギブアップ)機能 (v4.8):
- クエスト進行中にブラウザバック等で拠点に戻り、再度クエストを「再開」するフローがある場合、コンポーネントのアンマウント(離脱)時にゲストNPCなどの状態を無条件でクリアしてはならない。ロック状態が維持されている限り、ゲストNPCなどのクエスト内進行状態は維持すべきである。
- クエストがロックされている状態で他のクエストを二重受注するのを防ぐため、クエストマウント時に
/api/quest/startによるロックおよび Conflict (409) 判定を導入する。さらに、別のクエスト進行中にギルド(またはUGCギルド)を開いた際、通常の依頼リストの代わりに「進行中のクエストがあります。再開しますか?諦めますか?」を提示するモーダル(ActiveQuestModal)を表示し、その場から/api/quest/give-upAPIを叩いてペナルティを適用しロックを解除できるようにすることで、優れたユーザー利便性と二重受注防止を両立する。 - クエスト画面への再進入(再開)時に毎回 Zustand の
startQuestなどの初期化アクションを呼ぶと、これまでのクエスト内でのHP推移や獲得したLootプール、進行フラグなどがリセットされてしまう。再開時は、Zustandにすでに同じクエストIDのデータが残っているか(questId === id且つisInQuestが true)を判定し、一致している場合はstartQuestによる初期化処理をスキップさせる制御を徹底すること。
キャラクター削除時の Stripe サブスクリプション自動解約の教訓(追加改修・v32.5)
- キャラクター削除(リセット)と Stripe 課金の連動:
- タイトル画面等のキャラクター削除(
/api/profile/reset)実行時に、データベース(user_profiles)だけを削除すると、Stripe 側のサブスクリプションが解約されずユーザーへの課金が継続する。 - キャラクター削除 API の実行時に Stripe API を呼び出し、該当する Stripe 顧客(Customer)の
statusがactiveまたはtrialingのサブスクリプションを即座に解約(cancel)する連動処理を実装する。 - Stripe API 呼び出しにおける例外(Stripe サービスの一時停止や API 鍵の未設定等)でゲーム内のキャラクターデータ削除自体が中断・フリーズしないよう、Stripe 側の解約処理は
try/catchで囲み、エラー時はログ出力のみとする堅牢なフォールバック設計を維持する。
- タイトル画面等のキャラクター削除(
公式Xへの外部リンク追加(追加改修・v32.6)
- 外部リンクとセキュリティ:
- タイトル画面のフッター(
src/app/title/page.tsx)および設定画面モーダル(src/components/inn/AccountSettingsModal.tsx)から外部 of SNS(公式X)などへリンクする際は、target="_blank"およびrel="noopener noreferrer"を設定し、セキュリティとユーザー利便性を両立する。 - ゲーム内設定モーダルのボタン追加時は、既存のメニュー階層に自然にフィットするよう、共通のボタンレイアウト(
py-2.5 px-4やrounded)およびデザインテーマ(Slate調カラーなど)を一貫して適用する。
- タイトル画面のフッター(
連打・フリーズ防止のための非アクティブ化と一貫したローディング表示の教訓(追加改修・v33.0)
- モーダルやボタン遷移における連打防止(disabled制御):
- クエスト結果画面やNPCダイアログなど、ユーザーのクリックにより非同期API通信や画面遷移が発生する箇所では、タップされた瞬間にローカルの
isClosingまたはisActionLoadingなどのステートをtrueにし、ボタンを非活性化(disabled)してテキストを「読み込み中…」に変更する。これにより、処理の待ち時間中のボタン連打によるAPI多重呼び出しを完全に防ぐ。
- クエスト結果画面やNPCダイアログなど、ユーザーのクリックにより非同期API通信や画面遷移が発生する箇所では、タップされた瞬間にローカルの
- モーダルクローズ時の非同期ラグの隠蔽(ローディングオーバーレイの即時表示):
- 依頼ボードや他モーダルの閉じるボタン(Xボタン)を押す際、親コンポーネントの再レンダリングや、アンマウント時の非同期処理(SWRフェッチ完了による競合など)の影響で、モーダルが即座に閉じずに数秒「フリーズして見える」バグが発生することがある。
- このような非同期クローズのラグが発生し得る箇所では、Xボタンがクリックされた瞬間に即座に
isClosingなどのローカルステートをtrueにし、モーダルの最前面に「読み込み中…」の半透明ローディングオーバーレイを即座に表示して他の操作を完全にブロックする。これにより、UIの応答遅延を視覚的にカバーし、固まったという印象を防ぐ。
- ローディング文言の一貫性:
- ゲーム内全体でローディングを表示する際は、表記のブレを防ぎユーザーの混乱を避けるため、表示文言を「読み込み中…」に統一する。
拠点移動時のローカル状態リセットと表示整合性の教訓(追加改修・v33.1)
- 所在地変更時のローカルキャッシュおよび関連ステートのリセット:
- アクションによって所在地(
worldState?.location_name等)が変化した際、コンポーネントがアンマウントされない SPA 画面(拠点画面など)では、コンポーネント内のローカルステート(名声reputationなど)に古い値が残ったままになるバグ(表示不整合)が発生しやすい。 - 所在地名が変更されたことを監視(
useEffect等による依存配列の検知)し、所在地名が変化した時点でローカルの関連状態を一旦nullなどの初期値にクリア(リセット)した上で、最新データを再取得(非同期フェッチのトリガー)する設計を徹底する。これにより、ハブと通常拠点の往復やエンカウント後の遷移時でも、常に正しい場所のデータが表示される。
- アクションによって所在地(
パラメータ定義および型定義の一貫性確保に関する教訓
- 物理スキーマとアプリケーション型定義(TypeScript)の完全一致:
- データベース(物理スキーマ)で定義されたカラム名(例:
location_name)と、アプリケーション側の型定義(src/types/game.tsなど)に定義されたプロパティ名(例:location_idのまま残るなど)の乖離は、不要なキャスト(any等)や実行時エラー、静的型チェックのすり抜けを引き起こす温床となる。 - 物理スキーマの変更(例:IDから名称ベースの参照への移行など)を行う際は、単にAPIやクエリのみを修正して終わらせず、対応する TypeScript の interface / type 定義も必ず連動してアップデートすること。
- データベース(物理スキーマ)で定義されたカラム名(例:
- 個人アライメントと世界アライメントの命名規則の峻別:
- 個人のアライメントは
user_profilesのorder_pts,chaos_pts等のように_pts(ポイント)で管理され、世界や拠点のアライメントはworld_statesのorder_score,chaos_score等のように_score(スコア)で管理されている。 - これらの命名規則の違いが混同されやすく、変数の取得間違いによる不具合が発生しやすいため、参照先オブジェクト(
userProfileかworldStateか)を意識して明確に使い分けること。
- 個人のアライメントは
拠点移動時の一括ロードとキャッシュ制御に関する教訓(追加改修・v33.2)
- キャッシュバイパス(強制ロード)の徹底:
- パフォーマンス最適化(SWRパターンなど)のために「直近数十秒以内のフェッチはAPIリクエストをスキップする」キャッシュ判定がある場合、拠点移動のように「データの置き換えが必須となる強制遷移アクション」の後には、必ず強制更新オプション(
force = trueなど)を渡してフェッチを呼び出すこと。これを怠ると、前回のフェッチから時間が経っていない場合にデータの再取得がスキップされ、古い拠点のデータ(名声、クエスト、施設NPC等)が画面上に残ったままになる。
- パフォーマンス最適化(SWRパターンなど)のために「直近数十秒以内のフェッチはAPIリクエストをスキップする」キャッシュ判定がある場合、拠点移動のように「データの置き換えが必須となる強制遷移アクション」の後には、必ず強制更新オプション(
- データ更新の網羅性確保:
- 移動が完了した瞬間(または開始時)の同期処理では、単に位置情報やアライメントなどの単一のステートを取得するだけでなく、宿屋で動作する名声、クエスト、酒場などのコンポーネント用データ(
prefetchTownDataのような一括同期処理)も漏れなくリフレッシュされるように、移動アクションの完了ハンドラ(賄賂支払いの成功時などを含む)で網羅的にロードを走らせること。
- 移動が完了した瞬間(または開始時)の同期処理では、単に位置情報やアライメントなどの単一のステートを取得するだけでなく、宿屋で動作する名声、クエスト、酒場などのコンポーネント用データ(
重い同期処理およびバッチ状態更新時のUIフリーズ防止の教訓(追加改修・v33.3)
- 同期的状態変化による UI 描画のロック(フリーズ感)対策:
- 同一のイベントハンドラー内で複数のローカルステート更新(例:
setIsActionLoading(true))と、親側の重いステート更新(例:setActiveModal('questBoard')によるコンポーネントツリーの切り替えやキャッシュデータ処理)が連続して行われる場合、非同期の境界(API通信など)を挟まないと、React はすべての更新をバッチ化して同一マクロタスク内で処理しようとする。 - これにより、ボタンを押した瞬間の「ローディング中表示」や「非活性化(disabled)」への切り替えが画面に描画される前にメインスレッドが計算でロックされ、UIが数秒間フリーズしてボタンやXボタンが利かなくなるバグを誘発する。
- 同一のイベントハンドラー内で複数のローカルステート更新(例:
- setTimeout による非同期境界の生成:
setIsActionLoading(true)やsetIsClosing(true)などの UI ロック用ステートをセットした直後に、実際の遷移や重い API コール処理をsetTimeout(..., 0)で囲むことで、イベントループを一度解放し、ブラウザに「ローディング&非活性状態」を即座にレンダリングさせることができる。- このアプローチをギルド会話モーダル(
NpcDialogModal)や酒場モーダル(TavernModal)などの各種クローズ、雇用、切り替え等のイベントハンドラーへ網羅的に適用することで、ユーザーに入力に対する即時応答(フリーズの完全な隠蔽)を提供できる。