# AI 꾸러미 — 기대값 탐색 (주사위 확률 마디) — Expectimax (chance nodes)
> 주사위 마디는 나올 수 있는 결과마다 확률을 곱해 평균을 내서, 「굴릴까 · 멈출까」를 기대 점수로 비교한다.  
> 견본: https://ai-techstudio.web.app/#t/i348

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

## 주문서

### 만들어 줘: 기대값 탐색 (주사위 확률 마디) — Expectimax (chance nodes)

#### 1. 목표
돼지 주사위 (굴리기 · 멈추기)의 AI 에 기대값 탐색을 넣어 줘 — 주사위 마디는 확률 평균으로. 분위기는 확률 나무와 그래프로 보여 주는 설명.

#### 2. 핵심 기술 용어
- **Expectimax (chance nodes)** — 내 마디 최댓값 · 주사위 마디 확률 평균
- **Expected value** — 결과 × 확률의 합
- **Pig dice hold/roll decision** — 돼지 주사위 — 이번 판 점수를 지킬까 더 굴릴까

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

#### 4. 조건
- 주사위 마디는 최대 · 최소가 아니라 확률 × 값의 합
- 확률은 결과마다 정확히 (공정한 주사위 1/6, 두 주사위 합은 1/36 ~ 6/36)
- 목표 점수 · 상대 점수가 있으면 「이길 확률」을 값으로 쓰는 쪽이 더 정확하다고 밝히기
- 설명 화면: 쌓은 점수 T 슬라이더 + 굴리기 · 멈추기 기대값 그래프(교차점 20)

#### 5. 완성 기준 (이게 보이면 성공)
- T 가 20 보다 작으면 「굴리기!」, 크면 「멈추기!」로 판정이 바뀐다
- 여섯 눈의 값이 하나씩 켜지며 합 → ÷ 6 평균이 계산된다
- 그래프에서 멈추기(대각선)와 굴리기(기울기 5/6 직선)가 T = 20 에서 만난다

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

## 원리
- 나무에 마디가 세 종류: 내 선택(최댓값), 상대 선택(최솟값), 주사위(확률 평균).
- 돼지 주사위: 이번 판에 쌓은 점수 T. 「멈추기」 = T 를 확정. 「굴리기」 = 1 이 나오면 이번 판 0, 2~6 이면 T + 눈.
- 굴리기의 기대값 = (0 + (T+2) + (T+3) + (T+4) + (T+5) + (T+6)) / 6 = (5T + 20) / 6.
- 이게 T 보다 크려면 T < 20 — 「이번 판 20점이 될 때까지 굴려라」 규칙이 계산으로 나온다 (한 번 굴리기만 볼 때).

## 핵심 코드 — 기대값 탐색 — 돼지 주사위 굴리기 · 멈추기
(발췌: demos/demosGameAI.ts I348 의 vals · roll 계산을 일반 꼴로 (재귀 부분은 새로 씀))
```ts
/** 한 번만 굴려 볼 때: 굴리기 기대값 vs 멈추기 */
function rollOrHold(T: number): 'roll' | 'hold' {
  const vals = [0, T + 2, T + 3, T + 4, T + 5, T + 6];   // 눈 1 은 이번 판 0
  const roll = vals.reduce((a, b) => a + b, 0) / 6;      // = (5T + 20) / 6
  return roll > T ? 'roll' : 'hold';                     // T < 20 이면 굴리기
}

/** 몇 번 더 굴릴 수 있을 때: 기대값 탐색 (내 선택 = 최대, 주사위 = 평균) */
function bestValue(T: number, rollsLeft: number): number {
  if (rollsLeft === 0) return T;
  const hold = T;
  let roll = 0;
  for (let face = 1; face <= 6; face++) {
    const v = face === 1 ? 0 : bestValue(T + face, rollsLeft - 1);
    roll += v / 6;                                       // 확률 1/6 씩
  }
  return Math.max(hold, roll);                           // 내 마디는 더 좋은 쪽
}
```

## 흔한 실수 · 확인 목록
- [ ] **주사위 마디를 최솟값으로 두면 AI 가 겁쟁이가 된다** — 주사위는 적이 아니다 — 확률 평균으로 계산한다.
- [ ] **점수 기대값만 보면 지고 있을 때도 조심스럽다** — 목표가 있는 게임은 「이길 확률」을 값으로 쓰면 지고 있을 때 더 과감해진다.
- [ ] **같은 상태를 재귀로 계속 다시 계산한다** — (T, 남은 횟수) 를 열쇠로 결과를 기억한다 (동적 계획).

## 완성 기준 체크리스트
- [ ] T 가 20 보다 작으면 「굴리기!」, 크면 「멈추기!」로 판정이 바뀐다
- [ ] 여섯 눈의 값이 하나씩 켜지며 합 → ÷ 6 평균이 계산된다
- [ ] 그래프에서 멈추기(대각선)와 굴리기(기울기 5/6 직선)가 T = 20 에서 만난다

