요청은 긴 규칙서 한 장으로 시작했습니다. 4~8명, 숨은 역할 셋, 원혼, 동시에 고르는 단계들, 그리고 판이 끝나면 "누가 언제 얼마나 대흉처럼 보였는지"를 보여 주는 확률 그래프. 처음 던진 질문은 두 가지였습니다. 모두가 동시에 고르는 단계를 차례 중심 플랫폼에서 어떻게 돌리고, 숨은 정보를 어떻게 새지 않게 할까? 그리고 말로 하는 게임에서 봇은 어떻게 사람처럼 보일까?

01무엇을 만들었나 — 규칙

  • 역할 — 평운 다수, 대길 1명, 대흉 1명(7~8인은 2명). 각자 주머니 구성이 다릅니다(길·평·흉: 평운 3·4·3, 대길 6·2·2, 대흉 2·3·5 / 7인 이상 2·2·6). 자기 운은 2~4라운드 중 정해진 때 각성해야 알고, 대길·대흉은 같은 라운드에 함께 각성합니다.
  • 낮 — 주머니에서 토큰 2개를 끌어 꺼내 확인하고, 하나만 공개합니다. 나머지는 비공개 더미에 쌓여 나만 봅니다. 이어서 7분 토론.
  • 밤 — 3라운드부터 고발 의사를 먼저 정하고, 밤 행동을 동시에 냅니다. 의심 표식, 수호(평운, 게임당 1번), 부적, 엿보기·축복·저주, 길잡이(대흉이 대길을 지목, 맞히면 즉시 승리·틀리면 대흉으로 공개된 채 원혼), 원혼의 눈, 그리고 평운의 조력자(고른 사람들의 비공개 더미 흉 합계를 귓속말로).
  • 새벽 — 가장 먼저 고발한 1명만 고발이 진행되고, 고발자 주머니에서 1장이 대가로 공개됩니다. 고발 토론 3분, 비밀 투표 20초. 대흉이 아닌 사람을 추방하면 그 사람은 원혼이 되어 대흉 편이 되고, 찬성한 사람 주머니에 흉이 들어갑니다. 의심 표식이 생존자 수 + 1만큼 쌓이면 그 사람의 비공개 더미 2장이 모두에게 공개됩니다.
  • 결과 선언 — 평운은 조력자를 쓴 밤마다 다음 낮에 "○○·○○ → 흉 N개"를 선언할 수 있고, 숫자는 자유입니다. 대길·대흉은 조력자가 없지만 격일로 지어낸 선언을 할 수 있습니다. 판이 끝나면 선언마다 정직·거짓이 판정됩니다.
  • 승리 — 선량 진영은 대흉을 모두 몰아내면, 대흉 진영(평운 원혼 포함)은 대길을 맞히거나 대흉 수가 선량 수와 같아지거나 라운드 상한(인원 + 1)까지 살아남으면 이깁니다.

02진행 방식 — 두 단계, 그리고 먼저 물은 것

이전 게임들처럼 AI 코딩 에이전트와 나눠 진행했습니다. 1단계는 규칙 모듈과 단위 테스트만, 확인한 뒤 2단계로 화면·등록·DB·룰북·아이콘·개발용 페이지. 규칙 모듈의 약속(순수 TypeScript, Math.random()·Date.now() 금지, 시드 난수, JSON으로만 저장되는 상태, 규칙이나 상태 모양이 바뀌면 규칙 버전 올리기)도 그대로였고, DB 원격 반영과 함수 배포는 사람이 직접 하도록 명령만 받았습니다.

에이전트가 먼저 물은 것들은 이랬습니다. 원혼은 무엇을 볼 수 있고 무엇의 대상이 되는가, 모두 제출하면 마감 전에 바로 넘어가는가, 길잡이에 실패한 대흉은 어떻게 되는가. 그리고 가장 큰 결정 — 플랫폼 공통 채팅 대신 이 게임만의 채팅을 서버에 저장하자는 답이 여기서 나왔습니다. 원혼끼리만 보이는 말이 있어야 했기 때문입니다.

03핵심 구조 — 동시에 고르는 단계와 시계

플랫폼은 "차례인 사람이 행동하고, 마감이 되면 첫 번째 사람에게 자동 행동"을 넣는 구조입니다. 운명의 패는 모든 단계가 동시 입력입니다. 이렇게 맞췄습니다.

단계        draw → discuss → (accuse) → night → (trial → vote → claim/counter) → 다음 라운드 … ending → over
모든 행동 = 동시 입력   — 버전 확인 없이 받고, 단계 번호(phaseSeq)로 지난 단계의 입력을 거른다
시스템 행동 timeout    — 마감이 되면 단계 전체를 한 번에 처리 (안 고른 사람은 정해진 기본값으로)
모두 고르면 바로        — 마지막 사람이 내는 순간 다음 단계
마감 유지  keepDeadline   — 채팅·선언처럼 단계를 바꾸지 않는 행동은 마감을 늘리지 않는다
연출 잠금  lockMs         — 공개·발표 연출 시간. 동시 입력은 서버 잠금을 지나치므로 모듈이 직접 거른다
             (지금 + 250ms < 마감 − 이 단계 제한시간 이면 거절)
