화면의 역할

웹 화면은 판을 그리는 데서 끝나지 않습니다. 사용자의 실수 입력을 줄이고, 서버 응답을 기다리는 동안 중복 착수를 막으며, 저장된 대국을 다시 불러와야 합니다. 특히 작은 휴대전화에서는 한 번의 터치로 돌을 확정하지 않는 편이 안전합니다.

1단계: 두 단계 착수를 만듭니다

빈 교차점을 처음 누르면 금색 예비 돌만 표시합니다. 같은 점을 다시 누르거나 ‘착수’ 버튼을 눌렀을 때 서버로 전송합니다. 다른 점을 누르면 예비 위치만 바뀝니다. 서버 응답 전에는 버튼과 판 입력을 잠시 잠가 같은 수가 두 번 저장되지 않게 합니다.

2단계: 서버를 대국 상태의 원본으로 둡니다

서버는 착수를 받을 때 대국 코드와 접근 키를 확인하고 현재 차례인지 검사합니다. 규칙 엔진을 통과한 수만 트랜잭션으로 저장한 뒤 AI 차례를 실행합니다. 브라우저가 가진 판을 그대로 믿지 말고 서버의 마지막 상태에 새 수를 적용해야 변조와 오래된 화면의 충돌을 막을 수 있습니다.

  1. 사람의 착수를 검증하고 저장합니다.
  2. 저장된 최신 판으로 AI에게 다음 수를 요청합니다.
  3. AI 수를 규칙 엔진으로 검사합니다.
  4. AI 수와 포획 결과를 저장합니다.
  5. 완성된 최신 상태를 화면에 돌려줍니다.

3단계: 기보 보기와 이어두기를 분리합니다

복기 중 앞뒤 화살표를 눌러도 실제 대국의 마지막 상태는 바뀌지 않아야 합니다. 과거 수를 보고 있는 상태에서 ‘이어두기’를 선택했을 때만 그 지점까지 복사한 새 변화도를 만듭니다. 기존 수순을 즉시 잘라 버리면 사용자가 실수로 원본 기보를 잃을 수 있습니다.

4단계: 종료와 계가를 솔직하게 표시합니다

양쪽이 연속으로 패스하거나 사용자가 계가를 선택하면 빈 영역과 잡은 돌, 백 덤을 계산할 수 있습니다. 그러나 판 위 죽은 돌의 사활을 완전히 판단하지 못한다면 결과를 ‘참고 계가’라고 표시해야 합니다. 자동 계산의 한계를 숨기는 것보다 어떤 값을 세었는지 설명하는 편이 사용자에게 더 유용합니다.

배포 전 확인 목록

  • 새로고침 뒤 진행 중인 판이 복구되는가
  • 느린 네트워크에서 한 수가 중복 저장되지 않는가
  • 다른 접근 키로 남의 대국을 열 수 없는가
  • 과거 수를 보는 동안 원본 대국이 바뀌지 않는가
  • 오류가 발생했을 때 판과 수순이 어긋나지 않는가

여기까지 연결하면 첫 번째 완성형이 됩니다. 이후에는 실제 대국 로그에서 발견된 축, 호구, 조기 패스 같은 실패를 한 종류씩 평가하고 고쳐 나갈 수 있습니다.

이 단계의 실제 코드

복기 화면은 저장된 수순을 처음부터 지정한 수까지 다시 두어 판을 만듭니다. 원본 수순은 건드리지 않으므로 앞뒤로 넘겨 봐도 실제 대국의 마지막 상태는 바뀌지 않습니다.

mariago-web/lib/board.tstypescript · 44줄
export type ReplayState = {
  board: Stone[];
  ko: Point | null;
  lastMove: Point | null;
  capB: number;
  capW: number;
  passStreak: number;
  toPlay: Stone;
};

/** 기보 0..upto 수까지 재현 (upto=0 이면 빈 판). */
export function replayMoves(
  moves: Array<{ color: 1 | 2; pass?: boolean; r?: number; c?: number }>,
  upto: number,
): ReplayState {
  let board = emptyBoard();
  let ko: Point | null = null;
  let lastMove: Point | null = null;
  let capB = 0;
  let capW = 0;
  let passStreak = 0;
  let toPlay: Stone = BLACK;
  const n = Math.max(0, Math.min(upto, moves.length));
  for (let i = 0; i < n; i++) {
    const m = moves[i];
    const who = m.color as Stone;
    if (m.pass) {
      passStreak += 1;
      lastMove = null;
    } else if (m.r != null && m.c != null) {
      const res = tryPlay(board, m.r, m.c, who, ko);
      if (res.ok) {
        board = res.board;
        ko = res.koPoint;
        lastMove = { r: m.r, c: m.c };
        if (who === BLACK) capB += res.captured;
        else capW += res.captured;
        passStreak = 0;
      }
    }
    toPlay = who === BLACK ? WHITE : BLACK;
  }
  return { board, ko, lastMove, capB, capW, passStreak, toPlay };
}

사용한 모델: Qwen3-VL-8B-Instruct + LoRA 어댑터(lora_mix_qwen_r5). 전체 소스와 모델 설명은 ‘8. 전체 소스 코드와 사용한 모델’ 글에서 볼 수 있습니다.