import type { GameState } from '../sdk/types.js';
import { existsSync, writeFileSync, unlinkSync, readFileSync, statSync } from 'fs';
import { join } from 'path';
import { execFileSync } from 'child_process';
import { GameClient } from '../lib/game-client.js';
import { ApiError, formatApiError } from '../lib/http-transport.js';
import { EventStore, extractNewEvents } from '../pipeline/event-store.js';
import { AuthStore } from '../lib/auth.js';
import { getProfileStateDir } from '../lib/init-command.js';
import type { Action } from '../sdk/action.js';
import type { Strategy, StrategyContext, BehaviorDecision } from './types.js';
import { resolveStrategy } from './loader.js';
import { CorpseMemory } from './game-utils.js';
import {
  meetingEndedInEvents,
  meetingStartedInEvents,
  shouldPauseForMeeting,
} from './meeting-gate.js';
import { createStrategyNewEventsBackfill } from './new-events-backfill.js';
import {
  buildKnowledgeView,
  currentGameId,
  emptyKnowledgeView,
  knowledgeFilePathForActiveAccount,
  readKnowledgeFileResult,
} from '../lib/knowledge-store.js';

function sleep(ms: number): Promise<void> {
  return new Promise(r => setTimeout(r, ms));
}

const RECENT_KILL_IGNORE_MS = 3000;
const SETUP_EVENTS_BACKFILLED_BY_STATE = new Set(['role_assigned', 'game_started', 'crab_teammates']);

function formatErrorMessage(err: unknown): string {
  if (err instanceof ApiError) return formatApiError(err);
  return err instanceof Error ? err.message : String(err);
}

export function eventsForStrategyStateLog(events: any): Record<string, any>[] {
  return extractNewEvents(events).filter((event) => !SETUP_EVENTS_BACKFILLED_BY_STATE.has(event.type));
}

function getPidPath(): string {
  const profile = new AuthStore().getActive();
  if (!profile) throw new Error('Not logged in.');
  return join(getProfileStateDir(profile), 'auto.pid');
}

function getStatusPath(): string {
  const profile = new AuthStore().getActive();
  if (!profile) throw new Error('Not logged in.');
  return join(getProfileStateDir(profile), 'auto.json');
}

function shouldWriteStrategyRuntimeFiles(): boolean {
  return process.env.CLAWCLAW_STRATEGY_RUNTIME_FILES === '1';
}

export function isStrategyRunning(): boolean {
  try {
    const pidPath = getPidPath();
    if (!existsSync(pidPath)) return false;
    const pid = Number(readFileSync(pidPath, 'utf8').trim());
    if (pid <= 0) return false;
    try {
      process.kill(pid, 0);
      return true;
    } catch {
      try { unlinkSync(pidPath); } catch {}
      try { unlinkSync(getStatusPath()); } catch {}
      return false;
    }
  } catch {
    return false;
  }
}

export function stopStrategyIfRunning(): void {
  try {
    const pidPath = getPidPath();
    if (existsSync(pidPath)) {
      const pid = Number(readFileSync(pidPath, 'utf8').trim());
      stopPid(pid);
      try { unlinkSync(pidPath); } catch {}
      try { unlinkSync(getStatusPath()); } catch {}
    }
    try { unlinkSync(getStatusPath()); } catch {}
  } catch {}
  stopOrphanProcesses();
}

function stopPid(pid: number): void {
  if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return;
  try { process.kill(pid, 'SIGTERM'); } catch {}
  const waitBuffer = new SharedArrayBuffer(4);
  Atomics.wait(new Int32Array(waitBuffer), 0, 0, 200);
  try {
    process.kill(pid, 0);
    process.kill(pid, 'SIGKILL');
  } catch {}
}

function stopOrphanProcesses(): void {
  if (process.platform === 'win32') return;
  try {
    const lines = execFileSync('ps', ['-axo', 'pid=,command='], { encoding: 'utf8' }).split('\n');
    for (const line of lines) {
      const match = line.trim().match(/^(\d+)\s+(.+)$/);
      if (!match) continue;
      const pid = Number(match[1]);
      const command = match[2];
      if (pid === process.pid) continue;
      if (!command.includes('clawclaw-cli') || !command.includes(' _strategy ')) continue;
      stopPid(pid);
    }
  } catch {}
}