판정용 마감 systemDeadline — 토론 7분 같은 마감은 자리비움으로 세지 않는다

새 서버 루프 없이, 단계 번호와 마감 하나로 동시 진행

연출 길이도 규칙의 일부입니다. 길이가 역할에 따라 달라지면 "이번 새벽이 길었다 = 누가 반격권을 골랐다"처럼 비밀이 새기 때문에, 연출 잠금은 모두에게 같은 공개 정보로만 정하고, 반격권을 고를 사람이 없을 때는 4~10초를 무작위로 기다립니다.

04숨은 정보 — 서버가 사람마다 다른 화면을 보낸다

  • view 걸러 내기 — 서버는 상태 전체가 아니라 getPlayerView로 사람마다 다른 조각을 보냅니다. 남의 비공개 더미·역할·밤 행동·조력자 결과는 판이 끝나기 전까지 빠집니다. 밤 행동 화면은 역할과 상관없이 같은 틀(쓸 수 없는 칸은 회색)이라, 화면 모양으로도 역할이 드러나지 않습니다.
  • 의심 표식 발동 — 모두에게 공개된 비공개 토큰 2장은 당사자 화면에서만 물음표로 보입니다. 어느 자리가 공개됐는지(인덱스)도 보내지 않아, 자기 더미를 다 알아도 무엇이 드러났는지 모릅니다. 채팅 알림은 당사자도 보기 때문에 토큰 이름을 적지 않고, 같은 사람이 다시 걸리면 아직 안 보인 자리부터 공개합니다.
  • 게임 전용 채팅 — 채팅을 판의 상태에 저장하고(서버 저장, 다음 판 전까지 남음), 원혼의 말은 원혼과 각성한 대흉에게만 보입니다. 원혼 화면에서는 원혼 채팅도 일반 채팅처럼 보여, 누가 읽고 있는지 알 수 없습니다. 플랫폼에는 레지스트리 gameChat 한 칸으로 "게임이 채팅을 직접 관리한다"는 길을 새로 냈습니다.
  • 공개되는 것은 분명하게 — 반대로 모두가 알아야 하는 것은 화면에 고정했습니다. 수호가 남아 있으면 연하늘색 광택 방패, 발동하면 깨지며 회색으로 바뀝니다(누구를 지켰는지는 비공개). 길잡이에 실패하면 누구를 지목했는지(그 사람은 대길이 아님)까지 발표합니다.

05확률 모델 — 운명의 그래프와 봇의 판단

판이 끝나면 라운드마다 모두의 "대흉일 확률"을 꺾은선으로 보여 줍니다. 서버가 공개 정보만으로 계산하고, 봇도 같은 계산에 자기만 아는 정보(엿본 토큰, 원혼의 눈, 받은 귓속말)를 더해 판단합니다.

사전 확률     인원별 역할 수 (6인: 평운 4 · 대길 1 · 대흉 1)
공개한 운세   bestOf2  — 두 개를 뽑아 더 좋은 쪽을 낼 확률 (대부분 좋은 쪽을 내므로)
비공개 더미   worstOf2 — 표식 발동·엿보기로 본 토큰은 '나쁜 쪽이 쌓인 곳'의 가능도
주머니 공개   share    — 고발 대가·원혼의 눈은 주머니에서 그대로 꺼낸 토큰
저장          천분율 정수 — + − × ÷만 쓰니 어느 기기에서나 같은 값

역할 사전 확률 × 관측 가능도, 관측의 종류마다 다른 식

처음엔 표식으로 공개된 비공개 토큰을 주머니 토큰처럼 계산했는데, 비공개 더미는 "두 개 중 버린 쪽"이 쌓이는 곳이라 흉이 더 많이 나오는 게 정상입니다. 같은 흉이라도 더미에서 나온 흉은 덜 의심스럽게 보도록 식을 나눴습니다.

06새로운 시도 — 봇이 채팅에 합류하다

추리 게임에서 말 없는 봇은 금방 티가 납니다. 그렇다고 AI에게 게임을 통째로 맡기면, 작은 모델은 규칙을 틀리고 숨은 정보를 흘리고 투표와 말이 어긋납니다. 그래서 일을 둘로 나눴습니다.

무엇을 말할지  규칙 (core/bot-talk/fate-tokens.ts)
               — 봇 자기 view만 보고 '할 말'을 정한다: "민수님이 대흉 같다. 근거: 공개한 운세에 흉 2개."
               — 근거는 공개 정보만 (공개 운세·주머니 공개·선언) → 엿본 토큰 같은 숨은 정보가 말로 새지 않는다
               — 판단은 행동 봇과 같은 확률 → 말과 투표가 어긋나지 않는다
