# 依頼 v9 の報告：ロボットが地図の上で自分で道を引く（2026-10-24）

書いた日時: 2026-10-24 11:55
依頼: unity/claude_code_request_v9_map_planner.md

やったこと: 0〜6 章を入れて、7 章 A（人のいない店で道を引く確かめ）を回した。
7 章 B（③ ロボット2台・種0 を1回）はエイイチが押す。
**先に「決めた値」と「気になる所」を書きます（6 章のあと）。読んでから B を押してください。**


====================================================================
1. 直したファイル
====================================================================

## 新しく作ったファイル（全文はこの報告のいちばん後ろ）

  Assets/ServiceSim/Robot/GridPlanner.cs
      地図（RobotMap）と、その上で道を引く道探し（GridPlanner）。
  Assets/ServiceSim/Robot/MapPlanCheck.cs
      確かめ A の中身。Unity のメニューからも、Unity の外からも同じものを呼ぶ。
  Assets/ServiceSim/Robot/RobotPathLine.cs
      いま引いている道を床に細い線で出す（6 章）。見た目だけ。
  Assets/ServiceSim/Editor/ServiceSimMapPlanner.cs
      メニュー「ServiceSim/Map Planner/③⑦ 確かめ A（地図で道を引く・回さない）」。

## 直したファイル

  Store/JsonStoreLayout.cs
      ・地図の欄 mapJson（TextAsset）を足し、店の JSON と一緒に読む。
      ・読んだあとの確かめ（1 章）：2・1・0 の数が JSON の counts と同じ／
        版の番号が店の形と同じ／1 マスの大きさが meta.settings.res と同じ／
        受け渡し口の点・全卓の止まる位置・待機場所が 1 以上のマス。
        どれか合わなければ Compose が止まる（店を建てない）。前と同じ決まり。
      ・Build Store の報告に地図の行が出る。
      ・MapGeneration を外から読めるようにした（run_info.json に出すため）。

  Store/StorePlan.cs / Store/StoreBuilder.cs
      読んだ地図を plan.robotMap に持たせ、StoreBuilder.RobotMap で読めるようにした。

  Robot/RobotSpec.cs
      ・enum PathMode { MapPlanner, SiteRoutes } を足し、pathMode を足した（既定 MapPlanner）。
      ・2〜4 章の数字を足した（narrowCellCost 3 / centerWeight 2 / preferClearM 0.6 /
        replanMinIntervalSec 0.5 / detourAfterSec 3 / robotPathAheadM 4 など）。
      ・UseMapPlanner と PlannerSettings() を足した。
      ・走り・加速・減速・向きを変える速さ・前のセンサー・動き出すまでの1秒・
        卓での25秒・60秒・600秒は**何も変えていない**。

  Robot/ServingRobotController.cs（いちばん大きく直した）
      ・MapPlanner のときは SetDestination が NavMesh ではなく GridPlanner を呼ぶ。
        行き先は卓の止まる位置（または受け渡し口）そのもので、サイトの routes の点は
        たどらない。NavMesh はロボットの道探しには使わない。
      ・PathAhead・CurrentLegSegment は、サイトの道順でも地図で引いた道でも
        「これから進む道」を返すようにした（人のよけ方は変えていない）。
        まとめて見る WayCount / WayIndex / WayPoint を足した。
      ・3 章：前がふさがって 3 秒たったら回り込む（UpdateDetour / TryDetour）。
        近く 3 m の人・ロボットをその 1 回だけ通れないマスとして置く
        （人は 人の半径＋ロボットの半径＋0.1 m、ロボットは 2 台の半径の和＋0.1 m）。
        延びた距離が もとの残り×2＋5 m より長い道は取らずに待つ。
        引けなければ 3 秒ごとに探し直す。止まる位置そのものに人がいるときは回り込まない。
      ・4 章：YieldRank（料理を載せている 0 ＞ 空で戻る 1、同じなら 1 号機が先）と
        UpdateRobotYieldMap。優先が低い側は、相手が 3 m 以内に来て先の道（4 m 分）が
        かぶったら、相手の道を重くして引き直す。かぶらない道が引けないときは、
        2.5 m 以内で相手の道から 0.6 m 以上離れた通れるマスへ動いて待つ（最大 20 秒）。
        向かい合って 10 秒止まったら、引き直しを待たずによける場所へ動く。
      ・引き直しは 1 台につき 0.5 秒に 1 回まで（replanMinIntervalSec）。
      ・MapPlanner では「止まって10秒ごとに引き直す」と escape を使わない。
      ・SiteRoutes のときだけ 戻りの線（useReturnLine）・横へどく点（yieldOrder）が働く。
      ・記録：Replans / Detours / RobotYields / PlanMsMax / SiteRouteM / TripEndS /
        MapSnapStarts / PlanFails を足した。
      ・6 章：UpdatePathLine で床の線を出す。

  Experiment/TripLogger.cs
      ・trips.csv の**後ろに**足した（今の列は 1 つも動かしていない）:
          trip_end_s, path_mode, replans, detours, robot_yields,
          site_route_m, detour_extra_m, narrow_used, plan_ms_max
      ・stops.csv の resolved に「回り込んで動き出した」「よける場所へ動いた」を足した。
        止まりの原因の分け方は今のまま。
      ・run_info.json に path_mode / map_generation / narrow_cell_cost / center_weight /
        prefer_clear_m / detour_after_sec / plan_ms_max_per_trip / map_snap_starts /
        plan_fails を足した。

  Experiment/ExperimentRunner.cs
      ・上の列に値を入れる。
      ・角の箱は MapPlanner のときは入れない（下の「気になる所 (4)」）。

  Store/KitchenMonitor.cs
      6 章：ロボットのいまの様子に 走る／止まる／回り込む／よける を出す。

  Service/ServiceOps.cs
      1 行だけ：yields.csv の in_corner を MapPlanner では立てない（角の箱と同じ理由）。

## 写したファイル（0 章）

  Store/Layouts/summary.json                   ← site_copy_2fa10547a2b7/summary.json
  Store/Layouts/p1_efficient_corner.json       ← 同 unity/
  Store/Layouts/p5_crammed.json                ← 同 unity/
  Store/Layouts/p1_efficient_corner_map.json   ← 同 unity/（新しい）
  Store/Layouts/p5_crammed_map.json            ← 同 unity/（新しい）
  Experiment/serving-robot-params.json         ← site_copy_2fa10547a2b7/

  新しい 2 本に .meta を作り、StoreLayout_p1_efficient_corner.asset と
  StoreLayout_p5_crammed.asset の mapJson に結び、fetchedAt を
  「2026-10-24 11:00:00 JST（版 2fa10547a2b7）」に直した。
  RobotSpec.asset に v9 の値を書き足した。

  4 つの版の番号はぜんぶ 2fa10547a2b7 でそろっている（写したファイルを読んで確かめた）:
      params の layout_generation  2fa10547a2b7
      p1: 店 2fa10547a2b7 / summary 2fa10547a2b7 / 地図 2fa10547a2b7（res 0.025 / 0.025）
      p5: 店 2fa10547a2b7 / summary 2fa10547a2b7 / 地図 2fa10547a2b7（res 0.025 / 0.025）
  回すときの確かめも 3 か所でかかる:
      店 ↔ summary       JsonStoreLayout.CheckGeneration（前からある）
      店 ↔ 地図           JsonStoreLayout.ReadMap（今回足した）
      店 ↔ 試算の値       ExperimentRunner の simParams.Load（前からある）
  そろわなければ建てない・回さない。


====================================================================
2. 確かめ A の結果（回した）
====================================================================

回した所: Unity の外（mono）。
Unity Editor がプロジェクトを押さえていて 2 つめの Unity を立てられないので、
Assets の MapPlanCheck.cs / GridPlanner.cs / SimpleJson.cs を**そのまま**
Unity 同梱の mono で組み立てて回した。Unity のメニュー
「ServiceSim/Map Planner/③⑦ 確かめ A」が呼ぶのと**同じ MapPlanCheck.Run** なので、
Unity で押しても同じ数字が出るはずです（押して確かめてください）。

## ③ p1_efficient_corner

  地図: 版 2fa10547a2b7 / 960 × 640 = 614400 マス / 1 マス 0.025 m
  マスの数: 2（ふだんの道）148223 / 1（通れる幅だけ）30397 / 0（通れない）435780
        → 地図の JSON の counts と **一致**
  受け渡し口の前 (15.3, 5.95) → マスの種類 2

  狭い道を使った卓     引いた道 0 卓 / サイトの routes の use 0 卓   → **一致**
  道が引けない卓       0 / 42
  長さの比（引いた道 ÷ サイトの length）  最小 0.959 / 平均 1.019 / 最大 1.057
  道探しの時間         平均 13.2 ms / 中央 11.2 ms / 最大 35.5 ms
  出発点・行き先を寄せた卓  0 / 42
  待機場所1 (16.9, 6.05) マスの種類 1 → 引けた（1.605 m・0.5 ms・狭い道を使った）
  待機場所2 (17.7, 6.05) マスの種類 1 → 引けた（2.403 m・0.9 ms・狭い道を使った）
  待機場所3 (18.5, 6.05) マスの種類 1 → 引けた（3.202 m・1.1 ms・狭い道を使った）
  → 確かめ A は通った

