요청은 한 장짜리 규칙서로 시작했습니다. 198장의 도구 카드, 플레이어마다 작업장 두 칸, 손패는 받은 순서대로 고정. 그리고 행동 2 "트레이딩" — 차례 플레이어가 카드 2장을 공개하면 다른 사람들이 그 카드를 받는 대가로 자기 손패를 제안하고, 차례 플레이어가 수락하거나 거절합니다. 협상을 건 사람은 20초 안에 대가를 골라야 하고, 거래 단계 전체는 5분입니다. 처음 던진 질문은 이것이었습니다. 마감이 판에 하나뿐인 플랫폼에서, 사람마다 다른 시계(협상 20초)를 가진 동시 협상이 되는가? 그리고 봇은 어떻게 남의 차례에 협상을 거는가?

01무엇을 만들었나 — 규칙

  • 카드와 수익 — 물건 6종(자개공예·얼음조각·투조공예·다마스쿠스·다이아몬드·반도체), 종류마다 장수와 수익표가 다릅니다. 같은 도구를 올린 장수가 가공 단계이고, 수익을 실현하면 도달한 단계 중 가장 높은 수익(1억~3.5억)을 받고 작업장을 비웁니다.
  • 작업장 규칙 — 한 작업장에는 한 종류만. 카드는 ① 같은 종류가 있는 작업장 ② 없으면 빈 작업장 ③ 둘 다 없으면 작업장 하나를 강제로 수익 실현해 비운 뒤 그 자리. 수익이 0억인 작업장은 실현할 수 없고, 강제 상황에서 모든 작업장이 0억일 때만 비울 수 있습니다. 최고 단계까지 가공한 작업장에는 더 올리지 못합니다.
  • 차례 — 행동 1 작업진행(손패 맨 앞 1장은 반드시, 이어서 원하는 만큼) → 행동 2 트레이딩(공개 → 협상·제안·수락/거절 → 공개 구역 정리) → 행동 3 보충 4장.
  • 끝 — 덱은 한 번만 씁니다. 덱이 떨어지는 바퀴의 마지막 순서까지 하고, 모든 작업장을 강제로 실현해 돈이 많은 순으로 순위를 매깁니다. 같으면 차례가 늦은 사람이 위.
  • 특별룰 — 보충 3장, 그리고 모든 카드를 2/3만 쓰는 132장짜리 짧은 판.

02진행 방식 — 먼저 물은 것

이전 게임들과 같이 AI 코딩 에이전트와 나눠 진행했습니다. 1단계는 규칙 모듈과 단위 테스트만 만들고 멈춰서 보고, 확인한 뒤 2단계로 화면·봇·효과음·등록·DB. 규칙 모듈의 약속(순수 TypeScript, Math.random()·Date.now() 금지, 시드 난수, JSON으로 저장되는 상태)도 그대로였습니다.

코드를 쓰기 전에 에이전트가 물은 것은 네 가지였습니다.

  • 봇의 협상 — 플랫폼은 "지금 행동할 사람이 봇뿐일 때"만 봇을 움직입니다. 남의 차례에 봇이 협상을 걸 수 없는 구조라 플랫폼을 고칠지 물었고, 답은 "고친다"였습니다.
  • 거래 단계 5분 — 6명이면 한 바퀴에 30분. 차례 플레이어의 [거래 마치기] 버튼을 두기로 했습니다.
  • 거래로 받은 카드 — 받은 사람이 직접 끌어다 놓기로.
  • 제안 카드 — 같은 손패를 두 협상에 동시에 쓰지 못하게(제안에 쓴 카드는 잠김).

1단계 보고에는 "해석해서 정한 부분" 13가지를 따로 적었습니다. 그중 "2단계 이상이면 0억이어도 실현 가능"은 바로 "0억이면 실현 불가"로 바뀌었습니다. 이 게임에서 반복된 패턴의 시작이었습니다 — 모호한 곳을 목록으로 드러내면, 사람이 하나씩 짚어 고친다.

03핵심 — 마감 하나로 여러 사람의 시계를

플랫폼에는 판마다 마감이 하나뿐이고, 시간이 지나면 "지금 행동할 사람 중 첫 번째"에게만 자동 행동을 넣습니다. 협상장인의 거래 단계에는 시계가 여러 개입니다 — 거래 단계 5분, 협상자마다 20초, 수락은 시간 제한 없음. 이렇게 풀었습니다.

