M9: 컴퓨터 상대 — 게임 22종 컴퓨터 전략, 서버 구동(BotDriver), 연습 방

- packages/shared/src/bots.ts: 컴퓨터 id(bot:<n>:<ulid>)·프로필(컴퓨터 n)·BOT_GAMES
  (도블·할리갈리·그림 맞히기·마피아 제외)
- packages/games/src/bots: 전략 계약(자기 view + 합법 수만), decideBotAction(validate 재검사),
  게임별 <게임>/bot.ts 22개 + 행동 테스트, 전 게임 판 끝까지 돌리는 공통 테스트
- 서버: Room.addBot/removeBot(방장·대기실만, 방은 친구만), RoomManager.createPractice
  (POST /api/rooms {practice:true}), BotDriver(사람 같은 지연, 사람과 같은 창이면 더 천천히,
  재시작 후 다시 예약), 연습 게임은 전적 제외(result_json.practice), 사람이 모두 나가면 무효
- 프로토콜 addBot/removeBot, 문서 03·06 §12·08·09 §8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-06 13:32:28 +09:00
parent c27cb0a11a
commit a3f81ded37
67 changed files with 5113 additions and 18 deletions

View File

@@ -28,6 +28,8 @@
| `start` | — | 방장: 시작 |
| `kick` | `userId` | 방장: 내보내기 |
| `host` | `userId` | 방장 넘기기 |
| `addBot` | `seat?: number` | 방장: 빈자리에 컴퓨터 넣기(대기실에서만, 06 §12) |
| `removeBot` | `userId` | 방장: 컴퓨터 빼기(대기실에서만) |
| `act` | `cs: number`, `a: Action` | 게임 행동. `cs`는 클라이언트 행동 번호(1부터 증가) |
| `chat` | `text`(1~200자) | 채팅 |
| `emote` | `id` | 빠른 반응(👍, 😂, "잘했어요!" 등 고정 목록) |

View File

@@ -95,3 +95,12 @@ interface Room {
```
- 한 사용자는 동시에 한 방에만 플레이어로 있을 수 있다(다른 방에 들어가면 이전 방 대기실 자리에서 빠짐. 이전 방이 게임 중이면 "진행 중인 게임이 있어요. 그래도 이동할까요?").
- 제한: 서버 전체 방 2,000개, 사용자당 동시에 만든 방 3개.
## 12. 컴퓨터 상대 (혼자 연습)
- 들어가는 곳: 게임 상세와 홈의 [🤖 컴퓨터와 연습하기]. 친구만 방이 열리고, 바로 시작할 수 있을 만큼 컴퓨터가 앉아 있다(예: 오목 1명, 마작 2명, 뱅 3명).
- 대기실: 방장은 첫 빈자리의 [🤖 컴퓨터 넣기]로 넣고, 컴퓨터 자리의 [✕]로 뺀다(대기실에서만). 컴퓨터 이름은 "컴퓨터 1", "컴퓨터 2"…, 아바타는 🤖. 늘 준비 완료이고, 방장은 컴퓨터에게 넘어가지 않는다. 사람이 한 명은 앉아야 시작된다.
- 컴퓨터가 앉은 방은 연습 방이다: 공개로 바꿀 수 없고, 공개 방 목록·빠른 시작에 나오지 않는다. 결과는 남지만 전적(`user_stats`)에는 넣지 않는다. 화면에 "연습 게임이에요. 기록에는 남지 않아요." 표시.
- 컴퓨터가 할 수 있는 게임: 실시간·대화 게임(도블, 할리갈리, 그림 맞히기, 마피아)을 뺀 전부(`packages/shared/src/bots.ts`의 `BOT_GAMES`).
- 움직임: 서버의 `BotDriver`(`apps/server/src/rooms/bots.ts`)가 게임이 컴퓨터를 기다릴 때(차례, 반응 창, 동시 선택, 다음 판 준비) 0.4~1.5초 쉬었다가 한 수를 둔다. 사람과 같은 행동 경로(검사·기록)를 거치고, 서버가 다시 켜져도 이어서 둔다.
- 수 고르기: `packages/games/src/<게임>/bot.ts`. 자기 화면(`view(state, 나)`)과 둘 수 있는 수만 보고 고르므로 남의 패를 볼 수 없다. 강하지 않지만 엉뚱하지 않게(오목은 4를 막고, 오셀로는 귀를 잡고, 카드 게임은 패 세기에 맞게).
- 사람이 모두 나가면 게임은 무효로 끝난다.

View File

@@ -86,13 +86,13 @@ CREATE TABLE games (
started_at INTEGER NOT NULL,
ended_at INTEGER,
end_reason TEXT, -- 'normal' | 'resign' | 'abandoned' | 'void'
result_json TEXT
result_json TEXT -- 컴퓨터와 한 연습 게임은 "practice": true
);
CREATE INDEX games_room ON games(room_id);
CREATE TABLE game_players (
game_pk TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
user_id TEXT NOT NULL, -- 컴퓨터는 'bot:<번호>:<ULID>'(users에 없음)
seat INTEGER NOT NULL,
rank INTEGER, -- 1 = 1등, 무승부는 같은 순위
score REAL,
@@ -117,7 +117,7 @@ CREATE TABLE user_stats (
| 대기실 변경(자리, 옵션, 준비) | `room_state` update(+ `room_log`) |
| 게임 시작 | `games` insert, `room_state`, `room_log` |
| 게임 행동 / 시간 초과 | `room_log` insert + `room_state` update (한 트랜잭션) |
| 게임 끝 | `games` update(결과, 시드 공개), `game_players`, `user_stats` upsert (한 트랜잭션) |
| 게임 끝 | `games` update(결과, 시드 공개), `game_players`, `user_stats` upsert (한 트랜잭션). 무효·연습 게임은 `user_stats` 제외 |
| 방 닫힘 | `rooms.status='closed'`, `room_state` 삭제 |
## 4. 정리 작업(매시간)

View File

@@ -120,3 +120,8 @@ export interface GameUI<V> {
- 게임별 불변식(카드 총수 보존, 칩 총합 보존 등)
3. **정보 유출 검사**: 매 단계 각 플레이어 view의 JSON에 남의 비공개 카드 ID가 없는지(게임이 `secretsOf(state, player)`를 테스트용으로 제공).
4. **시간 초과 테스트**: 모든 상태에서 `onTimeout`이 합법 행동을 돌려주는지.
## 8. 컴퓨터 상대 (`packages/games/src/bots/`, 06 §12)
- 게임마다 `<게임>/bot.ts`의 전략 하나: `(입력: { me, view, legal, random }) => 행동`. `view`는 `view(state, me)`, `legal`은 `legalActions(state, me)`. 게임 상태는 넘기지 않는다(비공개 정보 차단).
- `decideBotAction`이 고른 수를 `validate`로 다시 검사하고, 틀리면 다른 합법 수로 바꾼다. 서버는 그래도 실패하면 게임의 `onTimeout` 행동을 쓴다.
- 테스트(`bots/bots.test.ts`): 게임마다 "사람 1명은 시간 초과만, 나머지는 컴퓨터"와 "컴퓨터끼리" 판을 끝까지 돌려, 모든 수가 합법이고 컴퓨터 때문에 멈추지 않으며 한 수가 충분히 빠른지 본다.