게시판오목으로 이해하는 자기대국과 모방 학습예제 소스

예제 소스

오목으로 이해하는 자기대국과 모방 학습 · 파일 59개

vlmClient.ts

/** Browser client → Next `/api/vlm/move` → VLM TCP(:9299). 별도 WS 브리지 불필요. */

export type VlmMove =
  | { ok: true; row: number; col: number }
  | { ok: false; error: string; retryable?: boolean };

const GATEWAY_HINT =
  "연결이 잠깐 끊겼습니다(게이트웨이). 새 대국 후 다시 두거나, 잠시 뒤 재시도하세요.";

function looksLikeHtml(raw: string): boolean {
  const s = raw.trimStart().slice(0, 32).toLowerCase();
  return s.startsWith("<!doctype") || s.startsWith("<html") || s.startsWith("<head");
}

function isTransientGateway(status: number, raw: string): boolean {
  if ([502, 503, 504].includes(status)) return true;
  if (/bad gateway|gateway time-out|service unavailable/i.test(raw)) return true;
  return looksLikeHtml(raw);
}

function sleep(ms: number): Promise<void> {
  return new Promise((r) => setTimeout(r, ms));
}

async function fetchVlmOnce(body: string, signal: AbortSignal): Promise<VlmMove> {
  const res = await fetch("/api/vlm/move", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body,
    signal,
  });
  const raw = await res.text();

  if (isTransientGateway(res.status, raw)) {
    return { ok: false, error: GATEWAY_HINT, retryable: true };
  }

  let msg: {
    type?: string;
    row?: number;
    col?: number;
    error?: string;
  };
  try {
    msg = JSON.parse(raw) as typeof msg;
  } catch {
    return {
      ok: false,
      error: `서버 응답이 JSON이 아닙니다 (HTTP ${res.status}). ${GATEWAY_HINT}`,
      retryable: true,
    };
  }

  if (!res.ok || msg.type === "error") {
    return { ok: false, error: msg.error || `HTTP ${res.status}` };
  }
  if (msg.type === "move" && msg.row != null && msg.col != null) {
    return { ok: true, row: Number(msg.row), col: Number(msg.col) };
  }
  return { ok: false, error: "알 수 없는 응답" };
}

export async function requestVlmMove(opts: {
  board: number[];
  size: number;
  aiSide: 1 | 2;
  timeoutMs?: number;
}): Promise<VlmMove> {
  const timeoutMs = opts.timeoutMs ?? 300_000;
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), timeoutMs);
  const body = JSON.stringify({
    board: opts.board,
    size: opts.size,
    ai_side: opts.aiSide,
  });

  try {
    // 터널 재시작 직의 HTML 502 는 짧게 재시도
    const delays = [0, 1500, 3000];
    let last: VlmMove = { ok: false, error: GATEWAY_HINT, retryable: true };
    for (let i = 0; i < delays.length; i++) {
      if (delays[i]! > 0) await sleep(delays[i]!);
      try {
        last = await fetchVlmOnce(body, ctrl.signal);
      } catch (e) {
        if (e instanceof DOMException && e.name === "AbortError") {
          return { ok: false, error: "VLM 응답 시간 초과" };
        }
        last = {
          ok: false,
          error: `VLM API 호출 실패: ${String(e)}. ${GATEWAY_HINT}`,
          retryable: true,
        };
        continue;
      }
      if (last.ok) return last;
      if (!last.retryable) return last;
    }
    return last;
  } finally {
    clearTimeout(timer);
  }
}