랭체인을 활용한 오목 AI 도전기!

이번에 실제 동작되는 Application을 구현해보고 싶은 생각에 게임을 구현해봐야 겠다고 생각했습니다.

그 중 떠오른게 스무고개 아키네이터와 오목 AI 였는데, 스무고개는 ChatGPT로만 어느정도 되는 것 같아서, GPT와 랭체인을 활용한 오목 AI를 도전하게 되었습니다!

처음에는 강의 때 들었던 앙상블 리트리버를 이용하여 구현해볼 생각이였는데, 구체화 시키기가 굉장히 어렵더라구요 ㅠㅠ… 그래서 일반적인 tool들을 활용하여 만들기로 결정했습니다!

결론적으로는 만족스럽지 못한 성능이였고 새로 적용해본 기술은 없었지만, 실제로 구체화된 애플리케이션을 만들어본다는 점에서 많이 배웠던 토이 프로젝트 였습니다.


만들기 앞서서 구현하고자한 계획은 4가지의 tool을 제작하여 agent가 알아서 적절한 시점에 tool을 활용하여 오목을 두고 경기를 제어할 수 있도록 처리할 계획이였습니다.

  1. 현재 상황에 맞는 오목판을 만드는 tool

  2. 방어적인 측면에서 오목을 분석하는 tool

  3. 공격적인 측면에서 오목을 분석하는 tool

  4. 승부가 결정 났는지 확인하는 tool


1. 현재 상황에 맞는 오목판을 만드는 tool

처음에는 prompt를 구성하고 흑돌의 착수 지점, 백돌의 착수 지점 리스트를 입력하여 오목판 자체를 prompt에 지정된 규칙에 맞게 AI가 그릴 수 있도록 구현하고자 했습니다. 하지만 gpt 3.5 뿐만이아니라 gpt-4o 에서도 칸이 하나씩 밀려 그리는 등 원활한 성능을 보이지는 못했습니다.

아래는 실패한 프롬프트 입니다 ㅠㅠ.

You are responsible for drawing the gomoku board.
It looks at the input black stone and white stone information and prints out an gomoku board.

The appearance of the gomoku board is as follows. (+: blank, ●: black stone, ○: white stone)
   A B C D E F G H I J K L M N O
1  + + + + + + + + + + + + + + +
2  + + + + + + + + + + + + + + +
3  + + + + + + + + + + + + + + +
4  + + + + + + + + + + + + + + +
5  + + + + + + + + + + + + + + +
6  + + + + + + + + + + + + + + +
7  + + + + + + + + + + + + + + +
8  + + + + + + + + + + + + + + +
9  + + + + + + + + + + + + + + +
10 + + + + + + + + + + + + + + +
11 + + + + + + + + + + + + + + +
12 + + + + + + + + + + + + + + +
13 + + + + + + + + + + + + + + +
14 + + + + + + + + + + + + + + +
15 + + + + + + + + + + + + + + +

-----
In the input, you can enter multiple position values by separating them with ,. 
The first part of each value means the column position, and the last part means the row position.
An input example is as follows.

Black Stone: A1, O15
White Stone: A15, O1

-----
The output is to display the positional input values for black and white stones on a checkerboard.
Just display the board below without saying anything else.
An example output for the below input is as follows.
Black Stone: A1, O15
White Stone: A15, O1

output

   A B C D E F G H I J K L M N O
1  ● + + + + + + + + + + + + + ○
2  + + + + + + + + + + + + + + +
3  + + + + + + + + + + + + + + +
4  + + + + + + + + + + + + + + +
5  + + + + + + + + + + + + + + +
6  + + + + + + + + + + + + + + +
7  + + + + + + + + + + + + + + +
8  + + + + + + + + + + + + + + +
9  + + + + + + + + + + + + + + +
10 + + + + + + + + + + + + + + +
11 + + + + + + + + + + + + + + +
12 + + + + + + + + + + + + + + +
13 + + + + + + + + + + + + + + +
14 + + + + + + + + + + + + + + +
15 ○ + + + + + + + + + + + + + ●

