/** エントリポイント: ゲームループと入力処理。 */
import './style.css';
import {
  audioUnlocked,
  getBuses,
  initAudio,
  isMusicPlaying,
  musicPosition,
  playSfx,
  setMusicIntensity,
  resetAudio,
  updateAudio,
} from './audio';
import {
  BASKET_TIER,
  CONTAINER_CAP,
  GENERATORS,
  HAZARDS,
  MERGER_CAP,
  POLE_RADIUS,
  PALLET_TIER,
  SHIFT_TICKS,
  STOCKER_CAP,
  TRUCK_CAP,
  machineDef,
} from './core/config';

const DIR_LABEL = ['東', '南', '西', '北'] as const;

const FORKLIFT_STATE: Record<string, string> = {
  parked: '待機中',
  to_pallet: 'パレットへ向かう',
  to_container: 'コンテナへ向かう',
  to_dock: 'バースへ運搬中',
  returning: '詰所へ帰投中',
};
import { idx, tx, ty } from './core/grid';
import { Phase, type HazardKind, type HandlerEquipment, type TileKind } from './core/types';
import {
  addLogisticsRoute,
  assignCat,
  buyUpgrade,
  createGame,
  fireCat,
  goToAction,
  goToNextDay,
  hireCat,
  cycleTiers,
  cycleYardMode,
  isStation,
  placeTile,
  removeLogisticsRoute,
  research,
  setBreakPlan,
  setEquipment,
  setMaterialGrade,
  startShift,
  tickShift,
  toggleCatwalk,
  unmannedBenches,
} from './sim/game';
import { findConveyorRoute } from './core/pathfinding';
import { hasForwardConnection, lineIsConnected } from './sim/factory';
import { raiseHazard, recalcMitigation } from './sim/incidents';
import { clearSave, hasSave, loadGame, loadSaveMeta, saveGame } from './sim/save';
import type { GameState } from './sim/state';
import {
  closeModal,
  getSelection,
  hideTooltip,
  initHud,
  showGameOver,
  showHelp,
  showReport,
  showAction,
  showStaffModal,
  showTechTree,
  showTooltip,
  toast,
  updateHud,
  type BuildSelection,
} from './ui/hud';
import { pickTile, render, resizeCanvas, type ViewState } from './ui/render';

const canvas = document.getElementById('canvas') as HTMLCanvasElement;
const ctx = canvas.getContext('2d')!;

let state: GameState = createGame();
const view: ViewState = {
  hover: -1,
  ghost: 'conveyor',
  selectedCat: null,
  showPaths: false,
  showLineFlow: false,
  dragStart: null,
  dragPath: [],
};

let painting = false;
let lastPainted = -1;
/** モーダルを開いた時のフェーズ。フェーズが変わったら開き直す。 */
let shownPhase: Phase | null = null;
/** タイトル画面を表示中はシミュレーションを進めない。 */
let onTitle = true;

resizeCanvas(canvas);
window.addEventListener('resize', () => resizeCanvas(canvas));

// ------------------------------------------------------------------ タイトル画面

const titleScreenEl = document.getElementById('title-screen')!;
const appEl = document.getElementById('app')!;

/** ゲーム画面を表示し、シミュレーションを動かし始める。 */
function enterGame(): void {
  onTitle = false;
  titleScreenEl.classList.add('hidden');
  appEl.classList.remove('hidden');
  // タイトル画面の間は #app が display:none だったので、寸法を取り直す
  resizeCanvas(canvas);
  last = performance.now();
  shownPhase = null;
}

/** 現在の進行状況を保存してタイトル画面へ戻る。 */
function returnToTitle(): void {
  if (state.phase === Phase.GameOver) {
    // 決着の付いた回はもう「つづき」がないので消しておく
    clearSave();
  } else {
    saveGame(state);
  }
  onTitle = true;
  appEl.classList.add('hidden');
  closeModal();
  titleScreenEl.classList.remove('hidden');
  showTitlePanel('title-menu');
  updateTitleMenu();
}