어떻게 말할지  AI (내 PC의 Ollama · Qwen3 8B)
               — 그 내용에 말투만 입힌다: "민수님 흉만 두 번이네요, 좀 수상한데요?"
               — AI에는 '할 말'과 최근 채팅 12줄만 보낸다. 게임 상태·역할은 보내지 않는다

판단은 코드에게, 문장은 AI에게 — LOG_04에서 계산과 해석을 나눈 것과 같은 원칙

흐름은 이렇습니다. 기존 게임 함수와 별개인 Edge Function bot-talk를 두고, 봇 행동을 묻는 사람(접속한 사람 중 자리가 가장 앞인 한 명)의 화면이 토론·고발 토론 동안 6초마다 부릅니다.

브라우저   useBotTalk (VITE_BOT_TALK=1 로 빌드했을 때만) ── 6초마다 ──▶ bot-talk
bot-talk   1) 호출자 JWT 확인 · 판 읽기
           2) 봇마다 '할 말' 고르기 — 없으면 AI를 부르지 않고 끝
           3) AI에 말투 입히기 → 대사 다듬기·검사 (통과 못 하면 최대 3번 다시)
           4) 판 다시 읽기 → 기존 op "bot" 경로로 커밋
              — 그 봇의 chooseConcurrent만 이번 대사를 돌려주게 감싼다
              — 방·판 확인, 게임의 채팅 규칙 검사, 실시간 신호가 사람 채팅과 똑같다
