소개
청강으로 뇌과학 강의를 시청하게 되었는데, 뇌과한 기반으로 바이브 코딩으로 게임을 만드는 것이 너무 재미있어보였다. 게임은 특히나 복잡한 UI/UX, 비즈니스 로직을 갖는 경우가 많아서 AI로 만들기가 어렵다고 생각해서 시도해보지 않았지만, 잘~~ 해보면 가능하지 않을까 해서 시도해보게 되었다. 일단은 만든 게 게임이라 뇌과학 스터디 태그를 했다.
2주차 토요일 모각에서 Google AI Studio로 앱을 만드는 것을 보고, 이번에는 AI Studio만으로 잘 개발해보자는 생각에 여러 시행착오를 거쳐 개발해보았다.
모각 발표 내용을 그대로 따라하는 것은 글쎄? 재밌어보이지 않아서 요즘 관심을 갖고 있는 "추상 전략게임"인 <<쿼리도>>를 만들어 보았다.
쿼리도는 1900년대 말에 개발된 게임으로, 체스/장기와 같이 말을 움직이는 전략과 바둑/오목과 같이 말을 내려놓는 추상 전략이 합쳐진 재밌는 게임이다. (인기는 그다지 없어서 온라인을 하고 싶어도 잘 없다. 그나마 있는 사람도 고인물들이라 상대가 안됨)
청강생이지만 주제가 재밌어보여서 이번주에는 바이브 코딩을 잘 해본 것으로 만족하고, 다음 주에는 뇌과학 원리가 들어간 게임도 시도해볼 예정이다.
진행 방법
이번에 모각에서 발표해주신 분의 꿀팁에 따르면, 비즈니스 로직을 먼저 만들고나서 이를 "의사코드"로 만들면 AI 에이전트가 더 정확하고 잘 만들어준다는 것이다...! 실제로 엄청난 효과를 봤고, 잘 동작했다.
일단 GPT와 함께 비즈니스 로직과 그에 대한 의사코드를 만든다. GPT가 쿼리도 게임의 규칙을 이미 알고 있어서 비즈니스 로직을 만드는 것은 그리 어렵지 않았다.
# 게임 앱 설계 지시
## Prompt:
너는 이제부터 Web App을 만드는 에이전트에게 게임을 만들도록 지시할 거야. 한 번에 많은 사항을 지시하지 말고 단계적으로 수행해야해.
너는 게임을 진행하기 위한 앱 설계에 대해서 전반적으로 꼼꼼하게 설명 및 지시사항을 생성해.
특히 디자인과 Asset 등은 손쉽게 변경 가능하도록 설계하고, 각 컴포넌트가 의존성이 최소화되어서 추후 리팩토링 및 교체가 용이하도록 설계해.
만들고자하는 게임은 Quoridor 라는 추상전략 게임이야. 4인도 가능하지만 2인용으로 개발하고, 추후 4인용으로 확장할 수 있도록 잘 설계해.
먼저, 어떤 순서로 지시해야하는지 지시할 단계를 먼저 말해봐
2/2
## Prompt:
스택은 React로 하되, 관리가 쉽게 프레임 워크는 최소화 해.
Outline 다시 만들어
2/2
## Prompt:
위 내용을 보고 만들 타겟 게임의 비즈니스 로직을 만들어봐.
코드로 구현하지 말고 자연어로 만들어. 아주 디테일하게 만들어. ultra think
3/5
## Response:
22s 동안 생각함
쿼리도르 웹 앱의 비즈니스 로직에 대해, 도메인 모델, 규칙 엔진, 상태 전환, 입력, 유효성 검사, 엣지 케이스, 실행 취소/다시 실행, 2인용 게임, 4인용 확장 가능성, 테마 및 자산 독립성 등 세부적으로 다뤄야 할 부분들이 많아요. 코드 없이, 초점을 맞추어 깔끔하고 주제를 잘 설명해볼게요.
보드 좌표, 벽 슬롯, 이동 종류, 제한, 경로 탐색(BFS), 승리 조건, 불법 이동 감지, 동점 조건(없음), 턴 흐름, 데이터 구조 등을 자연어로 정의해야 해요. API 계약도 명확히 하고, 에러 코드, 불변 조건, 우연성(없음), 재생 로그, 충돌 해결도 다루어야 해요. 4인용 확장도 생각해야죠.
쿼리도르에서 벽은 교차할 수 없어요. 벽은 서로 겹칠 수 없고, 기존의 벽을 “통과”할 수 없어요. 벽은 두 칸 사이에 놓여야 하고, 교차나 중첩은 절대 불가능하죠. 각 플레이어는 목표로 가는 길을 하나 이상은 남겨두어야 해요.
말은 벽에 막히지 않으면 인접한 칸으로 수평 또는 수직으로 이동할 수 있어요. 상대말이 인접하면 벽이 없을 때 점프할 수 있고, 벽이 있다면 대각선으로 돌아갈 수 있어요. 대각선은 점프 상황에서만 가능하죠. 2인용 설정에서 북쪽 플레이어는 8번 4번 칸에서 시작해요.
보드는 9x9 크기이며, 각 플레이어는 각 편에서 벽을 놓을 수 있어요. 벽은 수평과 수직으로 배치할 수 있는데, 수평 벽은 두 행을 가로지르는 8x8 좌표로, 수직 벽은 두 열을 가로지르는 8x8 좌표로 정의할 수 있습니다. 벽은 서로 겹치거나 교차하지 않도록 배치해야 해요.
완료
좋아. 아래는 \*\*Quoridor(2인용 우선, 4인 확장 가능)\*\*의 \*\*비즈니스 로직(자연어 스펙)\*\*이야. 코드 없음. 규칙엔진 중심으로 **순수 함수**, **의존성 최소**, **테마/에셋 교체 영향 없음**을 전제로 썼어.
* * *
0\. 핵심 원칙 (Principles)
======================
* **순수성**: 규칙엔진은 UI/네트워크/시간(랜덤 포함)과 분리. 입력(State, Action) → 출력(Result)만.
* **불변 업데이트 모델**: 입력 상태를 변형하지 않고 결과로 새 상태를 반환.
* **결정적(Deterministic)**: 같은 입력은 항상 같은 결과.
* **구성가능성**: 2인/4인은 `GameConfig` 파라미터로만 달라짐(코드 분기 최소화).
* **검증 우선(Validate-then-Apply)**: 모든 Action은 적용 전 **검증**을 통과해야만 적용.
* **역직렬화 가능 상태**: 상태는 JSON 직렬화→복원 1:1 가능.
* **Undo/Redo 친화**: Action 로그만으로 완전 복원.
* * *
1\. 보드/좌표 체계 (Board & Coordinates)
==================================
1.1 셀
-----
* 크기: **9 × 9**.
* 셀 좌표: `(row, col)` with `row, col ∈ [0..8]`.
* 시작점(2인):
* **P1**: `(0, 4)` (상단 중앙), **승리 라인**: `row = 8`.
* **P2**: `(8, 4)` (하단 중앙), **승리 라인**: `row = 0`.
1.2 벽 슬롯 (Wall slots)
---------------------
* 벽은 길이 2의 “펜스” 조각. **수평(H)** 또는 **수직(V)**.
* **수평(H)**: “행 사이 간격”을 기준으로 배치.
* 좌표: `(r, c)` where `r ∈ [0..7]`(row와 row+1 사이), `c ∈ [0..7]`(col 시작점).
* `(r,c)`는 `row r ↔ r+1` 사이, `col c`와 `c+1` 사이 **두 칸**을 가로막음.
* **수직(V)**: “열 사이 간격”을 기준으로 배치.
* 좌표: `(r, c)` where `c ∈ [0..7]`(col과 col+1 사이), `r ∈ [0..7]`(row 시작점).
* `(r,c)`는 `col c ↔ c+1` 사이, `row r`와 `r+1` 사이 **두 칸**을 세로로 막음.
> Note: 이렇게 정의하면 H/V 모두 64개 슬롯(8×8)이 생기고, “교차 금지/중복 금지” 같은 규칙을 명확히 판정 가능.
* * *
2\. 게임 구성요소 (Domain Model)
==========================
2.1 플레이어(Player)
----------------
* 속성:
* `id`: 고유 식별자 (`"P1"|"P2"|...`).
* `pawn`: 현재 말 위치 `(row,col)`.
* `remainingWalls`: 남은 벽 수 (2인=10, 4인=5).
* `goal`: **목표 도달 조건**(2인: 상대 진영 끝줄, 4인: 반대쪽 변).
* `name`/`color` 등은 **UI 전용**(엔진 비의존).
2.2 보드 상태(BoardState)
---------------------
* `pawns`: `{[playerId]: Cell}`
* `wallsH`: `Set<WallHSlot>` (배치된 수평 벽 좌표 집합)
* `wallsV`: `Set<WallVSlot>` (배치된 수직 벽 좌표 집합)
* (파생) `blockedEdges`: 캐시 가능. 이동 여부 연산 시 사용(선택).
2.3 게임 상태(GameState)
--------------------
* `board`: BoardState
* `players`: Player\[\]
* `turnOrder`: PlayerId\[\] (2인: `[P1, P2]`; 4인: `[P1,P2,P3,P4]`)
* `activePlayerId`: 현재 턴
* `moveCount`: 진행 수(무승부 관련 정책 없으나 로그/통계용)
* `phase`: `"playing" | "ended"`
* `winnerId?`: 승자(종료 시)
* `history`: ActionLog\[\] (Undo/Redo용)
2.4 게임 설정(GameConfig)
---------------------
* `boardSize`: 9 (확장 대비 파라미터화)
* `initialPawns`: PlayerId→Cell
* `winCondition`: PlayerId→GoalPredicate(예: “row==8”)
* `initialWallsPerPlayer`: 2인=10, 4인=5
* `rules`: 세부 옵션(확장성):
* `allowPass`: 기본 false(Quoridor는 패스 없음).
* `diagonalOnlyWhenBlocked`: true(규정).
* `forbidWallCrossing`: true(교차 금지).
* `forbidWallOverlap`: true(중복 금지).
* `mustKeepAtLeastOnePathPerPlayer`: true(핵심).
* `winOnMoveEnd`: true(자신의 이동 종료 즉시 승리).
* `tieRule`: 없음(턴 교대로 인해 동시 승리 불가).
* * *
3\. 액션 모델 (Actions)
===================
3.1 액션 타입
---------
* `MovePawn(toCell)`
* `PlaceWall(orientation, slot)` where `orientation ∈ {H, V}`, `slot`는 위 1.2 정의.
* (옵션) `Resign`(기권) — 기본 스펙엔 포함하지 않아도 됨.
* **Pass**는 지원하지 않음(규정).
3.2 액션 공통 입력/출력 규격
------------------
* 입력: `(state: GameState, action: Action, actor: PlayerId)`
* 출력:
* `Ok(newState, events[])` 또는
* `Err(code, message, details?)`
* 대표 에러 코드:
* `NOT_YOUR_TURN`
* `ILLEGAL_MOVE`
* `OUT_OF_BOUNDS`
* `WALL_CONFLICT`(overlap/crossing)
* `NO_WALLS_LEFT`
* `PATH_BLOCKED`(경로 완전 차단 위반)
* `GAME_ALREADY_ENDED`
* `INVALID_TARGET`(이동 타겟 부적합)
* `BLOCKED_BY_WALL`(벽에 의해 막힘)
* `OCCUPIED`(셀 점유 충돌)
* `RULE_VIOLATION`(기타)
* * *
4\. 이동 규칙 (Pawn Movement)
=========================
4.1 기본 이동
---------
* **직교 1칸**(상/하/좌/우)만 가능.
* 이동 경로 사이에 **벽**이 있으면 불가.
* 목적 셀에 **다른 말**이 있으면 불가(점프 케이스 제외).
4.2 인접 상대 말 점프
--------------
* 현재 말과 **직교 인접**한 셀에 상대 말이 있고,
* 그 **바로 뒤 셀**이 보드 안이며 **벽으로 막혀 있지 않으면**,
* 상대 말을 **“뛰어넘어” 2칸 직선 이동** 가능.
* 예: P1가 (4,4), P2가 (5,4), (6,4)가 보드 안이고 (5,4)-(6,4) 사이가 벽으로 막혀 있지 않다면, P1는 (6,4)로 점프.
4.3 대각 점프(우회 대각 이동)
-------------------
* 인접 상대 말의 “뒤”가 **벽으로 막혀** 직선 점프가 불가능할 때,
* **대각 두 칸 중 하나로 이동** 허용(상세: 상대 말과 인접한 셀의 좌/우 혹은 상/하 대각 위치).
* 대각 후보가 **벽으로 막혀 있지 않고** 보드 안이어야 함.
* 대각 이동은 **오직 위 상황에서만** 허용(`diagonalOnlyWhenBlocked: true`).
4.4 다중 말 인접 (4인 확장 고려)
----------------------
* 내 인접 셀에 **둘 이상의 말**이 있을 수 있음(4인).
* 점프 로직은 “내가 바라보는 방향 기준 인접한 말” 각각에 대해 독립 판정.
* 여러 후보가 생기면 **플레이어가 선택**(UI에서 표시). 엔진은 **유효 후보 집합**만 계산.
4.5 승리 판정
---------
* **내 말이 내 승리 조건을 만족하는 셀**에 도달하면 **즉시 승리**.
* 2인 기준: P1는 `row==8`, P2는 `row==0`.
* 4인: 각자 반대편 변(행 또는 열).
* * *
5\. 벽 배치 규칙 (Wall Placement)
============================
5.1 일반 제약
---------
* **남은 벽 수 ≥ 1**이어야 함, 아니면 `NO_WALLS_LEFT`.
* 지정한 슬롯 `(r, c)`가 **보드 범위 내**여야 함.
* 해당 슬롯에 **이미 같은 방향의 벽이 있으면** `WALL_CONFLICT(overlap)`.
* **수직↔수평이 교차**하는 형태 금지 → 중심 교차점에서 겹치면 `WALL_CONFLICT(crossing)`.
* 직관적으로: H(r,c)와 V(r,c)는 **교차**로 간주(동일 톱니에 꽂는 느낌).
* H(r,c)와 V(r-1,c) 또는 V(r,c-1) 등은 **교차 아님**(T자/ㄴ자 가능).
* **벽은 격자 밖으로 나갈 수 없음**(슬롯 정의가 이것을 이미 보장).
5.2 연장(연속 배치)
-------------
* 같은 방향의 벽을 **옆에** 연속해서 놓아 **긴 벽**을 만들 수 있음.
* 단, **중복 슬롯** 배치 금지.
5.3 경로 보존(핵심 규칙)
----------------
* 벽을 놓은 뒤에도 **모든 플레이어**는 자신의 승리 라인에 도달하는 **최소 1개 이상의 경로**가 존재해야 함.
* 판정은 **BFS/최단경로 탐색**으로 수행.
* 시작: 각 플레이어의 현재 말 위치.
* 목표: 해당 플레이어의 **GoalPredicate**(예: 특정 row/col)에 도달 가능한지.
* 간선: 인접 4방, 단 **벽으로 막힌 간선 제거**. 대각은 **이동 규칙 4.3 상황에서만** 간주되므로, **경로 존재 검증에서는 직교 간선만** 사용(업계 관행).
* 이유: “경로가 있다”는 정의는 **격자 그래프 상 직교 경로** 기준으로 충분하며, 실제 플레이 이동은 점프/대각 규칙으로 더 유리해질 수 있어도 **차단 검증은 엄격한 기준**이 안전.
* * *
6\. 상태 전이(턴) (Turn Flow)
========================
6.1 전이 스텝
---------
1. **SelectAction**: 플레이어가 `MovePawn` 또는 `PlaceWall` 중 하나 선택.
2. **Validate**:
* 공통: `phase=="playing"`, `actor==activePlayer`, 입력 파라미터 범위 체크.
* 액션별 상세 검증 실행(이동/벽 규칙).
3. **Apply**:
* 이동: 말 위치 갱신 → `moveCount++` → 승리 체크.
* 벽: 벽 집합에 추가, `remainingWalls--` → `moveCount++`.
4. **WinCheck**(이동 액션 시):
* 목표 충족 시 `phase="ended"`, `winnerId=actor`.
5. **SwitchTurn**:
* 종료가 아니면 `activePlayerId`를 `turnOrder`에 따라 다음으로 순환.
6.2 불가 액션 처리
------------
* Validate에서 실패 시 `Err(code, message)` 반환, 상태 변화 없음.
* UI는 이 에러를 사용자 피드백으로만 사용(엔진 비의존).
* * *
7\. 검증 알고리즘 (Validation Details)
================================
7.1 MovePawn 검증
---------------
* (1) **타겟 유효성**: 타겟 셀 보드 범위 내.
* (2) **자기 말 기준 규칙 검사**:
* **직교 인접 1칸**: 사이 간선이 **벽으로 막혔는지** 확인. 막혔으면 `BLOCKED_BY_WALL`.
* **점프(2칸 직선)**:
* 중간 셀에 **상대 말 존재** 필요.
* **중간 간선**과 **뒤 간선**이 벽으로 막히지 않아야 함.
* **대각 우회**:
* 인접 상대 말 있음 + **정면 점프 불가**인 경우에 한함.
* 대각 후보 각 간선 **벽 여부**로 유효성 판단.
* (3) **점유 충돌**: 최종 타겟 셀에 다른 말이 있으면 `OCCUPIED` (대각/점프 규칙이 해결하는 경우 제외).
* (4) **기타**: 그 외는 `INVALID_TARGET` 처리.
7.2 PlaceWall 검증
----------------
* (1) **재고**: `remainingWalls > 0`.
* (2) **슬롯 범위**: `r,c`가 0..7 내인지 체크.
* (3) **중복/교차**:
* 동일 슬롯 동일 방향 존재 → `WALL_CONFLICT(overlap)`.
* 교차 금지:
* H(r,c) 배치 시 V(r,c) 존재하면 불가.
* (4) **경로 보존**:
* 임시로 벽을 배치한 보드로 각 플레이어에 대해 BFS 수행.
* **하나라도 도달 불가**면 `PATH_BLOCKED`.
* (5) **최종**: 모두 통과 시만 적용.
* * *
8\. BFS(경로 존재) 스펙 (Shortest Path Exists)
========================================
8.1 그래프 모델
----------
* 노드: 모든 셀 `(r,c)`.
* 간선: 상하좌우 이웃 노드 간 **벽이 없는 경우에 한해** 양방향 간선 존재.
* 목표 노드 집합:
* 2인: 각 플레이어의 **반대편 행 전체**.
* 4인: 각 플레이어의 **반대편 변 전체(행 또는 열)**.
8.2 절차
------
* 각 플레이어에 대해:
1. `queue := [currentPawnCell]`, `visited := Set()`.
2. BFS 진행, 목표 집합에 닿으면 **즉시 true**.
3. 탐색 종료까지 미도달이면 false.
> 성능: 9×9=81노드, 간선 ≤ 4\*81. 한 번의 벽 배치 검증에서 플레이어 수만큼 BFS → 미미한 비용. 필요 시 캐시/워커 분리 가능.
* * *
9\. 히스토리/리플레이/Undo-Redo
=======================
9.1 Action Log 항목 구조(자연어 스키마)
-----------------------------
* `t`: 시퀀스 번호(0부터 증가)
* `actor`: PlayerId
* `type`: `"MovePawn"` | `"PlaceWall"`
* `payload`:
* MovePawn: `{from: Cell, to: Cell, kind: "orthogonal"|"jump"|"diagonal"}`
* PlaceWall: `{orientation: "H"|"V", slot: {r,c}}`
* `resultingStateHash`: 선택(회귀 검증용)
9.2 Undo/Redo 정책
----------------
* **Undo**: 마지막 `Ok` 액션 단위로 취소(검증 실패 액션은 기록하지 않음).
* **Redo**: Undo로 되돌린 액션을 재적용.
* **분기**: Undo 후 새로운 액션이 발생하면 **이후 분기 로그 폐기**(일반 규칙).
9.3 리플레이
--------
* 초기 상태 + Action Log 순차 적용 → **동일 최종 상태** 도달해야 함(결정성 보증).
* * *
10\. 초기화 & 시작 플레이어
==================
10.1 초기화
--------
* 각 플레이어:
* `pawn`: `initialPawns[playerId]`
* `remainingWalls`: `initialWallsPerPlayer`
* 보드:
* `wallsH = ∅`, `wallsV = ∅`
* `turnOrder`: Config에서 전달.
* `activePlayerId`: 랜덤 또는 고정(P1) — **엔진은 입력으로만 받음**(랜덤 시드/시간 의존은 상위 레이어 책임).
10.2 시작 결정
----------
* 규칙엔진은 **시작자 결정 로직 비포함**(비즈 레이어 정책).
* 예: 첫 게임 P1 시작, 이후 스왑 등은 상위 정책.
* * *
11\. 4인 확장 규칙 (엔진 관점)
=====================
11.1 시작/승리 조건
-------------
* 시작점: 각 변 중앙(상/하/좌/우).
* 승리: **반대편 변** 도달(상 ↔ 하, 좌 ↔ 우).
* 남은 벽: 기본 **5개**.
* 턴 순환: `[P1,P2,P3,P4]` 순서로 반복.
11.2 이동/점프 상호작용
---------------
* 내 정면에 **여러 말**이 줄지어 있어도, **첫 인접 말**만 점프/대각 기준이 됨(규정 관습).
* 정면 점프 불가 시 대각 후보는 **둘**(좌/우)일 수 있고, **벽에 막힌 후보는 제외**.
* 두 후보 모두 유효면 **선택지 둘 다 합법**.
11.3 경로 보존 검증
-------------
* 모든 플레이어에 대해 BFS.
* 한 명이라도 막히면 **배치 불가**.
* * *
12\. 에러/엣지케이스 표준화
=================
* **벽 겹침(Overlap)**: 동일 슬롯 동일 방향 배치 시 즉시 오류.
* **벽 교차(Crossing)**: H(r,c) ↔ V(r,c) 금지.
* **경계 밖 이동**: 타겟 좌표 out-of-bounds → `OUT_OF_BOUNDS`.
* **벽으로 막힌 이동**: 간선 체크에서 차단 → `BLOCKED_BY_WALL`.
* **점프 조건 미충족**: 중간에 말 없음/뒤가 벽 → `ILLEGAL_MOVE`.
* **대각 조건 미충족**: 정면 점프 가능하면 대각 **허용 안 함**.
* **말 위로 착지 금지**: 도착 셀 점유 → `OCCUPIED`.
* **게임 종료 후 액션**: `GAME_ALREADY_ENDED`.
* * *
13\. 파생 정보 & 헬퍼 (UI가 쓰기 쉬운 형태로 제공)
==================================
엔진 출력 또는 셀렉터로 제공(계산은 규칙엔진이 해도 되고, 어댑터가 해도 됨. 순수 함수로):
* **Legal Moves for Active Player**: 현재 말 위치에서 가능한 모든 합법 이동 후보(직교/점프/대각 구분 포함).
* **Legal Wall Slots**: 현재 남은 벽이 있을 때 배치 가능한 모든 `H/V (r,c)` 목록(성능 고려해 on-demand 생성).
* **Path Distance Heuristic**(선택): 각 플레이어에 대해 최단 거리 길이(경기 AI/힌트용; 규칙엔진의 일부 아님).
* **Blocked Edge Map**: 벽 기준으로 차단된 간선 맵(디버깅/시각화).
* * *
14\. 직렬화/호환
===========
14.1 상태 직렬화
-----------
* 모든 원시 타입으로 표현 가능한 구조(JSON).
* `Set`류는 **소트된 배열**로 직렬화(예: `"H":[{r,c},...]`, `"V":[{r,c},...]`).
* 버전 필드 `schemaVersion` 포함(변경 대비).
14.2 공유 링크 인코딩
--------------
* 상태→JSON→gzip(옵션)→base64url.
* 입력 검증: decode 실패/버전 불일치 시 `Err(INVALID_STATE_BLOB)`.
* * *
15\. 성능/안정성 가이드(엔진 레벨)
======================
* BFS는 O(노드+간선)로 충분히 작음 → 메인스레드 OK. 필요 시 워커 분리.
* `blockedEdges` 캐시(키: `(wallsH,wallsV)` 해시)로 중복 계산 최소화.
* 불변 업데이트 시 구조적 공유(참조 재사용)로 GC 압박 완화.
* * *
16\. 테스트 시나리오(필수)
=================
1. **기본 이동**: 빈 보드에서 상하좌우 1칸.
2. **벽 차단**: 간선에 해당하는 벽을 놓고 이동 불가 확인.
3. **직선 점프 OK**: 상대 인접 + 뒤 셀 개방.
4. **직선 점프 불가 → 대각 OK**: 뒤가 벽, 대각 후보 중 하나 가능.
5. **대각 둘 다 가능**: 두 후보 다 합법.
6. **대각 금지 케이스**: 정면 점프 가능한데 대각 시도 → `ILLEGAL_MOVE`.
7. **벽 중복/교차**: 각각 `WALL_CONFLICT`.
8. **경로 차단 금지**: 벽 배치로 한쪽 경로 완전 차단 시 `PATH_BLOCKED`.
9. **승리 조건**: 목표 라인 도달 즉시 승리.
10. **Undo/Redo**: 연속 액션 되돌림/재적용 일관성.
11. **4인 BFS**: 한 명 차단 시 배치 실패.
12. **리플레이 결정성**: 동일 로그 → 동일 최종 상태.
* * *
17\. 이벤트(옵저버블) (선택)
===================
* `onMoveApplied({actor, from, to, kind})`
* `onWallPlaced({actor, orientation, slot})`
* `onTurnChanged({from,to})`
* `onGameEnded({winnerId})`
> 엔진은 콜백을 호출하지 않고, **이벤트 배열**을 Result로 돌려주는 형태가 더 순수함. 상위 레이어가 구독/로깅.
* * *
18\. 보안/치팅 방지(멀티 대비, 개념만)
=========================
* 서버 검증 시 동일 비즈니스 로직으로 재검증(서버=SSOT).
* 클라이언트는 **명령(Action)만 전송**, 상태는 서버가 진실.
* 리플레이 로그에 **행동 해시** 포함해 변조 탐지(선택).
* * *
19\. 폴더/구성(비즈니스 로직 단위, 참고)
==========================
* `rules/geometry` : 좌표/간선/벽-간선 맵핑
* `rules/move` : 이동 후보 생성/검증
* `rules/wall` : 벽 배치 검증
* `rules/path` : BFS/경로 보존
* `rules/apply` : 상태 적용(불변 업데이트)
* `rules/validate` : 공통/액션별 종합 검증
* `state/schema` : GameState/Config 스키마
* `history` : 로그/undo-redo/리플레이
* `selectors` : 파생 정보(legal moves 등)
* * *
필요하면 이 스펙을 기반으로 **액션 I/O 포맷** 샘플, **BFS 판정 예시 케이스 다이어그램**, **불변 업데이트 단계별 diff 설명**까지 바로 이어서 써줄게.
## Prompt:
이 비즈니스 로직 전체를 의사코드로 작성해. 엄밀하고 정확하게 만들어야해.
## Response:
16s 동안 생각함
사용자가 명시적으로 캔버스를 요청하지 않아서 캔버스는 만들지 않으려 해요. 하지만 좀 더 도움이 될 수 있다면 좋을 것 같기도 해서 직접 채팅으로 제공할 거예요. "전체"와 "엄밀"을 강조한 만큼, 꼭 간결하면서도 충분히 상세한 답변을 제공하려고 하죠. 마크다운으로 코드 블록을 사용하여 pseudocode를 제시할게요.
필요한 함수들을 포함해야겠어요. 예를 들면 타입 정의, 상태 생성, 해시, 그리고 헬퍼 함수들(inBounds, edgeBlocked 등). 이동과 벽 검증, BFS 경로 존재 확인 같은 함수들, 역사 기록을 위한 푸시/팝, 직렬화/역직렬화, 4인용 확장 등을 고려해요. 이 벽 관련된 조건도 명확히 정의해야겠죠.
H(r,c)는 (r,c)와 (r+1,c) 사이의 이동을 막습니다. 이는 r과 r+1 사이의 경계를 가로지르며, c와 c+1도 차단됩니다. c에서 이동 시, H(r, c)와 H(r, c-1)를 체크해야 할 것 같아요. 이 동작을 정확히 판단하려면 두 벽 조건을 모두 고려해야 합니다.
H(r, c-1)도 이동을 차단하는 것 같아요. (r,c)와 (r+1,c)의 경계가 H(r, c-1) 또는 H(r, c)로 차단됩니다. 그게 Quoridor 게임에서 일반적인 규칙과 일치해요. 비슷하게, (r, c)와 (r, c+1) 사이의 가로 이동은 V(r, c) 또는 V(r-1, c)로 차단되며, BFS로 경로 존재 여부를 체크할 수 있겠네요.
완료
오케이. 아래는 **Quoridor(2인 우선, 4인 확장 가능)** 비즈니스 로직 **의사코드 전체 스펙**이야.
목표: **순수함수/불변 업데이트/결정성** 보장, UI·네트워크와 완전 분리.
형식: **타입 → 헬퍼 → 검증 → 적용 → 턴/이벤트 → 히스토리 → 직렬화 → 테스트 포인트**.
* * *
```pseudocode
PSEUDOCODE SPEC: QUORIDOR RULE ENGINE (2-4 Players)
VERSION = "1.0.0"
```
1) 타입/스키마
=========
```pseudocode
TYPE PlayerId = STRING // "P1","P2","P3","P4" 등
TYPE Coord = { r: INT, c: INT } // 0..8 (boardSize-1)
TYPE Orientation = "H" | "V"
TYPE Phase = "playing" | "ended"
TYPE MoveKind = "orthogonal" | "jump" | "diagonal"
TYPE WallHSlot = { r: INT, c: INT } // r,c ∈ [0..boardSize-2]
TYPE WallVSlot = { r: INT, c: INT } // r,c ∈ [0..boardSize-2]
TYPE Player = {
id: PlayerId
pawn: Coord
remainingWalls: INT
goal: GoalPredicate // (Coord) -> BOOL
}
TYPE BoardState = {
wallsH: SET<WallHSlot> // 수평 벽 슬롯의 집합
wallsV: SET<WallVSlot> // 수직 벽 슬롯의 집합
}
TYPE GameConfig = {
boardSize: INT // 기본 9
initialPawns: MAP<PlayerId, Coord>
initialWallsPerPlayer: INT // 2인=10, 4인=5
turnOrder: LIST<PlayerId> // 2인: [P1,P2]
winCondition: MAP<PlayerId, GoalPredicate> // 각자 GoalPredicate
rules: {
allowPass: BOOL // 기본 false
diagonalOnlyWhenBlocked: BOOL // true
forbidWallCrossing: BOOL // true
forbidWallOverlap: BOOL // true
mustKeepAtLeastOnePathPerPlayer: BOOL // true
winOnMoveEnd: BOOL // true
}
}
TYPE GameState = {
schemaVersion: STRING = VERSION
phase: Phase
board: BoardState
players: MAP<PlayerId, Player>
activePlayerId: PlayerId
turnOrder: LIST<PlayerId>
moveCount: INT
winnerId: PlayerId | NULL
history: LIST<ActionLog> // 성공한 액션만 기록
redoStack: LIST<ActionLog> // Undo 후 Redo용 (선택)
}
TYPE Action =
| { type: "MovePawn", to: Coord }
| { type: "PlaceWall", orientation: Orientation, slot: { r: INT, c: INT } }
TYPE Result =
| { ok: TRUE, state: GameState, events: LIST<Event> }
| { ok: FALSE, error: Error }
TYPE ErrorCode =
"NOT_YOUR_TURN" | "GAME_ALREADY_ENDED" | "OUT_OF_BOUNDS" |
"ILLEGAL_MOVE" | "BLOCKED_BY_WALL" | "OCCUPIED" |
"INVALID_TARGET" | "WALL_CONFLICT" | "NO_WALLS_LEFT" |
"PATH_BLOCKED" | "RULE_VIOLATION" | "DECODE_FAILED" | "VERSION_MISMATCH"
TYPE Error = { code: ErrorCode, message: STRING, details?: ANY }
TYPE Event =
| { name: "MoveApplied", actor: PlayerId, from: Coord, to: Coord, kind: MoveKind }
| { name: "WallPlaced", actor: PlayerId, orientation: Orientation, slot: {r:INT,c:INT} }
| { name: "TurnChanged", from: PlayerId, to: PlayerId }
| { name: "GameEnded", winnerId: PlayerId }
TYPE ActionLog =
{ t: INT, actor: PlayerId, action: Action, fromStateHash: STRING, toStateHash: STRING }
```
2) 초기화
======
```pseudocode
FUNCTION InitGame(config: GameConfig, firstPlayerId: PlayerId): GameState
ASSERT firstPlayerId ∈ config.turnOrder
players := EMPTY MAP
FOR each pid IN config.turnOrder:
players[pid] := {
id: pid,
pawn: config.initialPawns[pid],
remainingWalls: config.initialWallsPerPlayer,
goal: config.winCondition[pid]
}
state := {
schemaVersion: VERSION,
phase: "playing",
board: { wallsH: ∅, wallsV: ∅ },
players,
activePlayerId: firstPlayerId,
turnOrder: COPY(config.turnOrder),
moveCount: 0,
winnerId: NULL,
history: [],
redoStack: []
}
RETURN state
END
```
3) 헬퍼(좌표/경계/간선 차단)
==================
```pseudocode
FUNCTION InBounds(cfg: GameConfig, p: Coord): BOOL
RETURN (0 ≤ p.r < cfg.boardSize) AND (0 ≤ p.c < cfg.boardSize)
END
FUNCTION EqualCoord(a: Coord, b: Coord): BOOL
RETURN (a.r == b.r) AND (a.c == b.c)
END
FUNCTION Add(a: Coord, b: Coord): Coord
RETURN { r: a.r + b.r, c: a.c + b.c }
END
CONST DIRS := {
N: {r:-1, c:0},
S: {r:+1, c:0},
W: {r:0, c:-1},
E: {r:0, c:+1}
}
FUNCTION IsOccupied(state: GameState, target: Coord): BOOL
FOR each p IN state.players.values():
IF EqualCoord(p.pawn, target) THEN RETURN TRUE
RETURN FALSE
END
```
3-1) 간선 차단 판단(핵심)
-----------------
> 규칙: H(r,c)는 **행 r↔r+1** 사이의 **col c**와 **c+1** 두 세로 간선을 차단.
> V(r,c)는 **열 c↔c+1** 사이의 **row r**와 **r+1** 두 가로 간선을 차단.
```pseudocode
FUNCTION EdgeBlockedByWall(board: BoardState, a: Coord, b: Coord): BOOL
// a와 b는 반드시 직교 이웃이어야 함
IF a.r == b.r THEN
// 수평 이동 (좌↔우)
row := a.r
minC := MIN(a.c, b.c) // 이동은 col minC ↔ minC+1
// 이 가로 간선은 V(row, minC) 또는 V(row-1, minC) 중 하나로 막힘
IF EXISTS board.wallsV CONTAINS {r: row, c: minC} THEN RETURN TRUE
IF EXISTS board.wallsV CONTAINS {r: row-1, c: minC} THEN RETURN TRUE
RETURN FALSE
ELSE IF a.c == b.c THEN
// 수직 이동 (상↔하)
col := a.c
minR := MIN(a.r, b.r) // 이동은 row minR ↔ minR+1
// 이 세로 간선은 H(minR, col) 또는 H(minR, col-1) 중 하나로 막힘
IF EXISTS board.wallsH CONTAINS {r: minR, c: col} THEN RETURN TRUE
IF EXISTS board.wallsH CONTAINS {r: minR, c: col-1} THEN RETURN TRUE
RETURN FALSE
ELSE
// 대각은 간선 정의 바깥. 여기서 차단 여부 판단하지 않음.
RAISE "EdgeBlockedByWall requires orthogonal neighbors"
END
```
4) 합법 이동 후보 생성(점프/대각 포함)
========================
```pseudocode
FUNCTION LegalMovesFor(state: GameState, cfg: GameConfig, pid: PlayerId): LIST<{to:Coord, kind:MoveKind}>
ASSERT state.phase == "playing"
me := state.players[pid]
src := me.pawn
moves := EMPTY LIST
// 4방 처리
FOR each (name, d) IN DIRS:
nbr := Add(src, d)
IF NOT InBounds(cfg, nbr) THEN CONTINUE
IF EdgeBlockedByWall(state.board, src, nbr) THEN CONTINUE
IF IsOccupied(state, nbr) == FALSE THEN
APPEND moves, { to: nbr, kind: "orthogonal" }
CONTINUE
ENDIF
// 인접 칸에 말이 있음 → 점프/대각 판단
behind := Add(nbr, d)
// 4.2 직선 점프
IF InBounds(cfg, behind) AND (NOT EdgeBlockedByWall(state.board, nbr, behind)) AND (IsOccupied(state, behind) == FALSE) THEN
APPEND moves, { to: behind, kind: "jump" }
CONTINUE // 직선 점프 가능하면 대각은 규칙상 선택지로도 허용되지 않음?
// 표준 규정: 정면이 열려 있으면 대각 금지 → diagonalOnlyWhenBlocked=true
ENDIF
// 4.3 대각 점프 (정면이 막혀 있는 경우만)
IF cfg.rules.diagonalOnlyWhenBlocked == TRUE THEN
// 정면 "뒤"가 막혀서 직선 점프 불가 → 대각 후보 열기
// 수직 이동(N/S)인 경우 좌우로, 수평 이동(E/W)인 경우 상하로 분기
IF d == DIRS.N OR d == DIRS.S THEN
FOR each side IN [DIRS.W, DIRS.E]:
diag := Add(nbr, side)
IF NOT InBounds(cfg, diag) THEN CONTINUE
// nbr → diag 이동 간선이 벽으로 막히면 불가
IF EdgeBlockedByWall(state.board, nbr, diag) THEN CONTINUE
// 최종 도착 칸 점유 금지
IF IsOccupied(state, diag) THEN CONTINUE
APPEND moves, { to: diag, kind: "diagonal" }
ENDFOR
ELSE // d == E or W
FOR each side IN [DIRS.N, DIRS.S]:
diag := Add(nbr, side)
IF NOT InBounds(cfg, diag) THEN CONTINUE
IF EdgeBlockedByWall(state.board, nbr, diag) THEN CONTINUE
IF IsOccupied(state, diag) THEN CONTINUE
APPEND moves, { to: diag, kind: "diagonal" }
ENDFOR
ENDIF
ENDIF
ENDFOR
RETURN moves
END
```
5) 승리 판정
========
```pseudocode
FUNCTION IsWinnerAfterMove(cfg: GameConfig, pid: PlayerId, newPos: Coord): BOOL
goal := cfg.winCondition[pid] // GoalPredicate: Coord -> BOOL
RETURN goal(newPos)
END
```
6) 벽 배치 검증 (충돌/교차/경로 보존)
========================
```pseudocode
FUNCTION SlotInBounds(cfg: GameConfig, slot: {r:INT,c:INT}): BOOL
RETURN (0 ≤ slot.r < cfg.boardSize-1) AND (0 ≤ slot.c < cfg.boardSize-1)
END
FUNCTION WallConflict(board: BoardState, o: Orientation, slot): BOOL
IF o == "H" THEN
// 중복
IF slot ∈ board.wallsH THEN RETURN TRUE
// 교차: H(r,c)와 V(r,c)는 교차 금지
IF {r:slot.r, c:slot.c} ∈ board.wallsV THEN RETURN TRUE
RETURN FALSE
ELSE // "V"
IF slot ∈ board.wallsV THEN RETURN TRUE
IF {r:slot.r, c:slot.c} ∈ board.wallsH THEN RETURN TRUE
RETURN FALSE
END
```
6-1) 경로 보존(BFS)
---------------
```pseudocode
FUNCTION PathExistsFor(board: BoardState, cfg: GameConfig, start: Coord, goalPred: GoalPredicate): BOOL
Q := new QUEUE
visited := new SET<Coord>
ENQUEUE(Q, start)
ADD visited, start
WHILE Q not empty:
cur := DEQUEUE(Q)
IF goalPred(cur) THEN RETURN TRUE
FOR each d IN DIRS:
nxt := Add(cur, d)
IF NOT InBounds(cfg, nxt) THEN CONTINUE
IF EdgeBlockedByWall(board, cur, nxt) THEN CONTINUE
IF nxt ∈ visited THEN CONTINUE
ADD visited, nxt
ENQUEUE(Q, nxt)
ENDFOR
ENDWHILE
RETURN FALSE
END
FUNCTION AllPlayersHaveAPath(board: BoardState, cfg: GameConfig, players: MAP<PlayerId, Player>): BOOL
FOR each p IN players.values():
IF NOT PathExistsFor(board, cfg, p.pawn, p.goal) THEN
RETURN FALSE
RETURN TRUE
END
```
7) 액션 검증 (Validate)
===================
```pseudocode
FUNCTION ValidateCommon(state: GameState, cfg: GameConfig, actor: PlayerId): Error | NULL
IF state.phase != "playing" THEN
RETURN {code:"GAME_ALREADY_ENDED", message:"game ended"}
IF state.activePlayerId != actor THEN
RETURN {code:"NOT_YOUR_TURN", message:"not your turn"}
RETURN NULL
END
```
7-1) 이동 검증
----------
```pseudocode
FUNCTION ValidateMovePawn(state: GameState, cfg: GameConfig, actor: PlayerId, to: Coord):
{ ok:BOOL, kind?: MoveKind, error?: Error }
// 공통
err := ValidateCommon(state, cfg, actor)
IF err != NULL THEN RETURN {ok:FALSE, error:err}
IF NOT InBounds(cfg, to) THEN
RETURN {ok:FALSE, error:{code:"OUT_OF_BOUNDS", message:"target out of bounds"}}
src := state.players[actor].pawn
IF EqualCoord(src, to) THEN
RETURN {ok:FALSE, error:{code:"INVALID_TARGET", message:"same cell"}}
// 합법 후보 생성 후 포함 여부로 판정 (정의와 검증 일치시킴)
legal := LegalMovesFor(state, cfg, actor)
FOR each m IN legal:
IF EqualCoord(m.to, to) THEN
RETURN {ok:TRUE, kind:m.kind}
RETURN {ok:FALSE, error:{code:"ILLEGAL_MOVE", message:"not in legal moves"}}
END
```
7-2) 벽 배치 검증
------------
```pseudocode
FUNCTION ValidatePlaceWall(state: GameState, cfg: GameConfig, actor: PlayerId, o: Orientation, slot): Error | NULL
err := ValidateCommon(state, cfg, actor)
IF err != NULL THEN RETURN err
IF state.players[actor].remainingWalls <= 0 THEN
RETURN {code:"NO_WALLS_LEFT", message:"no walls left"}
IF NOT SlotInBounds(cfg, slot) THEN
RETURN {code:"OUT_OF_BOUNDS", message:"wall slot out of bounds"}
IF cfg.rules.forbidWallOverlap OR cfg.rules.forbidWallCrossing THEN
IF WallConflict(state.board, o, slot) THEN
RETURN {code:"WALL_CONFLICT", message:"overlap or crossing"}
ENDIF
// 경로 보존: 임시 보드로 검사
tempBoard := CloneBoard(state.board)
IF o == "H" THEN
ADD tempBoard.wallsH, slot
ELSE
ADD tempBoard.wallsV, slot
ENDIF
IF cfg.rules.mustKeepAtLeastOnePathPerPlayer == TRUE THEN
IF NOT AllPlayersHaveAPath(tempBoard, cfg, state.players) THEN
RETURN {code:"PATH_BLOCKED", message:"at least one player has no path"}
ENDIF
ENDIF
RETURN NULL
END
```
8) 적용(Apply) + 이벤트 + 턴 전환
=========================
```pseudocode
FUNCTION ApplyMovePawn(state: GameState, cfg: GameConfig, actor: PlayerId, to: Coord, kind: MoveKind):
newState := DeepCopy(state)
from := newState.players[actor].pawn
newState.players[actor].pawn := to
newState.moveCount := newState.moveCount + 1
events := [{ name:"MoveApplied", actor, from, to, kind }]
IF cfg.rules.winOnMoveEnd AND IsWinnerAfterMove(cfg, actor, to) THEN
newState.phase := "ended"
newState.winnerId := actor
APPEND events, { name:"GameEnded", winnerId: actor }
// 턴 전환 없음
RETURN { state: newState, events }
ENDIF
// 턴 전환
next := NextPlayerId(newState.turnOrder, actor)
old := newState.activePlayerId
newState.activePlayerId := next
APPEND events, { name:"TurnChanged", from: old, to: next }
RETURN { state: newState, events }
END
FUNCTION ApplyPlaceWall(state: GameState, cfg: GameConfig, actor: PlayerId, o: Orientation, slot):
newState := DeepCopy(state)
IF o == "H" THEN
ADD newState.board.wallsH, slot
ELSE
ADD newState.board.wallsV, slot
ENDIF
newState.players[actor].remainingWalls := newState.players[actor].remainingWalls - 1
newState.moveCount := newState.moveCount + 1
events := [{ name:"WallPlaced", actor, orientation:o, slot }]
// 턴 전환
next := NextPlayerId(newState.turnOrder, actor)
old := newState.activePlayerId
newState.activePlayerId := next
APPEND events, { name:"TurnChanged", from: old, to: next }
RETURN { state: newState, events }
END
FUNCTION NextPlayerId(order: LIST<PlayerId>, current: PlayerId): PlayerId
i := INDEX_OF(order, current)
RETURN order[(i+1) MOD LENGTH(order)]
END
```
9) 최상위 명령 처리(Validate → Apply → 기록)
===================================
```pseudocode
FUNCTION Step(state: GameState, cfg: GameConfig, actor: PlayerId, action: Action): Result
// 공통: 게임 종료 상태 차단
IF state.phase == "ended" THEN
RETURN { ok:FALSE, error:{code:"GAME_ALREADY_ENDED", message:"game ended"} }
fromHash := HashState(state)
SWITCH action.type
CASE "MovePawn":
v := ValidateMovePawn(state, cfg, actor, action.to)
IF NOT v.ok THEN RETURN { ok:FALSE, error:v.error }
applied := ApplyMovePawn(state, cfg, actor, action.to, v.kind)
newState := applied.state
events := applied.events
CASE "PlaceWall":
err := ValidatePlaceWall(state, cfg, actor, action.orientation, action.slot)
IF err != NULL THEN RETURN { ok:FALSE, error:err }
applied := ApplyPlaceWall(state, cfg, actor, action.orientation, action.slot)
newState := applied.state
events := applied.events
DEFAULT:
RETURN { ok:FALSE, error:{code:"RULE_VIOLATION", message:"unknown action"} }
ENDSWITCH
// 히스토리 기록 (성공한 액션만)
toHash := HashState(newState)
logEntry := {
t: state.moveCount, actor, action,
fromStateHash: fromHash, toStateHash: toHash
}
newState.history := AppendCopy(newState.history, logEntry)
newState.redoStack := [] // 새로운 분기 발생 → redo 폐기
RETURN { ok:TRUE, state: newState, events }
END
```
10) Undo / Redo
===============
```pseudocode
// 구현 전략 A: 스냅샷 저장 (간단, 메모리↑)
// 구현 전략 B: Action 로그 재적용 (결정성 필수, 성능 OK)
// 여기선 B: 초기 상태 + history[0..k] 재적용
FUNCTION Undo(current: GameState, cfg: GameConfig, initial: GameState): Result
IF LENGTH(current.history) == 0 THEN
RETURN { ok:FALSE, error:{code:"RULE_VIOLATION", message:"nothing to undo"} }
// 마지막 로그를 redoStack으로 이동
last := LAST(current.history)
baseLogs := SLICE(current.history, 0, LENGTH(current.history)-1)
// 재생성
s := Clone(initial)
FOR each e IN baseLogs:
res := Step(s, cfg, e.actor, e.action)
ASSERT res.ok
s := res.state
ENDFOR
// redoStack 업데이트
s.redoStack := AppendCopy(current.redoStack, last)
RETURN { ok:TRUE, state: s, events: [] }
END
FUNCTION Redo(current: GameState, cfg: GameConfig): Result
IF LENGTH(current.redoStack) == 0 THEN
RETURN { ok:FALSE, error:{code:"RULE_VIOLATION", message:"nothing to redo"} }
// 다음 redo 항목 꺼내 재적용
redoEntry := LAST(current.redoStack)
newRedo := SLICE(current.redoStack, 0, LENGTH(current.redoStack)-1)
res := Step(current, cfg, redoEntry.actor, redoEntry.action)
IF NOT res.ok THEN
RETURN { ok:FALSE, error:{code:"RULE_VIOLATION", message:"redo failed"} }
newState := res.state
newState.redoStack := newRedo
RETURN { ok:TRUE, state: newState, events: res.events }
END
```
11) 상태 해시(회귀 검증/리플레이 무결성)
=========================
```pseudocode
FUNCTION HashState(s: GameState): STRING
// 정규화된 직렬화 후 안정 해시
// - Set은 소트된 배열로 변환
// - 불필요 필드(history/redoStack) 제외
normalized := {
schemaVersion: s.schemaVersion,
phase: s.phase,
board: {
H: SORT_BY(s.board.wallsH, key=(r,c)),
V: SORT_BY(s.board.wallsV, key=(r,c))
},
players: SORT_BY(
MAP(s.players, (pid,p) => [pid, {pawn:p.pawn, walls:p.remainingWalls}]),
key=pid
),
activePlayerId: s.activePlayerId,
turnOrder: s.turnOrder,
moveCount: s.moveCount,
winnerId: s.winnerId
}
json := JSON_STRINGIFY(normalized)
RETURN STABLE_HASH(json) // e.g., SHA-256 → base64url
END
```
12) 직렬화/복원
==========
```pseudocode
FUNCTION SerializeState(s: GameState): STRING
// history/redoStack 포함 여부는 정책에 따름 (공유 링크는 보통 제외)
payload := {
schemaVersion: s.schemaVersion,
phase: s.phase,
board: {
H: SORT_BY(s.board.wallsH, key=(r,c)),
V: SORT_BY(s.board.wallsV, key=(r,c))
},
players: MAP_TO_ARRAY_SORTED(s.players, by=PlayerId), // [ [pid, {pawn,remainingWalls}]... ]
activePlayerId: s.activePlayerId,
turnOrder: s.turnOrder,
moveCount: s.moveCount,
winnerId: s.winnerId
}
json := JSON_STRINGIFY(payload)
return BASE64URL_ENCODE(GZIP(json)) // 옵션: gzip
END
FUNCTION DeserializeState(blob: STRING): Result
TRY
json := UNGZIP(BASE64URL_DECODE(blob))
obj := JSON_PARSE(json)
CATCH
RETURN { ok:FALSE, error:{code:"DECODE_FAILED", message:"invalid blob"} }
IF obj.schemaVersion != VERSION THEN
RETURN { ok:FALSE, error:{code:"VERSION_MISMATCH", message:"schema mismatch"} }
// 최소 검증 (좌표/벽 슬롯 범위 등)
IF NOT ValidateDeserialized(obj) THEN
RETURN { ok:FALSE, error:{code:"DECODE_FAILED", message:"struct invalid"} }
// GameState로 재구성
state := {
schemaVersion: obj.schemaVersion,
phase: obj.phase,
board: { wallsH: SET(obj.board.H), wallsV: SET(obj.board.V) },
players: ARRAY_TO_MAP(obj.players), // id→Player( goal은 Config에서 주입 )
activePlayerId: obj.activePlayerId,
turnOrder: obj.turnOrder,
moveCount: obj.moveCount,
winnerId: obj.winnerId,
history: [],
redoStack: []
}
RETURN { ok:TRUE, state, events: [] }
END
```
13) 4인 확장(파라미터화)
================
```pseudocode
// Config만 바꾸면 동작. 핵심 로직은 turnOrder 길이에 의존.
CONFIG_PRESET_4P := {
boardSize: 9,
turnOrder: ["P1","P2","P3","P4"],
initialPawns: {
P1: {0,4}, // 위 중앙
P2: {8,4}, // 아래 중앙
P3: {4,0}, // 왼 중앙
P4: {4,8} // 오른 중앙
},
initialWallsPerPlayer: 5,
winCondition: {
P1: (pos) => pos.r == 8,
P2: (pos) => pos.r == 0,
P3: (pos) => pos.c == 8,
P4: (pos) => pos.c == 0
},
rules: { /* 2인과 동일 설정 */ }
}
```
14) 성능/캐싱(옵션)
=============
```pseudocode
// 벽 집합의 해시를 키로 간선 차단 맵 캐시
GLOBAL cacheBlockedEdges: MAP<string, ANY>
FUNCTION BoardKey(board: BoardState): STRING
H := SORT_BY(board.wallsH, key=(r,c))
V := SORT_BY(board.wallsV, key=(r,c))
return STABLE_HASH(JSON_STRINGIFY({H,V}))
END
FUNCTION EdgeBlockedWithCache(board, a, b): BOOL
key := BoardKey(board)
if NOT cacheBlockedEdges.has(key):
cacheBlockedEdges[key] := BuildBlockedEdgeMap(board) // 선택: 미리 전 간선을 bool로
map := cacheBlockedEdges[key]
return map[EdgeId(a,b)] // 정규화된 edge id
END
```
15) 에러 메시지 표준
=============
```pseudocode
ERRORS:
NOT_YOUR_TURN: "not your turn"
GAME_ALREADY_ENDED: "game ended"
OUT_OF_BOUNDS: "target out of bounds"
ILLEGAL_MOVE: "illegal move"
BLOCKED_BY_WALL: "blocked by wall"
OCCUPIED: "cell occupied"
INVALID_TARGET: "invalid target"
WALL_CONFLICT: "overlap or crossing"
NO_WALLS_LEFT: "no walls left"
PATH_BLOCKED: "path would be fully blocked"
RULE_VIOLATION: "rule violation"
DECODE_FAILED: "decode failed"
VERSION_MISMATCH: "schema version mismatch"
```
16) 테스트 포인트(필수 시나리오를 의사코드로)
===========================
```pseudocode
TEST "orthogonal move on empty board":
s := InitGame(CONFIG_2P, "P1")
res := Step(s, CONFIG_2P, "P1", {type:"MovePawn", to:{1,4}})
ASSERT res.ok
ASSERT res.state.players["P1"].pawn == {1,4}
TEST "blocked by horizontal wall":
s := InitGame(CONFIG_2P, "P1")
// P1 (0,4) -> (1,4) 를 막는 H(0,4) 또는 H(0,3) 배치
s.board.wallsH := SET([{r:0,c:4}])
res := Step(s, CONFIG_2P, "P1", {type:"MovePawn", to:{1,4}})
ASSERT !res.ok AND res.error.code == "ILLEGAL_MOVE" OR "BLOCKED_BY_WALL"
TEST "straight jump allowed":
// P1 (4,4), P2 (5,4), 뒤 (6,4) 개방
s := CustomState(...)
res := Step(s, CONFIG_2P, "P1", {type:"MovePawn", to:{6,4}})
ASSERT res.ok AND lastEvent(res).name == "MoveApplied" AND kind=="jump"
TEST "diagonal when blocked behind":
// P1 (4,4), P2 (5,4), (5,4)-(6,4) 간선 벽으로 막음 → (5,3) 또는 (5,5) 허용
s := CustomState(...)
res := Step(s, CONFIG_2P, "P1", {type:"MovePawn", to:{5,5}})
ASSERT res.ok AND kind=="diagonal"
TEST "place wall overlap/crossing":
s := InitGame(CONFIG_2P, "P1")
res1 := Step(s, CONFIG_2P, "P1", {type:"PlaceWall", orientation:"H", slot:{0,4}})
ASSERT res1.ok
res2 := Step(res1.state, CONFIG_2P, "P2", {type:"PlaceWall", orientation:"H", slot:{0,4}})
ASSERT !res2.ok AND res2.error.code == "WALL_CONFLICT"
res3 := Step(res1.state, CONFIG_2P, "P2", {type:"PlaceWall", orientation:"V", slot:{0,4}})
ASSERT !res3.ok AND res3.error.code == "WALL_CONFLICT"
TEST "path preservation":
s := InitGame(CONFIG_2P, "P1")
// 경로를 완전 차단하는 벽 조합 중 마지막 조각을 놓으려 할 때
attempt := Step(s, CONFIG_2P, "P1", {type:"PlaceWall", orientation:"H", slot:{...}})
ASSERT !attempt.ok AND attempt.error.code == "PATH_BLOCKED"
TEST "win condition on move end":
s := StateNearGoal(P1 at {7,4}; goal row=8)
res := Step(s, CONFIG_2P, "P1", {type:"MovePawn", to:{8,4}})
ASSERT res.ok AND res.state.phase=="ended" AND res.state.winnerId=="P1"
TEST "undo/redo":
s0 := InitGame(CONFIG_2P, "P1")
s1 := Step(s0, CONFIG_2P, "P1", {MovePawn to {1,4}}).state
s2 := Step(s1, CONFIG_2P, "P2", {MovePawn to {7,4}}).state
u := Undo(s2, CONFIG_2P, s0)
ASSERT u.ok
r := Redo(u.state, CONFIG_2P)
ASSERT r.ok
```
* * *
17) 구현 메모(정확성 보강 포인트)
=====================
* **간선 차단 규칙**은 실수 많이 나는 부분이라 위 `EdgeBlockedByWall`의 인덱스 관계를 그대로 코드에 반영해.
* 세로 이동 차단: `H(minR, col)` 또는 `H(minR, col-1)`
* 가로 이동 차단: `V(row, minC)` 또는 `V(row-1, minC)`
* **대각 이동**은 반드시 “정면 점프 불가”일 때만 허용. 정면 뒤 셀이 out-of-bounds이거나 벽으로 막혀 있어야 함. 대각 후보는 **nbr→diag** 간선이 열려 있어야 함.
* **경로 보존 BFS**는 **직교 간선**만으로 판정(대각/점프 규칙과 분리). 이게 더 엄격하고 안전.
* **결정성**을 위해 Step/Undo/Redo는 **입력 순서/정렬/해시**를 안정적으로 유지.
* **성능**은 9×9에선 이슈 거의 없음. 그래도 간선 맵 캐시/워커 분리는 옵션으로 열어두기.
* * *
원하면 이 의사코드 그대로 **TypeScript 인터페이스/함수 시그니처**로 옮겨줄게. 필요하면 **좌표/벽-간선 시각 다이어그램**도 그려줄 수 있어.이후 이 내용을 AI Studio에게 집어넣고 앱을 만들었더니 버그는 조금 있지만 처음부터 꽤 그럴싸한 앱이 등장했다. 한 번 잘 만들어지고 나니, 버그들도 수월하게 삭제되었고, UI 디자인 등도 원활하게 수정할 수 있었다. 그냥 구글의 능력일지도 모른다.
다만 AI Studio 자체의 버그는 좀 있는 것 같다. 갑자기 이미지를 만들려고 하더니 그 상태로 멈춰서 동작하지 않을 때도 있고, 코드수정은 안하고 혼자 생각을 10분씩이나 하더니 그냥 주절거리다가 끝나는 경우도 있다.
다른 에이전트들과 달리 자동 저장따위는 없다. 절.대.저.장.해
여러번의 수정 끝에 최종 결과:
기존에 만들어진 디자인을 참고해서 AI Studio Chat에서 unit들을 생성 시키고, 배경 제거한 다음에, builder에서 해당 이미지를 밀어넣고 고급지게 만들어달라고 튜닝했다.
후기 및 추후 계획
이번에 얻은 바이브 코딩 인사이트로 세상에 없던 게임을 만들어보고자 한다.
쿼리도는 게임 룰 자체가 매우 단순하다. 실시간성도 없고 턴제게임이다. 다만, 벽을 설치할 수 있는 조건에 알고리즘이 들어가는데 AI Studio는 문제없이 해결하는 모습을 보여줬다. 다음에는 조금 더 과감하게 어려운 과제를 수행해보고자 한다.