# AI 꾸러미 — 동시에 둔 수 정리 (순서 정하기) — Server-side ordering (first-arrival wins)
> 두 사람이 거의 동시에 같은 칸에 둔 수를 서버에 먼저 「도착한」 순서로 정해, 늦은 쪽은 거절 · 되돌려 두 화면의 판을 같게 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i442

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

## 주문서

### 만들어 줘: 동시에 둔 수 정리 (순서 정하기) — Server-side ordering (first-arrival wins)

#### 1. 목표
온라인 칸 차지 게임에서 두 사람이 거의 동시에 같은 칸에 두면 서버 하나가 순서를 정하게 해 줘 — 먼저 도착한 수만 받고, 늦은 쪽은 「거절 · 이미 차지됨」으로 되돌리기. 두 화면과 서버 순서표를 두 화면 나란히(으)로.

#### 2. 핵심 기술 용어
- **Server-side ordering (first-arrival wins)** — 서버 도착 순서로 정하기
- **Race condition** — 거의 동시에 일어나 결과가 순서에 달린 상황
- **Transaction / compare-and-set** — 「비어 있으면 쓰기」를 한 번에 — Firebase runTransaction 등
- **Reject and rollback** — 늦은 쪽 거절 · 되돌림

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

#### 4. 조건
- 순서는 서버 하나가 정한다 — 「비어 있으면 차지」를 한 번에(트랜잭션)
- 늦은 쪽은 거절 메시지를 받고 흐린 수를 지운 뒤 이긴 수를 놓기
- 이긴 쪽 · 진 쪽 화면이 끝에 같은 판이 되는지 나란히 보여 주기
- 내 지연 · 상대 지연 슬라이더로 「먼저 보낸 쪽이 지는」 경우도 보이게

#### 5. 완성 기준 (이게 보이면 성공)
- 두 화면 모두 ⑤ 칸에 자기 수를 흐리게 놓았다가, 서버 순서표 #1 ✓ · #2 ✕ 에 따라 같은 수로 바뀐다
- 내 지연을 길게 하면 내가 먼저 보내도 상대가 이긴다
- 진 쪽 화면에 「되돌림 ↶」이 보인다

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