function updateTitleMenu(): void {
  const meta = loadSaveMeta();
  const continueBtn = document.getElementById('btn-title-continue') as HTMLButtonElement;
  const info = document.getElementById('title-save-info')!;
  if (meta) {
    continueBtn.disabled = false;
    const saved = new Date(meta.savedAt);
    const pad = (n: number): string => String(n).padStart(2, '0');
    const stamp = `${saved.getFullYear()}/${pad(saved.getMonth() + 1)}/${pad(saved.getDate())} ${pad(saved.getHours())}:${pad(saved.getMinutes())}`;
    info.textContent = meta.won
      ? `クリア済みのデータがあります（${stamp} 保存）`
      : `${meta.day}日目・資金¥${meta.money.toLocaleString()}（${stamp} 保存）`;
  } else {
    continueBtn.disabled = true;
    info.textContent = '';
  }
}

/** タイトルの3枚のパネル (メニュー/クレジット/上書き確認) のうち1枚だけを見せる。 */
function showTitlePanel(id: 'title-menu' | 'title-credits' | 'title-confirm'): void {
  for (const panelId of ['title-menu', 'title-credits', 'title-confirm'] as const) {
    document.getElementById(panelId)!.classList.toggle('hidden', panelId !== id);
  }
}

function startNewGame(): void {
  clearSave();
  state = createGame();
  recalcMitigation(state);
  resetAudio();
  view.selectedCat = null;
  enterGame();
  shownPhase = Phase.Plan;
  showHelp();
}

document.getElementById('btn-title-new')!.addEventListener('click', () => {
  // 上書き確認はブラウザ標準の confirm() だと、繰り返し表示による自動抑制や
  // 埋め込み環境での無効化で「クリックしても何も起きない」事態になりうるため、
  // タイトル画面内に自前のパネルを用意している。
  if (hasSave()) {
    showTitlePanel('title-confirm');
    return;
  }
  startNewGame();
});

document.getElementById('btn-confirm-overwrite')!.addEventListener('click', () => {
  showTitlePanel('title-menu');
  startNewGame();
});
document.getElementById('btn-confirm-cancel')!.addEventListener('click', () => {
  showTitlePanel('title-menu');
});

document.getElementById('btn-title-continue')!.addEventListener('click', () => {
  const loaded = loadGame();
  if (!loaded) {
    toast('セーブデータの読み込みに失敗しました');
    updateTitleMenu();
    return;
  }
  state = loaded;
  recalcMitigation(state);
  resetAudio();
  view.selectedCat = null;
  enterGame();
});

document.getElementById('btn-title-credits')!.addEventListener('click', () => showTitlePanel('title-credits'));
document.getElementById('btn-credits-back')!.addEventListener('click', () => showTitlePanel('title-menu'));

document.getElementById('btn-to-title')!.addEventListener('click', () => returnToTitle());

window.addEventListener('beforeunload', () => {
  if (!onTitle) saveGame(state);
});

initHud({
  selectBuild(kind) {
    view.ghost = kind;
  },
  selectCatwalk() {
    view.ghost = 'catwalk';
  },
  selectStack() {
    view.ghost = 'stack';
  },
  setBreakPlan(id) {
    setBreakPlan(state, id);
  },
  setMaterial(grade) {
    const r = setMaterialGrade(state, grade);
    if (!r.ok && r.message) toast(r.message);
  },
  research(id) {
    const r = research(state, id);
    if (!r.ok) toast(r.message ?? '開発できません');
    else {
      playSfx('chime');
      showTechTree(state);
    }
  },
  showTech() {
    showTechTree(state);
  },
  showStaff() {
    showStaffModal(state);
  },
  selectCat(id) {
    view.selectedCat = id;
  },
  hireCat() {
    const r = hireCat(state);
    if (r.ok) {
      playSfx('meow');
      toast('新しい猫を雇用しました');
    } else if (r.message) {
      toast(r.message);
    }
  },
  fireCat(id) {
    const r = fireCat(state, id);
    if (r.ok) {
      playSfx('hiss');
      toast('従業員を解雇しました');
      if (view.selectedCat === id) view.selectedCat = null;
    } else if (r.message) {
      toast(r.message);
    }
  },
  setEquipment(catId, equipment) {
    const r = setEquipment(state, catId, equipment as HandlerEquipment);
    if (r.ok) {
      playSfx('clank');
    } else if (r.message) {
      toast(r.message);
    }
  },
  start() {
    if (state.phase !== Phase.Plan) return;
    if (!lineIsConnected(state)) {
      toast('搬入口から出荷口までラインが繋がっていません');
      return;
    }
    const r = startShift(state);
    if (!r.ok && r.message) {
      toast(r.message);
      return;
    }
    const unmanned = unmannedBenches(state);
    if (unmanned.length > 0) {
      toast(`担当者のいない作業台が ${unmanned.length} 台あります — そこで詰まります`);
    }
  },
  setSpeed(n) {
    state.speed = n;
  },
  buy(id) {
    const r = buyUpgrade(state, id);
    if (!r.ok) toast(r.message ?? '実施できません');
    else showAction(state, state.lastReport);
  },
  toAction() {
    goToAction(state);
    shownPhase = null;
  },
  nextDay() {
    goToNextDay(state);
    closeModal();
    shownPhase = null;
  },
  restart() {
    state = createGame();
    recalcMitigation(state);
    resetAudio();
    view.selectedCat = null;
    shownPhase = null;
    closeModal();
  },
  togglePaths(on) {
    view.showPaths = on;
  },
  toggleLineFlow() {
    view.showLineFlow = !view.showLineFlow;
    toast(view.showLineFlow ? 'ライン確認モード ON [L]' : 'ライン確認モード OFF');
  },
});