interface RoomTarget {
  name: string;
  x: number;
  y: number;
}

function pointFromMapEntry(entry: any): { x: number; y: number } | null {
  const x = Number(entry?.x ?? entry?.[0]);
  const y = Number(entry?.y ?? entry?.[1]);
  return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null;
}

function roomName(r: any, index = 0): string {
  return String(r?.name ?? r?.id ?? r?.room ?? `room-${index + 1}`);
}

function taskLocationsByRoom(allTaskLocations: any[] | undefined): Map<string, any[]> {
  const byRoom = new Map<string, any[]>();
  if (!Array.isArray(allTaskLocations)) return byRoom;
  for (const task of allTaskLocations) {
    const room = typeof task?.room === 'string' ? task.room : '';
    const point = pointFromMapEntry(task);
    if (!room || !point) continue;
    const list = byRoom.get(room) ?? [];
    list.push(task);
    byRoom.set(room, list);
  }
  return byRoom;
}

function roomAnchor(r: any, index = 0, allTaskLocationsByRoom = new Map<string, any[]>()): RoomTarget {
  const taskLocations = Array.isArray(r?.task_locations) ? r.task_locations : [];
  const name = roomName(r, index);
  const candidates = [
    ...taskLocations,
    ...(allTaskLocationsByRoom.get(name) ?? []),
  ]
    .map(pointFromMapEntry)
    .filter((p): p is { x: number; y: number } => p != null);
  if (candidates.length > 0) {
    return { name, ...candidates[Math.floor(Math.random() * candidates.length)] };
  }

  const poly: number[][] = Array.isArray(r?.polygon) ? r.polygon : [];
  if (poly.length === 0) return { name, x: 0, y: 0 };

  let area = 0;
  let cx = 0;
  let cy = 0;
  for (let i = 0; i < poly.length; i += 1) {
    const [x1, y1] = poly[i];
    const [x2, y2] = poly[(i + 1) % poly.length];
    const cross = x1 * y2 - x2 * y1;
    area += cross;
    cx += (x1 + x2) * cross;
    cy += (y1 + y2) * cross;
  }
  area *= 0.5;
  if (Math.abs(area) < 1e-6) {
    const ax = poly.reduce((s, p) => s + p[0], 0) / poly.length;
    const ay = poly.reduce((s, p) => s + p[1], 0) / poly.length;
    return { name, x: ax, y: ay };
  }
  return { name, x: cx / (6 * area), y: cy / (6 * area) };
}

function learnTeammatesFromEvents(events: any[] | undefined, teammates: Set<string>): void {
  if (!Array.isArray(events)) return;
  for (const evt of events) {
    if (evt?.type !== 'crab_teammates') continue;
    const list = Array.isArray(evt.teammates) ? evt.teammates : [];
    for (const name of list) {
      if (typeof name === 'string' && name.length > 0) teammates.add(name);
    }
  }
}

const MOVEMENT_TERMINAL_EVENT_TYPES = new Set(['move_end', 'move_interrupted']);

function ownMovementTerminalEvent(events: any[] | undefined, playerName: string): any | null {
  if (!Array.isArray(events)) return null;
  return events.find(evt => {
    if (!MOVEMENT_TERMINAL_EVENT_TYPES.has(evt?.type)) return false;
    if (evt.actor_name && evt.actor_name !== playerName) return false;
    return true;
  }) ?? null;
}

function ownKillEvent(events: any[] | undefined, playerName: string): boolean {
  if (!Array.isArray(events) || !playerName) return false;
  return events.some(evt => evt?.type === 'kill' && evt.actor_name === playerName);
}

function learnSeatNames(players: any[] | undefined, target: Record<string, string>): void {
  if (!Array.isArray(players)) return;
  for (const player of players) {
    const seat = player?.seat;
    const name = player?.name;
    if (seat == null || typeof name !== 'string' || name.length === 0) continue;
    target[String(seat)] = name;
  }
}