## ⑦ p5_crammed

  地図: 版 2fa10547a2b7 / 960 × 640 = 614400 マス（counts と一致）

  狭い道を使った卓     引いた道 24 卓 / サイトの routes の use 24 卓  → **一致**
  道が引けない卓       0 / 42
  長さの比           最小 0.959 / 平均 1.037 / 最大 1.227
  道探しの時間         平均 30.3 ms / 中央 22.6 ms / 最大 79.4 ms
  出発点・行き先を寄せた卓  0 / 42
  待機場所1〜3 ぜんぶ 引けた（1.6 / 2.4 / 3.2 m・0.5〜1.1 ms・狭い道を使った）
  → 確かめ A は通った

長さの比が 1 を少し超えるのは、真ん中寄りにした分です（依頼どおり）。
最小が 0.959（サイトより少し短い）になるのは、サイトの折れ線を
マス目の 8 方向でたどり直すと斜めが少しまっすぐになるからです。

## 道探しそのものの確かめ（確かめ A では通らない道を見た）

  ○ 1. 同じ条件なら毎回同じ道      点 9 個・10.825 m を 2 回引いて同じ（乱数なし）
  ○ 2. 人を通れないマスにすると道が変わる   10.825 m → 11.697 m
  ○ 3. 重いマスを差し込んでも引ける        10.825 m → 16.145 m（別の道を選んだ）
  ○ 4. 行き先をふさぐと引けない           「つながっていない」
  ○ 5. 出発点が 0 のマスでも寄せて引ける   (10.0, 4.5) マスの種類 0 → 0.088 m 寄せて引けた
  ○ 6. 曲がり角の点がすべて通れるマスの上   はみ出し 0 点
  ○ 7. 曲がり角だけにしても長さが暴れない   マスをたどった 11.122 m → 縮めた 10.825 m

## 出力フォルダ

  unity/screens/
    20261024_1150_v9_checkA_map_planner.txt          確かめ A の全文
    20261024_1150_v9_planner_unit_checks.txt         上の 7 つの確かめ
    20261024_1150_v9_map_plan_p1_efficient_corner.csv  卓ごと（use・長さ・比・角の数・ms）
    20261024_1150_v9_map_plan_p5_crammed.csv


====================================================================
3. 決めきれなかった値・この依頼どおりにすると困る所
====================================================================
（依頼の「報告」のとおり、ここを読んでから B を押してください。
 (2) と (4) はエイイチに決めてほしいところです）

## (1) 地図の free_class は「中心を置ける所」で、通路の幅のぶんもう削ってある

  free_class は体の半径（0.25 m）ではなく、**道の幅の半分**（1.0 m の道なら 0.5 m、
  0.8 m の道なら 0.4 m）で削ってあります。地図を横断して測った実際の値:

    ③ 通路 A01（幅 1.2 m）  中心を置ける帯 0.45 m（うち 2 のマスは 0.25 m）
    ⑦ 通路 A01（幅 0.95 m） 中心を置ける帯 0.20 m（ぜんぶ 1 のマス）

  2 台がすれ違うには中心どうし 0.5 m 要るので、**通路の中では 2 台は並べません**。
  体だけで見れば 1.2 m の通路には 0.75 m の帯があって並べるのですが、
  依頼の「地図を塗り直さない・通れる所を足さない」に従って free_class だけを使いました。

  その結果、4 章の「相手の道とかぶらない道を選ぶ」「0.6 m 横へどく」は
  **細い通路の中では成り立ちません**（どいても 0.225 m / 0.1 m しか離れられない）。
  通路の中で正面から出会ったら、片方が通路の外へ出るか、20 秒で待つのをやめて進むか、
  60 秒で立ち往生になります。

  ただし、v8b の比べる回（20261024_1004）の stops.csv を見ると、
  **60 秒の止まり 51 回（ロボットどうし）は全部が細い通路の外**でした
  （aisle の欄が空・in_narrow が 0）。止まった所の内訳:

    (10.0, 4.5) 11回  (10.5, 4.5) 9回   ← 通路 A03 の入口の手前
    (17.0, 2.5)  6回  (19.0, 2.5) 6回   ← レジ・入口のあたり
    (13.0, 8.0)  5回  (13.0, 10.0) 5回  (13.5, 7.5) 4回  ← 太い通路
    (17.5, 2.5)  3回  (16.0, 4.0) 1回   (5.0, 4.5) 1回

  どの所でも 2.5 m 以内に「0.6 m どける余地のあるマス（clear ≥ 850 mm）」が
  8〜17257 個ありました。だから 4 章は**働く場所で働く**見込みです。
  これは「このままでよい」という報告で、直してほしいことではありません。

## (2) ロボットが、地図では通れないマス（free_class 0）に立っていることがある ★決めてほしい

  いちばん大きな見落としだと思います。依頼に決まりが無かったので、こちらで決めました。

  上の 51 回のうち **36 回**は、地図では free_class 0 の所で止まっていました
  （clear は 287〜309 mm しかない）。(10.0,4.5) (10.5,4.5) (17.0,2.5) (19.0,2.5)
  (17.5,2.5) (5.0,4.5) がそれです。

  なぜそうなるか: NavMesh は体の半径 0.25 m で焼いてあるので、卓の角から 0.25 m まで
  面があります。いっぽう free_class は道の幅（0.4 / 0.5 m）で削ってあるので、
  通路の入口で待つ点（AisleWaitPoint）や横へどいた点は NavMesh には乗るけれど
  free_class では 0 です。そこから MapPlanner で道を引こうとすると
  「出発点が通れない」で 1 本も引けません。

  決めたこと（勝手に決めた分）:
    地図は塗り直さず、**出発点だけ近くの通れるマスへ寄せて引く**（mapSnapM 1.0 m 以内、
    いちばん近いマス。同じ距離ならマスの番号の小さい方）。
    寄せた回数は run_info.json の map_snap_starts に出します。
    確かめ 5（上）で、(10.0, 4.5) から 0.088 m 寄せて引けることを見ています。

  残っている気になる所:
    通路の入口で 1 台ずつ入る決まり（aisleYielding）は「変えないもの」なので、
    **待つ点はいまも NavMesh で決めています**（AisleWaitPoint）。つまり
    その点は free_class 0 のことがあり、毎回こちらで寄せが入ります。
    入口の待つ点も地図のマスから選ぶようにするなら、言ってください
    （「変えないもの」に入っているので、勝手には変えませんでした）。

## (3) 依頼に数字が無かったので、こちらで決めた値

  headOnDotMax      −0.5   「向かい合っている」と見る 2 台の向きの内積の上限。
                            −1 が真正面、−0.5 は 120 度より正面寄り
  robotPathCostAdd   6     相手の先の道を重くする強さ（1 歩の重さに足す）。
                            narrowCellCost 3 より大きくして、相手の道を避けさせた
  planMaxExpand  200000    1 回の道探しで広げるマスの上限。超えたら引けないことにする。
                            回が止まらないようにするための保険（ふだんは当たらない）
  mapSnapM         1.0 m   上の (2) の寄せる半径
  planSimplifyMaxM   6 m   曲がり角だけにするとき、近道 1 本の長さの上限
  よける場所の候補  3 つまで・互いに 0.5 m 以上離して、近い順に試す
  robot_yields の数え方
      「よける場所へ動いた回数」＋「相手の道をよけた道に引き直した回数」。
      同じ相手との 1 回の出会いは 1 回だけ数える（近くにいるあいだ 0.5 秒ごとに
      引き直すので、毎回数えると膨らむ）。
      よける場所へ動いた分は yields.csv にも 1 行ずつ出ます（前と同じ形）。

## (4) 角の箱（cornerMin/Max）を MapPlanner で切ると、比べる手がかりが 1 つ減る ★決めてほしい

  依頼どおり MapPlanner では働かないようにしました。ただしこれは
  **測る範囲だけで、動きには何も効きません**（止まった所が角の中かどうかを数えるだけ）。
  切ると summary.json の corner_stops / corner_stop_s / corner_stop_time_share が 0 になり、
  v8b の「止まった時間の約3分の1がこの角」と並べられなくなります。

  残したいなら、ExperimentRunner の 1 行（`&& !robotSpec.UseMapPlanner`）と
  ServiceOps の 1 行を外すだけです。どちらにしますか。
  （今は依頼のとおり「切る」で入っています）

## (5) 回す時間が延びる見込み

  1 回の道探しは ③ 平均 13 ms・最大 35 ms、⑦ 平均 30 ms・最大 79 ms（上の確かめ A）。
  引き直しを 1 台 0.5 秒に 1 回までに抑えたので、最悪で
  2 台 × 2 回/秒 × 30 ms = 120 ms（店の中の 1 秒あたり）。
  v8b の回は 10825 秒を 828 秒（13.1 倍）で回していたので、最悪で **+22 分**くらい。
  ふだんは出会っていないので引き直さないので、実際はもっと少ないはずです。
  run_info.json の plan_ms_max_per_trip で実際の値が見られます。

  重くならないように入れた工夫:
    ・「2 のマスだけで行けるか」は、地図を読んだときに作る「つながりの組」で
      一瞬で見る（A* を空振りさせない）。⑦は卓の半分が狭い道なので、これが効きます。
    ・作業用の配列は作り直さず使い回す。道探しは 2 台で 1 つを使い回す。

## (6) 待機場所は 1 のマス（狭い道）

  待機場所（16.9 / 17.7 / 18.5, 6.05）は壁ぎわなので free_class 1（clear 462 mm）です。
  依頼の「待機場所の真ん中も1以上（壁ぎわなので1になる）」のとおりでした。
  そこへ引くと「狭い道を使った」になりますが、MapPlanner の往復は
  受け渡し口の点（2 のマス）で終わるので、trips.csv の narrow_used には出ません。

  別件で気づいたこと（v9 では触っていません）:
    GoStandby は NavMeshAgent.SetDestination で待機場所へ向かわせていますが、
    NavMeshAgent は updatePosition = false で、Update() は Idle / Done のときに
    先に return します。つまり **transform は動かず、待機場所へ実際には動いていない**
    はずです。4 章の優先の順に「待機場所へ動いている」があるので、
    一度確かめた方がよさそうです。今回は「変えないもの」なので手を入れていません。