// ------------------------------------------------------------------ 入力

function applyAt(index: number, remove: boolean, shiftKey = false): void {
  if (state.phase !== Phase.Plan) return;

  // Shift+クリックで置き場の受入モード切替
  if (shiftKey && !remove) {
    const t = state.tiles[index];
    if (t.kind === 'stocker' || t.kind === 'pallet' || t.kind === 'basket' || t.kind === 'container') {
      const r = cycleYardMode(state, index);
      if (r.ok) playSfx('clank');
      else if (r.message) toast(r.message);
      return;
    }
  }

  const selection: BuildSelection = remove ? 'floor' : getSelection();
  if (selection === null) return;

  // 従業員を選択した状態で持ち場をクリック → 割り当て
  if (!remove && view.selectedCat !== null && isStation(state.tiles[index])) {
    assignCat(state, view.selectedCat, index);
    toast('担当を割り当てました');
    return;
  }

  const r =
    selection === 'catwalk'
      ? toggleCatwalk(state, index)
      : selection === 'stack'
        ? cycleTiers(state, index)
        : placeTile(state, index, selection as TileKind);
  if (r.ok) playSfx('clank');
  else if (r.message) toast(r.message);
}

canvas.addEventListener('mousedown', (e) => {
  const i = pickTile(canvas, e);
  if (i < 0) return;

  // 稼働中はクリックで猫を選択して観察する
  if (state.phase !== Phase.Plan) {
    const near = state.cats.find(
      (c) => Math.abs(c.x - tx(i)) < 0.9 && Math.abs(c.y - ty(i)) < 0.9,
    );
    view.selectedCat = near ? near.id : null;
    return;
  }

  e.preventDefault();

  // ライン確認モード中のクリック: 左クリック=追加ドラッグ開始, 右クリック=削除ドラッグ開始
  if (view.showLineFlow) {
    if (e.button === 0) {
      view.dragStart = i;
      view.dragMode = 'add';
      view.dragPath = [];
      return;
    }
    if (e.button === 2) {
      view.dragStart = i;
      view.dragMode = 'remove';
      view.dragPath = [];
      return;
    }
  }

  painting = true;
  lastPainted = i;
  applyAt(i, e.button === 2, e.shiftKey);
});

canvas.addEventListener('mousemove', (e) => {
  const i = pickTile(canvas, e);
  view.hover = i;
  if (i >= 0) updateTooltip(i, e);
  else hideTooltip();

  // ライン確認モード中のドラッグ配線プレビュー
  if (view.showLineFlow && view.dragStart !== null) {
    if (i >= 0) {
      view.dragPath = findConveyorRoute(state.tiles, view.dragStart, i);
    }
    return;
  }

  if (!painting || i < 0 || i === lastPainted) return;
  lastPainted = i;
  const selection = getSelection();
  // ドラッグ中は同じ種別のマスを飛ばす (再クリック=向き変更の暴発を防ぐ)
  if (selection !== 'catwalk' && selection !== 'stack' && state.tiles[i].kind === selection) return;
  applyAt(i, (e.buttons & 2) !== 0, e.shiftKey);
});