-----
input
Black Stone: {black_stone_positions}
White Stone: {white_stone_positions}

계속 틀린 오목판을 그리는 AI를 제쳐두고 그냥 단순히 코드를 작성하기로 변경하여 다시 작업했습니다.

class GomokuBoardPainter:
    row_list = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15"]
    col_list = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O"]
    blank = "+"
    black_stone = "●"
    white_stone = "○"

    def paint(self, black_stone_positions, white_stone_positions):
        row_length = 16
        col_length = 32
        matrix = [["" for j in range(col_length)] for i in range(row_length)]

        for i in range(0, row_length):
            for j in range(0, col_length):
                if i == 0:
                    if j % 2 == 0:
                        matrix[i][j] = " "
                    elif j // 2 == 0:
                        matrix[i][j] = " "
                    else:
                        matrix[i][j] = self.col_list[j // 2 - 1]
                else:
                    if j == 0:
                        row = self.row_list[i - 1]
                        if len(row) == 1:
                            matrix[i][j] = row
                        else:
                            matrix[i][j] = row[:1]
                    elif j == 1:
                        row = self.row_list[i - 1]
                        if len(row) == 1:
                            matrix[i][j] = " "
                        else:
                            matrix[i][j] = row[1:]
                    elif j % 2 == 0:
                        matrix[i][j] = " "
                    else:
                        matrix[i][j] = self.blank

        for position in black_stone_positions:
            col = position[:1]
            row = position[1:]
            col_idx = self.col_list.index(col)
            row_idx = self.row_list.index(row)
            matrix[row_idx + 1][(col_idx + 1) * 2 + 1] = self.black_stone

        for position in white_stone_positions:
            col = position[:1]
            row = position[1:]
            col_idx = self.col_list.index(col)
            row_idx = self.row_list.index(row)
            matrix[row_idx + 1][(col_idx + 1) * 2 + 1] = self.white_stone

        return "\n".join(["".join(row) for row in matrix])

2. 방어적인 측면에서 오목을 분석하는 tool

처음에는 prompt로 오목의 게임 규칙을 상세히 설명하는 등 좀더 자세한 prompt를 작성했습니다.

그런데 오히려 역효과가 나면서 더 안좋은 성능을 보이더라구요. 그래서 위협을 분석하고 3, 4 줄을 막으라는 비교적 단순한 prompt로 변경했습니다.

구현하면서 테스트해보니 오목의 규칙을 설명하는 것 보다는 구체적인 전술을 prompt에 넣어주는 것이 좀 더 효과적인 것 같았습니다.

from dotenv import load_dotenv
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import PromptTemplate
from langchain_openai import ChatOpenAI

from gomoku_board_painter import GomokuBoardPainter

load_dotenv()


class GomokuDefensiveAnalyzer:
    def __init__(self, stone_type):
        self.stone_type = stone_type

    __template = """
You are a gomoku player.
Your a {stone_type} player
Your style is to prefer defensive play.

The board is composed of 15x15, with columns A to O and rows 1 to 15.
The current appearance of the gomoku board is as follows (+: blank, ●: black stone, ○: white stone).
{gomoku_board}

This is the order of starting the black and white stones on the board above.
Black Stone: {black_stone_positions}
White Stone: {white_stone_positions}

The latest location of the opposite player is as follows.
Latest Opposite Player Stone: {latest_position}

Look at the gomoku board, analyze the risk, for example block the 3 or 4 lines. 
and place the stone where you must prevent the risk.
Answer the risks and where you should put them.
"""

    __prompt = PromptTemplate.from_template(__template)
    __llm = ChatOpenAI(model_name="gpt-4o")
    __parser = StrOutputParser()
    __chain = __prompt | __llm | __parser
    __board_painter = GomokuBoardPainter()

    def analyze(self, black_stone_positions, white_stone_positions, latest_position):
        return self.__chain.invoke({
            "stone_type": self.stone_type,
            "gomoku_board": self.__board_painter.paint(black_stone_positions, white_stone_positions),
            "black_stone_positions": ", ".join(black_stone_positions),
            "white_stone_positions": ", ".join(white_stone_positions),
            "latest_position": latest_position,
        })

3. 공격적인 측면에서 오목을 분석하는 tool

방어 분석을 하는 tool과 유사한 형태로 구현했습니다.

from dotenv import load_dotenv
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import PromptTemplate
from langchain_openai import ChatOpenAI

from gomoku_board_painter import GomokuBoardPainter

load_dotenv()


class GomokuOffensiveAnalyzer:
    def __init__(self, stone_type):
        self.stone_type = stone_type

    __template = """
You are a gomoku player.
Your a {stone_type} player
Your style is to prefer offensive play.

The board is composed of 15x15, with columns A to O and rows 1 to 15.
The current appearance of the gomoku board is as follows (+: blank, ●: black stone, ○: white stone):
{gomoku_board}

This is the order of starting the black and white stones on the board above.
Black Stone: {black_stone_positions}
White Stone: {white_stone_positions}

The latest location of the opposite player is as follows.
Latest Opposite Player Stone: {latest_position}

Look at the gomoku board, analyze offensive route. for example create 3 or 4 lines. 
and place the stone where you must victory.
Answer the aggressive path to victory and where you should put them.
"""

    __prompt = PromptTemplate.from_template(__template)
    __llm = ChatOpenAI(model_name="gpt-4o")
    __parser = StrOutputParser()
    __chain = __prompt | __llm | __parser
    __board_painter = GomokuBoardPainter()

    def analyze(self, black_stone_positions, white_stone_positions, latest_position):
        return self.__chain.invoke({
            "stone_type": self.stone_type,
            "gomoku_board": self.__board_painter.paint(black_stone_positions, white_stone_positions),
            "black_stone_positions": ", ".join(black_stone_positions),
            "white_stone_positions": ", ".join(white_stone_positions),
            "latest_position": latest_position,
        })

4. 승부가 결정 났는지 확인하는 tool

한쪽이 먼저 5줄을 만들어 승부가 끝나면 프로그램이 종료되게 하기 위해서는 해당 tool이 반드시 필요하여 구현하게 되었습니다.

from dotenv import load_dotenv
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import PromptTemplate
from langchain_openai import ChatOpenAI

from gomoku_board_painter import GomokuBoardPainter

load_dotenv()


class GomokuReferee:
    __template = """
You are a gomoku referee.
Look at the positions of black and white and determine whether the gomoku ended in someone's victory right now.

The board is composed of 15x15, with columns A to O and rows 1 to 15.
The current appearance of the gomoku board is as follows (+: blank, ●: black stone, ○: white stone):
{gomoku_board}

This is the order of starting the black and white stones on the board above.
Black Stone: {black_stone_positions}
White Stone: {white_stone_positions}

Answer who is win, and if there is a winner, please explain why

--------
Black win answer:
Black win!
explain why.

White win answer:
White win!
explain why.

No winner answer:
Keep going.
"""

    __prompt = PromptTemplate.from_template(__template)
    __llm = ChatOpenAI(model_name="gpt-4o")
    __parser = StrOutputParser()
    __chain = __prompt | __llm | __parser
    __board_painter = GomokuBoardPainter()

    def judge(self, black_stone_positions, white_stone_positions):
        return self.__chain.invoke({
            "gomoku_board": self.__board_painter.paint(black_stone_positions, white_stone_positions),
            "black_stone_positions": ", ".join(black_stone_positions),
            "white_stone_positions": ", ".join(white_stone_positions),
        })

오목 Agent

이제 위에 작성된 tool들을 활용하여 agent를 구현해볼 시간입니다. 구현하면서 다양한 이슈가 발생되었 었는데요… 아래에 간략히 정리해봤습니다.

  • tool에 다양한 분석들을 종합하여 최종 착수지점을 선택하는 부분도 만들었었는데, 성능이 잘 안나오더라구요. 그래서 그부분은 그냥 agent에 맡기도록 했습니다.

  • tool에 오목판을 그리는 tool도 추가하여 agent가 오목판을 답하도록 구현도 해보았는데, 그렇게 되면 메모리의 용량이 문제인지 어떤 문제인지 파악은 못했지만, 추후 백돌의 착수 히스토리를 조작하는 현상이 발생되어, 그 부분을 또 제거했습니다. Agent의 응답을 백돌의 착수지점으로 하고 오목판은 별도의 코드로 작성하여 print하도록 변경하니, 백돌의 착수 히스토리를 조작하는 현상이 사라졌습니다.

  • 위에서 만든 승부 판단 tool을 추가한 후 최종적으로 승부를 판단하여 승부가 난 경우, 한쪽이 이겼다고 출력을 하도록 구현했었습니다. 그런데 또 어떤 영향을 주었는지 아직 파악을 못했지만, 정상적인 승부판단을 하지 못하고 오목 성능까지도 떨어져 우선 해당 기능을 제거한 상태입니다.

from dotenv import load_dotenv
from langchain.agents import create_tool_calling_agent, AgentExecutor
from langchain_community.chat_message_histories import ChatMessageHistory
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import ConfigurableFieldSpec
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI

from gomoku_board_painter import GomokuBoardPainter
from gomoku_defensive_analyzer import GomokuDefensiveAnalyzer
from gomoku_offensive_analyzer import GomokuOffensiveAnalyzer

load_dotenv()


@tool()
def defensive_analyzer_tool(black_stone_positions, white_stone_positions, latest_position):
    """
    Gomoku player to prefer defensive play.
    """
    return GomokuDefensiveAnalyzer("white").analyze(black_stone_positions, white_stone_positions,
                                                    latest_position)


@tool()
def offensive_analyzer_tool(black_stone_positions, white_stone_positions, latest_position):
    """
    Gomoku player to prefer offensive play.
    """
    return GomokuOffensiveAnalyzer("white").analyze(black_stone_positions, white_stone_positions,
                                                    latest_position)


prompt = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            """
You are a top gomoku player. You are a white stone player. Human is a black stone player.
Therefore, human start the game first and each player take a stone at a time.
Analyze the defensive and offensive aspects from various angles to find the best move.
Choice the only one best white location through defensive and offensive analysis.
If there is a risky situation where you could lose, prioritize defensive play. 
Finally answer your best white location choice and don't say anything else.

Answer example:
E8

black_stone_positions parameter is string array type of black stone (Human) position history.
black_stone_positions is added to human input.
Position already added in black_stone_positions never be lost.
white_stone_positions parameter is string array type of white stone (You) position history.
white_stone_positions is added to the best position you chose the previous white stone position.
Position already added in white_stone_positions never be lost.

latest_position parameter is human latest input position.  ex) "H11"
            """
        ),
        ("placeholder", "{chat_history}"),
        ("human", "{input}"),
        ("placeholder", "{agent_scratchpad}"),
    ]
)
llm = ChatOpenAI(model_name="gpt-4o")
memory = ChatMessageHistory()
tools = [defensive_analyzer_tool, offensive_analyzer_tool]
agent = create_tool_calling_agent(llm, tools, prompt)
agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)
agent_with_memory = RunnableWithMessageHistory(
    agent_executor,
    lambda session_id: memory,
    input_messages_key="input",
    history_messages_key="chat_history",
    history_factory_config=[
        ConfigurableFieldSpec(
            id="session_id",
            annotation=str,
            name="Session ID",
            description="Unique identifier.",
            default="",
            is_shared=True,
        ),
    ],
)