모든 행동 = 동시 입력   — 버전 확인 없이 받고, 카드 id·작업장 id·제안 id로 오래된 입력을 스스로 거른다
단계마다 마감 유지      행동 1·공개·정리 60초 · 거래 5분 · 받은 카드 정리 30초
                       — 같은 단계 안의 행동(남의 수익 실현, 협상)은 마감을 늘리지 않는다 (keepDeadline)
협상 20초              플랫폼 마감이 아니라 상태에 적은 서버 시각(ctx.now)으로 잰다
                       — 늦게 온 제안은 거절, 지난 협상은 다음 행동 때 정리, 화면은 같은 시각으로 남은 초를 그린다
비공개                 협상자가 고른 공개카드는 제안 전까지 본인에게만 (getPlayerView에서 거른다)

판의 마감은 하나 — 나머지 시계는 상태에 적은 시각으로 센다

수익 실현은 "차례가 아니어도 언제든"이라 다른 사람의 행동 1 도중에도 들어옵니다. 모든 행동을 동시 입력으로 받으니 버전 충돌로 거절되는 일이 없고, 대신 규칙 모듈이 모든 입력을 다시 검사합니다. validateAction과 applyAction은 같은 함수(exec)를 복사본에 돌리는 방식이라, 검사와 적용이 어긋날 수 없습니다. 시간 초과 행동도 단계마다 정해진 "묶음"으로 만들어, 받은 카드를 강제 수익 실현까지 포함해 한 번에 올리게 했습니다.

04봇이 남의 차례에 끼어들기 — 플랫폼 확장

봇은 지금까지 시간 초과 경로로만 움직였습니다. 차례인 사람이 봇이면 마감을 봇의 생각 시간으로 짧게 잡고, 마감이 되면 봇이 고른 행동을 적용합니다. 남의 차례에 협상을 거는 봇은 이 경로로 만들 수 없습니다. 플랫폼에 요청 하나를 더했습니다.

GameBot.chooseConcurrent(view, me)   — 차례와 상관없이 봇이 지금 할 행동, 없으면 null
레지스트리 botPollMs(view)          — 물어볼 간격 (협상장인: 거래·정리 단계 2.5초)
op "bot"   게임 화면에 접속한 사람 중 자리가 가장 앞인 한 명이 간격마다 보낸다
         서버: 봇마다(시드로 순서를 돌려) 자기 시점만 보고 고른 행동을 묻고,
               처음으로 규칙을 통과한 동시 입력 행동 하나만 적용
         — 사람이 보내도 들어가는 건 봇이 정한 행동뿐 · 자리비움으로 세지 않음 · 할 일 없으면 409

새 서버 루프 없이, 접속한 화면 하나가 봇을 깨운다

봇 알고리즘은 작업장의 "기대 가치"(지금 수익 + 다음 수익까지 진행한 만큼)로 행동을 고릅니다. 자기 차례에는 단계마다 행동을 한 번에 묶어 보내고, 거래 단계에서는 들어온 제안을 손익으로 수락·거절하며, 남의 차례에는 쓸모 있는 공개카드에 협상을 걸고 쓸모가 적은 손패를 대가로 냅니다. 원하는 카드가 없으면 [협상안함]을 누릅니다.

숫자 하나는 직접 재 보고 정했습니다. 처음에는 "이득일 때만 카드를 더 올린다"로 했는데, 봇 1명과 시간 초과만 하는 3명으로 60판을 돌리니 1등이 34판이었습니다. 기준을 바꿔 가며 다시 재 보니 크게 손해가 아니면 계속 올리는 쪽이 45판으로 가장 좋았습니다(평균 11.6억 대 8.3억). 난이도는 의미 있게 나누기 어려워 하나로 두었습니다.

05해 보고 고친 규칙 — rulesVersion 1 → 7

규칙이나 상태 모양이 바뀔 때마다 규칙 버전을 올렸습니다. 진행 중인 판은 시작할 때의 버전으로 끝까지 갑니다.

v2  수익이 0억인 작업장은 실현 불가 (자개공예는 3단계부터) · 강제 실현도 같은 기준
v3  받은 카드 정리 30초 — 차례가 끝날 때 받은 카드를 아직 안 올렸으면 기다린다
v4  다이아몬드↔다마스쿠스 교체 (다이아몬드가 더 귀하게) · 짧은 판 132장 · [협상안함] · 원해요·줄게요 · 진행시간
v5  최고 단계 작업장에는 더 못 올림 — 실현해서 그 자리에서 새로 시작
    협상안함 기록을 다음 차례까지 · 마지막 바퀴를 덱이 떨어지기 전에 미리 계산