window.addEventListener('mouseup', () => {
  // ライン確認モード中のドラッグ確定 (左: 設定, 右: 削除)
  if (view.showLineFlow && view.dragStart !== null) {
    if (view.dragMode === 'add') {
      if (view.hover >= 0 && view.hover !== view.dragStart) {
        const r = addLogisticsRoute(state, view.dragStart, view.hover);
        if (r.ok) {
          playSfx('clank');
          toast(r.message || '手作業運搬ラインを設定しました');
        } else if (r.message) {
          toast(r.message);
        }
      }
    } else if (view.dragMode === 'remove') {
      const to = view.hover >= 0 && view.hover !== view.dragStart ? view.hover : undefined;
      const r = removeLogisticsRoute(state, view.dragStart, to);
      if (r.ok) {
        playSfx('clank');
        toast(r.message || '手作業運搬ラインを削除しました');
      } else if (r.message) {
        toast(r.message);
      }
    }
    view.dragStart = null;
    view.dragMode = undefined;
    view.dragPath = [];
  }

  painting = false;
  lastPainted = -1;
});

canvas.addEventListener('mouseleave', () => {
  view.hover = -1;
  hideTooltip();
});

canvas.addEventListener('contextmenu', (e) => e.preventDefault());

const YARD_LABELS: Record<string, string> = {
  all: 'すべて',
  raw: '材料のみ',
  wip: '仕掛品のみ',
  done: '完成品のみ',
};