function eventsSinceCurrentSession(events: any[] | undefined): any[] {
  if (!Array.isArray(events)) return [];
  let idx = -1;
  for (let i = events.length - 1; i >= 0; i--) {
    if (events[i]?.type === 'session_started') { idx = i; break; }
  }
  return idx >= 0 ? events.slice(idx + 1) : events;
}

interface MoveTarget {
  kind: 'point' | 'room';
  x?: number;
  y?: number;
  room?: string;
}

interface ActiveMoveTarget extends MoveTarget {
  until: number;
}

const SAME_MOVE_TARGET_DISTANCE = 10;

function moveTargetFromPayload(payload: Record<string, any>): MoveTarget | null {
  if (typeof payload.target === 'string' && payload.target.trim()) {
    return { kind: 'room', room: payload.target.trim().toLowerCase() };
  }
  const x = Number(payload.target_x);
  const y = Number(payload.target_y);
  if (Number.isFinite(x) && Number.isFinite(y)) return { kind: 'point', x, y };
  return null;
}

function sameMoveTarget(a: MoveTarget, b: MoveTarget): boolean {
  if (a.kind !== b.kind) return false;
  if (a.kind === 'room') return a.room === b.room;
  const dx = (a.x ?? 0) - (b.x ?? 0);
  const dy = (a.y ?? 0) - (b.y ?? 0);
  return Math.sqrt(dx ** 2 + dy ** 2) <= SAME_MOVE_TARGET_DISTANCE;
}

function activeMoveTargetFromPayload(payload: Record<string, any>, until: number): ActiveMoveTarget | null {
  const target = moveTargetFromPayload(payload);
  return target ? { ...target, until } : null;
}

function repeatsActiveMoveTarget(decision: BehaviorDecision, activeMoveTarget: ActiveMoveTarget | null): boolean {
  if (!activeMoveTarget || Date.now() >= activeMoveTarget.until) return false;
  const payload = decision.action.toJSON();
  if (payload.action !== 'move') return false;
  const target = moveTargetFromPayload(payload);
  return !!target && sameMoveTarget(target, activeMoveTarget);
}

function pickDecision(
  decisions: BehaviorDecision[],
  blockedMoveTarget: ({ x: number; y: number; until: number }) | null,
  activeMoveTarget: ActiveMoveTarget | null,
): BehaviorDecision | null {
  return decisions.find(d => {
    if (repeatsActiveMoveTarget(d, activeMoveTarget)) return false;
    const a = d.action.toJSON();
    if (a.action !== 'move') return true;
    if (!blockedMoveTarget || Date.now() >= blockedMoveTarget.until) return true;
    const x = Number(a.target_x);
    const y = Number(a.target_y);
    if (!Number.isFinite(x) || !Number.isFinite(y)) return true;
    const dx = x - blockedMoveTarget.x;
    const dy = y - blockedMoveTarget.y;
    return Math.sqrt(dx ** 2 + dy ** 2) > 10;
  }) ?? null;
}

function isSpeechDecision(decision: BehaviorDecision): boolean {
  return decision.action.toJSON().action === 'speech';
}

function supportsSidecarSpeech(strategyId: string): boolean {
  return strategyId === 'task-report'
    || strategyId === 'corpse-patrol'
    || strategyId === 'shrimp-memory'
    || strategyId === 'paradise-fish';
}