if __name__ == '__main__':
    board_painter = GomokuBoardPainter()
    black_stone_history = []
    white_stone_history = []
    while True:
        black_input = input("Black Stone: ")
        black_stone_history.append(black_input)
        res = agent_with_memory.invoke({"input": black_input}, config={"configurable": {"session_id": "gomoku_test"}})
        output = res["output"]
        if "win" in output.lower():
            print(output)
            break
        white_stone_history.append(output)
        print(f"White Stone: {output}")
        print(board_painter.paint(black_stone_history, white_stone_history))

원하고자 하는 수준의 오목 AI는 탄생하지 못했지만 (단순한 3, 4 공격도 제대로 방어하지 못하는 경우가 많습니다 ㅠ.ㅠ) 나름 의미 있는 시간이였습니다.

  • agent가 tool 내에서 사용한 class의 함수 파라미터 쓰임새를 분석하여 자체적으로 고민한 뒤 tool에 사용되는 파라미터를 구성해 주었습니다. 이부분이 가장 신기했습니다.

  • 확실히 prompt에 따라 GPT의 응답이 천지차이로 뒤바뀌었으며, prompt의 엔지니어링이 왜 중요한지 새삼 깨닫게 되는 시간이였습니다.

  • GPT 3.5 는 오목에 대한 분석 및 오목판을 그리는 성능 조차도 제대로 발휘하지 못하면서 확실히 추론능력이 떨어지는 것을 확인할 수 있었습니다.

  • 오목 게임을 진행하면 할 수록 AI 성능이 안좋아지게 되었는데, 이는 chat history memory 부분이 큰 영향을 차지한다고 생각했습니다. 추후엔 이 부분에 대한 학습을 해야겠다는 생각을 가지게 되었습니다.

  • 게임을 진행하다가 본인이 졌다고 판단되는 상황에서는 같은 수를 두는 등 엉뚱한 결과값을 내던데, 이부분도 어떤 이유인지 궁금합니다.

  • 다양한 오목의 전략을 tool로 넣어놓고 적재적소에 좋은 전략을 사용할 수 있도록 디벨롭 되면 좋겠다는 생각을 했는데, 일단 어떻게 그렇게 구성할 수 있을지 막막하고 어렵네요 ㅠㅠ. GPT만으로는 구성이 어렵고 오목 전술로 파인튜닝된 모델이 필요할 것 같다는 생각이 듭니다.