====================================================================
4. 組み立ての確かめ
====================================================================

Unity Editor がプロジェクトを押さえているので、Unity を立てずに
Unity 同梱の csc（Roslyn）で ServiceSim の .cs 49 本を**ぜんぶ**組み立てて、
エラーが 0 であることを確かめています（直すたびに毎回）。
なので Unity に切り替えたときに赤い字が出る心配は小さいですが、
Console は見てください（.meta の作られ方など、Unity 側でしか分からない分があります）。


====================================================================
5. エイイチにお願いすること
====================================================================

1. Unity に切り替える（新しい .cs が取り込まれる）。Console にエラーが無いか見る。
2. ServiceSim/Build Store で ③ を建て直す。
   報告（store_report）に「地図 (map.json): 版 2fa10547a2b7 / マス 960 × 640 …
   JSON の counts と 一致」と「止まる位置: 42 卓すべて 1 以上のマス」が出るはず。
3. ServiceSim/Map Planner/③⑦ 確かめ A（地図で道を引く・回さない）を押す。
   上の「2. 確かめ A の結果」と同じ数字が出るはず（③ 0 卓 / ⑦ 24 卓・一致）。
4. 上の「3.」の (2) と (4) を決める。
5. ServiceSim/Site Run/③ スタッフ4人・ロボット2台 を 1 回（7 章 B）。

B で比べる相手は 20261024_p1_efficient_corner_fam_p1_s4_r2_a42_s0_1004（v8b）。
見るのは:
    60 秒の止まりの数と、そのうちロボットどうしの数   v8b: 57 回・51 回
    逃がし弁                                      v8b: あきらめた 61・移した 54・卓を空けた 19
    1 往復で止まった秒（平均）、ロボットが運んだ皿の割合
    detours・robot_yields・detour_extra_m の平均（新しい列）
    最後まで回り切る（全員が店を出る）。来た組は 64 組

B の結果を見せてもらったら、段階Bの18回の回し直しを頼むかどうかを相談します。
B が最後まで回り切ったら Check-in して番号を報告します。


====================================================================
6. 新しいファイルの全文
====================================================================