v6  원해요·줄게요를 가장 최근 하나만 · 흔들기를 서버 사건으로
v7  원해요·줄게요를 각각 가장 최근 것으로 둘 다

대부분 "해 보니 이상하다"에서 나온 변경

  • 받은 카드가 강제로 실현되던 문제 — 협상으로 카드를 받았는데 놓을 곳을 고르기도 전에 차례 플레이어가 정리를 끝내면, 차례가 넘어가며 자동으로 올라가 작업장이 강제로 실현됐습니다. 차례 끝에 받은 카드가 남아 있으면 30초짜리 "받은 카드 정리" 단계를 두고, 다른 사람에게는 "작업을 진행중입니다.."를 보여 줍니다.
  • 최고 단계에 계속 쌓이던 문제 — 수익표의 마지막 단계를 넘어 카드가 계속 올라갔습니다. 같은 종류라도 최고 단계면 막고, 그 작업장을 실현해야 새로 시작하게 했습니다. 봇의 시뮬레이션도 같은 규칙으로 고쳤습니다.
  • 마지막 바퀴 — 처음엔 덱이 떨어진 순간 알렸습니다. "남은 덱 ≤ 보충 장수 × 이번 바퀴에 남은 차례"면 그 바퀴가 마지막이라, 바퀴에 들어서는 순간 "마지막 바퀴!"와 마지막 차례인 사람을 미리 표시합니다.
  • [협상안함] — 할 협상이 없는 사람이 빨리 알리고 넘어가게 했습니다. 차례 플레이어 말고 모두 누르고 진행 중인 협상이 없으면 거래 단계가 끝나고, 공개 구역에 "○○님은 이번 협상에 참여하지 않습니다"가 다음 차례까지 남습니다.

06화면 — 도트 카드, 쥔 손패, 끌어다 놓기

  • 도트 카드 — 도구 6종을 12×12 도트로 찍어 SVG 사각형으로 그렸습니다. 어떤 크기에서도 선명합니다. 물건마다 색과 함께 무늬(점·빗금·체크·마름모·가로줄·격자)를 넣어 색이 비슷해 보여도 구분되게 했고, 카드 윗칸에는 가공할 물건 이름을 적었습니다.
  • 부채꼴 손패 — 실제로 손에 쥔 것처럼 기울여 펼치고, 오른쪽 끝이 맨 앞 카드입니다. 카드가 많아지면 작게, 덜 기울여 촘촘하게 놓아 양끝이 화면 밖으로 나가지 않습니다.
  • 끌어다 놓기 — 카드를 작업장에 놓는 모든 경우는 드래그&드롭입니다. 끄는 동안 놓을 수 있는 작업장이 초록으로 빛나고, 다른 종류가 진행 중인 곳에 놓으면 카드가 되돌아가며 "다른 작업이 진행중입니다."를 띄웁니다.
  • 안내는 꼭 필요할 때만 — "작업장을 정리하세요" 안내는 반드시 놓아야 하는 카드가 갈 곳이 없을 때만 띄우고, 상황에 따라 문구를 나눴습니다(모두 0억일 때·한쪽만 수익이 있을 때·둘 다 있을 때·최고 단계에 막혔을 때). 0억 작업장은 늘 "실현 불가", 버튼은 "작업장 정리"로 바뀝니다.
  • 차례 순서 — 참가자 칸을 내 다음 사람부터 차례대로 화살표로 잇고, 끝에 내 칸을 둡니다.
  • 원해요·줄게요 — 공개 구역 오른쪽 버튼으로 도구 카드 6종 중 하나를 고르면, 모두의 화면에서 그 카드가 손에 쥐고 흔들리듯 움직이며 "원해요!"가 떠오릅니다. 참가자 칸에는 각각 가장 최근 것이 남습니다.
  • 연출 — 시작할 때 "게임시작!"과 도트 구름이 화면을 덮었다가 0.5초 뒤 걷히고, 끝날 때 "게임 종료!" 아래 1.5초 동안 모든 작업장이 차례로 정리되며 금액이 튀어 오른 뒤 도트 왕관의 결산 순위가 뜹니다. 상단에는 일시정지를 뺀 대략의 진행시간(분)과 바퀴·차례 수, 남은 장수에 따라 두께가 바뀌는 덱을 둡니다.
  • 뿅망치 — 수익 없이 강제로 작업장을 비운 사람은 그 차례 동안 다른 사람들에게 5번씩 맞을 수 있습니다(LOG_07의 뿅망치를 가져왔습니다).