위 내용 이외에도 개인적으로 얻은 부분들이 많지만 전부 기록하기가 힘드네요~ ㅎㅎ. 마지막으로 저와 오목 AI가 한판 붙은 예시를 보여드리며 마무리 하겠습니다. 긴글 봐주셔서 감사합니다!

Black Stone: G8
Parent run 235bd4b5-93f5-4fc0-814f-c01ad0759cb8 not found for run 23e83c1f-2d17-4d10-acc1-7614fd22426e. Treating as a root run.
White Stone: F8
   A B C D E F G H I J K L M N O
1  + + + + + + + + + + + + + + +
2  + + + + + + + + + + + + + + +
3  + + + + + + + + + + + + + + +
4  + + + + + + + + + + + + + + +
5  + + + + + + + + + + + + + + +
6  + + + + + + + + + + + + + + +
7  + + + + + + + + + + + + + + +
8  + + + + + ○ ● + + + + + + + +
9  + + + + + + + + + + + + + + +
10 + + + + + + + + + + + + + + +
11 + + + + + + + + + + + + + + +
12 + + + + + + + + + + + + + + +
13 + + + + + + + + + + + + + + +
14 + + + + + + + + + + + + + + +
15 + + + + + + + + + + + + + + +
Black Stone: H9
Parent run 77338967-b91b-4f9a-85a3-4a8c55d6d06e not found for run 60d6c380-0ba6-4836-ba1b-c0cdc9b0227b. Treating as a root run.
White Stone: I10
   A B C D E F G H I J K L M N O
