# AI 꾸러미 — 지연 숨기기 (먼저 보여 주고 확인) — Optimistic update (client-side prediction)
> 온라인 대전에서 내 수를 누르자마자 흐리게 판에 보여 주고 서버 확인이 오면 진하게, 거절되면 되돌려 지연을 느끼지 않게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i439

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

## 주문서

### 만들어 줘: 지연 숨기기 (먼저 보여 주고 확인) — Optimistic update (client-side prediction)

#### 1. 목표
온라인 틱택토 · 오목에서 내 수를 낙관적으로 먼저 보여 줘 — 누르자마자 흐리게(확인 대기) 판에 놓고, 서버 확인이 오면 진하게, 거절되면 되돌리며 짧은 안내. 지연 50 ~ 800ms 에서 보통 와이파이.

#### 2. 핵심 기술 용어
- **Optimistic update (client-side prediction)** — 먼저 보여 주고 확인 — 지연 숨기기
- **Server authority / acknowledgement** — 서버가 정하고 확인을 보냄
- **Rollback** — 거절되면 되돌리기
- **Round-trip time (RTT)** — 왕복 지연 = 한 방향 지연 × 2

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

#### 4. 조건
- 누르는 순간 판에 흐리게(대기) 놓기 — 서버 답을 기다리지 않기
- 서버 확인이 오면 진하게, 거절이 오면 지우고 「거절 → 되돌렸어요」 안내
- 판 상태의 진짜 주인은 서버 — 거절 뒤에는 서버가 보낸 수로 맞추기
- 지연 슬라이더(50 ~ 800ms) 와 「지연 숨기기」 켬/끔으로 비교

#### 5. 완성 기준 (이게 보이면 성공)
- 켬: 누르자마자 X 가 흐리게 보이고, 지연 뒤 「확인됨 ✓」과 함께 진해진다
- 끔: 누른 뒤 모래시계만 돌다 왕복 지연 뒤에야 X 가 보인다
- 거절 상황에서는 흐린 X 가 사라지고(되돌림 ↶) 상대 O 가 놓인다, 오른쪽 메시지 흐름 그림에 순서가 보인다

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

## 원리
- 서버 답을 기다렸다 그리면 누른 뒤 왕복 지연(한 방향 × 2)만큼 아무 일도 없다 — 260ms 면 0.5초.
- 누르는 순간 내 수를 「확인 대기」 상태(흐리게)로 판에 놓고, 서버로 보낸다.
- 서버가 받아 순서를 매기면 「확인」을 보내고, 그때 진하게 굳힌다.
- 그 사이 상대 수가 서버에 먼저 도착해 칸이 찼다면 서버는 「거절」 — 흐린 수를 지우고 상대 수를 놓는다.

## 핵심 코드 — 먼저 보여 주고 · 확인 · 거절이면 되돌리기
(발췌: 새로 씀 (demos/demosMusicNet.ts demoPredict() 의 흐름: set(wait=true) → 확인 ✓ / 거절 ✕ → undo))
```ts
type Cell = { p: 'X' | 'O'; wait: boolean } | null;
const board: Cell[] = Array(9).fill(null);

function onTap(i: number): void {
  if (board[i] || !myTurn) return;
  board[i] = { p: 'X', wait: true }; // 1) 바로 흐리게 — 확인 대기
  render();
  send({ type: 'move', cell: i });   // 2) 서버로
}

// 3) 서버 답
function onServer(msg: { type: 'ack' | 'reject' | 'move'; cell: number; p?: 'X' | 'O' }): void {
  const c = board[msg.cell];
  if (msg.type === 'ack' && c?.wait) board[msg.cell] = { p: c.p, wait: false }; // 진하게 굳히기
  if (msg.type === 'reject' && c?.wait) {
    board[msg.cell] = null;            // 되돌리기
    toast('거절 → 되돌렸어요');
  }
  if (msg.type === 'move' && msg.p) board[msg.cell] = { p: msg.p, wait: false }; // 서버가 정한 수가 진짜
  render(); // wait 인 칸은 옅게 (globalAlpha 0.45 등)
}
```

## 흔한 실수 · 확인 목록
- [ ] **거절됐는데 흐린 수를 그대로 두면 두 화면의 판이 달라진다** — 거절이 오면 반드시 지우고, 서버가 보낸 수로 판을 맞춘다 — 판의 주인은 서버.
- [ ] **확인 대기 수를 진한 수와 똑같이 그리면 거절될 때 「버그」로 보인다** — 대기 중엔 흐리게 · 작은 시계 표시로 구별한다.
- [ ] **지연 0 인 개발 서버에서만 시험하면 문제를 못 본다** — 지연을 일부러 넣어(200 ~ 800ms) 두 탭으로 시험한다.

## 완성 기준 체크리스트
- [ ] 켬: 누르자마자 X 가 흐리게 보이고, 지연 뒤 「확인됨 ✓」과 함께 진해진다
- [ ] 끔: 누른 뒤 모래시계만 돌다 왕복 지연 뒤에야 X 가 보인다
- [ ] 거절 상황에서는 흐린 X 가 사라지고(되돌림 ↶) 상대 O 가 놓인다, 오른쪽 메시지 흐름 그림에 순서가 보인다