function updateTooltip(index: number, e: MouseEvent): void {
  const t = state.tiles[index];
  const lines: string[] = [`(${tx(index)}, ${ty(index)})`];
  switch (t.kind) {
    case 'bench':
    case 'lathe':
    case 'press':
    case 'robot':
    case 'packer': {
      const def = machineDef(t.kind)!;
      const cat = state.cats.find((c) => c.id === t.assigned);
      lines.push(def.label);
      lines.push(def.needsOperator ? `担当: ${cat ? cat.name : '未割当'}` : '担当: 不要（自動運転）');
      lines.push(`バッファ: ${t.queue.length} 個`);
      lines.push(`加工時間: ${(def.workTicks / 60).toFixed(1)}秒 / 電力 ${def.power} / 精度 +${def.precision}`);
      if (!state.powerCoverage[index]) lines.push('⚡ 配電盤の範囲外です（動きません）');
      else if (state.powerRatio < 1) lines.push(`⚡ 電力不足で ${Math.round(state.powerRatio * 100)}% の速度`);
      if (def.packs) lines.push('通した完成品は梱包済み（単価+20%）');
      if (def.hazard) lines.push(`固有の労災: ${HAZARDS[def.hazard].label}`);
      if (t.brokenFor > 0) lines.push(`停止中 (残り ${Math.ceil(t.brokenFor / 60)}秒)`);
      break;
    }
    case 'conveyor':
      lines.push(`コンベア (${DIR_LABEL[t.dir]}向き)`);
      if (t.cargo) {
        lines.push(`積荷: ${['原材料', '一次加工品', '完成品'][t.cargo.stage]}${t.cargo.defective ? ' ※不良' : ''}`);
      }
      break;
    case 'stocker':
      lines.push(`一時保管ストッカー (${DIR_LABEL[t.dir]}向き / ${t.tiers}段積み)`);
      lines.push(`受入: ${YARD_LABELS[t.yardMode ?? 'all']} (Shift+クリックで変更)`);
      lines.push(`在庫: ${t.queue.length} / ${STOCKER_CAP * t.tiers}`);
      if (t.tiers > 1) lines.push('二段目以降は崩落の危険があります');
      break;
    case 'splitter':
      lines.push(`分岐機 (正面: ${DIR_LABEL[t.dir]})`);
      lines.push(`受け取り: ${DIR_LABEL[(t.dir + 2) % 4]}側からのみ`);
      lines.push(`次の払い出し: ${DIR_LABEL[[t.dir, (t.dir + 3) % 4, (t.dir + 1) % 4][t.rr]]}`);
      break;
    case 'merger':
      lines.push(`合流機 (正面: ${DIR_LABEL[t.dir]})`);
      lines.push(`待避: ${t.queue.length} / ${MERGER_CAP}`);
      break;
    case 'intake':
      lines.push('資材搬入口');
      break;
    case 'output':
      lines.push('出荷口 (トラック出荷バース)');
      if (t.truck !== null) {
        lines.push(`トラック荷台: ${t.truck.length} / ${TRUCK_CAP} 個 (12個で満載出発・単価+25%)`);
      } else {
        lines.push(`次のトラック到着待ち (${Math.ceil(t.truckTimer / 60)}秒)`);
      }
      if (t.queue.length > 0) {
        lines.push(`積み込み待ち: ${t.queue.length} 個`);
      }
      break;
    case 'boiler':
      lines.push('ボイラー — 立入禁止区域');
      break;
    case 'rest':
      lines.push(`休憩所 (本日 ${t.uses} 回利用)`);
      break;
    case 'litter':
      lines.push(`トイレ (本日 ${t.uses} 回利用)`);
      break;
    case 'scratch':
      lines.push(`爪とぎタワー (本日 ${t.uses} 回利用)`);
      break;
    case 'feeder':
      lines.push(`自動給餌器 (本日 ${t.uses} 回利用)`);
      break;
    case 'box':
      lines.push(`段ボール箱 (本日 ${t.uses} 匹を鎮静化)`);
      break;
    case 'pallet':
      lines.push(`パレット置き場 (${t.tiers}段積み)`);
      lines.push(`受入: ${YARD_LABELS[t.yardMode ?? 'all']} (Shift+クリックで変更)`);
      lines.push(`積載: ${t.queue.length} / ${PALLET_TIER * t.tiers}`);
      lines.push(`${PALLET_TIER}個で1パレット。満載になるとフォークリフトが引き取ります`);
      if (t.tiers > 1) lines.push('二段目以降は崩落の危険があります');
      break;
    case 'basket': {
      const isFeeder = hasForwardConnection(state.tiles, index);
      lines.push(`カゴ置き場 (${DIR_LABEL[t.dir]}向き / ${t.tiers}段積み)`);
      lines.push(`役割: ${isFeeder ? '供給用 (ライン下流へ自動払い出し)' : '回収用 (荷役作業員がピックアップ)'}`);
      lines.push(`受入: ${YARD_LABELS[t.yardMode ?? 'all']} (Shift+クリックで変更)`);
      lines.push(`積載: ${t.queue.length} / ${BASKET_TIER * t.tiers}`);
      if (t.tiers > 1) lines.push('二段目は崩落の危険があります');
      break;
    }
    case 'container':
      lines.push('大型コンテナ');
      lines.push(`受入: ${YARD_LABELS[t.yardMode ?? 'all']} (Shift+クリックで変更)`);
      lines.push(`積載: ${t.queue.length} / ${CONTAINER_CAP}`);
      lines.push('大容量の鉄製コンテナ。フォークリフトが優先的に引き取ります');
      break;
    case 'handler_post': {
      const handler = state.cats.find((c) => c.id === t.assigned);
      lines.push('荷役ステーション');
      lines.push(`荷役作業員: ${handler ? handler.name : '未割当'}`);
      lines.push('手作業でカゴや置き場から荷物を運搬します');
      break;
    }
    case 'forkdepot': {
      const driver = state.cats.find((c) => c.id === t.assigned);
      const fk = state.forklifts.find((f) => f.depot === index);
      lines.push('フォークリフト詰所');
      lines.push(`運転手: ${driver ? driver.name : '未割当'}`);
      if (fk) lines.push(`状態: ${FORKLIFT_STATE[fk.state]} / 積載 ${fk.load.length}`);
      break;
    }
    case 'dock': {
      const loader = state.cats.find((c) => c.id === t.assigned);
      lines.push('トラックバース');
      lines.push(`積み込み担当: ${loader ? loader.name : '未割当'}`);
      lines.push(`積み込み待ち: ${t.queue.length} 個`);
      if (t.truck) lines.push(`荷台: ${t.truck.length} / ${TRUCK_CAP}`);
      else lines.push(`次のトラック待ち (${Math.ceil(t.truckTimer / 60)}秒)`);
      break;
    }
    case 'lab': {
      const researcher = state.cats.find((c) => c.id === t.assigned);
      lines.push('開発室');
      lines.push(`担当: ${researcher ? researcher.name : '未割当'}`);
      lines.push(`研究ポイント: ${Math.floor(state.researchPoints)} RP`);
      break;
    }
    case 'pole':
      lines.push('配電盤');
      lines.push(`送電範囲: 半径${POLE_RADIUS}マス`);
      lines.push(`需要 ${state.powerDemand.toFixed(1)} / 供給 ${state.powerSupply.toFixed(1)}`);
      break;
    case 'generator':
      lines.push('ディーゼル発電機');
      lines.push(`発電量 ${GENERATORS.generator.output} / 燃料費 ¥${GENERATORS.generator.fuel.toLocaleString()}/日`);
      break;
    case 'solar':
      lines.push('太陽光パネル');
      lines.push(`発電量 ${(GENERATORS.solar.output * state.solarFactor).toFixed(1)} (本日の日射 ${Math.round(state.solarFactor * 100)}%)`);
      break;
    case 'breakroom':
      lines.push('休憩室');
      lines.push(`部屋の広さ: ${state.breakRoomBest} マス (総 ${state.breakRoomTiles} マス)`);
      lines.push(`本日の利用: ${t.uses} 回`);
      break;
    case 'office': {
      const clerk = state.cats.find((c) => c.id === t.assigned);
      lines.push('事務机');
      lines.push(`担当: ${clerk ? clerk.name : '未割当'}`);
      lines.push(`未処理書類: ${state.paperwork.length} 件`);
      lines.push(`本日の処理: ${t.uses} 件`);
      break;
    }
    case 'wall':
      lines.push('壁');
      break;
    default:
      lines.push('床');
      break;
  }
  if (t.catwalk) lines.push('キャットウォーク敷設済み');

  const stage = document.getElementById('stage')!.getBoundingClientRect();
  showTooltip(lines.join('\n'), e.clientX - stage.left, e.clientY - stage.top);
}

