처음 완성시켜본 게임 바이브 코딩 - 쿼리도

소개

청강으로 뇌과학 강의를 시청하게 되었는데, 뇌과한 기반으로 바이브 코딩으로 게임을 만드는 것이 너무 재미있어보였다. 게임은 특히나 복잡한 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분씩이나 하더니 그냥 주절거리다가 끝나는 경우도 있다.

다른 에이전트들과 달리 자동 저장따위는 없다. 절.대.저.장.해

여러번의 수정 끝에 최종 결과:

Quarrior라는 게임의 스크린 샷

기존에 만들어진 디자인을 참고해서 AI Studio Chat에서 unit들을 생성 시키고, 배경 제거한 다음에, builder에서 해당 이미지를 밀어넣고 고급지게 만들어달라고 튜닝했다.

Quarrior라는 게임의 스크린 샷

후기 및 추후 계획

이번에 얻은 바이브 코딩 인사이트로 세상에 없던 게임을 만들어보고자 한다.

쿼리도는 게임 룰 자체가 매우 단순하다. 실시간성도 없고 턴제게임이다. 다만, 벽을 설치할 수 있는 조건에 알고리즘이 들어가는데 AI Studio는 문제없이 해결하는 모습을 보여줬다. 다음에는 조금 더 과감하게 어려운 과제를 수행해보고자 한다.

2
4개의 답글

뉴스레터 무료 구독