-------------------------------------------------------------
--- Assets/ServiceSim/Robot/GridPlanner.cs
-------------------------------------------------------------

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Text;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// ロボットが走れる所の地図（サイトの {名前}_map.json）。
    ///
    /// 2026-10-24 にエイイチと決めたこと（依頼 v9）:
    ///   ロボットは**動ける範囲を地図で持ち、道はその都度自分で引く**。
    ///   地図はサイトが正（版 2fa10547a2b7 で公開）。Unity は塗り直さない・通れる所を足さない。
    ///   NavMesh はロボットの道探しには使わない（人に自分をよけてもらうためだけに残す）。
    ///
    /// マスの意味（JSON の free_class_values そのまま）:
    ///   2 … ふだん走らせる幅の道（normal_width 1.0 m）が通れる
    ///   1 … 通れる幅の道だけ（pass_width 0.8 m）通れる
    ///   0 … 通れない
    /// clear_mm … そのマスから、いちばん近い障害物までの距離（mm）
    ///
    /// 大事な読み方: free_class は「**ロボットの中心を置ける所**」で、
    /// 道の幅のぶんをもう削ってある（体の半径 0.25 m ではなく、道の幅の半分で削ってある）。
    /// だから 1.2 m の通路でも、中心を置ける帯は 0.45 m しかない。
    /// 2 台（中心どうし 0.5 m）は通路の中では並べない。
    ///
    /// 座標: マス (ix, iy) の中心は x=(ix+0.5)*res、y=(iy+0.5)*res。
    ///       JSON の x → Unity の X、y → Unity の Z。番号は iy*nx+ix。
    /// </summary>
    public class RobotMap
    {
        public readonly string generation = "";
        public readonly float res;
        public readonly int nx, ny;
        public readonly float normalWidthM, passWidthM;

        readonly byte[] _class;
        readonly ushort[] _clearMm;

        /// <summary>JSON が持っている counts（読んだものと突き合わせる）</summary>
        readonly int[] _jsonCounts = new int[3];
        readonly int[] _realCounts = new int[3];

        /// <summary>つながりの番号。2 だけでたどれる組と、1 以上でたどれる組</summary>
        int[] _region2, _region1;

        GridPlanner _planner;

        RobotMap(string generation, float res, int nx, int ny,
                 float normalWidthM, float passWidthM,
                 byte[] cls, ushort[] clearMm, int[] jsonCounts)
        {
            this.generation = generation ?? "";
            this.res = res;
            this.nx = nx;
            this.ny = ny;
            this.normalWidthM = normalWidthM;
            this.passWidthM = passWidthM;
            _class = cls;
            _clearMm = clearMm;
            for (int i = 0; i < 3; i++) _jsonCounts[i] = jsonCounts[i];
            for (int i = 0; i < cls.Length; i++)
            {
                int c = cls[i];
                if (c >= 0 && c <= 2) _realCounts[c]++;
            }
        }

        public int CellCount { get { return nx * ny; } }

        /// <summary>この地図を使う道探し（1つを使い回す。配列が大きいので台ごとに作らない）</summary>
        public GridPlanner Planner
        {
            get
            {
                if (_planner == null) _planner = new GridPlanner(this);
                return _planner;
            }
        }

        // ---------------- 読む ----------------

        /// <summary>
        /// 地図の JSON を読む。読めなければ error に理由を入れて null を返す。
        /// free_class・clear_mm は base64 → gzip を解く（JSON の encoding のとおり）。
        /// </summary>
        public static RobotMap Read(string json, out string error)
        {
            error = null;
            JsonValue root = JsonValue.Parse(json, out error);
            if (root == null || !root.IsObject)
            {
                error = "地図の JSON を読めません: " + error;
                return null;
            }

            float res = root["res"].F(0f);
            int nx = root["nx"].I(0);
            int ny = root["ny"].I(0);
            if (res <= 0.0001f || nx <= 0 || ny <= 0)
            {
                error = "地図の res / nx / ny がありません";
                return null;
            }

            int want = nx * ny;
            byte[] cls = Unpack(root["free_class"].S(""), 1, want, "free_class", out error);
            if (cls == null) return null;
            byte[] raw = Unpack(root["clear_mm"].S(""), 2, want, "clear_mm", out error);
            if (raw == null) return null;

            ushort[] clear = new ushort[want];
            for (int i = 0; i < want; i++)
                clear[i] = (ushort)(raw[i * 2] | (raw[i * 2 + 1] << 8));   // 小さい桁が先

            int[] counts = new int[3];
            JsonValue cj = root["counts"];
            for (int c = 0; c <= 2; c++)
                counts[c] = cj[c.ToString(CultureInfo.InvariantCulture)].I(-1);

            return new RobotMap(root["meta"]["generation"].S(""), res, nx, ny,
                                root["normal_width"].F(0f), root["pass_width"].F(0f),
                                cls, clear, counts);
        }

        static byte[] Unpack(string b64, int bytesPerCell, int cells, string what, out string error)
        {
            error = null;
            if (string.IsNullOrEmpty(b64))
            {
                error = what + " がありません";
                return null;
            }
            byte[] packed;
            try { packed = Convert.FromBase64String(b64); }
            catch (Exception e)
            {
                error = what + " の base64 を解けません: " + e.Message;
                return null;
            }

            byte[] data;
            try
            {
                using (MemoryStream src = new MemoryStream(packed))
                using (GZipStream gz = new GZipStream(src, CompressionMode.Decompress))
                using (MemoryStream dst = new MemoryStream(cells * bytesPerCell))
                {
                    byte[] buf = new byte[1 << 16];
                    int n;
                    while ((n = gz.Read(buf, 0, buf.Length)) > 0) dst.Write(buf, 0, n);
                    data = dst.ToArray();
                }
            }
            catch (Exception e)
            {
                error = what + " の gzip を解けません: " + e.Message;
                return null;
            }

            if (data.Length != cells * bytesPerCell)
            {
                error = what + " の長さが合いません: " + data.Length + " バイト / 要るのは " +
                        (cells * bytesPerCell) + " バイト（nx × ny × " + bytesPerCell + "）";
                return null;
            }
            return data;
        }

        // ---------------- マスを引く ----------------

        public int Index(int ix, int iy) { return iy * nx + ix; }

        /// <summary>その点のマスの番号。店の外なら -1</summary>
        public int CellAt(Vector3 p)
        {
            int ix = Mathf.FloorToInt(p.x / res);
            int iy = Mathf.FloorToInt(p.z / res);
            if (ix < 0 || ix >= nx || iy < 0 || iy >= ny) return -1;
            return iy * nx + ix;
        }

        /// <summary>マスの真ん中（Unity の XZ。y は 0）</summary>
        public Vector3 Center(int index)
        {
            int iy = index / nx;
            int ix = index - iy * nx;
            return new Vector3((ix + 0.5f) * res, 0f, (iy + 0.5f) * res);
        }

        /// <summary>そのマスの種類（2 / 1 / 0）。店の外は 0</summary>
        public int ClassOf(int index)
        {
            return index < 0 || index >= _class.Length ? 0 : _class[index];
        }

        /// <summary>その点の種類（店の外は 0）</summary>
        public int ClassAt(Vector3 p) { return ClassOf(CellAt(p)); }

        /// <summary>いちばん近い障害物までの距離（m）。店の外は 0</summary>
        public float ClearM(int index)
        {
            return index < 0 || index >= _clearMm.Length ? 0f : _clearMm[index] * 0.001f;
        }

        /// <summary>読んだマスの数（種類ごと）</summary>
        public int CountOf(int cls) { return cls >= 0 && cls <= 2 ? _realCounts[cls] : 0; }

        /// <summary>JSON の counts と読んだ数が合っているか</summary>
        public bool CountsMatch
        {
            get
            {
                for (int c = 0; c <= 2; c++)
                    if (_jsonCounts[c] != _realCounts[c]) return false;
                return true;
            }
        }

        /// <summary>
        /// 斜めに進めるか（両隣が通れるときだけ。サイトの道の引き方と同じ）。
        /// ix,iy → jx,jy は斜め隣のこと
        /// </summary>
        public bool DiagonalOpen(int ix, int iy, int jx, int jy, int minClass)
        {
            return _class[iy * nx + jx] >= minClass && _class[jy * nx + ix] >= minClass;
        }

        // ---------------- つながり（2 だけで行けるかを一瞬で見る）----------------

        /// <summary>
        /// 「行き先まで 2 のマスだけで行けるか」を、毎回 A* を空振りさせずに見るための組分け。
        /// 道の引き方（8方向・斜めは両隣が通れるときだけ）とまったく同じつなぎ方で塗る。
        /// </summary>
        public void BuildRegions()
        {
            if (_region2 != null) return;
            _region2 = Flood(2);
            _region1 = Flood(1);
        }

        int[] Flood(int minClass)
        {
            int n = nx * ny;
            int[] region = new int[n];
            for (int i = 0; i < n; i++) region[i] = -1;
            int[] stack = new int[n];
            int next = 0;

            for (int s = 0; s < n; s++)
            {
                if (region[s] >= 0 || _class[s] < minClass) continue;
                int id = next++;
                int top = 0;
                stack[top++] = s;
                region[s] = id;
                while (top > 0)
                {
                    int i = stack[--top];
                    int iy = i / nx;
                    int ix = i - iy * nx;
                    for (int d = 0; d < 8; d++)
                    {
                        int jx = ix + GridPlanner.Dx[d];
                        int jy = iy + GridPlanner.Dy[d];
                        if (jx < 0 || jx >= nx || jy < 0 || jy >= ny) continue;
                        int j = jy * nx + jx;
                        if (region[j] >= 0 || _class[j] < minClass) continue;
                        if (GridPlanner.Dx[d] != 0 && GridPlanner.Dy[d] != 0 &&
                            !DiagonalOpen(ix, iy, jx, jy, minClass)) continue;
                        region[j] = id;
                        stack[top++] = j;
                    }
                }
            }
            return region;
        }

        /// <summary>2 つのマスが、その種類だけでつながっているか</summary>
        public bool SameRegion(int a, int b, int minClass)
        {
            BuildRegions();
            int[] r = minClass >= 2 ? _region2 : _region1;
            if (a < 0 || b < 0 || a >= r.Length || b >= r.Length) return false;
            return r[a] >= 0 && r[a] == r[b];
        }

        // ---------------- 通れるマスへ寄せる ----------------

        /// <summary>
        /// その点のいちばん近い「通れるマス」。
        ///
        /// なぜ要るか: ロボットがいま立っている所が、地図では 0（通れない）のことがある。
        /// NavMesh は体の半径 0.25 m で焼いてあるのに、free_class は道の幅（0.4 m／0.5 m）で
        /// 削ってあるので、通路の端・通路の入口で待つ点・横へどいた点は 0 になる。
        /// （v8b の 20261024_1004 の回では、60 秒の止まり 51 回のうち 36 回がそういう所だった）
        /// 地図は塗り直さず、**出発点だけを近くの通れるマスへ寄せる**。寄せた回数は記録に出す。
        ///
        /// 見つからなければ -1。
        /// </summary>
        public int NearestPassable(Vector3 p, int minClass, float searchM)
        {
            int here = CellAt(p);
            if (here >= 0 && _class[here] >= minClass) return here;

            int ix0 = Mathf.FloorToInt(p.x / res);
            int iy0 = Mathf.FloorToInt(p.z / res);
            int maxR = Mathf.Max(1, Mathf.CeilToInt(searchM / res));

            int best = -1;
            float bestD2 = float.MaxValue;
            for (int r = 1; r <= maxR; r++)
            {
                for (int iy = iy0 - r; iy <= iy0 + r; iy++)
                {
                    if (iy < 0 || iy >= ny) continue;
                    for (int ix = ix0 - r; ix <= ix0 + r; ix++)
                    {
                        if (ix < 0 || ix >= nx) continue;
                        // 輪の上だけ見る（中は前の回で見た）
                        if (Mathf.Abs(ix - ix0) != r && Mathf.Abs(iy - iy0) != r) continue;
                        int i = iy * nx + ix;
                        if (_class[i] < minClass) continue;
                        Vector3 c = Center(i);
                        float dx = c.x - p.x, dz = c.z - p.z;
                        float d2 = dx * dx + dz * dz;
                        // 同じ距離なら番号の小さい方（毎回同じ答えにする）
                        if (d2 < bestD2 - 1e-9f) { bestD2 = d2; best = i; }
                    }
                }
                if (best >= 0) return best;      // 輪を1つ見終わって見つかれば、それが最短
            }
            return best;
        }

        // ---------------- 報告 ----------------

        public string Report()
        {
            StringBuilder sb = new StringBuilder();
            sb.AppendLine("  地図 (map.json): 版 " + generation +
                          " / マス " + nx + " × " + ny + " = " + CellCount +
                          " / 1 マス " + F(res) + " m");
            sb.AppendLine("    ふだんの道の幅 " + F(normalWidthM) + " m / 通れる幅 " + F(passWidthM) + " m");
            sb.AppendLine("    マスの数: 2（ふだんの道）" + _realCounts[2] +
                          " / 1（通れる幅だけ）" + _realCounts[1] +
                          " / 0（通れない）" + _realCounts[0] +
                          "  … JSON の counts と " + (CountsMatch ? "一致" : "ちがう") +
                          (CountsMatch ? "" : "（JSON: 2=" + _jsonCounts[2] + " 1=" + _jsonCounts[1] +
                                              " 0=" + _jsonCounts[0] + "）"));
            return sb.ToString().TrimEnd();
        }

        static string F(float v) { return v.ToString("0.###", CultureInfo.InvariantCulture); }
    }

    /// <summary>
    /// 地図の上で道を引く（依頼 v9 の 2 章）。ロボットだけが使う。NavMesh は使わない。
    ///
    /// 決まり:
    ///   ・8 方向。斜めは両隣が通れるときだけ（サイトと同じ）
    ///   ・行き先まで 2 のマスだけで行けるなら 2 だけで引く。行けなければ 1 も使い、
    ///     1 のマスを narrowCellCost 倍（目安 3 倍）重くする
    ///   ・通路の真ん中寄りを走る: 1 歩の重さに
    ///     centerWeight × max(0, preferClearM − clear) / preferClearM を足す
    ///   ・乱数を使わない。同じ条件なら毎回同じ道（同じ重さのときは h → マスの番号の順）
    ///   ・引けた道は曲がり角の点だけに縮める。近道は「もとの道で通っていた所より
    ///     障害物に近づかない所」だけ（端に寄った線に戻さないため）
    ///
    /// 回り込み・よけのために、その 1 回だけ「通れないマス」「重いマス」を差し込める。
    /// 配列が大きいので、1 つを台どうしで使い回す（道探しはその場で終わるので取り合わない）。
    /// </summary>
    public class GridPlanner
    {
        /// <summary>8 方向の順（決まった順。乱数を使わない）</summary>
        public static readonly int[] Dx = { 1, 1, 0, -1, -1, -1, 0, 1 };
        public static readonly int[] Dy = { 0, 1, 1, 1, 0, -1, -1, -1 };

        /// <summary>道探しの結果</summary>
        public enum Status
        {
            Ok,             // 引けた
            StartBlocked,   // 出発点が通れないマス（寄せても見つからない）
            GoalBlocked,    // 行き先が通れないマス
            NoPath,         // つながっていない
            TooMany         // 探しすぎ（maxExpand を超えた）
        }

        /// <summary>道の引き方の数字（RobotSpec から写す）</summary>
        public struct Settings
        {
            public float narrowCellCost;
            public float centerWeight;
            public float preferClearM;
            public int maxExpand;
            public float snapM;

            /// <summary>曲がり角だけに縮めるとき、1 本の近道の長さの上限（m）</summary>
            public float simplifyMaxM;

            public static Settings Default
            {
                get
                {
                    Settings s = new Settings();
                    s.narrowCellCost = 3f;
                    s.centerWeight = 2f;
                    s.preferClearM = 0.6f;
                    s.maxExpand = 200000;
                    s.snapM = 1.0f;
                    s.simplifyMaxM = 6f;
                    return s;
                }
            }
        }

        readonly RobotMap _map;
        readonly int _nx, _ny, _n;
        readonly float _res;

        // ---- 道探しの作業場（毎回作り直さない）----
        readonly float[] _g;
        readonly int[] _from;
        readonly int[] _seen;          // この回で見たか（_runStamp と比べる）
        readonly int[] _closed;
        int _runStamp;

        // ---- 差し込んだ「通れないマス」「重いマス」----
        readonly int[] _blockStamp;
        readonly int[] _costStamp;
        readonly float[] _costAdd;
        int _markStamp;
        bool _hasMarks;

        // ---- 二分の山（f が小さい順。同じなら h → 番号）----
        int[] _heap;
        float[] _heapF, _heapH;
        int _heapCount;

        readonly List<Vector3> _points = new List<Vector3>();
        readonly List<int> _cells = new List<int>();
        readonly Stopwatch _watch = new Stopwatch();

        public GridPlanner(RobotMap map)
        {
            _map = map;
            _nx = map.nx; _ny = map.ny; _n = map.nx * map.ny; _res = map.res;
            _g = new float[_n];
            _from = new int[_n];
            _seen = new int[_n];
            _closed = new int[_n];
            _blockStamp = new int[_n];
            _costStamp = new int[_n];
            _costAdd = new float[_n];
            _heap = new int[1024];
            _heapF = new float[1024];
            _heapH = new float[1024];
            // 印は 1 から始める。0 のままだと、何も印をつけていないマス（0）が
            // 「印がついている」に見えてしまう
            _markStamp = 1;
            map.BuildRegions();
        }

        public RobotMap Map { get { return _map; } }

        // ---- 直前の結果 ----
        public Status LastStatus { get; private set; }

        /// <summary>引けた道の曲がり角の点（最初は出発のマスの真ん中、最後は行き先のマスの真ん中）</summary>
        public IReadOnlyList<Vector3> Points { get { return _points; } }

        /// <summary>道に 1 のマスが入ったか（＝狭い道を使った）</summary>
        public bool UsedNarrow { get; private set; }

        /// <summary>この 1 回にかかった時間（ミリ秒）</summary>
        public float LastPlanMs { get; private set; }

        /// <summary>広げたマスの数（重さを調べるため）</summary>
        public int LastExpanded { get; private set; }

        /// <summary>マスをたどった長さ（縮める前, m）</summary>
        public float LastRawLengthM { get; private set; }

        /// <summary>縮めたあとの長さ（m）</summary>
        public float LastLengthM { get; private set; }

        /// <summary>出発のマスの真ん中と、渡された点のあいだの距離（m）</summary>
        public float LastStartSnapM { get; private set; }

        /// <summary>行き先のマスの真ん中と、渡された点のあいだの距離（m）</summary>
        public float LastGoalSnapM { get; private set; }

        /// <summary>
        /// 出発点が、地図では通れないマス（0）だったか。
        /// ロボットは NavMesh（体の半径 0.25 m）で焼いた面を歩くので、
        /// 通路の端・通路の入口で待つ点・横へどいた点は free_class では 0 になる。
        /// そういうときは近くの通れるマスへ寄せて引く。何回あったかを記録に出す
        /// </summary>
        public bool LastStartWasBlocked { get; private set; }

        /// <summary>行き先が、地図では通れないマス（0）だったか</summary>
        public bool LastGoalWasBlocked { get; private set; }

        public string StatusText
        {
            get
            {
                switch (LastStatus)
                {
                    case Status.Ok: return UsedNarrow ? "引けた（狭い道）" : "引けた";
                    case Status.StartBlocked: return "出発点が通れない";
                    case Status.GoalBlocked: return "行き先が通れない";
                    case Status.NoPath: return "つながっていない";
                    default: return "探しすぎ";
                }
            }
        }

        // ---------------- 差し込み（その 1 回だけ）----------------

        /// <summary>差し込んだものをぜんぶ消す（道を引く前に必ず呼ぶ）</summary>
        public void ClearMarks()
        {
            _markStamp++;
            _hasMarks = false;
        }

        /// <summary>丸い所を「通れないマス」にする（人・ほかのロボットの体）</summary>
        public void BlockCircle(Vector3 center, float radiusM)
        {
            MarkCapsule(center, center, radiusM, true, 0f);
        }

        /// <summary>線のまわりを「通れないマス」にする</summary>
        public void BlockLine(Vector3 a, Vector3 b, float radiusM)
        {
            MarkCapsule(a, b, radiusM, true, 0f);
        }

        /// <summary>線のまわりを重くする（相手の先の道から離れて走らせる）</summary>
        public void CostLine(Vector3 a, Vector3 b, float radiusM, float add)
        {
            MarkCapsule(a, b, radiusM, false, add);
        }

        void MarkCapsule(Vector3 a, Vector3 b, float radiusM, bool block, float add)
        {
            if (radiusM <= 0f) return;
            _hasMarks = true;

            float minX = Mathf.Min(a.x, b.x) - radiusM, maxX = Mathf.Max(a.x, b.x) + radiusM;
            float minZ = Mathf.Min(a.z, b.z) - radiusM, maxZ = Mathf.Max(a.z, b.z) + radiusM;
            int ix0 = Mathf.Max(0, Mathf.FloorToInt(minX / _res));
            int ix1 = Mathf.Min(_nx - 1, Mathf.FloorToInt(maxX / _res));
            int iy0 = Mathf.Max(0, Mathf.FloorToInt(minZ / _res));
            int iy1 = Mathf.Min(_ny - 1, Mathf.FloorToInt(maxZ / _res));
            float r2 = radiusM * radiusM;

            for (int iy = iy0; iy <= iy1; iy++)
            {
                for (int ix = ix0; ix <= ix1; ix++)
                {
                    float cx = (ix + 0.5f) * _res, cz = (iy + 0.5f) * _res;
                    if (SegDist2(cx, cz, a.x, a.z, b.x, b.z) > r2) continue;
                    int i = iy * _nx + ix;
                    if (block)
                    {
                        _blockStamp[i] = _markStamp;
                        continue;
                    }
                    if (_costStamp[i] == _markStamp) _costAdd[i] = Mathf.Max(_costAdd[i], add);
                    else { _costStamp[i] = _markStamp; _costAdd[i] = add; }
                }
            }
        }

        static float SegDist2(float px, float pz, float ax, float az, float bx, float bz)
        {
            float vx = bx - ax, vz = bz - az;
            float l2 = vx * vx + vz * vz;
            float t = 0f;
            if (l2 > 1e-12f)
            {
                t = ((px - ax) * vx + (pz - az) * vz) / l2;
                t = t < 0f ? 0f : (t > 1f ? 1f : t);
            }
            float dx = px - (ax + t * vx), dz = pz - (az + t * vz);
            return dx * dx + dz * dz;
        }

        // ---------------- 道を引く ----------------

        /// <summary>
        /// from から to まで道を引く。引けたら Status.Ok で、Points に曲がり角の点が入る。
        ///
        /// 「2 のマスだけで行けるなら 2 だけ」は、つながりの組（RobotMap.SameRegion）で
        /// 一瞬で見る。A* を空振りさせない（⑦では卓の半分が狭い道なので、空振りが重い）。
        /// </summary>
        public Status Plan(Vector3 from, Vector3 to, Settings st)
        {
            _watch.Reset();
            _watch.Start();

            _points.Clear();
            _cells.Clear();
            UsedNarrow = false;
            LastExpanded = 0;
            LastRawLengthM = 0f;
            LastLengthM = 0f;
            LastStartSnapM = 0f;
            LastGoalSnapM = 0f;
            LastStartWasBlocked = _map.ClassAt(from) < 1;
            LastGoalWasBlocked = _map.ClassAt(to) < 1;

            // 行き先は、まず 1 以上のマスに乗っているかを見る（サイトが通れる所に置いている）
            int goal = _map.NearestPassable(to, 1, st.snapM);
            if (goal < 0)
            {
                LastStatus = Status.GoalBlocked;
                return Done();
            }
            LastGoalSnapM = Flat(_map.Center(goal) - to).magnitude;

            int start = _map.NearestPassable(from, 1, st.snapM);
            if (start < 0)
            {
                LastStatus = Status.StartBlocked;
                return Done();
            }
            LastStartSnapM = Flat(_map.Center(start) - from).magnitude;

            // 2 のマスだけで行けるか（出発点・行き先がどちらも 2 で、同じ組）
            int minClass = 1;
            if (_map.ClassOf(start) >= 2 && _map.ClassOf(goal) >= 2 &&
                _map.SameRegion(start, goal, 2) && !_hasMarks)
            {
                minClass = 2;
            }
            else if (_map.ClassOf(start) >= 2 && _map.ClassOf(goal) >= 2 &&
                     _map.SameRegion(start, goal, 2))
            {
                // 差し込み（回り込み・よけ）があるときは、つながりの組では決められない。
                // まず 2 だけで試し、だめなら 1 も使う
                if (Search(start, goal, 2, st) == Status.Ok)
                {
                    Finish(st, 2);
                    LastStatus = Status.Ok;
                    return Done();
                }
            }

            if (minClass == 1 && !_map.SameRegion(start, goal, 1))
            {
                LastStatus = Status.NoPath;
                return Done();
            }

            Status s = Search(start, goal, minClass, st);
            if (s != Status.Ok)
            {
                LastStatus = s;
                return Done();
            }
            Finish(st, minClass);
            LastStatus = Status.Ok;
            return Done();
        }

        Status Done()
        {
            _watch.Stop();
            LastPlanMs = (float)(_watch.Elapsed.TotalMilliseconds);
            return LastStatus;
        }

        Status Search(int start, int goal, int minClass, Settings st)
        {
            _runStamp++;
            _heapCount = 0;
            _cells.Clear();

            if (Blocked(start, minClass) || Blocked(goal, minClass)) return Status.NoPath;

            int gy = goal / _nx, gx = goal - gy * _nx;
            float prefMm = Mathf.Max(0.001f, st.preferClearM);
            float d2 = _res * 1.41421356f;
            int expanded = 0;

            _g[start] = 0f;
            _from[start] = -1;
            _seen[start] = _runStamp;
            Push(start, Heuristic(start, gx, gy, d2), Heuristic(start, gx, gy, d2));

            while (_heapCount > 0)
            {
                int i = Pop();
                if (_closed[i] == _runStamp) continue;
                _closed[i] = _runStamp;
                expanded++;
                if (i == goal)
                {
                    LastExpanded = expanded;
                    Trace(start, goal);
                    return Status.Ok;
                }
                if (expanded > st.maxExpand)
                {
                    LastExpanded = expanded;
                    return Status.TooMany;
                }

                int iy = i / _nx, ix = i - iy * _nx;
                float gi = _g[i];

                for (int d = 0; d < 8; d++)
                {
                    int jx = ix + Dx[d], jy = iy + Dy[d];
                    if (jx < 0 || jx >= _nx || jy < 0 || jy >= _ny) continue;
                    int j = jy * _nx + jx;
                    if (_closed[j] == _runStamp) continue;
                    if (Blocked(j, minClass)) continue;

                    float step;
                    if (Dx[d] != 0 && Dy[d] != 0)
                    {
                        // 斜めは両隣が通れるときだけ
                        if (Blocked(iy * _nx + jx, minClass) || Blocked(jy * _nx + ix, minClass)) continue;
                        step = d2;
                    }
                    else step = _res;

                    float mult = _map.ClassOf(j) == 1 ? st.narrowCellCost : 1f;
                    float clearM = _map.ClearM(j);
                    if (clearM < st.preferClearM)
                        mult += st.centerWeight * (st.preferClearM - clearM) / prefMm;
                    if (_costStamp[j] == _markStamp) mult += _costAdd[j];

                    float ng = gi + step * mult;
                    if (_seen[j] == _runStamp && ng >= _g[j] - 1e-9f) continue;

                    _g[j] = ng;
                    _from[j] = i;
                    _seen[j] = _runStamp;
                    float h = Heuristic(j, gx, gy, d2);
                    Push(j, ng + h, h);
                }
            }

            LastExpanded = expanded;
            return Status.NoPath;
        }

        /// <summary>そのマスは通れないか（地図の種類＋差し込んだ「通れないマス」）</summary>
        bool Blocked(int i, int minClass)
        {
            if (_map.ClassOf(i) < minClass) return true;
            return _hasMarks && _blockStamp[i] == _markStamp;
        }

        /// <summary>残りの見込み（8 方向のまっすぐな長さ）。1 歩の重さの下限は 1 倍なので、下から見ている</summary>
        float Heuristic(int i, int gx, int gy, float d2)
        {
            int iy = i / _nx, ix = i - iy * _nx;
            int dx = ix > gx ? ix - gx : gx - ix;
            int dy = iy > gy ? iy - gy : gy - iy;
            int lo = dx < dy ? dx : dy;
            return _res * (dx + dy) + (d2 - 2f * _res) * lo;
        }

        void Trace(int start, int goal)
        {
            _cells.Clear();
            int i = goal;
            while (i != start)
            {
                _cells.Add(i);
                i = _from[i];
                if (i < 0) break;
            }
            _cells.Add(start);
            _cells.Reverse();
        }

        void Finish(Settings st, int minClass)
        {
            // 1 のマスが入ったか
            for (int k = 0; k < _cells.Count; k++)
                if (_map.ClassOf(_cells[k]) == 1) { UsedNarrow = true; break; }

            float raw = 0f;
            for (int k = 1; k < _cells.Count; k++)
                raw += Flat(_map.Center(_cells[k]) - _map.Center(_cells[k - 1])).magnitude;
            LastRawLengthM = raw;

            Simplify(st, minClass);

            float len = 0f;
            for (int k = 1; k < _points.Count; k++)
                len += Flat(_points[k] - _points[k - 1]).magnitude;
            LastLengthM = len;
        }

        // ---------------- 曲がり角の点だけにする ----------------

        /// <summary>
        /// マスの並びを曲がり角の点だけに縮める。
        /// 近道してよいのは「もとの道で通っていた所より障害物に近づかない」ときだけ
        /// （＝近道の上のどのマスも、飛ばしたマスのいちばん狭い所より clear が大きい）。
        /// 端に寄った線に戻らないようにするため。
        /// </summary>
        void Simplify(Settings st, int minClass)
        {
            _points.Clear();
            if (_cells.Count == 0) return;
            _points.Add(_map.Center(_cells[0]));
            if (_cells.Count == 1) return;

            int step = Mathf.Max(1, Mathf.RoundToInt(0.1f / _res));   // 0.1 m ごとに試す
            int maxCells = Mathf.Max(step, Mathf.RoundToInt(st.simplifyMaxM / _res));

            int i = 0;
            while (i < _cells.Count - 1)
            {
                int best = i + 1;
                float floorM = Mathf.Min(_map.ClearM(_cells[i]), _map.ClearM(_cells[i + 1]));

                int j = i + step;
                float runFloor = floorM;
                while (j < _cells.Count && j - i <= maxCells)
                {
                    for (int k = Mathf.Max(i + 1, j - step + 1); k <= j; k++)
                        runFloor = Mathf.Min(runFloor, _map.ClearM(_cells[k]));
                    if (!SegmentOk(_cells[i], _cells[j], runFloor, minClass)) break;
                    best = j;
                    j += step;
                }
                // 最後の点までちょうど届くなら、そこで終わる
                int last = _cells.Count - 1;
                if (best < last && last - i <= maxCells)
                {
                    float f = runFloor;
                    for (int k = best + 1; k <= last; k++) f = Mathf.Min(f, _map.ClearM(_cells[k]));
                    if (SegmentOk(_cells[i], _cells[last], f, minClass)) best = last;
                }

                _points.Add(_map.Center(_cells[best]));
                i = best;
            }
        }

        /// <summary>まっすぐ結んだ線が通れて、もとの道より障害物に近づかないか</summary>
        bool SegmentOk(int a, int b, float floorM, int minClass)
        {
            Vector3 pa = _map.Center(a), pb = _map.Center(b);
            float len = Flat(pb - pa).magnitude;
            int n = Mathf.Max(1, Mathf.CeilToInt(len / (_res * 0.5f)));
            for (int k = 0; k <= n; k++)
            {
                float t = (float)k / n;
                float x = pa.x + (pb.x - pa.x) * t;
                float z = pa.z + (pb.z - pa.z) * t;
                int ix = Mathf.FloorToInt(x / _res);
                int iy = Mathf.FloorToInt(z / _res);
                if (ix < 0 || ix >= _nx || iy < 0 || iy >= _ny) return false;
                int i = iy * _nx + ix;
                if (Blocked(i, minClass)) return false;
                if (_map.ClearM(i) < floorM - 1e-6f) return false;
                if (_hasMarks && _costStamp[i] == _markStamp) return false;   // 重い所へは近道しない
            }
            return true;
        }

        // ---------------- 二分の山 ----------------

        void Push(int index, float f, float h)
        {
            if (_heapCount == _heap.Length) Grow();
            int k = _heapCount++;
            _heap[k] = index; _heapF[k] = f; _heapH[k] = h;
            while (k > 0)
            {
                int p = (k - 1) >> 1;
                if (!Less(k, p)) break;
                Swap(k, p);
                k = p;
            }
        }

        int Pop()
        {
            int top = _heap[0];
            _heapCount--;
            if (_heapCount > 0)
            {
                _heap[0] = _heap[_heapCount];
                _heapF[0] = _heapF[_heapCount];
                _heapH[0] = _heapH[_heapCount];
                int k = 0;
                while (true)
                {
                    int l = (k << 1) + 1, r = l + 1, m = k;
                    if (l < _heapCount && Less(l, m)) m = l;
                    if (r < _heapCount && Less(r, m)) m = r;
                    if (m == k) break;
                    Swap(k, m);
                    k = m;
                }
            }
            return top;
        }

        /// <summary>同じ重さのときの並べ方を決めておく（f → h → マスの番号）。乱数を使わない</summary>
        bool Less(int a, int b)
        {
            if (_heapF[a] < _heapF[b] - 1e-9f) return true;
            if (_heapF[a] > _heapF[b] + 1e-9f) return false;
            if (_heapH[a] < _heapH[b] - 1e-9f) return true;
            if (_heapH[a] > _heapH[b] + 1e-9f) return false;
            return _heap[a] < _heap[b];
        }

        void Swap(int a, int b)
        {
            int i = _heap[a]; _heap[a] = _heap[b]; _heap[b] = i;
            float f = _heapF[a]; _heapF[a] = _heapF[b]; _heapF[b] = f;
            float h = _heapH[a]; _heapH[a] = _heapH[b]; _heapH[b] = h;
        }

        void Grow()
        {
            int size = _heap.Length * 2;
            int[] hi = new int[size];
            float[] hf = new float[size];
            float[] hh = new float[size];
            Array.Copy(_heap, hi, _heapCount);
            Array.Copy(_heapF, hf, _heapCount);
            Array.Copy(_heapH, hh, _heapCount);
            _heap = hi; _heapF = hf; _heapH = hh;
        }

        static Vector3 Flat(Vector3 v) { v.y = 0f; return v; }
    }
}