07소리 — 합성에서 받은 파일로, 크기는 BGM에 맞춰

처음 효과음은 원카드·5or10의 카드 소리를 작게 줄인 복사본과, 코드로 합성한 동전·거래 성사·거절 소리였습니다. 이후 사람이 고른 효과음 파일이 올라오면서 하나씩 바꿨습니다 — 카드 올리기, 수익 실현, 0억 정리(꽥!), 결산(다른 소리를 멈추고 하나만). 해 보니 다른 사람 작업장 소리까지 다 들려 정신없어서, 작업장 소리는 내 작업장 것만 울립니다.

아이폰 사파리는 코드로 audio.volume을 바꿀 수 없어서, 효과음 크기는 파일 자체로 맞춰야 합니다. 올라온 파일들은 최대 0~−12dB로 제각각이었습니다. 처음엔 최대 크기를 −9dB로 맞췄는데, 원래 작던 소리는 오히려 커졌습니다. 귀에 들리는 크기는 평균이 정하므로 평균 크기(LUFS)를 BGM이 실제로 울리는 크기에 맞추는 스크립트(scripts/level-sounds.mjs)를 남겼습니다.

BGM        −14.8 LUFS × 재생 음량 0.35(−9.1dB) ≈ −24 LUFS
효과음 목표  −24 LUFS (올라온 파일들: −8 ~ −25 → 모두 약 −24.4)
짧은 소리    0.4초보다 짧으면 평균을 잴 수 없다 → 최대 크기 −9dB 기준
다시 실행    ±1dB 안이면 다시 변환하지 않는다 (mp3를 두 번 굽지 않게)

08플랫폼에 남은 것 — 움직이는 이모티콘

이모티콘은 협상장인에서 시작했지만 플랫폼 기능으로 만들었습니다. 레지스트리에 emotes: true 한 줄이면 다른 게임에도 켜집니다.

  • 이모티콘박스 — 화면을 따라다니고 끌어서 옮길 수 있습니다(위치는 기기에 기억). 고르면 방 채널로 모두에게 보내고, 박스 위에 "이모티콘 + 닉네임"이 말풍선처럼 아래에서 위로 쌓여 2초 동안 보입니다. 쿨타임은 3초, 내가 차단한 사람의 이모티콘은 보이지도 들리지도 않습니다.
  • 그림은 폴더에서 — 처음엔 노란 얼굴·흰 장갑의 도트 이모티콘 6종을 코드로 그렸고, 이어 사람이 만든 움직이는 webp 8종으로 바뀌었습니다. 빌드할 때 public/emoji/에서 숫자로 시작하는 그림을 읽어 목록을 만들고(파일 이름의 한글이 이름), 그 숫자로 효과음을 고릅니다. 그림을 바꾸거나 더해도 코드는 그대로입니다.
  • 음성 — 처음 요청은 "AI로 만든 음성"이었는데, 작업 환경에 음성 합성 도구가 없었습니다. 모음 울림(포먼트)을 흉내 낸 합성 소리로 대신해 두었다가, 나중에 사람이 고른 음성 파일로 바꿨습니다.
  • 채팅 — 휴대폰에서 채팅 기록을 연 채 뒤로가기를 누르면 페이지를 떠나지 않고 기록만 닫히게 했고, 기록 높이는 5줄로 줄였습니다. 모든 게임에 적용됩니다.