## 이 기술 정보
- id: `i348` · 분류: 게임 시스템 · AI › 게임 AI · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 주사위 마디마다 결과 수(6)배로 가지가 는다. 한두 수 앞은 순식간, 깊어지면 표로 미리 계산(동적 계획).
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i348
- 쓰면 좋을 때: 주사위 · 카드 뽑기처럼 운이 섞인 게임 / 「위험을 감수할까」를 수학으로 보여 주는 설명
- 쓰지 말 때: 운이 없는 게임 — 미니맥스 · 알파베타(i340 · i341) / 결과 가지가 아주 많은 주사위 여러 개 — 깊이를 줄이거나 몬테카를로 표본으로

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### I348 — `src/demos/demosGameAI.ts:1881`
```ts
const I348: DemoMap = {
  i348: {
    kind: '2d',
    caption: '돼지 주사위: 「굴리기」는 확률 마디 — 여섯 눈의 결과를 1/6 씩 평균 내서 「멈추기」와 비교해요 (1이 나오면 이번 판 점수 0)',
    make() {
      const st = { speed: 1 };
      const Ts = [6, 14, 26, 18, 32];
      let ti = 0;
      let Tv = Ts[0]!;
      let auto = true;
      let ph = 0;
      const CYC = 4.6;
      return {
        draw(g, w, h, t, dt) {
          reset(g);
          ph += Math.min(dt, 0.1) * st.speed;
          if (ph > CYC) {
            ph = 0;
            if (auto) {
              ti = (ti + 1) % Ts.length;
              Tv = Ts[ti]!;
            }
          }
          const u = scaleOf(w, h);
          bg(g, w, h);
          header(g, w, u, '기대값 탐색', C.text, `이번 판 쌓은 점수 T = ${Tv}`, C.gold);
          const vals = [0, Tv + 2, Tv + 3, Tv + 4, Tv + 5, Tv + 6];
          const roll = vals.reduce((a, b) => a + b, 0) / 6;
          const hold = Tv;
          const litN = clamp(Math.floor((ph - 0.6) / 0.3) + 1, 0, 6);
          const partial = vals.slice(0, litN).reduce((a, b) => a + b, 0);
          const showAvg = ph > 2.5;
          const decide = ph > 3.1;
          const rollWins = roll > hold;
          // 나무 (왼쪽 60%)
          const TW = w * 0.6;
          const rootX = TW * 0.42;
          const rootY = 34 * u;
          const holdX = TW * 0.12;
          const chX = TW * 0.6;
          const midY = 34 * u + (h - 70 * u) * 0.45;
          const leafY = h - 30 * u;
          // 줄
          const edge = (x0: number, y0: number, x1: number, y1: number, hot: boolean): void => {
            g.strokeStyle = hot ? C.gold : 'rgba(160,175,230,0.45)';
            g.lineWidth = (hot ? 2.2 : 1.1) * u;
            g.beginPath();
            g.moveTo(x0, y0);
            g.lineTo(x1, y1);
            g.stroke();
          };
          edge(rootX, rootY, holdX, midY, decide && !rollWins);
          edge(rootX, rootY, chX, midY, decide && rollWins);
          const dieS = Math.min(16 * u, (TW - 16 * u) / 7.2);
          const leafX = (i: number): number => chX + (i - 2.5) * dieS * 1.28 - 0 * u;
          for (let i = 0; i < 6; i++) {
            const lx = clamp(leafX(i), 8 * u + dieS / 2, TW - dieS / 2);
            g.strokeStyle = i < litN ? 'rgba(92,200,255,0.8)' : 'rgba(160,175,230,0.25)';
            g.lineWidth = 1 * u;
            g.beginPath();
            g.moveTo(chX, midY);
            g.lineTo(lx, leafY - dieS / 2);
            g.stroke();
            if (i < 6) txt(g, '1/6', lerp(chX, lx, 0.8), lerp(midY, leafY - dieS / 2, 0.8) - 3 * u, 5.5 * u, 'rgba(160,190,255,0.7)', 'center', 700);
          }
          // 뿌리
          rr(g, rootX - 15 * u, rootY - 8 * u, 30 * u, 16 * u, 3 * u);
          g.fillStyle = '#2f8a66';
          g.fill();
          txt(g, 'MAX', rootX, rootY + 0.3 * u, 7.5 * u, '#fff', 'center', 900);
          txt(g, '나의 선택', rootX + 19 * u, rootY, 6.5 * u, C.sub, 'left', 700);
          // 멈추기 잎
          rr(g, holdX - 17 * u, midY - 11 * u, 34 * u, 22 * u, 4 * u);
          g.fillStyle = decide && !rollWins ? 'rgba(255,209,102,0.25)' : 'rgba(184,146,255,0.18)';
          g.fill();
          g.strokeStyle = C.violet;
          g.lineWidth = 1 * u;
          g.stroke();
          txt(g, '멈추기', holdX, midY - 4 * u, 7 * u, C.violet, 'center', 900);
          txt(g, `${hold}점`, holdX, midY + 5 * u, 7.5 * u, '#fff', 'center', 900);
          // 확률 마디
          const pulse = litN > 0 && litN < 6 ? 0.5 + 0.5 * Math.sin(t * 10) : 0;
          glow(g, chX, midY, 20 * u, C.o, 0.25 + 0.2 * pulse);
          g.fillStyle = '#1d5d86';
          g.beginPath();
          g.arc(chX, midY, 11 * u, 0, TAU);
          g.fill();
          g.strokeStyle = C.o;
          g.lineWidth = 1.2 * u;
          g.stroke();
          txt(g, showAvg ? roll.toFixed(1) : 'Σ', chX, midY + 0.3 * u, 8 * u, '#fff', 'center', 900);
          txt(g, '굴리기 (확률)', chX + 14 * u, midY - 8 * u, 6.5 * u, C.o, 'left', 800);
          txt(g, showAvg ? `(${vals.join('+')}) ÷ 6` : litN ? `합 ${partial}` : '', chX + 14 * u, midY + 4 * u, 6 * u, C.sub, 'left', 700);
          // 주사위 잎
          for (let i = 0; i < 6; i++) {
            const lx = clamp(leafX(i), 8 * u + dieS / 2, TW - dieS / 2);
            const lit = i < litN;
            const bounce = lit && i === litN - 1 ? back(((ph - 0.6) % 0.3) / 0.15) : 1;
            die(g, lx, leafY - (1 - bounce) * 3 * u, dieS, i + 1, lit);
            txt(g, i === 0 ? '0' : `${vals[i]}`, lx, leafY + dieS / 2 + 6 * u, 6.5 * u, i === 0 ? C.red : lit ? '#fff' : C.dim, 'center', 900);
          }
          if (decide) pill(g, rollWins ? `굴리기! ${roll.toFixed(1)} > ${hold}` : roll === hold ? '똑같음' : `멈추기! ${hold} ≥ ${roll.toFixed(1)}`, rootX, rootY + 17 * u, 7 * u, C.gold);
          // 오른쪽 그래프
          const gx = TW + 10 * u;
          const gw = w - gx - 10 * u;
          const gy = 30 * u;
          const gh = h - gy - 24 * u;
          const MX = 36;
          const PX = (v: number): number => gx + (v / MX) * gw;
          const PY = (v: number): number => gy + gh - (v / MX) * gh;
          g.strokeStyle = 'rgba(255,255,255,0.15)';
          g.lineWidth = 0.8 * u;
          g.strokeRect(gx, gy, gw, gh);
          g.lineWidth = 1.6 * u;
          g.strokeStyle = C.violet;
          g.beginPath();
          g.moveTo(PX(0), PY(0));
          g.lineTo(PX(MX), PY(MX));
          g.stroke();
          g.strokeStyle = C.o;
          g.beginPath();
          g.moveTo(PX(0), PY(20 / 6));
          g.lineTo(PX(MX), PY((5 * MX + 20) / 6));
          g.stroke();
          g.setLineDash([2 * u, 2 * u]);
          g.strokeStyle = 'rgba(255,209,102,0.6)';
          g.lineWidth = 1 * u;
          g.beginPath();
          g.moveTo(PX(20), gy);
          g.lineTo(PX(20), gy + gh);
          g.stroke();
          g.setLineDash([]);
          txt(g, '20', PX(20), gy + gh + 6 * u, 6.5 * u, C.gold, 'center', 800);
          txt(g, 'T', gx + gw, gy + gh + 6 * u, 6.5 * u, C.sub, 'right', 700);
          txt(g, '멈추기', PX(MX) - 2 * u, PY(MX) + 7 * u, 6 * u, C.violet, 'right', 800);
          txt(g, '굴리기', PX(4), PY((5 * 4 + 20) / 6) - 7 * u, 6 * u, C.o, 'left', 800);
          const cx = PX(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.violet;
          g.beginPath();
          g.arc(cx, PY(hold), 2.8 * u, 0, TAU);
          g.fill();
          g.fillStyle = C.o;
          g.beginPath();
          g.arc(cx, PY(roll), 2.8 * u, 0, TAU);
          g.fill();
          txt(g, 'T < 20 이면 굴리기', gx + gw / 2, h - 8 * u, 6.5 * u, C.sub, 'center', 700);
        },
        controls: [
          speedCtl(st),
          { type: 'range', label: '쌓은 점수 T', min: 0, max: 35, step: 1, value: 6, on: (v) => { Tv = v; auto = false; ph = 0; } },
          { type: 'toggle', label: 'T 자동으로 바꾸기', value: true, on: (v) => { auto = v; } },
        ] as Control[],
      };
    },
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [미니맥스 게임 나무](https://ai-techstudio.web.app/ai/t/i340.md) `i340`
- 다음에 해 볼 기술: [끝에서 거꾸로 푸는 완전 해법 (후퇴 분석)](https://ai-techstudio.web.app/ai/t/i349.md) `i349`
- 참고 문서: [Wikipedia — Expectiminimax](https://en.wikipedia.org/wiki/Expectiminimax)