## 원리
- 두 사람이 0.04초 차이로 같은 칸을 누르면 각자 화면에는 자기 수가 먼저 보인다 (낙관적 표시).
- 누가 먼저 「보냈는지」가 아니라 서버에 먼저 「도착한」 것이 이긴다 — 지연이 짧은 쪽이 유리할 수 있다.
- 서버는 도착하는 대로 칸이 비었는지 보고 차지(#1 ✓), 이미 찼으면 거절(#2 ✕).
- 이긴 쪽에는 「확인」, 진 쪽에는 「거절」 + 이긴 수를 보내 두 화면을 같게 맞춘다.

## 핵심 코드 — 서버 — 먼저 도착한 수만 받기
(발췌: 새로 씀 (demos/demosMusicNet.ts demoRace() 의 arrive() 와 같은 규칙))
```ts
// 서버 쪽 (한 방의 판). 메시지는 도착한 순서대로 하나씩 처리된다
const cells: ('X' | 'O' | null)[] = Array(9).fill(null);
let seq = 0;

function onArrive(from: Player, cell: number): void {
  if (cells[cell] === null) {
    cells[cell] = from.mark;
    seq++;
    sendTo(from, { type: 'ack', cell, seq });                     // 이긴 쪽: 확인 ✓
    sendTo(other(from), { type: 'move', cell, p: from.mark, seq }); // 상대에게 알림
  } else {
    // 늦게 도착 — 이미 차지됨. 이긴 수를 함께 보내 화면을 맞추게
    sendTo(from, { type: 'reject', cell, p: cells[cell] });
  }
}

// Firebase 실시간 DB 라면 같은 일을 트랜잭션으로:
// runTransaction(ref(db, 'rooms/' + id + '/cells/' + cell), (cur) => (cur === null ? mark : undefined));
// undefined 를 돌려주면 쓰기를 그만둔다 → 진 쪽
```

## 흔한 실수 · 확인 목록
- [ ] **각자 화면에서 「내가 먼저 눌렀다」로 정하면 두 화면이 서로 다른 판이 된다** — 순서는 서버 하나가 정하고, 화면은 서버 결과로 맞춘다.
- [ ] **「읽고 → 비었으면 쓰기」를 두 번에 나눠 하면 둘 다 비었다고 읽고 둘 다 쓴다** — 트랜잭션(compare-and-set) 한 번으로 한다.
- [ ] **진 쪽에 거절만 보내고 이긴 수를 안 보내면 그 칸이 빈 채로 남는다** — 거절과 함께 그 칸의 진짜 수를 보낸다.

## 완성 기준 체크리스트
- [ ] 두 화면 모두 ⑤ 칸에 자기 수를 흐리게 놓았다가, 서버 순서표 #1 ✓ · #2 ✕ 에 따라 같은 수로 바뀐다
- [ ] 내 지연을 길게 하면 내가 먼저 보내도 상대가 이긴다
- [ ] 진 쪽 화면에 「되돌림 ↶」이 보인다

## 이 기술 정보
- id: `i442` · 분류: 게임 시스템 · AI › 온라인 대전 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 수마다 서버 검사 한 번 · 거절 메시지 하나. 데이터베이스 트랜잭션이면 다툼이 있을 때 다시 시도가 생긴다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i442
- 쓰면 좋을 때: 차례 없이 동시에 두는 실시간 게임 / 선착순 · 먼저 누르기 대결
- 쓰지 말 때: 차례제 게임 — 차례 검사로 충분하다 (상대 차례 수는 처음부터 거절) / 서버 없이 두 기기끼리만(P2P) — 순서를 정할 주인이 없다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### demoRace — `src/demos/demosMusicNet.ts:2234`
```ts
function demoRace(): Made {
  const net = new Net();
  const latMe = { v: 300 };
  const latOpp = { v: 180 };
  const me = new Board();
  const op = new Board();
  let cyc = 0;
  let order: { who: string; col: string; sent: number; got: number; ok: boolean }[] = [];
  const run = (): void => {
    net.restart();
    me.clear();
    op.clear();
    for (const b of [me, op]) {
      b.set(0, 'X', false, -9);
      b.set(2, 'O', false, -9);
      b.set(6, 'O', false, -9);
    }
    order = [];
    const cell = 4;
    const lm = latMe.v / 1000;
    const lo = latOpp.v / 1000;
    const dMe = cyc % 2 ? 0 : 0.04;
    const dOp = cyc % 2 ? 0.04 : 0;
    cyc++;
    let taken: P | null = null;
    const arrive = (who: 0 | 2, sent: number): void => {
      const p: P = who === 0 ? 'X' : 'O';
      const col = who === 0 ? ME_COL : OPP_COL;
      const n = order.length + 1;
      if (!taken) {
        taken = p;
        order.push({ who: who === 0 ? '나' : '상대', col, sent, got: net.now, ok: true });
        net.mark(1, `#${n} ${who === 0 ? '나' : '상대'} ✓`, OK_COL);
        net.send(1, 0, lm, who === 0 ? '확인 ✓' : `${p} → ⑤`, who === 0 ? OK_COL : col, () => me.set(cell, p, false, net.now));
        net.send(1, 2, lo, who === 2 ? '확인 ✓' : `${p} → ⑤`, who === 2 ? OK_COL : col, () => op.set(cell, p, false, net.now));
      } else {
        order.push({ who: who === 0 ? '나' : '상대', col, sent, got: net.now, ok: false });
        net.mark(1, `#${n} ${who === 0 ? '나' : '상대'} ✕`, BAD_COL);
        const back = who === 0 ? lm : lo;
        net.send(1, who, back, '거절 · 이미 차지됨', BAD_COL, () => {
          const b = who === 0 ? me : op;
          net.mark(who, '되돌림 ↶', BAD_COL);
          void b;
        });
      }
    };
    // 거의 같은 순간에 둘 다 ⑤ 칸
    net.after(0.3 + dMe, () => {
      const s = net.now;
      me.set(cell, 'X', true, net.now);
      net.send(0, 1, lm, 'X → ⑤', ME_COL, () => arrive(0, s));
    });
    net.after(0.3 + dOp, () => {
      const s = net.now;
      op.set(cell, 'O', true, net.now);
      net.send(2, 1, lo, 'O → ⑤', OPP_COL, () => arrive(2, s));
    });
    const period = 0.35 + 2 * Math.max(lm, lo) + 2;
    net.after(period, run);
  };
  // 남이 이긴 칸: 내 흐린 수를 지우고 상대 수 (set 이 같은 p 만 굳히므로 직접 처리)
  const origSet = Board.prototype.set;
  const fix = (b: Board): void => {
    b.set = function (i: number, p: P, wait: boolean, now: number, auto = false): void {
      const old = this.c[i];
      if (old && old.wait && old.p !== p && !wait) this.undo(i, now);
      origSet.call(this, i, p, wait, now, auto);
    };
  };
  fix(me);
  fix(op);
  run();
  return {
    controls: [
      latControl(latMe, '내 지연 (ms)'),
      latControl(latOpp, '상대 지연 (ms)'),
      { 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, '둘 다 ⑤ 칸을 거의 동시에!', '#ffb547');
      const gap = 4 * u;
      const s2 = Math.min(L.bs * 0.62, (L.colW - L.pad - gap) / 2);
      const y0 = L.by + (big ? 10 : 7) * u;
      const lx = L.pad + (L.colW - L.pad - (s2 * 2 + gap)) / 2;
      drawBoard(g, lx, y0, s2, me, net.now, u * 0.75, '내 화면', ME_COL);
      drawBoard(g, lx + s2 + gap, y0, s2, op, net.now, u * 0.75, '상대 화면', OPP_COL);
      // 서버 순서표
      const ty = y0 + s2 + (big ? 10 : 7) * u;
      const rowH = (big ? 15 : 10.5) * u;
      const tw = s2 * 2 + gap;
      panel(g, lx, ty, tw, rowH * 2.9, 4 * u);
      const fp = (big ? 7.6 : 5.6) * u;
      txt(g, '서버 도착 순서', lx + 4 * u, ty + rowH * 0.5, fp, SRV_COL, 'left', 800);
      txt(g, '보냄 → 도착', lx + tw - 4 * u, ty + rowH * 0.5, fp, '#6f7a99', 'right');
      order.forEach((o, i) => {
        const yy = ty + rowH * (1.4 + i * 0.95);
        txt(g, `#${i + 1} ${o.who}`, lx + 4 * u, yy, fp, o.col, 'left', 800);
        txt(g, `${(o.sent % 100).toFixed(2)}s → ${(o.got % 100).toFixed(2)}s`, lx + tw * 0.62, yy, fp * 0.95, '#c9d2ee', 'center');
        txt(g, o.ok ? '차지 ✓' : '거절 ✕', lx + tw - 4 * u, yy, fp, o.ok ? OK_COL : BAD_COL, 'right', 800);
      });
      if (big) txt(g, '먼저 「보낸」 쪽이 아니라 서버에 먼저 「도착한」 쪽이 이긴다 — 서버 하나가 순서를 정해야 두 화면이 같아진다', L.pad, h - L.pad * 0.7, 8 * u, '#8d97b8');
      drawSeq(g, L.sx, L.sy, L.sw, L.sh - (big ? 14 * u : 0), net, u, big);
    },
  };
}
```

### i442 견본 항목 — `src/demos/demosMusicNet.ts:2528`
```ts
  i442: { kind: '2d', caption: '둘이 거의 동시에 같은 칸 — 서버에 먼저 도착한 수만 받고, 늦은 쪽은 거절 · 되돌림', make: demoRace }
```

## 관련 기술
- 먼저 알면 좋은 기술: [지연 숨기기 (먼저 보여 주고 확인)](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`
