# AI 꾸러미 — 난이도 = 사람 같은 실수 (온도 소프트맥스) — Softmax with temperature
> 수마다 매긴 점수를 온도 T 로 확률로 바꿔 뽑아서, T 가 낮으면 늘 최선(고수) · 높으면 그럴듯한 차선도 가끔(유아) 두게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i358

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

## 주문서

### 만들어 줘: 난이도 = 사람 같은 실수 (온도 소프트맥스) — Softmax with temperature

#### 1. 목표
모든 대전 게임 AI의 난이도를 온도 소프트맥스로 나눠 줘 — 낮은 난이도는 그럴듯한 실수를 하게. 분위기는 확률 막대 위로 공이 떨어져 고르는 설명.

#### 2. 핵심 기술 용어
- **Softmax with temperature** — p ∝ exp((점수 − 최고 점수) / T)
- **Epsilon-greedy** — ε 확률로 아무 수나, 아니면 최선 — 비교용
- **Difficulty by stochastic play** — 실수를 확률로 섞어 난이도 나누기

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

#### 4. 조건
- exp 안에서 최고 점수를 뺀다 (넘침 방지)
- 점수 크기(척도)를 게임마다 맞춘 뒤 T 를 정한다 — 같은 T 라도 점수 범위가 다르면 성격이 다르다
- 난이도별 T 표 (견본 기준 고수 < 0.08 · 중수 < 0.4 · 초보 < 1.2 · 유아)
- 바로 이기는 수 · 바로 지는 것 막기는 쉬움 단계에서도 놓칠지 정책을 정한다
- 설명 화면: 확률 막대 · 고른 기록 30개 · 최선 수 확률 곡선, ε 방식 토글

#### 5. 완성 기준 (이게 보이면 성공)
- T 를 낮추면 최선 수(금빛) 막대가 거의 100% 가 되고, 높이면 막대가 고르게 퍼진다
- 높은 T 에서도 점수가 아주 나쁜 수(빨강)는 좋은 수보다 덜 뽑힌다
- ε 방식으로 바꾸면 나쁜 수도 좋은 수와 같은 비율로 뽑히는 차이가 보인다

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

## 원리
- 탐색 · 평가로 수마다 점수 v 를 얻는다 (견본 7수 −0.9 ~ +0.9).
- 확률 p = exp((v − v최고) / T) 를 모두 더한 값으로 나눈다. 최고를 빼는 건 수가 너무 커지지 않게.
- T 가 작으면(0.03) 최선 수가 거의 100%, 커질수록(3) 점수 차가 무뎌져 차선 · 엉뚱한 수도 나온다 — 하지만 나쁜 수일수록 덜 나온다.
- ε-무작위는 ε 확률로 아무 수(아주 나쁜 수도 같은 확률), 아니면 최선 — 실수가 덜 사람답다. 견본에서 둘을 바꿔 비교.
- 0~1 난수에서 확률을 차례로 빼다 0 이하가 되는 수를 고른다.

## 핵심 코드 — 온도 소프트맥스 확률 · 뽑기 (ε-무작위 비교)
(발췌: demos/demosGameAI.ts I358 의 probs() · 뽑기를 정리)
```ts
/** 점수 → 확률. eps 이면 ε-무작위 (ε = T / 2) */
function probs(vals: number[], T: number, eps = false): number[] {
  const best = vals.indexOf(Math.max(...vals));
  if (eps) {
    const e = Math.min(1, Math.max(0, T / 2));
    return vals.map((_, i) => (i === best ? 1 - e : 0) + e / vals.length);
  }
  const ex = vals.map((v) => Math.exp((v - vals[best]!) / T));   // 최고를 빼서 넘침 방지
  const s = ex.reduce((a, b) => a + b, 0);
  return ex.map((v) => v / s);
}

function pick(p: number[], rnd = Math.random): number {
  let x = rnd();
  for (let i = 0; i < p.length; i++) {
    x -= p[i]!;
    if (x <= 0) return i;
  }
  return p.length - 1;
}

// 난이도 → 온도 (점수 −1 ~ +1 기준)
const TEMP = { 유아: 2.2, 초보: 0.7, 중수: 0.18, 고수: 0.04 } as const;
const move = moves[pick(probs(scores, TEMP.초보))];
```

## 흔한 실수 · 확인 목록
- [ ] **exp(v / T) 를 그대로 쓰면 T 가 작을 때 Infinity 가 나온다** — 최고 점수를 빼고 exp — 가장 큰 값이 1 이 된다.
- [ ] **ε-무작위로 쉬움을 만들면 「말도 안 되는 수」를 둬 오히려 어색하다** — 소프트맥스는 나쁜 수일수록 덜 고르니 실수가 그럴듯하다.
- [ ] **점수 범위가 게임마다 달라 같은 T 가 다른 난이도가 된다** — 점수를 −1 ~ +1 쯤으로 맞춘 뒤 T 표를 쓴다.