-------------------------------------------------------------
--- Assets/ServiceSim/Robot/MapPlanCheck.cs
-------------------------------------------------------------

using System.Collections.Generic;
using System.Globalization;
using System.Text;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// 確かめ A（依頼 v9 の 7 章 A）：人のいない店で、受け渡し口から全卓へ道を引く。
    ///
    /// 見るもの:
    ///   ・狭い道を使った卓が、サイトの routes の use と同じか（③は 0 卓・⑦は 24 卓）
    ///   ・引いた道の長さと、サイトの routes の length の比（最小・平均・最大）
    ///   ・道探しの時間（平均・中央・最大）
    ///   ・道が引けない卓が無いこと。待機場所へも引けること
    ///
    /// 店も人も建てずに、JSON と地図だけで回せるようにしてある。
    /// Unity のメニュー（ServiceSim/Map Planner）と、Unity の外（mono）の両方から
    /// **同じこの関数**を呼ぶので、出る数字は必ず同じになる。
    /// </summary>
    public static class MapPlanCheck
    {
        /// <summary>1 卓ぶんの結果</summary>
        public struct Row
        {
            public string tableId;
            public string siteUse;      // サイトの routes の use（normal / narrow）
            public string planUse;      // 引いた道（normal / narrow）
            public float siteLengthM;
            public float planLengthM;
            public float ratio;         // 引いた道 ÷ サイトの length
            public int corners;
            public float planMs;
            public string status;
        }

        /// <summary>1 店ぶんの結果</summary>
        public class Result
        {
            public bool ok;
            public string note = "";
            public string report = "";
            public readonly List<Row> rows = new List<Row>();
            public int narrowMine, narrowSite, failed, mismatch, snapped;
            public float ratioMin, ratioMean, ratioMax;
            public float planMsMean, planMsMedian, planMsMax;
            public int standbyOk, standbyCount;

            /// <summary>CSV（table,...）。そのまま書き出せる</summary>
            public string Csv()
            {
                StringBuilder sb = new StringBuilder();
                sb.Append("table,site_use,plan_use,site_length_m,plan_length_m,ratio,corners,plan_ms,status\n");
                for (int i = 0; i < rows.Count; i++)
                {
                    Row r = rows[i];
                    sb.Append(r.tableId).Append(',').Append(r.siteUse).Append(',').Append(r.planUse).Append(',')
                      .Append(F(r.siteLengthM)).Append(',').Append(F(r.planLengthM)).Append(',')
                      .Append(F(r.ratio)).Append(',').Append(r.corners).Append(',')
                      .Append(F(r.planMs)).Append(',').Append(r.status).Append('\n');
                }
                return sb.ToString();
            }
        }

        /// <summary>
        /// 回す。storeJson は店の形の JSON、mapJsonText は地図の JSON。
        /// spec が null なら GridPlanner.Settings.Default で引く。
        /// </summary>
        public static Result Run(string label, string storeJson, string mapJsonText, RobotSpec spec)
        {
            Result res = new Result();
            StringBuilder sb = new StringBuilder();
            sb.AppendLine("## " + label);

            string err;
            JsonValue store = JsonValue.Parse(storeJson, out err);
            if (store == null || !store.IsObject)
            {
                res.note = "店の形の JSON を読めません: " + err;
                sb.AppendLine("  " + res.note);
                res.report = sb.ToString().TrimEnd();
                return res;
            }

            RobotMap map = RobotMap.Read(mapJsonText, out err);
            if (map == null)
            {
                res.note = "地図を読めません: " + err;
                sb.AppendLine("  " + res.note);
                res.report = sb.ToString().TrimEnd();
                return res;
            }
            sb.AppendLine(map.Report());

            GridPlanner.Settings st = spec != null ? spec.PlannerSettings() : GridPlanner.Settings.Default;
            sb.AppendLine("  道の引き方: narrowCellCost " + F(st.narrowCellCost) +
                          " / centerWeight " + F(st.centerWeight) +
                          " / preferClearM " + F(st.preferClearM) +
                          " / maxExpand " + st.maxExpand +
                          " / snapM " + F(st.snapM) +
                          " / simplifyMaxM " + F(st.simplifyMaxM));

            Vector3 pass = Vector3.zero;
            bool hasPass = false;
            List<Vector3> standby = new List<Vector3>();
            JsonValue places = store["places"];
            for (int i = 0; i < places.Count; i++)
            {
                string kind = places[i]["kind"].S("");
                Vector3 p = new Vector3(places[i]["point"][0].F(0f), 0f, places[i]["point"][1].F(0f));
                if (kind == "FoodPass") { pass = p; hasPass = true; }
                else if (kind == "RobotStandby") standby.Add(p);
            }
            if (!hasPass)
            {
                res.note = "places に FoodPass（受け渡し口の前）がありません";
                sb.AppendLine("  " + res.note);
                res.report = sb.ToString().TrimEnd();
                return res;
            }
            sb.AppendLine("  受け渡し口の前 " + P(pass) + " → マスの種類 " + map.ClassAt(pass));

            GridPlanner planner = map.Planner;
            JsonValue fromPass = store["routes"]["from_pass"];
            JsonValue tables = store["tables"];

            List<float> ratios = new List<float>();
            List<float> times = new List<float>();
            List<string> mismatchList = new List<string>();

            for (int i = 0; i < tables.Count; i++)
            {
                Row row = new Row();
                row.tableId = tables[i]["id"].S("T" + (i + 1).ToString("00"));
                Vector3 stop = new Vector3(tables[i]["stop"][0].F(0f), 0f, tables[i]["stop"][1].F(0f));

                JsonValue route = fromPass[row.tableId];
                row.siteUse = route["use"].S("");
                row.siteLengthM = route["length"].F(-1f);
                if (row.siteUse == "narrow") res.narrowSite++;

                planner.ClearMarks();
                GridPlanner.Status s = planner.Plan(pass, stop, st);
                row.planMs = planner.LastPlanMs;
                times.Add(row.planMs);
                if (planner.LastStartWasBlocked || planner.LastGoalWasBlocked) res.snapped++;

                if (s != GridPlanner.Status.Ok)
                {
                    res.failed++;
                    row.status = planner.StatusText;
                    res.rows.Add(row);
                    sb.AppendLine("  × " + row.tableId + " 道が引けません: " + row.status);
                    continue;
                }

                row.planUse = planner.UsedNarrow ? "narrow" : "normal";
                if (planner.UsedNarrow) res.narrowMine++;
                row.planLengthM = planner.LastLengthM;
                row.corners = planner.Points.Count;
                row.status = "ok";
                row.ratio = row.siteLengthM > 0.0005f ? row.planLengthM / row.siteLengthM : -1f;
                if (row.ratio > 0f) ratios.Add(row.ratio);
                if (row.planUse != row.siteUse)
                {
                    res.mismatch++;
                    mismatchList.Add(row.tableId + "（サイト " + row.siteUse + " / 引いた道 " + row.planUse + "）");
                }
                res.rows.Add(row);
            }

            sb.AppendLine("  狭い道を使った卓: 引いた道 " + res.narrowMine +
                          " 卓 / サイトの routes の use " + res.narrowSite + " 卓  → " +
                          (res.mismatch == 0 ? "一致" : "ちがう（" + res.mismatch + " 卓）"));
            for (int i = 0; i < mismatchList.Count && i < 12; i++)
                sb.AppendLine("    - " + mismatchList[i]);
            if (mismatchList.Count > 12)
                sb.AppendLine("    - ほか " + (mismatchList.Count - 12) + " 卓");
            sb.AppendLine("  道が引けない卓: " + res.failed + " / " + tables.Count);

            if (ratios.Count > 0)
            {
                ratios.Sort();
                float sum = 0f;
                for (int i = 0; i < ratios.Count; i++) sum += ratios[i];
                res.ratioMin = ratios[0];
                res.ratioMean = sum / ratios.Count;
                res.ratioMax = ratios[ratios.Count - 1];
                sb.AppendLine("  長さの比（引いた道 ÷ サイトの length）: 最小 " + F(res.ratioMin) +
                              " / 平均 " + F(res.ratioMean) + " / 最大 " + F(res.ratioMax) +
                              "（真ん中寄りにした分だけ延びる）");
            }
            if (times.Count > 0)
            {
                times.Sort();
                float sum = 0f;
                for (int i = 0; i < times.Count; i++) sum += times[i];
                res.planMsMean = sum / times.Count;
                res.planMsMedian = times[times.Count / 2];
                res.planMsMax = times[times.Count - 1];
                sb.AppendLine("  道探しの時間: 平均 " + F(res.planMsMean) + " ms / 中央 " +
                              F(res.planMsMedian) + " ms / 最大 " + F(res.planMsMax) + " ms");
            }
            sb.AppendLine("  出発点・行き先が地図で通れないマス（0）だったので寄せた卓: " +
                          res.snapped + " / " + tables.Count);

            res.standbyCount = standby.Count;
            for (int i = 0; i < standby.Count; i++)
            {
                planner.ClearMarks();
                GridPlanner.Status s = planner.Plan(pass, standby[i], st);
                bool good = s == GridPlanner.Status.Ok;
                if (good) res.standbyOk++;
                sb.AppendLine("  待機場所" + (i + 1) + " " + P(standby[i]) +
                              " マスの種類 " + map.ClassAt(standby[i]) + " → " +
                              (good ? "引けた（" + F(planner.LastLengthM) + " m・" +
                                      F(planner.LastPlanMs) + " ms・" +
                                      (planner.UsedNarrow ? "狭い道を使った" : "ふだんの道") + "）"
                                    : "引けない：" + planner.StatusText));
            }

            res.ok = res.failed == 0 && res.mismatch == 0 &&
                     res.standbyOk == res.standbyCount && map.CountsMatch;
            sb.AppendLine("  → " + (res.ok ? "確かめ A は通った" : "確かめ A は通っていない"));
            res.report = sb.ToString().TrimEnd();
            return res;
        }

        static string F(float v) { return v.ToString("0.###", CultureInfo.InvariantCulture); }
        static string P(Vector3 v) { return "(" + F(v.x) + ", " + F(v.z) + ")"; }
    }
}