내 PC      Ollama (qwen3:8b, VRAM 8GB) ◀── Cloudflare Tunnel (https://….trycloudflare.com) ◀── 3)

기존 파일은 GamePage의 두 줄만 바뀌었다 — 게임 모듈·플랫폼·DB는 그대로

  • 사람처럼 보이게 — 할 말은 여러 갈래에서 고릅니다. 의견은 지목·두 사람 비교·남들 생각 묻기·남의 의심에 동조·지켜보자·모르겠음 중 하나, 근거는 하나씩 돌아가며 들고, 이번 라운드에 이미 지목한 사람은 다음 후보로 넘깁니다. 봇마다 성격(차분·밝음·소심·직설)이 고정되고, 말투는 모두 존댓말입니다.
  • 대흉 봇의 연기 — 각성한 대흉 봇은 희생양(자기를 뺀 공개 의심 1위)을 의심하는 척하고, 고발당하면 결백을 주장합니다. 겉으로는 선량한 봇과 똑같은 문장 틀을 씁니다.
  • 비공개를 물으면 — 사람이 봇 이름을 부르며 비공개 토큰 개수를 물으면 정직 / 숨김 / 거짓 중 하나로 답합니다(평운 75/20/5%, 대길 30/30/40%, 대흉 20/30/50%). 거짓은 모두가 아는 총합은 그대로 두고 대흉은 흉을, 대길은 길을 줄여 말하며, 같은 라운드에 다시 물으면 같은 답을 합니다. AI에게는 숫자만 주고 진짜인지 거짓인지는 알려 주지 않으며, AI가 숫자를 바꾸면 그 대사는 버립니다.
  • 도배 방지 — 라운드당 최대 4줄, 내 줄이 최근 두 줄 안에 있으면 쉬고, 의견을 낸 뒤에는 다른 사람이 2줄 이상 말해야 다시 냅니다. 먼저 말을 꺼낼 확률은 토론 15%, 고발 토론 찬반 30%이고, 사람에게 대답하는 말은 바로 합니다.
  • 꺼져도 게임은 그대로 — AI가 꺼져 있거나 실패하면 봇은 말만 안 하고 평소처럼 행동합니다. 비상 스위치(BOT_TALK_ENABLED=0) 한 줄로 서버가 아무것도 하지 않고, 폴더 둘·파일 셋을 지우면 기능이 통째로 빠지게 떼어 두었습니다. 정식 서버에는 켜지 않고 테스트 서버에서만 실험합니다.

07AI 봇의 구조 ① — 왜 고성능 AI가 아니라 내 PC의 로컬 AI인가

처음에는 "게임 봇에 AI를 연결할 수 있나, 서버에 심을 수 있나, 토큰은 얼마나 드나"를 먼저 따졌습니다. 답을 정한 건 AI에게 무엇을 시킬 것인가였습니다. 판단(누구를 의심할지, 찬성할지, 거짓말할지)은 이미 규칙 봇이 확률로 하고 있으니, AI가 할 일은 그 결론을 80자 안의 한국어 한 줄로 자연스럽게 옮기는 것뿐입니다. 이 정도 일에 큰 모델은 필요 없었습니다.

AI에게 맡기는 일   말투 입히기만 — 입력 1~3천 토큰, 출력 한 줄(대사 최대 200토큰)
맡기지 않는 일     규칙 해석 · 추리 · 역할 판단 · 숫자 계산 — 모두 규칙 코드가 한다

API로 하면         Haiku급 모델 기준 판당 약 150번 호출 · 판당 100원 안팎 (호출 간격에 따라 수 배까지)
로컬로 하면        호출당 0원 · 전기세만 — 가지고 있던 RTX 3060 Ti(VRAM 8GB)를 그대로 쓴다

모델 고르기        VRAM 8GB에 통째로 올라가는 크기
  Qwen3 4B          빠르지만 한국어가 어색하다
  Qwen3 8B (Q4)     약 5GB · GPU에 다 올라간다 · 대사 한 줄 0.4~0.9초 ← 선택
  Qwen3 14B         8GB를 넘어 일부가 CPU로 → 느리다
  Qwen3 30B-A3B     품질은 가장 좋지만 시스템 RAM으로 넘겨야 해서 실험용

"할 말"을 규칙이 정해 주니, 작은 모델로 충분하다 — 효율은 모델 크기가 아니라 일을 나누는 데서 나왔다

  • 작은 모델에 맞춘 설계 — 8B 모델에게 게임을 통째로 맡기면 규칙을 틀리고 숨은 정보를 흘립니다. 그래서 AI에게는 결론이 다 적힌 문장("민수님이 대흉 같다. 근거: 공개한 운세에 흉 2개.")만 주고, 표현만 바꾸게 했습니다. 실수할 여지 자체를 줄인 것이 작은 GPU로도 쓸 만한 대사가 나오는 이유입니다.
  • 받아들인 한계 — PC가 꺼지면 AI도 멈추고, GPU 하나가 한 번에 한 요청씩 처리하니 방 1~2개를 시험하는 정도가 적당합니다. 그래서 정식 서버가 아니라 테스트 서버에서만 켭니다. 대신 AI가 없어도 봇은 말만 안 할 뿐 게임은 그대로 진행되게 만들어, 꺼져 있어도 문제가 되지 않습니다.
  • 나중에 바꿀 수 있게 — AI 서버 주소와 방식(ollama / OpenAI 호환 openai)을 설정값으로만 정합니다. 정식 서비스에서 API나 GPU 임대 서버로 옮길 때도 코드는 그대로 두고 주소만 바꾸면 됩니다.

08AI 봇의 구조 ② — 클라우드 서버와 내 PC의 AI는 이렇게 이어진다

게임 서버(Supabase Edge Function)는 클라우드에 있고, AI는 내 방의 PC에서 돕니다. 클라우드에서 내 PC의 localhost에는 바로 닿지 않기 때문에, 그 사이를 Cloudflare 터널로 이었습니다. 대사 한 줄이 채팅창에 뜨기까지의 경로입니다.

① 브라우저   test.dotroboard.com (빌드 변수 VITE_BOT_TALK=1 로 빌드했을 때만)
             봇 행동을 묻는 사람 1명의 화면이, 토론·고발 토론 동안 6초마다
             supabase.functions.invoke("bot-talk", { roomId }) — 할 말이 있는지는 서버가 정한다
                │
② bot-talk   Supabase Edge Function (Deno · 클라우드)
             JWT 확인 · 방 참가자인지 · 예전 규칙 버전 판인지 · 방마다 4초 간격
             game_load → 봇마다 '할 말' 고르기 — 없으면 여기서 끝 (AI 호출 없음)
                │  HTTPS POST  {BOT_TALK_URL}/api/chat
                │  { model: "qwen3:8b", think: false, temperature: 0.9, num_predict: 200 } · 8초 제한
③ Cloudflare Quick Tunnel   https://○○○.trycloudflare.com — 켤 때마다 주소가 바뀐다
                │
④ 내 PC      cloudflared → localhost:11434 → Ollama → qwen3:8b (RTX 3060 Ti)
                │  대사 원문
⑤ bot-talk   다듬기 · 검사 (외국어 · 예시 베끼기 · 되풀이 · 숫자) — 통과 못 하면 최대 3번 다시
             game_load 다시 → planBotSay (기존 op "bot" 경로) → game_commit_step (버전 확인)
                │
⑥ 모두의 화면   Realtime 신호 → 채팅창에 봇의 말이 뜬다 — 사람 채팅과 같은 길

AI 키나 주소는 브라우저에 없다 — 모두 Edge Function의 비밀값

  • 별도 함수로 떼어 둔 이유 — 게임 규칙은 네트워크를 쓰지 않는 순수 함수가 원칙이고, 봇의 chooseAction도 즉시 답을 돌려줘야 합니다. 몇 초씩 걸리는 AI 호출을 그 안에 넣을 수 없어서, AI를 부르는 일은 별도 Edge Function bot-talk가 하고, 결과는 "그 봇이 보낸 채팅 행동"으로 기존 길에 태웠습니다. 그래서 게임 모듈·플랫폼 규칙·DB는 하나도 바꾸지 않았습니다.
  • 기다리는 동안 바뀐 판 — AI가 답하는 1초 남짓 동안 다른 사람이 행동할 수 있어, 커밋 직전에 판을 다시 읽고 버전이 부딪히면 다시 시도합니다. 게임 업데이트로 규칙 버전이 바뀐 판에는 아예 쓰지 않아, 게임 함수와 bot-talk의 배포 시점이 어긋나도 상태가 꼬이지 않습니다.
  • 설정은 비밀값으로 — BOT_TALK_ENABLED(비상 스위치), BOT_TALK_URL(터널 주소), BOT_TALK_MODEL, BOT_TALK_PROVIDER(ollama · openai · AI 없이 시험하는 template), BOT_TALK_TIMEOUT_MS, BOT_TALK_FALLBACK(AI 실패 때 정해진 문장으로 대신 말하기). 터널 주소가 바뀌면 이 값 하나만 다시 넣으면 되고, 함수를 다시 배포할 필요는 없습니다.

09AI 봇의 구조 ③ — 내 PC 쪽 구성과 AI가 실제로 받는 것

설치        Ollama → D:\Ollama (설치 프로그램 /DIR) · 모델은 OLLAMA_MODELS=D:\Ollama\models
            cloudflared.exe도 같은 폴더에 → 지울 때는 폴더 하나
상주        OLLAMA_KEEP_ALIVE=-1 — 첫 호출에 모델을 올리는 5~10초가 8초 제한을 넘지 않게 메모리에 붙잡아 둔다
시험        npm run bot-talk:try — 샘플 '할 말' 10개를 내 PC의 AI에 보내 대사·버린 대사·걸린 시간을 본다
켤 때마다   Ollama 실행 → 터널 실행 → 새 주소를 BOT_TALK_URL에 넣기
끝낼 때     터널 창 닫기 — Ollama에는 인증이 없어서, 주소를 아는 사람은 누구나 GPU를 쓸 수 있다

AI에게 보내는 메시지는 매번 같은 앞부분과, 이번에만 바뀌는 끝부분으로 나뉩니다. 앞부분이 고정이라 Ollama가 계산해 둔 결과를 다시 쓰고, 예시를 늘려도 응답 시간은 거의 늘지 않았습니다.

시스템      "너는 보드게임 채팅에 참여한 플레이어 ○○" · 말투는 항상 존댓말 · 성격(차분·밝음·소심·직설 중 봇마다 고정)
            규칙 5줄 — 할 말의 입장을 바꾸지 말 것 · 없는 사실과 숫자를 지어내지 말 것
                     · AI라고 말하지 말 것 · 되풀이하지 말 것 · 대사 한 줄만
예시 18쌍   질문(할 말) → 답(대사) 대화 모양 · 가상 이름(가람·나래·다온·새봄)
            의견 · 모르겠음 · 부인 · 결백 주장 · 비공개 대답 · 찬반
이번 요청   최근 채팅 12줄(참고만) · 내가 이미 한 말 8줄(다시 쓰지 말 것)
            할 말: "…" (존댓말, 성격) /no_think

AI가 받는 것은 이것이 전부 — 역할·비공개 더미·게임 상태는 보내지 않는다

  • 돌아온 대사 검사 — 생각 태그·이름표·따옴표를 지우고 첫 줄만 남깁니다. 한자·일본어 가나가 섞였거나 한글이 거의 없으면, 예시를 이름만 바꿔 베꼈으면, 최근 채팅이나 내가 한 말을 되풀이했으면, 할 말의 숫자를 바꿨으면 버리고 다시 묻습니다(최대 3번). 세 번 다 버려지면 그 차례엔 말하지 않습니다.
  • 로그로 보이는 것 — 함수 로그에 할 말의 종류, 몇 번째 시도인지, 걸린 시간, AI 원문, 다듬은 대사, 버린 이유가 남습니다. 말하지 않고 끝낸 경우에도 이유(nothing · throttled · ai-failed · disabled 등)를 남겨, 어디서 막혔는지 바로 찾습니다.
  • 비용과 부하 — AI 호출은 0원, 전기세는 대사를 만드는 몇 초 동안 GPU가 쓰는 정도입니다. 남는 비용은 Edge Function 호출로, 봇이 있는 판의 토론 동안 6초마다 한 번이라 판당 수백 번입니다. 무료 한도(월 약 50만 번) 안에서 시험하기에는 넉넉합니다.

10행동 봇 — 평운인 척하기, 희생양 만들기

  • 평운인 척하기 — 봇끼리 돌려 보니 각성 전 대길 봇이 길만 내서 금방 들키고, 그대로 길잡이에 잡히는 판이 많았습니다. 공개할 토큰을 고를 때 "이걸 내면 남이 보는 내가 평운일 확률이 높아지는 쪽"을 기본으로 바꿨습니다. 각성 전 대길이 공개한 토큰 중 길의 비율이 86%에서 58%로 내려갔습니다.
  • 교활한 대흉 — 대흉 봇은 두 장 모두 흉인 날이 약 22%라 구조적으로 들키기 쉽습니다. 토큰 수를 바꾸지 않고, 공개 의심 1위(자기 제외)를 희생양으로 정해 저주로 흉을 넣고(70%, 내가 의심 1위면 항상), 의심 표식을 몰아 주머니 공개를 노리고, 선언에 희생양을 넣어 흉을 부풀리게 했습니다.
  • 선언의 리듬 — 평운 봇은 조력자를 항상 쓰고 가끔 길·평을 추가 조사합니다. 대길·대흉 봇은 격일 제한 안에서 25%는 일부러 쉬어, 추가 조사로 2밤을 쉰 평운의 리듬도 흉내 냅니다.
  • 균형 — 봇끼리 인원별 200~300판을 돌려 선량 승률을 봅니다. 규칙을 바꿀 때마다 다시 재서, 지금은 4~8인 42~61%입니다.

11해 보고 고친 규칙 — rulesVersion 1 → 11

운명의 밤     공개 선택 → 비공개 대상 고르기 → 조력자의 밤 → 조력자 격일
각성          대길·대흉은 같은 라운드에 (평운은 각자)
길잡이 실패   대흉으로 공개된 채 평운 원혼과 같은 능력 · 지목한 사람도 발표
의심 표식     생존자 수 → 생존자 수 + 1에 발동 · 주머니가 아니라 비공개 더미에서 2장 · 당사자에겐 비공개
고발          여럿이면 무작위 → 가장 먼저 고발한 1명만 (나머지는 표시도 대가도 없음)
귓속말 시점   고발이 있으면 고발 토론·투표가 끝난 뒤
승부          새벽 마무리에서만 확인 → 승부가 나는 순간 바로 종료
수호          발동 여부를 모두에게 (방패)
조력자        선택 → 필수 (시간 초과면 흉만·대상 무작위) · 길·평 추가 조사는 2밤 쉼, 본인만
선언          흉 개수로만 · 평운은 쓴 밤마다 · 대길·대흉은 격일

대부분 "해 보니 이상하다"와 "이러면 리듬으로 들킨다"에서 나온 변경

마지막 조력자 규칙은 사람이 직접 정리해 왔습니다. 의도는 선언의 리듬만으로는 아무것도 확정되지 않게 하는 것이었습니다. 기본 조사만 하는 평운도 격일로 선언하니 격일로 선언한다고 대길·대흉이라 단정할 수 없고, "격일로만"은 최대치라 대길·대흉이 일부러 더 쉬면 3일 주기도 흉내 낼 수 있습니다. 리듬은 확정 증거가 아니라 눈치의 재료가 됩니다.

12화면과 소리

  • 끌어서 뽑기 — 매일 어두운 보라색 끈 주머니에서 토큰 2개를 드래그로 꺼내 확인하고, 하나를 골라 공개합니다. 손가락으로 끌면 토큰이 손가락 위로 떠서 보입니다.
  • 가운데 안내 팝업 — 단계가 열릴 때마다 "지금 할 일"을 화면 가운데 띄웁니다. 팝업은 대기열로 하나씩, 최소 2.5초 보여 주어 겹쳐서 바로 사라지지 않습니다.
  • 조력자 연출 — 귓속말을 받은 평운 화면에서만 작은 조력자가 고른 카드 위를 날아다닌 뒤 말풍선을 띄웁니다.
  • 종료 화면 — [조력자의 밤 기록] 탭은 라운드별로 고른 대상·실제 결과·선언·정직/거짓 판정을 보여 주고, 거짓 줄은 금이 가며 붉어집니다. [확률 그래프] 탭은 역할별 굵기와 색으로 그린 꺾은선에 각성·추방·원혼·길잡이 표시를 얹습니다. 모든 카드에 비공개 더미가 공개됩니다.
  • 소리 — BGM은 3·2·1·시작! 카운트다운이 끝나는 순간 시작합니다. 효과음은 토큰 끌기·놓기·공개, 밤·새벽 진입, 추방, 각성(대길·대흉은 두근두근 두 번, 평운은 양 울음), 고발, 운명에 손댐, 수호의 방어, 승패까지 열일곱 가지이고, 따로 소리가 없는 버튼은 공통 클릭음을 냅니다. 같은 순간에 다른 효과음이 나면 팝업 소리는 생략합니다.

13문제 해결 기록 — 게임

  • 길잡이 실패 뒤에도 끝나지 않던 판 — 마지막 대흉이 길잡이에 실패해 원혼이 됐는데, 그 밤에 고발이 있으면 대흉이 없는데도 고발 토론과 투표가 이어졌습니다. 승리 확인을 새벽 마무리에서만 했기 때문입니다. 길잡이 실패와 원혼 발생 직후에 "대흉 0명 / 대흉 수 ≥ 선량 수"를 바로 보는 함수를 두었습니다(반격권 단계는 대흉이 이길 수도 있어 그대로).
  • 근거 없이 찍힌 '대흉 100%' — 그래프에 확정할 수 없는 상황인데 100%가 찍힌다는 제보였습니다. 봇끼리 1000판을 돌려 확인하니 공개 정보 계산만으로 100%가 된 점은 0개였고, 모두 판에서 빠지는 라운드에 결과(추방된 대흉, 길잡이 실패)를 덮어쓴 점이었습니다. 버그가 아니라 표시 방식의 문제라, 덮어쓰지 않고 표시로만 보여 주는 안을 사람에게 넘겼습니다.
  • 당사자에게 새던 표식 결과 — 표식으로 공개된 토큰을 채팅 알림에 적었더니 당사자도 무엇이 공개됐는지 알게 됐습니다. 채팅에는 장수만, 토큰은 다른 사람의 카드에만, 공개된 자리 인덱스는 view에서 아예 빼도록 바꿨습니다.
  • 사라지던 안내 팝업 — 개발 모드의 React StrictMode가 처음 그릴 때 정리 함수를 한 번 더 돌리면서 팝업 타이머를 지웠습니다. 팝업마다 자기 타이머를 따로 걸고 정리하게 바꿨습니다. 한꺼번에 밀려온 사건(다시 연결 등)이 늦은 연출로 쏟아지는 것도, 사건 12개·알림 6개를 넘으면 연출을 건너뛰게 막았습니다.
  • 개발용 페이지가 멈춤 — 장면 버튼으로 빨리 감다 보면 플랫폼의 자리비움 규칙(연속 시간 초과면 일시정지)에 걸렸습니다. 장면 함수에서 연속 시간 초과 수를 초기화했습니다.
  • BGM이 작다 — 다른 게임보다 작게 들려 파일 자체를 키웠습니다. 그냥 키우면 깨져서 리미터를 걸었는데, AAC 160k로 굽자 실제 최고점이 0dB를 넘었습니다. 192k twoloop에 리미터를 낮춰 −14.7 → −11.6 LUFS(최고점 −1.9 dBFS)로 맞추고, 명령을 스크립트로 남겼습니다.
  • dev에 없던 효과음 — 사람이 올린 효과음 두 개가 main에만 있어서 dev 화면에서는 소리가 나지 않았습니다. main에는 아무것도 푸시하지 않고 그 파일만 가져와 dev에 넣었습니다.
  • 배포 500 — 함수 배포가 업로드 뒤 "internal error"로 실패했습니다. Deno로 함수 코드를 타입 검사하고 서버에 올라가는 게임 코드를 모두 불러와 보니 정상이라, Supabase 쪽 일시 오류로 판단했고 다시 실행해 성공했습니다. 대시보드의 "마지막 배포 3시간 전"이 실패한 배포를 알려 주는 신호였습니다.

14문제 해결 기록 — AI 봇 채팅

  • 생각만 하다 비어 버린 대사 — Qwen3는 기본으로 사고 모드라, 토큰을 생각에 다 쓰고 대사가 비었습니다. OpenAI 호환 주소 대신 Ollama 전용 API(think:false)를 기본으로 바꾸고, 대사 최대 토큰을 120에서 200으로 늘렸습니다.
  • 예시를 그대로 베끼는 작은 모델 — 시스템 프롬프트 안 예시 문장을 그대로 따라 해서 부인·찬성 대사가 모두 같아졌습니다. 예시를 질문·답 대화 모양으로 18개 넣고(작은 모델은 이 모양을 더 잘 따릅니다), 이름은 게임에 안 나올 가상 이름으로, 예시의 할 말은 실제 문장 틀과 일부러 다르게 썼습니다. 이름만 바꿔 베낀 대사는 버리고 다시 묻습니다.
  • 반말하는 봇, 남의 이름 끼워 넣기 — 말투 지시를 시스템 프롬프트에만 두니 무시했습니다. 마지막 메시지에도 말투를 다시 적고(작은 모델은 가까운 지시를 더 잘 따릅니다), 결국 모두 존댓말로 통일해 성격만 다르게 했습니다. 예시의 "운세 보시면" 같은 구절은 다른 사람 이름을 끌어들여서 뺐습니다.
  • 섞여 나오는 한자·가나 — 한국어 대사에 한자나 일본어 가나가 섞이면 버리고, 한 번 더 묻습니다. 생각 태그와 너무 긴 대사도 다듬는 단계에서 걸러 냅니다.
  • 봇끼리 끝없는 "저 대흉 아니에요" — 봇 A가 B를 의심하면 B가 부인하고, 그 부인에 A가 또 답하며 끝없이 주고받았습니다. 봇의 말에서는 이름이 불려도 대답하지 않게 하고, 이름만 다른 같은 문장과 최근 채팅 되풀이를 버리며, 봇이 이미 한 말(최근 8줄)을 AI에게 "다시 쓰지 말 것"으로 보여 줍니다.
  • 너무 수다스러운 봇 — 실제로 해 보니 사람보다 봇이 더 떠들었습니다. 먼저 말을 꺼낼 확률을 절반으로(토론 0.3 → 0.15, 고발 토론 0.6 → 0.3) 줄이고, 대답은 그대로 두었습니다.
  • 클라우드에서 내 PC로 — Supabase 함수는 클라우드에 있어 내 PC의 localhost에 닿지 않습니다. Cloudflare Quick Tunnel로 Ollama 주소를 열었고, 첫 호출에 모델을 올리는 5~10초 때문에 시간 초과가 나지 않게 OLLAMA_KEEP_ALIVE=-1로 모델을 메모리에 붙잡아 둡니다. Ollama에는 인증이 없어서, 터널은 시험할 때만 켜고 끝나면 닫는 것을 원칙으로 문서에 적었습니다.
  • 화면이 bot-talk를 부르지 않음 — 비밀값과 함수 배포까지 마쳤는데 함수 로그가 비어 있었습니다. 브라우저 네트워크 탭에 bot-talk 요청이 하나도 없었고, 원인은 Cloudflare Worker의 빌드 변수였습니다. VITE_로 시작하는 값은 빌드할 때 화면 코드에 박히기 때문에, 변수를 넣은 뒤 다시 빌드해야 적용됩니다.
  • 진단과 안전장치 — 빌드 변수 VITE_BOT_TALK를 따옴표·공백·true까지 너그럽게 읽고 켜지면 콘솔에 표시하며, 함수 로그에는 말하지 않고 끝낸 이유까지 남깁니다. 예전 규칙 버전으로 시작한 판에는 쓰지 않게 막아, 게임 함수와 bot-talk 배포가 어긋나도 상태가 꼬이지 않습니다.

15검증 — 무엇을 어떻게 확인했나

운명의 패 규칙 테스트 38
  — 역할·주머니 · 뽑기·공개·비공개 더미 · 각성 · 밤 처리 순서 · 수호·부적·엿보기·축복·저주 · 길잡이 성공·실패
  — 고발(가장 먼저 1명)·투표·원혼·원혼의 저주·반격권 · 의심 표식(생존자 + 1, 안 보인 자리부터, 당사자 비공개)
  — 조력자(필수·추가 조사·쉬는 밤·시간 초과) · 선언(평운·격일) · 승부 즉시 종료 · 시간 초과만으로 4~8인 끝까지
행동 봇 테스트 4   — 봇끼리 4~8인 여러 판 모두 유효 · 인원별 승률 쏠림 · 희생양 전략
봇 채팅 테스트 25  — 할 말 고르기(공개 정보만) · 프롬프트 · 대사 다듬기·베끼기·되풀이·숫자 검사 · op "bot" 커밋
전체              — npm test 420 통과 · 빌드
균형              — 봇끼리 인원별 200~300판, 규칙을 바꿀 때마다 다시
화면              — 개발용 페이지(/dev/fate-tokens, 실제 서버 계획 함수 + 장면 버튼) + Playwright 375px

개발용 페이지는 서버 없이 실제 서버 계획 함수로 마감·시간 초과·봇 차례를 흉내 냅니다. 시점을 대흉·평운으로 바꾸고 [모두 각성]·[장면: 3라운드 밤]·[다음 밤까지]·[장면: 끝까지]로 드문 상황을 바로 만들어 화면을 찍었습니다. AI 채팅은 npm run bot-talk:try로 샘플 '할 말' 열 개를 내 PC의 AI에 보내 대사와 걸린 시간을 보고 프롬프트를 고칩니다.

16지금 이 자리

운명의 패는 테스트 서버에서 실제로 해 보고 고치는 반복(Iterate) 단계에 있습니다. 규칙 버전은 11까지 올라갔고, 효과음·문구·팝업 같은 요청이 스무 번 넘게 오갔습니다. AI 봇 채팅은 테스트 서버에서만 켜는 실험 기능입니다.

이번에 남은 원칙은 세 가지입니다. 판단은 코드가, 문장은 AI가 한다. AI에게는 말해도 되는 것만 준다. 리듬과 연출 길이까지 정보가 될 수 있으니, 공개할 것과 숨길 것을 끝까지 나눈다. 다음은 사람과 AI 봇이 섞인 판에서 봇의 말이 실제로 추리에 끼어드는지, 사람이 봇을 알아보는지를 관찰(Observe)하고, 같은 구조를 다른 게임의 봇에도 붙여 보는 일입니다.