1  + + + + + + + + + + + + + + +
2  + + + + + + + + + + + + + + +
3  + + + + + + + + + + + + + + +
4  + + + + + + + + + + + + + + +
5  + + + + + + + + + + + + + + +
6  + + + + + + + + + + + + + + +
7  + + + + + + + + + + + + + + +
8  + + + + + ○ ● + + + + + + + +
9  + + + + + + + ● + + + + + + +
10 + + + + + + + + ○ + + + + + +
11 + + + + + + + + + + + + + + +
12 + + + + + + + + + + + + + + +
13 + + + + + + + + + + + + + + +
14 + + + + + + + + + + + + + + +
15 + + + + + + + + + + + + + + +
Black Stone: G10
Parent run a9595846-0dce-4d55-a9b1-439165d9e6b3 not found for run 368a2fd7-7bc4-4b0e-940c-0a1ab58472d5. Treating as a root run.
White Stone: F11
   A B C D E F G H I J K L M N O
1  + + + + + + + + + + + + + + +
2  + + + + + + + + + + + + + + +
3  + + + + + + + + + + + + + + +
4  + + + + + + + + + + + + + + +
5  + + + + + + + + + + + + + + +
6  + + + + + + + + + + + + + + +
7  + + + + + + + + + + + + + + +
8  + + + + + ○ ● + + + + + + + +
9  + + + + + + + ● + + + + + + +
10 + + + + + + ● + ○ + + + + + +
11 + + + + + ○ + + + + + + + + +
12 + + + + + + + + + + + + + + +
13 + + + + + + + + + + + + + + +
14 + + + + + + + + + + + + + + +
15 + + + + + + + + + + + + + + +
Black Stone: G9
Parent run 407d495a-e17c-409f-8386-a71c9824931e not found for run 315a95c9-741f-4a70-816f-5e73f992c994. Treating as a root run.
White Stone: G11
   A B C D E F G H I J K L M N O