-------------------------------------------------------------
--- Assets/ServiceSim/Robot/RobotPathLine.cs
-------------------------------------------------------------

using System.Collections.Generic;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// ロボットがいま引いている道を、床の上に細い線で出す（依頼 v9 の 6 章）。
    ///
    /// 色は仕事で変える:
    ///   料理を載せているとき（卓へ向かっている）… だいだい
    ///   空で戻るとき・待機 … 水色
    ///
    /// 見た目だけ。当たりも NavMesh も持たないので、
    /// ロボットのセンサー・人のよけ方・道探しには入らない。
    /// RobotSpec.showPathLine を外すと出さない（測る回でも出してよいが、切れるようにする）。
    ///
    /// 置き場所: ロボットの prefab には足さない。ロボットが自分で子として作る
    /// （材料も prefab も要らないようにして、シーンの組み立てを変えないため）。
    /// </summary>
    [RequireComponent(typeof(LineRenderer))]
    public class RobotPathLine : MonoBehaviour
    {
        /// <summary>この部品を置く子の名前</summary>
        public const string RootName = "PathLine";

        static readonly Color CarryColor = new Color(1f, 0.55f, 0.1f, 0.9f);   // 料理を載せている
        static readonly Color EmptyColor = new Color(0.25f, 0.75f, 1f, 0.9f);  // 空で戻る

        LineRenderer _line;
        readonly List<Vector3> _points = new List<Vector3>();

        /// <summary>
        /// ロボットの下に線を作る（無ければ）。材料はその場で作るので、
        /// prefab や .asset をいじらなくていい。URP でも読める shader を順に試す
        /// </summary>
        public static RobotPathLine Attach(Transform robot, float widthM)
        {
            if (robot == null) return null;
            Transform t = robot.Find(RootName);
            GameObject go;
            if (t != null) go = t.gameObject;
            else
            {
                go = new GameObject(RootName);
                go.transform.SetParent(robot, false);
            }

            if (go.GetComponent<LineRenderer>() == null) go.AddComponent<LineRenderer>();
            RobotPathLine p = go.GetComponent<RobotPathLine>();
            if (p == null) p = go.AddComponent<RobotPathLine>();
            p.Setup(widthM);
            return p;
        }

        void Setup(float widthM)
        {
            if (_line == null) _line = GetComponent<LineRenderer>();
            _line.useWorldSpace = true;          // 親が回っても線は曲がらない
            _line.alignment = LineAlignment.View;
            _line.textureMode = LineTextureMode.Stretch;
            _line.numCapVertices = 2;
            _line.numCornerVertices = 2;
            _line.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
            _line.receiveShadows = false;
            _line.widthMultiplier = Mathf.Max(0.005f, widthM);
            _line.positionCount = 0;

            if (_line.sharedMaterial == null)
            {
                Shader s = Shader.Find("Universal Render Pipeline/Unlit");
                if (s == null) s = Shader.Find("Sprites/Default");
                if (s == null) s = Shader.Find("Unlit/Color");
                if (s != null)
                {
                    Material m = new Material(s);
                    m.name = "Mat_RobotPathLine";
                    _line.sharedMaterial = m;
                }
            }
        }

        /// <summary>
        /// 線を出し直す。points は道の曲がり角（世界の座標）。
        /// carrying は料理を載せているか。heightM は床から浮かせる高さ。
        /// </summary>
        public void Show(IReadOnlyList<Vector3> points, int fromIndex, Vector3 here,
                         bool carrying, float heightM)
        {
            if (_line == null) _line = GetComponent<LineRenderer>();

            _points.Clear();
            _points.Add(new Vector3(here.x, heightM, here.z));
            if (points != null)
                for (int i = Mathf.Max(0, fromIndex); i < points.Count; i++)
                    _points.Add(new Vector3(points[i].x, heightM, points[i].z));

            if (_points.Count < 2)
            {
                _line.positionCount = 0;
                return;
            }

            _line.positionCount = _points.Count;
            for (int i = 0; i < _points.Count; i++) _line.SetPosition(i, _points[i]);

            Color c = carrying ? CarryColor : EmptyColor;
            _line.startColor = c;
            _line.endColor = new Color(c.r, c.g, c.b, c.a * 0.35f);
            if (_line.sharedMaterial != null && _line.sharedMaterial.HasProperty("_BaseColor"))
                _line.sharedMaterial.SetColor("_BaseColor", c);
        }

        /// <summary>線を消す</summary>
        public void Hide()
        {
            if (_line == null) _line = GetComponent<LineRenderer>();
            _line.positionCount = 0;
        }
    }
}