// ------------------------------------------------------------------ ゲームループ

let acc = 0;
let last = performance.now();
const STEP = 1000 / 60;
/** 自動保存の最短間隔 (ms) */
const AUTOSAVE_INTERVAL = 8000;
let lastAutosave = 0;

/** シミュレーションを進め、UIを1フレーム分更新する。 */
function step(elapsed: number): void {
  acc += Math.min(elapsed, 200);
  while (acc >= STEP) {
    acc -= STEP;
    if (state.phase === Phase.Do) {
      for (let k = 0; k < state.speed; k++) {
        tickShift(state);
        if (state.phase !== Phase.Do) break;
      }
    }
  }

  syncModal();
  updateAudio(state);
  updateHud(state, view);
  render(ctx, state, view);

  const now = performance.now();
  if (state.phase !== Phase.GameOver && now - lastAutosave > AUTOSAVE_INTERVAL) {
    lastAutosave = now;
    saveGame(state);
  }
}

function frame(now: number): void {
  const elapsed = now - last;
  last = now;
  // タイトル画面の間はシミュレーションを止め、rAFの予約だけ続ける
  if (!onTitle) step(elapsed);
  requestAnimationFrame(frame);
}

/** フェーズに応じてモーダルを開く。 */
function syncModal(): void {
  if (shownPhase === state.phase) return;

  if (state.phase === Phase.Check && state.lastReport) {
    showReport(state, state.lastReport);
    shownPhase = state.phase;
  } else if (state.phase === Phase.Action) {
    showAction(state, state.lastReport);
    shownPhase = state.phase;
  } else if (state.phase === Phase.GameOver) {
    showGameOver(state);
    shownPhase = state.phase;
  } else if (state.phase === Phase.Plan || state.phase === Phase.Do) {
    closeModal();
    shownPhase = state.phase;
  }
}

initAudio();
recalcMitigation(state);
updateTitleMenu();
requestAnimationFrame(frame);

// デバッグ用にコンソールから触れるようにしておく
Object.assign(window as unknown as Record<string, unknown>, {
  __dcf: {
    get state() {
      return state;
    },
    HAZARDS,
    SHIFT_TICKS,
    idx,
    place: (x: number, y: number, kind: TileKind) => placeTile(state, idx(x, y), kind),
    rotate: (x: number, y: number) => placeTile(state, idx(x, y), 'conveyor'),
    catwalk: (x: number, y: number) => toggleCatwalk(state, idx(x, y)),
    start: () => startShift(state),
    /** 画面を介さずにシフトを最後まで進める (テスト用) */
    runShift: () => {
      let guard = 0;
      while (state.phase === Phase.Do && guard++ < SHIFT_TICKS + 10) tickShift(state);
      return state.lastReport;
    },
    connected: () => lineIsConnected(state),
    /** 労災の重篤化を統計的に確かめるためのフック */
    raise: (kind: HazardKind, catId = 0) =>
      raiseHazard(state, kind, state.cats.find((c) => c.id === catId) ?? null, null, {
        forceAccident: true,
      }),
    /** rAF が止まる環境でも1フレーム進めるためのフック */
    step: (ms = STEP) => step(ms),
    audio: {
      getBuses,
      playSfx,
      isMusicPlaying,
      musicPosition,
      setMusicIntensity,
      unlocked: audioUnlocked,
    },
  },
});