export async function runStrategyLoop(strategyId: string, args?: string[]): Promise<void> {
  const strategy = await resolveStrategy(strategyId, args);
  const store = EventStore.forActiveAccount();
  const pidPath = getPidPath();

  if (shouldWriteStrategyRuntimeFiles()) writeFileSync(pidPath, String(process.pid));
  store.append({ type: 'auto', message: 'strategy started', strategy: strategyId, pid: process.pid });

  const client = GameClient.fromAuth();
  await client.discoverGameServer();

  const teammates = new Set<string>();
  const playerNamesBySeat: Record<string, string> = {};
  let blockedMoveTarget: { x: number; y: number; until: number } | null = null;
  let consecutiveBlocks = 0;
  const BLOCK_SKIP_THRESHOLD = 3;
  let mapDirty = true;
  let lastMapRefreshAt = 0;
  const MAP_REFRESH_INTERVAL_MS = 10_000;

  const knowledgePath = (() => { try { return knowledgeFilePathForActiveAccount(); } catch { return ''; } })();
  const knowledgeGameId = currentGameId();
  let lastKnowledgeMtimeMs = -1;

  const ctx: StrategyContext = {
    taskData: [],
    taskLocations: [],
    emergency: null,
    taskLocalBlockedUntil: 0,
    reportCorpseTarget: null,
    reportBlockedUntil: 0,
    notifications: [],
    lastProgressNotifyAt: 0,
    teammates,
    alarmDone: false,
    rooms: [],
    playerNamesBySeat,
    forcePatrolAdvance: false,
    blockedMoveTarget: null,
    mySeat: 0,
    speechNotifications: [],
    agentAlerts: [],
    knownCorpses: [],
    knowledge: emptyKnowledgeView(),
    recentlyKilledTargets: new Map(),
  };

  // 全局唯一的尸体记忆：每 tick observe 后写入 ctx.knownCorpses，开会清场时 reset。
  const corpseMemory = new CorpseMemory();

  try { learnTeammatesFromEvents(eventsSinceCurrentSession(store.tail(1000)), teammates); } catch {}
  const newEventsBackfill = createStrategyNewEventsBackfill(store.path);
  try {
    const roleInfo = await client.getRoleInfo();
    learnSeatNames(roleInfo?.data?.all_seats ?? roleInfo?.all_seats, playerNamesBySeat);
    const crabTeammates: any[] = roleInfo?.data?.crab_teammates ?? roleInfo?.crab_teammates ?? [];
    for (const name of crabTeammates) {
      if (typeof name === 'string' && name.length > 0) teammates.add(name);
    }
    const mySeat = Number(roleInfo?.data?.seat ?? roleInfo?.seat ?? 0);
    if (mySeat > 0) ctx.mySeat = mySeat;
    const role: string = roleInfo?.data?.role ?? roleInfo?.role ?? '';
    if (role && strategy.updateRole) strategy.updateRole(role);
  } catch {}

  // Register custom modules before main loop so tick-1 events are captured
  const customModules = strategy.customModules?.() ?? [];
  if (customModules.length > 0) ctx.customModules = customModules;

  let running = true;
  let activeMoveTarget: ActiveMoveTarget | null = null;
  let pausedForMeeting = false;
  let releasedMeetingPause = false;
  let currentRole = '';
  let currentPlayerName = '';
  let pistolKillUsed = false;
  let pistolKillInitialized = false;
  const onSignal = () => { running = false; };
  process.on('SIGINT', onSignal);
  process.on('SIGTERM', onSignal);

  const submitAction = async (action: Action): Promise<{ acted: boolean }> => {
    try {
      const payload = action.toJSON();
      const actionType = payload.action;
      const result = await client.submitAction(payload as any);
      const newEvents = extractNewEvents(result);
      store.appendNewEvents(newEvents);
      // action 结果里回来的 corpse_spotted 也入账尸体记忆——主循环只 observe state.new_events，
      // 那条通道会被 runtime WS listener 抢游标，这里补上避免尸体坐标漏记（ctx.knownCorpses 与 list() 同引用，立即可见）。
      corpseMemory.ingestEvents(newEvents);
      learnTeammatesFromEvents(newEvents, teammates);
      if (currentRole === 'shrimp_pistol' && ownKillEvent(newEvents, currentPlayerName)) {
        pistolKillUsed = true;
      }
      const actionResult = result?.data ?? result;
      store.append({ type: 'auto', action: actionType, result: actionResult ?? result?.error ?? 'ok' });

      const errorCode = actionResult?.error?.code ?? actionResult?.code ?? result?.error?.code;
      const errorMessage = String(
        actionResult?.error?.message ?? actionResult?.message ?? result?.error?.message ?? actionResult?.reason ?? '',
      );
      const failed = errorCode === 'ACTION_FAILED' || actionResult?.success === false || result?.success === false;
      const queued = actionResult?.status === 'queued';

      if (failed) {
        ctx.reportCorpseTarget = null;
        const failureText = JSON.stringify(actionResult ?? result ?? {});
        if (actionType === 'kill' && currentRole === 'shrimp_pistol' && failureText.includes('role_cannot_kill')) {
          pistolKillUsed = true;
        }
        if (actionType === 'task' || actionType === 'kill' || actionType === 'report' || actionType === 'trigger_alarm') {
          activeMoveTarget = null;
        }
        if (actionType === 'kill' && errorMessage.includes('cannot_kill_teammate')) {
          const target = action.toJSON().target;
          if (typeof target === 'string' && target.length > 0) teammates.add(target);
        }
        if (actionType === 'task' && errorMessage.includes('not_at_task_location')) {
          ctx.taskLocalBlockedUntil = Date.now() + 5000;
          activeMoveTarget = null;
        }
        if (actionType === 'report') {
          // doing_task 型失败不是真报不了：服务端只是因正在做任务而拒绝，下一 tick 任务被 move 打断后即可补报。
          // 这种情况不设 5 秒退避，否则会错过「目击者刚靠近尸体」的报尸自证窗口；其余原因（距离不够等）仍退避避免空报刷屏。
          if (!failureText.includes('doing_task')) {
            ctx.reportBlockedUntil = Date.now() + 5000;
          }
          activeMoveTarget = null;
        }
        if (actionType === 'move') {
          if (errorMessage.includes('invalid_position_blocked')) {
            consecutiveBlocks++;
            const payload = action.toJSON();
            const x = Number(payload.target_x);
            const y = Number(payload.target_y);
            if (Number.isFinite(x) && Number.isFinite(y)) {
              if (consecutiveBlocks >= BLOCK_SKIP_THRESHOLD) {
                blockedMoveTarget = null;
                consecutiveBlocks = 0;
                ctx.forcePatrolAdvance = true;
                ctx.notifications.push('移动目标多次不可达，跳过。');
              } else {
                blockedMoveTarget = { x, y, until: Date.now() + 5000 };
              }
            }
          } else {
            consecutiveBlocks = 0;
          }
          activeMoveTarget = null;
        }
      }

      if (!failed && actionType === 'kill') {
        const target = typeof payload.target === 'string' ? payload.target.trim().toLowerCase() : '';
        if (target) ctx.recentlyKilledTargets?.set(target, Date.now() + RECENT_KILL_IGNORE_MS);
        if (currentRole === 'shrimp_pistol') pistolKillUsed = true;
        activeMoveTarget = null;
      }

      if (!failed && actionType === 'trigger_alarm') {
        activeMoveTarget = null;
      }

      if (queued) {
        if (actionType === 'task') {
          mapDirty = true;
          activeMoveTarget = null;
        }
      } else if (actionType === 'move' && !failed) {
        consecutiveBlocks = 0;
        const durationSecs = actionResult?.duration_secs ?? result?.duration_secs ?? 0;
        const arrivalAt = Date.now() + Math.max(0.5, durationSecs) * 1000;
        activeMoveTarget = activeMoveTargetFromPayload(payload, arrivalAt + 500);
      } else if (!failed && (actionType === 'task' || actionType === 'report')) {
        activeMoveTarget = null;
      }

      return { acted: true };
    } catch (e: any) {
      store.append({ type: 'auto', error: 'submit_action_failed', message: formatErrorMessage(e) });
      ctx.reportCorpseTarget = null;
      if (action.toJSON().action === 'move') {
        activeMoveTarget = null;
      }
      return { acted: true };
    }
  };

  while (running) {
    let state: GameState | null;
    try {
      state = await client.getGameState();
    } catch {
      await sleep(2000);
      continue;
    }

    if (!state) {
      store.append({ type: 'auto', message: 'no active game, retrying' });
      await sleep(2000);
      continue;
    }

    store.appendNewEvents(eventsForStrategyStateLog(state.new_events));
    state.new_events = newEventsBackfill.correct(state.new_events);
    currentRole = state.you.role ?? currentRole;
    currentPlayerName = state.you.name ?? currentPlayerName;
    if (currentRole === 'shrimp_pistol') {
      if (!pistolKillInitialized) {
        pistolKillUsed = pistolKillUsed || ownKillEvent(eventsSinceCurrentSession(store.tail(1000)), currentPlayerName);
        pistolKillInitialized = true;
      }
      if (state.you.kills_remaining === 0 || ownKillEvent(state.new_events, currentPlayerName)) {
        pistolKillUsed = true;
      }
      state.you.kills_remaining = pistolKillUsed ? 0 : (state.you.kills_remaining ?? 1);
    }

    learnTeammatesFromEvents(state.new_events, teammates);
    learnSeatNames(state.all_players, playerNamesBySeat);
    const terminalMove = ownMovementTerminalEvent(state.new_events, state.you.name);
    if (terminalMove) {
      activeMoveTarget = null;
    }

    // Process events through strategy-registered custom modules
    const currentTick = state.tick ?? 0;
    const newEvents = state.new_events ?? [];
    for (const mod of ctx.customModules ?? []) {
      mod.processEvents(newEvents, currentTick, state);
    }

    if (ctx.mySeat === 0 && state.you.seat != null) {
      ctx.mySeat = state.you.seat;
    }

    const prevEmergency = ctx.emergency;
    ctx.emergency = state.emergency ?? null;
    if (ctx.emergency && !prevEmergency) {
      const room = ctx.emergency.room ?? '未知';
      ctx.notifications.push(`紧急任务出现！「${ctx.emergency.task_name}」在${room}，剩余${Math.round(ctx.emergency.remaining_secs)}秒。`);
    }

    if (meetingStartedInEvents(state.new_events)) {
      releasedMeetingPause = false;
    }
    if (state.phase !== 'meeting') {
      releasedMeetingPause = false;
    }
    if (meetingEndedInEvents(state.new_events)) {
      releasedMeetingPause = true;
    }

    if (state.phase === 'game_over') {
      store.append({ type: 'auto', message: 'game over' });
      break;
    }
    if (state.you.is_alive === false) {
      store.append({ type: 'auto', message: 'player dead, stopping strategy', strategy: strategyId });
      break;
    }
    if (shouldPauseForMeeting(state, releasedMeetingPause)) {
      if (!pausedForMeeting) {
        store.append({ type: 'auto', message: 'meeting started, pausing strategy', strategy: strategyId });
        pausedForMeeting = true;
        activeMoveTarget = null;
      }
      await sleep(2000);
      continue;
    }
    if (pausedForMeeting) {
      store.append({ type: 'auto', message: 'meeting ended, resuming strategy', strategy: strategyId });
      pausedForMeeting = false;
      releasedMeetingPause = true;
      mapDirty = true;
      activeMoveTarget = null;
      blockedMoveTarget = null;
      consecutiveBlocks = 0;
      ctx.taskLocalBlockedUntil = 0;
      ctx.reportCorpseTarget = null;
      ctx.reportBlockedUntil = 0;
      ctx.forcePatrolAdvance = false;
      ctx.blockedMoveTarget = null;
      // 开会后尸体清场：清空尸体记忆，避免回避/报告已不存在的尸体。
      corpseMemory.reset();
      ctx.knownCorpses = [];
      // Notify strategy-registered custom modules (decay instead of full reset)
      for (const mod of ctx.customModules ?? []) {
        mod.onMeetingResume?.();
      }
      strategy.onMeetingResume?.();
    }

    if (mapDirty || Date.now() - lastMapRefreshAt >= MAP_REFRESH_INTERVAL_MS) {
      try {
        const mapData = await client.getMap();
        const taskFactionByName = new Map<string, 'lobster' | 'crab'>(
          (mapData?.all_task_locations ?? [])
            .filter((t: any) => t?.name && (t.faction === 'lobster' || t.faction === 'crab'))
            .map((t: any) => [t.name, t.faction]),
        );
        ctx.taskData = (mapData?.your_tasks ?? []).map((t: any) => ({
          task_id: t.name ?? '',
          task_name: t.name ?? '',
          room: t.room ?? '',
          status: t.status ?? 'normal',
          x: t.x,
          y: t.y,
          is_fake_shrimp: t.is_fake_shrimp ?? false,
          faction: taskFactionByName.get(t.name),
        }));
        ctx.taskLocations = (mapData?.all_task_locations ?? [])
          .filter((t: any) => t && typeof t.name === 'string' && typeof t.x === 'number' && typeof t.y === 'number')
          .map((t: any) => ({ name: t.name, room: t.room, x: t.x, y: t.y, faction: t.faction }));
        if (Array.isArray(mapData?.rooms)) {
          if (ctx.rooms.length === 0) {
            const byRoom = taskLocationsByRoom(mapData?.all_task_locations);
            ctx.rooms = mapData.rooms.map((r: any, index: number) => roomAnchor(r, index, byRoom));
          }
          // Notify custom modules of new map data
          for (const mod of ctx.customModules ?? []) {
            mod.onMapLoaded?.(mapData.rooms);
          }
        }
        mapDirty = false;
        lastMapRefreshAt = Date.now();
      } catch (e: any) {
        store.append({ type: 'auto', error: 'map_load_failed', message: formatErrorMessage(e) });
      }
    }

    const activeBlockedMoveTarget = ((): { x: number; y: number; until: number } | null => blockedMoveTarget)();
    ctx.blockedMoveTarget = activeBlockedMoveTarget && Date.now() < activeBlockedMoveTarget.until
      ? { x: activeBlockedMoveTarget.x, y: activeBlockedMoveTarget.y }
      : null;

    if (knowledgePath) {
      try {
        const mtime = existsSync(knowledgePath) ? statSync(knowledgePath).mtimeMs : 0;
        if (mtime !== lastKnowledgeMtimeMs) {
          lastKnowledgeMtimeMs = mtime;
          const result = readKnowledgeFileResult(knowledgePath);
          if (result.status !== 'invalid') {
            const scoped = result.status === 'ok' && result.file.gameId === knowledgeGameId ? result.file : null;
            ctx.knowledge = buildKnowledgeView(scoped, { playerNamesBySeat });
          }
        }
      } catch {}
    }

    // 更新跨 tick 尸体记忆，暴露给所有策略（检测/接近/任务回避/死亡确认统一读 ctx.knownCorpses）。
    corpseMemory.observe(state, ctx.recentlyKilledTargets);
    ctx.knownCorpses = corpseMemory.list();

    let decisions = strategy.decide(state, ctx);

    // Run custom module afterDecide pipeline (chained by registration order)
    for (const mod of ctx.customModules ?? []) {
      if (mod.afterDecide) {
        decisions = mod.afterDecide(decisions, state, ctx);
      }
    }

    const sidecarSpeech = supportsSidecarSpeech(strategyId) ? decisions.filter(isSpeechDecision) : [];
    const mainDecisions = sidecarSpeech.length > 0 ? decisions.filter(d => !isSpeechDecision(d)) : decisions;

    const decision = pickDecision(
      mainDecisions,
      activeBlockedMoveTarget,
      activeMoveTarget,
    );

    if (decision) {
      await submitAction(decision.action);
    }
    for (const speech of sidecarSpeech) {
      await submitAction(speech.action);
    }

    if (ctx.notifications.length > 0) {
      for (const msg of ctx.notifications) {
        store.append({ type: 'auto', message: msg, strategy: strategyId });
      }
      ctx.notifications.length = 0;
    }

    if (ctx.agentAlerts.length > 0) {
      const tick = typeof state.tick === 'number' ? state.tick : undefined;
      for (const msg of ctx.agentAlerts) {
        store.append({ type: 'strategy_alert', message: msg, strategy: strategyId, tick });
      }
      ctx.agentAlerts.length = 0;
    }

    if (ctx.speechNotifications.length > 0) {
      for (const msg of ctx.speechNotifications) {
        store.append({ type: 'robot_speak_rule', message: msg });
      }
      ctx.speechNotifications.length = 0;
    }

    await sleep(500);
  }

  process.off('SIGINT', onSignal);
  process.off('SIGTERM', onSignal);
  if (shouldWriteStrategyRuntimeFiles()) {
    try { unlinkSync(pidPath); } catch {}
  }
}