-------------------------------------------------------------
--- Assets/ServiceSim/Editor/ServiceSimMapPlanner.cs
-------------------------------------------------------------

using System;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEngine;

namespace ServiceSim.EditorTools
{
    /// <summary>
    /// 地図の上で道を引く確かめ（依頼 v9 の 7 章 A）。
    ///
    /// 店も人も建てず、回さない。サイトの JSON と地図だけで、
    /// 受け渡し口から全卓へ道を引いて、サイトの routes と突き合わせる。
    /// 中身は ServiceSim.MapPlanCheck（Unity の外からも同じものを呼べる）。
    /// </summary>
    public static class ServiceSimMapPlanner
    {
        const string P1Path = "Assets/ServiceSim/Store/StoreLayout_p1_efficient_corner.asset";
        const string P5Path = "Assets/ServiceSim/Store/StoreLayout_p5_crammed.asset";

        [MenuItem("ServiceSim/Map Planner/③⑦ 確かめ A（地図で道を引く・回さない）", false, 34)]
        public static void CheckBoth()
        {
            StringBuilder sb = new StringBuilder();
            sb.AppendLine("# 確かめ A：人のいない店で、受け渡し口から全卓へ道を引く（依頼 v9 の 7 章 A）");
            sb.AppendLine("作った日時: " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
            sb.AppendLine("店も人も建てていない（JSON と地図だけで引いた）");
            sb.AppendLine();

            RobotSpec spec = AssetDatabase.LoadAssetAtPath<RobotSpec>(ServiceSimSetup.RobotSpecPath);
            if (spec == null)
                sb.AppendLine("※ RobotSpec が見つからないので、道の引き方は既定の値で引きました");

            bool ok = true;
            ok &= One(sb, P1Path, "③ 効率重視型・入口が左下の角", spec);
            sb.AppendLine();
            ok &= One(sb, P5Path, "⑦ ぎっしり型", spec);

            sb.AppendLine();
            sb.AppendLine(ok ? "→ ③⑦ ともに確かめ A は通った" : "→ 通っていないものがある（上の × を見てください）");

            ExperimentPlan plan = AssetDatabase.LoadAssetAtPath<ExperimentPlan>(ServiceSimSetup.ExperimentPlanPath);
            string outDir = plan != null ? plan.screenshotDir : null;
            if (!string.IsNullOrEmpty(outDir))
            {
                try
                {
                    Directory.CreateDirectory(outDir);
                    string txt = Path.Combine(outDir,
                        DateTime.Now.ToString("yyyyMMdd_HHmm") + "_map_planner_checkA.txt");
                    File.WriteAllText(txt, sb.ToString(), new UTF8Encoding(false));
                    sb.AppendLine("書き出し: " + txt);
                }
                catch (Exception e) { sb.AppendLine("書き出せません: " + e.Message); }
            }

            if (ok) Debug.Log("[ServiceSim] " + sb.ToString());
            else Debug.LogError("[ServiceSim] " + sb.ToString());
        }

        static bool One(StringBuilder sb, string assetPath, string label, RobotSpec spec)
        {
            JsonStoreLayout layout = AssetDatabase.LoadAssetAtPath<JsonStoreLayout>(assetPath);
            if (layout == null)
            {
                sb.AppendLine("## " + label + "\n  店の形が見つかりません: " + assetPath);
                return false;
            }
            if (layout.layoutJson == null || layout.mapJson == null)
            {
                sb.AppendLine("## " + label + "（" + layout.layoutFileName + "）");
                sb.AppendLine("  " + (layout.layoutJson == null ? "layoutJson" : "mapJson") +
                              " が入っていません（依頼 v9 の 0 章を確かめてください）");
                return false;
            }

            MapPlanCheck.Result res = MapPlanCheck.Run(
                label + "（" + layout.layoutFileName + "）",
                layout.layoutJson.text, layout.mapJson.text, spec);
            sb.AppendLine(res.report);

            ExperimentPlan plan = AssetDatabase.LoadAssetAtPath<ExperimentPlan>(ServiceSimSetup.ExperimentPlanPath);
            string outDir = plan != null ? plan.screenshotDir : null;
            if (!string.IsNullOrEmpty(outDir) && res.rows.Count > 0)
            {
                try
                {
                    Directory.CreateDirectory(outDir);
                    string csv = Path.Combine(outDir,
                        DateTime.Now.ToString("yyyyMMdd_HHmm") + "_map_plan_" +
                        layout.layoutFileName + ".csv");
                    File.WriteAllText(csv, res.Csv(), new UTF8Encoding(false));
                    sb.AppendLine("  CSV: " + csv);
                }
                catch (Exception e) { sb.AppendLine("  CSV を書けません: " + e.Message); }
            }
            return res.ok;
        }
    }
}
