# AI 꾸러미 — 다시 접속 (판 이어 하기) — Reconnection with state resync
> 연결이 끊긴 사이 놓친 수가 있어도, 다시 접속할 때 서버에서 판 전체와 차례를 받아 화면을 맞추고 이어 하게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i440

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 다시 접속 (판 이어 하기) — Reconnection with state resync

#### 1. 목표
온라인 보드게임 방에 다시 접속을 넣어 줘 — 끊기면 판을 흐리게 덮고 「연결 중…」, 다시 붙으면 마지막으로 받은 수 번호를 보내고 서버에서 판 전체 + 차례를 받아 그대로 맞추기. 끊김 0.6 ~ 3초에서 잠깐 끊긴 와이파이.

#### 2. 핵심 기술 용어
- **Reconnection with state resync** — 다시 접속 + 상태 다시 맞추기
- **Authoritative snapshot** — 서버가 가진 판 전체 사진
- **Move sequence number** — 수 번호 — 어디까지 받았는지 (#3)
- **online / offline events** — 브라우저 연결 상태 알림

#### 3. 환경
- 플랫폼: TypeScript (브라우저), 라이브러리 없이 — 화면과 떨어진 순수 함수로
- 화면: 브라우저 — PC · 폰 모두

#### 4. 조건
- 끊긴 동안 판을 흐리게 덮고 「끊김… / 연결 중…」 표시, 입력 막기
- 다시 접속하면 판 전체 + 차례를 받아 통째로 맞추기 (놓친 수를 하나씩 맞추려 하지 않기)
- 맞춘 뒤 「이어서 ✓ 내 차례」처럼 지금 누구 차례인지 크게
- 「판 전체 받기」 켬/끔으로 어긋남 비교

#### 5. 완성 기준 (이게 보이면 성공)
- 켬: 끊긴 사이 상대가 둔 수(✕로 놓친 메시지)가 다시 접속하자 판에 나타나고 「판 맞춤 ✓」
- 끔: 다시 접속해도 상대 수가 없고 「판이 서버와 달라요!」
- 큰 화면에서는 내 화면과 서버 판을 나란히 보여 줘 같아지는 순간이 보인다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 끊긴 사이에도 상대는 둔다 — 그 수를 서버는 받았지만 내 화면은 못 받는다.
- 다시 붙었을 때 「연결 OK」만 받으면 내 판은 서버 판과 다른 채로 남는다 (판 어긋남).
- 다시 접속하면 「마지막으로 받은 수 #3」을 알리고, 서버는 판 전체 + 지금 차례를 보낸다.
- 받은 판으로 내 화면을 통째로 덮어쓴 뒤에야 입력을 연다.

## 핵심 코드 — 끊김 → 연결 중 → 판 전체 받아 맞추기
(발췌: 새로 씀 (demos/demosMusicNet.ts demoReconnect() 의 흐름: 다시 접속 · 마지막 #3 → 판 전체 + 차례 → copyFrom))
```ts
let lastSeq = 0;          // 마지막으로 받은 수 번호
let status: 'on' | 'off' | 'connecting' = 'on';

function onMove(msg: { seq: number; cell: number; p: 'X' | 'O' }): void {
  board[msg.cell] = msg.p;
  lastSeq = msg.seq;
  render();
}

function onDisconnect(): void {
  status = 'off';
  showVeil('끊김…'); // 판을 흐리게 덮고 입력 막기
}

async function onReconnect(): Promise<void> {
  status = 'connecting';
  showVeil('연결 중…');
  // 놓친 수를 하나씩 맞추지 말고, 서버 판 전체를 받아 덮어쓴다
  const snap = await request({ type: 'resync', since: lastSeq }); // → { board, turn, seq }
  board = snap.board.slice();
  myTurn = snap.turn === me;
  lastSeq = snap.seq;
  status = 'on';
  hideVeil();
  toast(myTurn ? '이어서 ✓ 내 차례' : '이어서 ✓ 상대 차례');
  render();
}
```

## 흔한 실수 · 확인 목록
- [ ] **다시 접속 때 「연결됨」만 확인하면 놓친 수 때문에 판이 어긋난다** — 판 전체 + 차례를 받아 통째로 맞춘다.
- [ ] **판을 맞추기 전에 입력을 열면 옛 판 위에 수를 둔다** — 판을 덮어쓴 뒤에 입력을 연다. 그동안은 흐린 덮개 + 「연결 중…」.
- [ ] **공유 개발 서버는 다른 작업 때문에 자꾸 다시 불러와져 끊김 시험이 엉킨다** — 고정된 사본 · HMR 끈 서버 · 두 탭으로 시험한다 (이 사이트 두 탭 시험법).

## 완성 기준 체크리스트
- [ ] 켬: 끊긴 사이 상대가 둔 수(✕로 놓친 메시지)가 다시 접속하자 판에 나타나고 「판 맞춤 ✓」
- [ ] 끔: 다시 접속해도 상대 수가 없고 「판이 서버와 달라요!」
- [ ] 큰 화면에서는 내 화면과 서버 판을 나란히 보여 줘 같아지는 순간이 보인다

## 이 기술 정보
- id: `i440` · 분류: 게임 시스템 · AI › 온라인 대전 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 다시 접속할 때만 판 전체(작은 JSON) 한 번. 평소 비용 없음.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i440
- 쓰면 좋을 때: 폰으로 하는 온라인 대전 — 와이파이 · 데이터 바뀜이 잦다 / 한 판이 몇 분 넘게 걸리는 게임
- 쓰지 말 때: 아주 짧은 판(몇 초) — 대신 끊기면 판을 끝내고 새로 매칭 / 서버가 판을 저장하지 않는 구조 — 먼저 서버(데이터베이스)에 판을 두어야 한다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### demoReconnect — `src/demos/demosMusicNet.ts:1998`
```ts
function demoReconnect(): Made {
  const net = new Net();
  const lat = { v: 220 };
  let downFor = 1.6;
  let resync = true;
  const me = new Board();
  const srv = new Board();
  let offline = false;
  let connecting = false;
  let status = '';
  let statusCol = '#9aa6c4';
  let moveNo = 0;
  const move = (who: 0 | 2, cell: number, connected: () => boolean): void => {
    const L = lat.v / 1000;
    const p: P = who === 0 ? 'X' : 'O';
    const col = who === 0 ? ME_COL : OPP_COL;
    if (who === 0) me.set(cell, p, false, net.now);
    net.send(who, 1, L, `${p} → ${cell + 1}`, col, () => {
      moveNo++;
      srv.set(cell, p, false, net.now);
      net.mark(1, `#${moveNo}`, SRV_COL);
      const other: Lane = who === 0 ? 2 : 0;
      const lost = other === 0 && !connected();
      net.send(1, other, L, `${p} → ${cell + 1}`, col, () => {
        if (other === 0) me.set(cell, p, false, net.now);
      }, { lost });
    });
  };
  const run = (): void => {
    net.restart();
    me.clear();
    srv.clear();
    offline = false;
    connecting = false;
    moveNo = 0;
    status = '연결됨';
    statusCol = OK_COL;
    const L = lat.v / 1000;
    const on = (): boolean => !offline && !connecting;
    net.after(0.2, () => move(0, 4, on));
    net.after(0.2 + 2 * L + 0.3, () => move(2, 0, on));
    net.after(0.2 + 4 * L + 0.6, () => move(0, 2, on));
    const dT = 0.2 + 6 * L + 1.2;
    net.after(dT, () => {
      offline = true;
      status = '끊김!';
      statusCol = BAD_COL;
      net.mark(0, '와이파이 끊김', BAD_COL);
      net.spans.push({ lane: 0, t0: net.now, t1: net.now + downFor + 2 * L, col: BAD_COL });
    });
    // 끊긴 사이 상대는 계속 둔다 (나는 못 받음)
    net.after(dT + 0.3, () => move(2, 6, on));
    net.after(dT + downFor, () => {
      offline = false;
      connecting = true;
      status = '연결 중…';
      statusCol = '#ffb547';
      net.send(0, 1, L, resync ? '다시 접속 · 마지막 #3' : '다시 접속', ME_COL, () => {
        if (resync) {
          net.mark(1, '판 · 차례 보내기', SRV_COL);
          net.send(1, 0, L, '판 전체 + 차례', SRV_COL, () => {
            me.copyFrom(srv, net.now);
            connecting = false;
            status = '이어서 ✓ 내 차례';
            statusCol = OK_COL;
            net.mark(0, '판 맞춤 ✓', OK_COL);
            net.after(0.5, () => move(0, 8, on));
          }, { fat: true });
        } else {
          net.send(1, 0, L, '연결 OK', SRV_COL, () => {
            connecting = false;
            status = '판이 서버와 달라요!';
            statusCol = BAD_COL;
            net.mark(0, '판 어긋남 ✕', BAD_COL);
          });
        }
      });
    });
    net.after(dT + downFor + 2 * L + 2.4, run);
  };
  run();
  return {
    controls: [
      latControl(lat),
      { type: 'range', label: '끊긴 시간 (초)', min: 0.6, max: 3, step: 0.1, value: 1.6, on: (v) => (downFor = v) },
      { type: 'toggle', label: '다시 접속 때 판 전체 받기', value: true, on: (v) => (resync = v) },
      { type: 'button', label: '처음부터', on: () => run() },
    ],
    draw(g, w, h, _t, dt) {
      reset(g);
      net.step(dt);
      const L = nlay(w, h);
      const { u, big } = L;
      netBg(g, w, h, u);
      statusLine(g, L, status, statusCol);
      if (big) {
        // 내 화면 + 서버 판 나란히
        const s2 = L.bs * 0.62;
        drawBoard(g, L.bx, L.by + 10 * u, s2, me, net.now, u, '내 화면', ME_COL);
        if (offline || connecting) veil(g, L.bx, L.by + 10 * u, s2, net.now, u, offline ? '끊김…' : '연결 중…');
        const s3 = L.bs * 0.36;
        drawBoard(g, L.bx + L.bs - s3, L.by + 10 * u + s2 - s3, s3, srv, net.now, u * 0.7, '서버 판', SRV_COL);
        txt(g, '끊긴 사이 생긴 수는', L.pad, L.by + s2 + 30 * u, 8 * u, '#8d97b8');
        txt(g, '다시 접속 때 「판 전체」로 한 번에 받는다', L.pad, L.by + s2 + 42 * u, 8 * u, '#8d97b8');
      } else {
        drawBoard(g, L.bx, L.by, L.bs, me, net.now, u);
        if (offline || connecting) veil(g, L.bx, L.by, L.bs, net.now, u, offline ? '끊김…' : '연결 중…');
        txt(g, '끊긴 동안 온 수 ✕ → 판 전체로 받기', L.bx + L.bs / 2, L.by + L.bs + 8 * u, 5.8 * u, '#8d97b8', 'center');
      }
      drawSeq(g, L.sx, L.sy, L.sw, L.sh, net, u, big);
    },
  };
}
```

### i440 견본 항목 — `src/demos/demosMusicNet.ts:2526`
```ts
  i440: { kind: '2d', caption: '끊긴 사이 놓친 수 ✕ → 다시 접속하면 서버에서 판 전체 · 차례를 받아 이어 한다', make: demoReconnect }
```

## 관련 기술
- 먼저 알면 좋은 기술: [지연 숨기기 (먼저 보여 주고 확인)](https://ai-techstudio.web.app/ai/t/i439.md) `i439`
- 다음에 해 볼 기술: [차례 시계 · 시간 초과](https://ai-techstudio.web.app/ai/t/i441.md) `i441` · [상대 상태 보여 주기 (생각 중 · 나감)](https://ai-techstudio.web.app/ai/t/i443.md) `i443`
- 참고 문서: [MDN — Window: online event](https://developer.mozilla.org/en-US/docs/Web/API/Window/online_event)