## 이 기술 정보
- id: `i439` · 분류: 게임 시스템 · AI › 온라인 대전 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 수마다 「대기 중」 표시 하나. 네트워크 양은 같다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i439
- 쓰면 좋을 때: 차례제 온라인 대전에서 누른 뒤 반응이 굼뜰 때 / 거절될 일이 드문 수 (내 차례에 빈칸 두기)
- 쓰지 말 때: 돈 · 아이템처럼 되돌리면 안 되는 일 — 대신 확인 뒤에만 보여 주고 「보내는 중」 표시 / 거절이 잦은 실시간 경쟁 — 되돌림이 자주 보여 오히려 혼란 (대신 서버 순서 정하기 i442)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### demoPredict — `src/demos/demosMusicNet.ts:1889`
```ts
function demoPredict(): Made {
  const net = new Net();
  const lat = { v: 260 };
  let optimistic = true;
  let mixReject = true;
  const me = new Board();
  let cyc = 0;
  let pressT = -1;
  let seenT = -1;
  let shownLag = 0;
  let status = '';
  let statusCol = '#9aa6c4';
  const preset = (): void => {
    me.clear();
    me.set(0, 'X', false, -9);
    me.set(4, 'O', false, -9);
    me.set(8, 'X', false, -9);
  };
  const run = (): void => {
    net.restart();
    preset();
    const L = lat.v / 1000;
    const reject = mixReject && cyc % 2 === 1;
    cyc++;
    status = '내 차례';
    statusCol = ME_COL;
    if (reject) {
      // 상대 수가 서버에 먼저 도착
      net.after(0.25, () => {
        net.send(2, 1, L, 'O → ③', OPP_COL, () => {
          net.mark(1, '#4 상대 ✓', OK_COL);
          net.send(1, 2, L, '확인', OK_COL);
          net.send(1, 0, L, 'O → ③', OPP_COL, () => {
            const c = me.c[2];
            if (c && c.wait) {
              me.undo(2, net.now);
              net.mark(0, '되돌림 ↶', BAD_COL);
            }
            me.hourglass = -1;
            me.set(2, 'O', false, net.now);
          });
        });
      });
    }
    net.after(0.4, () => {
      pressT = net.now;
      seenT = -1;
      status = optimistic ? '바로 보임 · 확인 기다림' : '서버 답을 기다리는 중…';
      statusCol = optimistic ? ME_COL : '#ffb547';
      if (optimistic) {
        me.set(2, 'X', true, net.now);
        seenT = net.now;
        shownLag = 0;
      } else me.hourglass = 2;
      net.mark(0, '③ 누름', ME_COL);
      net.send(0, 1, L, 'X → ③', ME_COL, () => {
        if (reject) {
          net.mark(1, '거절: 이미 둔 칸', BAD_COL);
          net.send(1, 0, L, '거절 ✕', BAD_COL, () => {
            status = optimistic ? '거절 → 되돌렸어요' : '거절됐어요';
            statusCol = BAD_COL;
            me.hourglass = -1;
          });
        } else {
          net.mark(1, '#4 나 ✓', OK_COL);
          net.send(1, 2, L, 'X → ③', ME_COL);
          net.send(1, 0, L, '확인 ✓', OK_COL, () => {
            me.hourglass = -1;
            me.set(2, 'X', false, net.now);
            if (seenT < 0) {
              seenT = net.now;
              shownLag = (seenT - pressT) * 1000;
            }
            status = '확인됨 ✓';
            statusCol = OK_COL;
          });
        }
      });
    });
    const period = Math.max(3, 0.4 + 2 * L + 1.7);
    net.after(period, run);
  };
  run();
  return {
    controls: [
      latControl(lat),
      { type: 'toggle', label: '지연 숨기기 (내 수 먼저 보여 주기)', value: true, on: (v) => (optimistic = v) },
      { type: 'toggle', label: '가끔 거절되는 상황 섞기', value: true, on: (v) => (mixReject = v) },
      { type: 'button', label: '처음부터', on: () => ((cyc = 0), 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, `지연 ${lat.v}ms`, '#9aa6c4');
      drawBoard(g, L.bx, L.by, L.bs, me, net.now, u);
      const iy = L.by + L.bs + (big ? 12 : 8) * u;
      txt(g, status, L.bx + L.bs / 2, iy, (big ? 9 : 6.8) * u, statusCol, 'center', 700);
      const lag = optimistic ? 0 : seenT > 0 ? shownLag : pressT > 0 ? (net.now - pressT) * 1000 : 0;
      txt(g, `누르고 보이기까지 ${Math.round(lag)} ms`, L.bx + L.bs / 2, iy + (big ? 14 : 10) * u, (big ? 8 : 6) * u, optimistic ? OK_COL : '#ffb547', 'center', 600);
      drawSeq(g, L.sx, L.sy, L.sw, L.sh, net, u, big);
    },
  };
}
```

### i439 견본 항목 — `src/demos/demosMusicNet.ts:2525`
```ts
  i439: { kind: '2d', caption: '내 수는 누르자마자 흐리게 보이고 서버 확인이 오면 진해짐 — 거절되면 되돌린다', make: demoPredict }
```

## 관련 기술
- 다음에 해 볼 기술: [동시에 둔 수 정리 (순서 정하기)](https://ai-techstudio.web.app/ai/t/i442.md) `i442` · [다시 접속 (판 이어 하기)](https://ai-techstudio.web.app/ai/t/i440.md) `i440`