1  + + + + + + + + + + + + + + +
2  + + + + + + + + + + + + + + +
3  + + + + + + + + + + + + + + +
4  + + + + + + + + + + + + + + +
5  + + + + + + + + + + + + + + +
6  + + + + + + + + + + + + + + +
7  + + + + + + + + + + + + + + +
8  + + + + + ○ ● + + + + + + + +
9  + + + + + + ● ● + + + + + + +
10 + + + + + + ● + ○ + + + + + +
11 + + + + + ○ ○ + + + + + + + +
12 + + + + + + + + + + + + + + +
13 + + + + + + + + + + + + + + +
14 + + + + + + + + + + + + + + +
15 + + + + + + + + + + + + + + +
Black Stone: I9
Parent run 4a5d74d0-b8cd-4b15-810a-0dbbdd4f93cc not found for run 9e07d3c9-4865-4c2e-8fbb-3d4201c56038. Treating as a root run.
White Stone: J9
   A B C D E F G H I J K L M N O
1  + + + + + + + + + + + + + + +
2  + + + + + + + + + + + + + + +
3  + + + + + + + + + + + + + + +
4  + + + + + + + + + + + + + + +
5  + + + + + + + + + + + + + + +
6  + + + + + + + + + + + + + + +
7  + + + + + + + + + + + + + + +
8  + + + + + ○ ● + + + + + + + +
9  + + + + + + ● ● ● ○ + + + + +
10 + + + + + + ● + ○ + + + + + +
11 + + + + + ○ ○ + + + + + + + +
12 + + + + + + + + + + + + + + +
13 + + + + + + + + + + + + + + +
14 + + + + + + + + + + + + + + +
15 + + + + + + + + + + + + + + +
Black Stone: G7
Parent run f36f2458-ae7f-41fe-8af2-d10f95ef662a not found for run 4560b659-fe25-44b7-85a9-f0bd1e5cf389. Treating as a root run.
White Stone: G6
   A B C D E F G H I J K L M N O