09문제 해결 기록

  • 뿅망치를 연타하면 올라오던 채팅창 — 빠르게 두 번 누르면 휴대폰이 화면을 확대하고, 하단 채팅 입력줄이 그 변화를 "키보드가 올라왔다"로 계산해 위로 떴습니다. 게임판 전체를 touch-action: manipulation으로 두 번 눌러 확대를 막았습니다.
  • 걷히지 않던 시작 구름 — 개발 모드의 React StrictMode가 처음 그릴 때 정리 함수를 한 번 더 돌리면서, 막 예약한 "구름 걷기" 타이머를 지웠습니다. 단계(덮기→걷기→끝)가 바뀔 때마다 타이머를 다시 거는 방식으로 바꿨습니다.
  • 사람이 혼자인 판이 멈춤 — 끝까지 자동으로 돌려 보는 테스트가 거래 단계 0:00에서 멈췄습니다. 버그가 아니라 플랫폼의 자리비움 규칙(사람 수 × 2번 연속 시간 초과면 일시정지)이었습니다. 개발용 화면에 멈춤 표시와 [이어하기], 끝까지 구경하는 [철수도 봇]을 넣었습니다.
  • 병합된 줄 알았던 커밋 — 같은 브랜치 이름으로 작업을 이어 갈 때, 병합된 PR이면 main에서 다시 시작하는 규칙을 따릅니다. 한 번은 출력을 잘못 읽어 병합되지 않은 커밋을 놓고 main에서 다시 시작했습니다. 원격 브랜치에 커밋이 그대로 있어 되돌리고 그 위에 main을 합쳤고, 그 뒤로는 git merge-base --is-ancestor로 병합 여부를 먼저 확인합니다.
  • 다시 실행한 크기 맞추기 — 효과음 하나를 더하고 스크립트를 다시 돌리자, 이미 맞춘 파일을 측정 오차 때문에 다시 굽고, 0.4초짜리 소리는 평균을 못 재서 오히려 크게 키웠습니다. 원본으로 되돌리고 허용 오차와 짧은 소리 규칙을 더했습니다.
  • 서버 없이 해 보는 페이지 — 개발용 페이지(/dev/arti-nego)는 실제 서버 계획 함수(planRequest)를 그대로 써서 마감·시간 초과·봇 차례·op "bot"·지연(0~1초)을 흉내 냅니다. [장면: 받은 카드 정리]·[장면: 뿅망치]·[장면: 손패 20장]·[덱 6장만 남기기]로 드문 상황을 바로 만들고, 같은 게임판 컴포넌트로 [다시 하기]를 눌러 판이 바뀔 때 내부 상태가 초기화되는지 봅니다. 화면은 모두 375px 폭에서 Playwright로 찍어 확인했습니다.

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

협상장인 단위 테스트 40
  — 카드 구성·수익표 · 올리는 곳·0억 보호·강제 실현·최고 단계 · 행동 1·공개·협상·제안·수락·거절
  — 대가 없는 요청·자동 거절·제안 카드 잠금·협상 20초 만료 · 거래 마치기·협상안함·받은 카드 정리
  — 보충·짧은 판·마지막 바퀴·끝과 동점 · 시간 초과가 항상 유효 · 비공개 정보 · 순수성
  — 무작위 행동으로 3~6인 12판을 끝까지: 매 행동마다 카드 198장 보존
봇 테스트 3   — 봇끼리 16판(차례 밖 행동 포함) 모두 유효 · 거래가 실제로 일어남 · 시간 초과만 하는 상대보다 잘함
플랫폼 테스트 — op "bot": 봇이 고른 동시 행동만 적용 · 마감 유지 · 자리비움으로 세지 않음 · 할 일 없음·지원 안 함·참가자 아님
전체          — npm test 335 통과 · 빌드
화면          — 개발용 페이지 + Playwright (375px, 가로 스크롤 없음)

무작위 테스트가 이 게임의 안전망입니다. 동시 입력·협상·자동 정리가 얽히면 카드가 사라지거나 두 곳에 있는 일이 생기기 쉬운데, 매 행동 뒤 "덱 + 손패 + 작업장 + 받은 카드 + 공개 구역 + 버림 = 198"을 확인합니다. 화면이 "지금 할 수 있다"고 보여 준 행동은 모두 서버가 받아야 한다는 것도 같은 테스트가 봅니다.

11지금 이 자리

협상장인은 실제로 해 보고 고치는 반복(Iterate) 단계에 있습니다. 규칙 버전은 7까지 올라갔고, 요청과 수정이 열 번 남짓 오가며 규칙·안내 문구·소리·연출이 함께 바뀌었습니다.

이번에 남은 원칙은 세 가지입니다. 마감은 하나로 두고 나머지 시계는 상태에 적는다. 봇도 사람처럼 자기 시점만 보고, 사람의 화면 하나가 깨운다. 모호한 규칙은 목록으로 드러내 사람이 정하게 한다. 다음은 봇이 섞인 판에서 협상이 실제로 얼마나 일어나는지, 거래 단계가 몇 초 만에 끝나는지를 관찰(Observe)하고, 이모티콘을 다른 게임에도 켜 보는 일입니다.