# AI 꾸러미 — 차례 시계 · 시간 초과 — Clock offset estimation (Cristian's algorithm)
> 핑 · 퐁으로 내 기기 시계와 서버 시계 차이를 재서, 차례 남은 시간을 서버 마감 시각 기준으로 보여 주고 초과하면 서버가 자동 처리한다.  
> 견본: https://ai-techstudio.web.app/#t/i441

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

## 주문서

### 만들어 줘: 차례 시계 · 시간 초과 — Clock offset estimation (Cristian's algorithm)

#### 1. 목표
온라인 빠른 대국에 차례 시계를 넣어 줘 — 서버가 마감 시각을 정해 보내고, 내 화면은 핑 · 퐁으로 잰 시계 차이로 보정해 남은 시간을 보여 주기. 시간이 다 되면 서버가 자동 수를 두고 두 화면에 알리기. 둥근 시계 고리.

#### 2. 핵심 기술 용어
- **Clock offset estimation (Cristian's algorithm)** — 시계 차이 재기 — 서버 시각 − (보낸 때 + 받은 때) / 2
- **Server-authoritative deadline** — 서버 시각으로 정한 마감
- **Turn timer / timeout** — 차례 시계 · 시간 초과
- **Clock skew** — 기기마다 다른 시계 어긋남

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

#### 4. 조건
- 마감은 서버 시각으로 정해 보내기 — 「남은 5초」가 아니라 「마감 시각」
- 시계 차이 = S − (t1 + t2) / 2, 차이를 재기 전에는 「맞추는 중…」
- 시간 초과 처리는 서버만 — 내 화면은 0초에서 멈춰 기다리기
- 「서버 시각으로 보정」 켬/끔과 「내 기기 시계 차이 −3 ~ +3초」 조절로 비교

#### 5. 완성 기준 (이게 보이면 성공)
- 시계 차이 +1.5초에서 보정 끔이면 내 화면 고리가 서버 고리보다 1.5초 먼저 0 이 되고 「내 화면만 0초?」
- 보정 켬이면 두 고리가 함께 줄고 함께 0 이 된다
- 시간이 다 되면 서버가 「시간 초과 → 자동 수」를 두 쪽에 보내고 판에 자동 수가 놓인다

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

## 원리
- 기기 시계는 몇 초씩 어긋나 있을 수 있다 — 내 시계로 마감을 세면 화면마다 남은 시간이 다르다.
- 핑: 보낸 때 t1(내 시계) → 서버가 자기 시각 S 를 담아 퐁 → 받은 때 t2. 차이 = S − (t1 + t2) / 2.
- 서버는 「마감 = 서버 지금 + 5초」를 보낸다. 내 화면 남은 시간 = 마감 − (내 시계 + 차이).
- 시간 초과 판정은 서버만 한다 — 서버가 자동 수를 두고 두 쪽에 알린다. 내 화면이 0초여도 스스로 처리하지 않는다.

## 핵심 코드 — 핑 · 퐁으로 시계 차이 재기 + 서버 마감 기준 남은 시간
(발췌: demos/demosMusicNet.ts demoClock() 의 offset 계산 · myLeft 를 정리)
```ts
let offset: number | null = null; // 서버 시각 − 내 시계 (초)
let deadline = -1;                  // 서버 시각으로 정한 마감

const myClock = (): number => Date.now() / 1000;

async function syncClock(): Promise<void> {
  const t1 = myClock();
  const S = await request({ type: 'ping' }); // 서버가 자기 시각을 담아 퐁
  const t2 = myClock();
  offset = S - (t1 + t2) / 2; // 왕복의 가운데에 서버가 S 였다고 본다
}

function onTurn(msg: { deadline: number }): void {
  deadline = msg.deadline; // 「남은 5초」가 아니라 「서버 시각 몇 초까지」
}

function secondsLeft(): number {
  const serverNow = myClock() + (offset ?? 0);
  return Math.max(0, deadline - serverNow); // 0 이 돼도 스스로 처리하지 않는다
}

// 시간 초과는 서버가 판단해 { type: 'timeout', autoMove } 를 두 쪽에 보낸다
```

## 흔한 실수 · 확인 목록
- [ ] **「남은 5초」를 보내면 받는 데 걸린 지연만큼 화면마다 다르다** — 남은 시간이 아니라 서버 시각으로 된 마감을 보낸다.
- [ ] **내 화면이 0초가 됐다고 스스로 수를 두면 서버와 판이 갈린다** — 시간 초과는 서버만 판단하고, 화면은 서버 알림을 기다린다.
- [ ] **핑을 한 번만 재면 그때 지연이 길었을 때 차이가 크게 틀린다** — 여러 번 재서 왕복(t2 − t1)이 가장 짧았던 것을 쓴다.

## 완성 기준 체크리스트
- [ ] 시계 차이 +1.5초에서 보정 끔이면 내 화면 고리가 서버 고리보다 1.5초 먼저 0 이 되고 「내 화면만 0초?」
- [ ] 보정 켬이면 두 고리가 함께 줄고 함께 0 이 된다
- [ ] 시간이 다 되면 서버가 「시간 초과 → 자동 수」를 두 쪽에 보내고 판에 자동 수가 놓인다

## 이 기술 정보
- id: `i441` · 분류: 게임 시스템 · AI › 온라인 대전 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 핑 · 퐁 한 번(작은 메시지 둘). 여러 번 재서 왕복이 가장 짧은 것을 쓰면 더 정확.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i441
- 쓰면 좋을 때: 차례마다 제한 시간이 있는 온라인 게임 / 두 사람 화면의 남은 시간이 달라 다툼이 날 때
- 쓰지 말 때: 혼자 하는 게임 — 내 시계(performance.now)면 충분 / 프레임 단위 동기가 필요한 실시간 액션 — 더 정밀한 시간 동기(여러 번 재서 평균)가 필요

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### demoClock — `src/demos/demosMusicNet.ts:2114`
```ts
function demoClock(): Made {
  const net = new Net();
  const lat = { v: 200 };
  let skew = 1.5;
  let fix = true;
  const TURN = 5;
  const me = new Board();
  let deadline = -1;
  let offset: number | null = null;
  let t1 = 0;
  let localOver = false;
  let srvOver = false;
  let gotDeadline = false;
  const run = (): void => {
    net.restart();
    me.clear();
    me.set(0, 'O', false, -9);
    me.set(4, 'X', false, -9);
    me.set(5, 'O', false, -9);
    offset = null;
    deadline = -1;
    localOver = false;
    srvOver = false;
    gotDeadline = false;
    const L = lat.v / 1000;
    // 시계 맞추기 (핑)
    net.after(0.1, () => {
      t1 = net.now + skew;
      net.send(0, 1, L, `핑 (내 시각 ${t1.toFixed(1)})`, '#c9d2ee', () => {
        const S = net.now;
        net.send(1, 0, L, `퐁 (서버 ${S.toFixed(1)})`, SRV_COL, () => {
          const t2 = net.now + skew;
          offset = S - (t1 + t2) / 2;
          net.mark(0, `차이 ${(-offset).toFixed(1)}초`, '#c9d2ee');
        });
      });
    });
    // 차례 시작 (서버 시각 기준 마감)
    net.after(0.3, () => {
      deadline = net.now + TURN;
      net.mark(1, `마감 ${deadline.toFixed(1)}`, SRV_COL);
      net.send(1, 0, L, '내 차례 · 마감 시각', SRV_COL, () => (gotDeadline = true));
      net.after(TURN, () => {
        srvOver = true;
        net.mark(1, '시간 초과', BAD_COL);
        net.send(1, 0, L, '시간 초과 → 자동 수', BAD_COL, () => me.set(8, 'X', false, net.now, true));
        net.send(1, 2, L, '자동 수', BAD_COL);
      });
    });
    net.after(0.3 + TURN + 2 * L + 1.8, run);
  };
  run();
  const ring = (g: G, cx: number, cy: number, R: number, left: number, col: string, title: string, sub: string, u: number, big: boolean): void => {
    g.lineWidth = 4 * u;
    g.strokeStyle = 'rgba(255,255,255,0.08)';
    g.beginPath();
    g.arc(cx, cy, R, 0, Math.PI * 2);
    g.stroke();
    const f = clamp(left / TURN, 0, 1);
    g.strokeStyle = left <= 1 ? BAD_COL : col;
    g.lineCap = 'round';
    g.beginPath();
    g.arc(cx, cy, R, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * f);
    g.stroke();
    g.lineCap = 'butt';
    txt(g, left > 0 ? left.toFixed(1) : '0.0', cx, cy, R * 0.62, '#ffffff', 'center', 800);
    txt(g, title, cx, cy + R + (big ? 9 : 6.5) * u, (big ? 8 : 6) * u, col, 'center', 700);
    if (sub) txt(g, sub, cx, cy + R + (big ? 20 : 13.5) * u, (big ? 7 : 5.2) * u, '#8d97b8', 'center');
  };
  return {
    controls: [
      { type: 'range', label: '내 기기 시계 차이 (초)', min: -3, max: 3, step: 0.1, value: 1.5, on: (v) => (skew = v) },
      { type: 'toggle', label: '서버 시각으로 보정', value: true, on: (v) => (fix = v) },
      latControl(lat),
      { 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);
      const local = net.now + skew;
      const srvLeft = deadline > 0 ? deadline - net.now : TURN;
      let myLeft = TURN;
      let sub = '';
      if (deadline > 0 && gotDeadline) {
        if (fix && offset !== null) {
          myLeft = deadline - (local + offset);
          sub = '보정됨';
        } else if (fix) {
          myLeft = deadline - local;
          sub = '맞추는 중…';
        } else {
          myLeft = deadline - local;
          sub = `${skew >= 0 ? '+' : ''}${skew.toFixed(1)}초 어긋남`;
        }
      } else if (deadline > 0) sub = '마감 받는 중';
      if (gotDeadline && myLeft <= 0 && !srvOver && !localOver) {
        localOver = true;
        net.mark(0, '내 화면만 0초?', BAD_COL);
      }
      statusLine(g, L, `서버 ${net.now.toFixed(1)} · 내 시계 ${local.toFixed(1)}`, '#9aa6c4');
      // 두 시계
      const R = L.bs * 0.17;
      const cy = L.by + R + 2 * u;
      ring(g, L.bx + L.bs * 0.25, cy, R, srvLeft, SRV_COL, '서버 남은 시간', '진짜 기준', u, big);
      ring(g, L.bx + L.bs * 0.75, cy, R, myLeft, ME_COL, '내 화면', sub, u, big);
      const by2 = cy + R + (big ? 28 : 19) * u;
      const s2 = L.by + L.bs - by2 + (big ? 30 : 20) * u;
      const bsz = Math.min(s2, L.bs * 0.62);
      drawBoard(g, L.bx + (L.bs - bsz) / 2, by2, bsz, me, net.now, u * 0.8);
      if (srvOver) chip(g, L.bx + L.bs / 2, by2 + bsz + 3 * u, '시간 초과 → 서버가 자동으로 둠', BAD_COL, (big ? 8 : 5.8) * u, 'center');
      drawSeq(g, L.sx, L.sy, L.sw, L.sh, net, u, big);
    },
  };
}
```

### i441 견본 항목 — `src/demos/demosMusicNet.ts:2527`
```ts
  i441: { kind: '2d', caption: '핑 · 퐁으로 내 기기 시계와 서버 시계 차이를 재서 남은 시간을 맞춘다 — 초과하면 서버가 자동 처리', make: demoClock }
```

## 관련 기술
- 먼저 알면 좋은 기술: [다시 접속 (판 이어 하기)](https://ai-techstudio.web.app/ai/t/i440.md) `i440`
- 다음에 해 볼 기술: [동시에 둔 수 정리 (순서 정하기)](https://ai-techstudio.web.app/ai/t/i442.md) `i442`
- 참고 문서: [Wikipedia — Cristian's algorithm](https://en.wikipedia.org/wiki/Cristian%27s_algorithm)