## 완성 기준 체크리스트
- [ ] T 를 낮추면 최선 수(금빛) 막대가 거의 100% 가 되고, 높이면 막대가 고르게 퍼진다
- [ ] 높은 T 에서도 점수가 아주 나쁜 수(빨강)는 좋은 수보다 덜 뽑힌다
- [ ] ε 방식으로 바꾸면 나쁜 수도 좋은 수와 같은 비율로 뽑히는 차이가 보인다

## 이 기술 정보
- id: `i358` · 분류: 게임 시스템 · AI › 게임 AI · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — exp 몇 번. 비용 없음 — 점수를 매기는 탐색이 비용의 전부.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i358
- 쓰면 좋을 때: 모든 대전 게임의 쉬움 · 보통 단계 (유아가 이길 수 있게) / AI 가 늘 같은 수만 두지 않게
- 쓰지 말 때: 어려움 단계 — 거의 완벽해야 한다(T 아주 작게, 빈틈은 드문 차선만) / 점수가 없는 AI(규칙 기반) — 먼저 수마다 점수를 매기는 단계가 필요

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### I358 — `src/demos/demosGameAI.ts:3704`
```ts
const I358: DemoMap = {
  i358: {
    kind: '2d',
    caption: '수마다 점수를 매긴 뒤 온도 T 로 확률을 만들어요 — T 가 낮으면 늘 최선(고수), 높으면 엉뚱한 수도 가끔(유아). 공이 그 확률대로 칸을 골라요',
    make() {
      const vals = [-0.7, 0.1, 0.55, 0.9, 0.6, -0.15, -0.9];
      const best = 3;
      let Tv = 0.3;
      let auto = true;
      let eps = false;
      let ph = 0;
      const st = { speed: 1 };
      let r = rng(7);
      const hist: number[] = [];
      let pick = -1;
      let pickT = 0;
      const LO = Math.log(0.03);
      const HI = Math.log(3);
      const probs = (T: number): number[] => {
        if (eps) {
          const e = clamp(T / 2, 0, 1);
          return vals.map((_, i) => (i === best ? 1 - e : 0) + e / vals.length);
        }
        const ex = vals.map((v) => Math.exp((v - vals[best]!) / T));
        const s = ex.reduce((a, b) => a + b, 0);
        return ex.map((v) => v / s);
      };
      return {
        draw(g, w, h, t, dt) {
          reset(g);
          const d = Math.min(dt, 0.1) * st.speed;
          ph += d;
          if (auto) Tv = Math.exp(lerp(LO, HI, (1 - Math.cos(ph * 0.55)) / 2));
          const p = probs(Tv);
          pickT += d;
          if (pickT > 0.55) {
            pickT = 0;
            let x = r();
            pick = p.length - 1;
            for (let i = 0; i < p.length; i++) {
              x -= p[i]!;
              if (x <= 0) {
                pick = i;
                break;
              }
            }
            hist.push(pick);
            if (hist.length > 30) hist.shift();
          }
          const u = scaleOf(w, h);
          bg(g, w, h);
          const level = Tv < 0.08 ? '고수' : Tv < 0.4 ? '중수' : Tv < 1.2 ? '초보' : '유아';
          header(g, w, u, eps ? 'ε-무작위 실수' : '소프트맥스 온도', C.text, `T = ${Tv.toFixed(2)} · ${level}`, C.gold);
          // 왼쪽: 확률 막대
          const LW = w * 0.56;
          const n = vals.length;
          const cw = (LW - 16 * u) / n;
          const baseY = h - 46 * u;
          const topY = 30 * u;
          for (let i = 0; i < n; i++) {
            const x = 10 * u + i * cw;
            const hh = p[i]! * (baseY - topY);
            const col = i === best ? C.gold : vals[i]! > 0.3 ? C.green : vals[i]! > 0 ? '#aab3d6' : C.red;
            rr(g, x + cw * 0.14, baseY - hh, cw * 0.72, hh, 2 * u);
            g.fillStyle = col;
            g.globalAlpha = 0.85;
            g.fill();
            g.globalAlpha = 1;
            txt(g, `${Math.round(p[i]! * 100)}%`, x + cw / 2, baseY - hh - 5 * u, 6 * u, col, 'center', 800);
            txt(g, `${vals[i]! > 0 ? '+' : ''}${vals[i]!.toFixed(1)}`, x + cw / 2, baseY + 7 * u, 6 * u, C.sub, 'center', 700);
            if (i === pick) {
              const k = clamp(pickT / 0.25, 0, 1);
              const by = lerp(topY - 4 * u, baseY - hh - 12 * u, easeOut(k));
              glow(g, x + cw / 2, by, cw * 0.7, '#fff', 0.35);
              g.fillStyle = '#fff';
              g.beginPath();
              g.arc(x + cw / 2, by, Math.min(4 * u, cw * 0.22), 0, TAU);
              g.fill();
            }
          }
          // 고른 기록
          const hy = h - 18 * u;
          txt(g, '고른 수', 10 * u, hy - 9 * u, 6 * u, C.sub, 'left', 700);
          const dw = (LW - 16 * u) / 30;
          hist.forEach((q, i) => {
            g.fillStyle = q === best ? C.gold : vals[q]! > 0.3 ? C.green : vals[q]! > 0 ? '#aab3d6' : C.red;
            g.beginPath();
            g.arc(10 * u + i * dw + dw / 2, hy, Math.min(dw * 0.38, 3 * u), 0, TAU);
            g.fill();
          });
          const bestRate = hist.length ? hist.filter((q) => q === best).length / hist.length : 0;
          txt(g, `최선 ${Math.round(bestRate * 100)}%`, LW - 6 * u, hy - 9 * u, 6 * u, C.gold, 'right', 800);
          // 오른쪽: 곡선 (최선 수 확률 · 평균 점수)
          const gx = LW + 10 * u;
          const gw = w - gx - 10 * u;
          const gy = 30 * u;
          const gh = h - gy - 32 * u;
          g.strokeStyle = 'rgba(255,255,255,0.15)';
          g.lineWidth = 0.8 * u;
          g.strokeRect(gx, gy, gw, gh);
          const X = (T: number): number => gx + ((Math.log(T) - LO) / (HI - LO)) * gw;
          const curve = (f: (pp: number[]) => number, col: string): void => {
            g.strokeStyle = col;
            g.lineWidth = 1.6 * u;
            g.beginPath();
            for (let k = 0; k <= 60; k++) {
              const T = Math.exp(lerp(LO, HI, k / 60));
              const y = gy + gh - f(probs(T)) * gh;
              if (k) g.lineTo(X(T), y);
              else g.moveTo(X(T), y);
            }
            g.stroke();
          };
          curve((pp) => pp[best]!, C.gold);
          curve((pp) => (pp.reduce((a, q, i) => a + q * vals[i]!, 0) + 1) / 2, C.teal);
          const cx = X(Tv);
          g.strokeStyle = '#fff';
          g.lineWidth = 0.8 * u;
          g.beginPath();
          g.moveTo(cx, gy);
          g.lineTo(cx, gy + gh);
          g.stroke();
          g.fillStyle = C.gold;
          g.beginPath();
          g.arc(cx, gy + gh - p[best]! * gh, 2.6 * u, 0, TAU);
          g.fill();
          (['고수', '중수', '초보', '유아'] as const).forEach((s, i) => txt(g, s, X([0.04, 0.18, 0.7, 2.2][i]!), gy + gh + 7 * u, 5.8 * u, s === level ? C.gold : C.dim, 'center', 800));
          txt(g, '최선 수 확률', gx + 3 * u, gy + gh - 15 * u, 5.8 * u, C.gold, 'left', 800);
          txt(g, '평균 수 점수', gx + 3 * u, gy + gh - 6 * u, 5.8 * u, C.teal, 'left', 800);
          txt(g, '온도 T (로그) →', gx + gw, h - 9 * u, 5.8 * u, C.sub, 'right', 700);
          void t;
        },
        controls: [
          speedCtl(st),
          { type: 'range', label: '온도 T', min: 0.03, max: 3, step: 0.01, value: 0.3, on: (v) => { Tv = v; auto = false; } },
          { type: 'toggle', label: 'T 자동으로 오르내리기', value: true, on: (v) => { auto = v; } },
          { type: 'toggle', label: 'ε-무작위 방식 (끄면 소프트맥스)', value: false, on: (v) => { eps = v; r = rng(7); } },
        ] as Control[],
      };
    },
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [평가 함수 설계 (가중치)](https://ai-techstudio.web.app/ai/t/i346.md) `i346` · [몬테카를로 트리 탐색 (UCT)](https://ai-techstudio.web.app/ai/t/i347.md) `i347`
- 다음에 해 볼 기술: [화면 안 멈추는 계산 (Web Worker)](https://ai-techstudio.web.app/ai/t/i359.md) `i359`
- 참고 문서: [Wikipedia — Softmax function](https://en.wikipedia.org/wiki/Softmax_function)