1  + + + + + + + + + + + + + + +
2  + + + + + + + + + + + + + + +
3  + + + + + + + + + + + + + + +
4  + + + + + + + + + + + + + + +
5  + + + + + + + + + + + + + + +
6  + + + + + + ○ + + + + + + + +
7  + + + + + + ● + + + + + + + +
8  + + + + + ○ ● + + + + + + + +
9  + + + + + + ● ● ● ○ + + + + +
10 + + + + + + ● + ○ + + + + + +
11 + + + + + ○ ○ + + + + + + + +
12 + + + + + + + + + + + + + + +
13 + + + + + + + + + + + + + + +
14 + + + + + + + + + + + + + + +
15 + + + + + + + + + + + + + + +
Black Stone: H8
Parent run 34a108b0-b51c-4674-8057-e376f9cbde54 not found for run 50c9b0d1-6763-4deb-a2cb-65b05d3d1384. Treating as a root run.
White Stone: I11
   A B C D E F G H I J K L M N O
1  + + + + + + + + + + + + + + +
2  + + + + + + + + + + + + + + +
3  + + + + + + + + + + + + + + +
4  + + + + + + + + + + + + + + +
5  + + + + + + + + + + + + + + +
6  + + + + + + ○ + + + + + + + +
7  + + + + + + ● + + + + + + + +
8  + + + + + ○ ● ● + + + + + + +
9  + + + + + + ● ● ● ○ + + + + +
10 + + + + + + ● + ○ + + + + + +
11 + + + + + ○ ○ + ○ + + + + + +
12 + + + + + + + + + + + + + + +
13 + + + + + + + + + + + + + + +
14 + + + + + + + + + + + + + + +
15 + + + + + + + + + + + + + + +
Black Stone: J10
Parent run cfb7290c-0a21-4f66-b4ad-8de401ac7eb7 not found for run 5f96448c-03d9-4b3a-bdd1-725509b1cd27. Treating as a root run.
White Stone: G11
   A B C D E F G H I J K L M N O
1  + + + + + + + + + + + + + + +
2  + + + + + + + + + + + + + + +
3  + + + + + + + + + + + + + + +
4  + + + + + + + + + + + + + + +
5  + + + + + + + + + + + + + + +
6  + + + + + + ○ + + + + + + + +
7  + + + + + + ● + + + + + + + +
8  + + + + + ○ ● ● + + + + + + +
9  + + + + + + ● ● ● ○ + + + + +
10 + + + + + + ● + ○ ● + + + + +
11 + + + + + ○ ○ + ○ + + + + + +
12 + + + + + + + + + + + + + + +
13 + + + + + + + + + + + + + + +
14 + + + + + + + + + + + + + + +
15 + + + + + + + + + + + + + + +
Black Stone: K11
Parent run eac5cada-8bb5-4bf2-9171-596479ab5536 not found for run 9b02f3b4-c23c-4ff6-be99-5e33f65a9511. Treating as a root run.
White Stone: H11
   A B C D E F G H I J K L M N O
1  + + + + + + + + + + + + + + +
2  + + + + + + + + + + + + + + +
3  + + + + + + + + + + + + + + +
4  + + + + + + + + + + + + + + +
5  + + + + + + + + + + + + + + +
6  + + + + + + ○ + + + + + + + +
7  + + + + + + ● + + + + + + + +
8  + + + + + ○ ● ● + + + + + + +
9  + + + + + + ● ● ● ○ + + + + +
10 + + + + + + ● + ○ ● + + + + +
11 + + + + + ○ ○ ○ ○ + ● + + + +
12 + + + + + + + + + + + + + + +
13 + + + + + + + + + + + + + + +
14 + + + + + + + + + + + + + + +
15 + + + + + + + + + + + + + + +


#11기 랭체인

6
1개의 답글

👉 이 게시글도 읽어보세요