Repository: MCRMasters/MCR-Masters-Game
Pull request #67: GAME-5 Finished making score calculator and add test frontend(and api) for it
Author: login: Hot6Mania
Created: 2025-03-11T12:34:03Z | Merged: 2025-03-11T21:01:44Z

[![GAME-5](https://badgen.net/badge/JIRA/GAME-5/0052CC)](https://mcrs.atlassian.net/browse/GAME-5) [<img width="16" alt="Powered by Pull Request Badge" src="https://user-images.githubusercontent.com/1393946/111216524-d2bb8e00-85d4-11eb-821b-ed4c00989c02.png">](https://pullrequestbadge.com/?utm_medium=github&utm_source=MCRMasters&utm_campaign=badge_info)<!-- PR-BADGE: PLEASE DO NOT REMOVE THIS COMMENT -->

# [GAME-5] Score Calculator 구현 완료 및 테스트 프론트엔드/ API 추가

## 개요
이번 PR에서는 **Score Calculator** 기능을 완전히 구현하고, 이를 테스트할 수 있는 프론트엔드와 API를 추가하였습니다. 프론트엔드 테스트 환경에서의 입력을 바탕으로 국표 룰북에 있는 모든 역에 대한 테스트 케이스를 검증하여 기존 로직의 오류를 다수 수정하였습니다.

## 주요 변경 사항
- **Score Calculator 로직 구현 완료 및 기존 코드 버그 수정**
  - 다양한 테스트케이스 검증을 통한 기존 역(Yaku) 판정 로직과 점수 계산 알고리즘 버그 수정
  - 일반 형태, 치또이 외에도 국사, 불고 형태에 대한 점수 계산 알고리즘 추가
  - 최고 점수 손패에 대한 역 별 점수 정보 반환 기능 구현 완료
- **테스트 프론트엔드 및 API 추가**
  - FastAPI 기반의 `/score-check` 엔드포인트 구현
  - 사용자 입력(손패, 화료패, 각종 화료 조건)에 따른 점수 결과를 확인할 수 있는 HTML 프론트엔드 추가
  - 결과를 테이블 형식으로 시각적으로 표시하여 검증 용이
- **pytest 테스트 일부 추가**
  - 관련 테스트 코드(`tests/test_example.py`, `tests/test_score_calculator_general.py`, `tests/test_utils.py`) 업데이트
  - 프론트엔드에서 검증한 모든 손패를 pytest에 옮기진 않았음

## 구현 세부 사항
- **Score Calculator**
  - 모든 손패 형태에 대해 블럭을 분할 후 역 점수를 계산
     -  불고, 국사는 한정된 역 구성이기에 블럭을 나누지 않고 진행
  - 블럭 분할 경우의 수들에 대한 점수 계산 결과를 비교하여 최고 점수를 도출, 그에 따른 각 역별 점수 내역을 최고 점수와 함께 반환
- **프론트엔드 & API**
  - **프론트엔드**: 사용자로부터 손패, 화료패, 화료시의 조건들을 입력받아 점수를 계산하고 결과를 테이블 형식으로 출력
  - **API**: POST 방식으로 요청을 받아 Score Calculator 로직을 수행한 후, JSON 형식으로 결과를 반환

## 테스트 및 검증
- 다양한 입력 케이스에 대해 Score Calculator의 점수 계산 결과를 검증
- 프론트엔드 인터페이스를 통해 빠르게 테스트 진행함
- 테스트 과정에서 발견한 누락 함수(오문제, 전구인 등) 추가와 기존 로직 에러 수정(삼색삼보고, 2블럭 관계 역의 결합 등) 진행함

---

Score Calculator를 완성시키다 보니 너무 PR 덩어리가 길어져서 죄송합니다. 테스트 하면서 버그 수정을 한다고 했는데 남아있을지 걱정이네요. 아무쪼록 많은 리뷰와 조언 부탁드립니다!

[GAME-5]: https://mcrs.atlassian.net/browse/GAME-5?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ


Diff:
diff --git a/app/index.html b/app/index.html
new file mode 100644
index 0000000..84c47aa
--- /dev/null
+++ b/app/index.html
@@ -0,0 +1,238 @@
+<!DOCTYPE html>
+<html lang="ko">
+
+<head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Score Checker</title>
+    <style>
+        body {
+            font-family: Arial, sans-serif;
+            margin: 2rem;
+        }
+
+        h1 {
+            margin-bottom: 1rem;
+        }
+
+        form {
+            /* 폼 자체는 왼쪽 정렬로 두고, 모바일 대응 위해 너비 제한 X */
+        }
+
+        .form-row {
+            display: flex;
+            align-items: center;
+            margin-bottom: 1rem;
+        }
+
+        .form-row label {
+            width: 180px;
+            margin-right: 0.5rem;
+        }
+
+        .form-row input,
+        .form-row select {
+            /* 기본적으로 auto 크기 */
+        }
+
+        table {
+            border-collapse: collapse;
+            margin-top: 1rem;
+            table-layout: auto;
+            /* 자동 레이아웃 */
+            width: auto;
+            /* 내용에 맞춰 자동으로 */
+        }
+
+        table,
+        th,
+        td {
+            border: 1px solid #ccc;
+        }
+
+        th,
+        td {
+            padding: 8px;
+            text-align: center;
+            white-space: nowrap;
+            /* 줄바꿈 없이 한 줄로 표시 */
+        }
+
+        th {
+            background-color: #f2f2f2;
+        }
+
+        /* 모바일 화면에서 너무 좁아지면 세로로 전환 */
+        @media (max-width: 600px) {
+            .form-row {
+                flex-direction: column;
+                align-items: flex-start;
+            }
+
+            .form-row label {
+                margin-right: 0;
+                margin-bottom: 0.3rem;
+                width: auto;
+                /* 레이블 너비 해제 */
+            }
+        }
+    </style>
+</head>
+
+<body>
+    <h1>Score Checker</h1>
+    <form id="scoreForm">
+        <div class="form-row">
+            <label for="raw_hand">Raw Hand:</label>
+            <input type="text" id="raw_hand" name="raw_hand" required />
+        </div>
+        <div class="form-row">
+            <label for="winning_tile">Winning Tile:</label>
+            <input type="text" id="winning_tile" name="winning_tile" required />
+        </div>
+        <div class="form-row">
+            <label for="is_discarded">Is Discarded:</label>
+            <input type="checkbox" id="is_discarded" name="is_discarded" checked />
+        </div>
+        <div class="form-row">
+            <label for="seat_wind">Seat Wind:</label>
+            <select id="seat_wind" name="seat_wind">
+                <option value="EAST" selected>EAST</option>
+                <option value="SOUTH">SOUTH</option>
+                <option value="WEST">WEST</option>
+                <option value="NORTH">NORTH</option>
+            </select>
+        </div>
+        <div class="form-row">
+            <label for="round_wind">Round Wind:</label>
+            <select id="round_wind" name="round_wind">
+                <option value="EAST" selected>EAST</option>
+                <option value="SOUTH">SOUTH</option>
+                <option value="WEST">WEST</option>
+                <option value="NORTH">NORTH</option>
+            </select>
+        </div>
+        <div class="form-row">
+            <label for="is_last_tile_in_the_game">Last Tile in Game:</label>
+            <input type="checkbox" id="is_last_tile_in_the_game" name="is_last_tile_in_the_game" />
+        </div>
+        <div class="form-row">
+            <label for="is_last_tile_of_its_kind">Last Tile of Its Kind:</label>
+            <input type="checkbox" id="is_last_tile_of_its_kind" name="is_last_tile_of_its_kind" />
+        </div>
+        <div class="form-row">
+            <label for="is_replacement_tile">Is Replacement Tile:</label>
+            <input type="checkbox" id="is_replacement_tile" name="is_replacement_tile" />
+        </div>
+        <div class="form-row">
+            <label for="is_robbing_the_kong">Is Robbing the Kong:</label>
+            <input type="checkbox" id="is_robbing_the_kong" name="is_robbing_the_kong" />
+        </div>
+        <button type="submit">Check Score</button>
+    </form>
+
+    <h2>Result</h2>
+    <div id="result"></div>
+
+    <script>
+        // 대문자를 기준으로 띄어쓰기 (CamelCase -> "Camel Case")
+        function splitCamelCase(str) {
+            return str.replace(/([A-Z])/g, " $1").trim();
+        }
+
+        const form = document.getElementById("scoreForm");
+        const resultDiv = document.getElementById("result");
+
+        form.addEventListener("submit", async (e) => {
+            e.preventDefault();
+
+            const raw_hand = document.getElementById("raw_hand").value;
+            const winning_tile = document.getElementById("winning_tile").value;
+            const is_discarded = document.getElementById("is_discarded").checked;
+            const seat_wind = document.getElementById("seat_wind").value;
+            const round_wind = document.getElementById("round_wind").value;
+            const is_last_tile_in_the_game = document.getElementById("is_last_tile_in_the_game").checked;
+            const is_last_tile_of_its_kind = document.getElementById("is_last_tile_of_its_kind").checked;
+            const is_replacement_tile = document.getElementById("is_replacement_tile").checked;
+            const is_robbing_the_kong = document.getElementById("is_robbing_the_kong").checked;
+
+            const data = {
+                raw_hand,
+                winning_tile,
+                is_discarded,
+                seat_wind,
+                round_wind,
+                is_last_tile_in_the_game,
+                is_last_tile_of_its_kind,
+                is_replacement_tile,
+                is_robbing_the_kong,
+            };
+
+            try {
+                const response = await fetch("/score-check", {
+                    method: "POST",
+                    headers: { "Content-Type": "application/json" },
+                    body: JSON.stringify(data),
+                });
+                const result = await response.json();
+
+                if (result.error) {
+                    resultDiv.textContent = result.error;
+                    return;
+                }
+
+                // 결과 영역 초기화
+                resultDiv.innerHTML = "";
+
+                // 1) Total Score 표시
+                const totalScoreDiv = document.createElement("div");
+                totalScoreDiv.style.marginBottom = "1rem";
+                totalScoreDiv.textContent = "Total Score: " + result.total_score;
+                resultDiv.appendChild(totalScoreDiv);
+
+                // 2) 점수 내림차순 정렬
+                const sortedList = [...result.yaku_score_list].sort((a, b) => b.score - a.score);
+
+                // 3) yaku_score_list 데이터를 테이블로 구성
+                const table = document.createElement("table");
+                const thead = document.createElement("thead");
+                const tbody = document.createElement("tbody");
+
+                // 테이블 헤더
+                const headerRow = document.createElement("tr");
+                const yakuHeader = document.createElement("th");
+                yakuHeader.textContent = "Yaku";
+                const scoreHeader = document.createElement("th");
+                scoreHeader.textContent = "Score";
+                headerRow.appendChild(yakuHeader);
+                headerRow.appendChild(scoreHeader);
+                thead.appendChild(headerRow);
+
+                // 테이블 본문
+                sortedList.forEach(({ name, score }) => {
+                    const row = document.createElement("tr");
+                    const yakuCell = document.createElement("td");
+                    const scoreCell = document.createElement("td");
+
+                    // 대문자 기준으로 띄어쓰기
+                    yakuCell.textContent = splitCamelCase(name);
+                    scoreCell.textContent = score;
+
+                    row.appendChild(yakuCell);
+                    row.appendChild(scoreCell);
+                    tbody.appendChild(row);
+                });
+
+                table.appendChild(thead);
+                table.appendChild(tbody);
+
+                // 결과 영역에 테이블 추가
+                resultDiv.appendChild(table);
+            } catch (err) {
+                resultDiv.textContent = "Error: " + err;
+            }
+        });
+    </script>
+</body>
+
+</html>
diff --git a/app/main.py b/app/main.py
index 8b2b5af..b4c9214 100644
--- a/app/main.py
+++ b/app/main.py
@@ -1,8 +1,83 @@
-from fastapi import FastAPI
+import os
+
+from fastapi import FastAPI, status
+from fastapi.responses import HTMLResponse, JSONResponse
+from pydantic import BaseModel
+
+from app.score_calculator.enums.enums import Tile, Wind
+from app.score_calculator.score_calculator import ScoreCalculator
+from tests.test_utils import (
+    create_default_winning_conditions,
+    name_to_tile,
+    raw_string_to_hand_class,
+)
 
 app = FastAPI()
 
 
-@app.get("/")
-def read_root() -> dict[str, str]:
-    return {"Hello": "World"}
+class ScoreCheckInput(BaseModel):
+    raw_hand: str
+    winning_tile: str
+    is_discarded: bool
+    seat_wind: str = Wind.EAST.name
+    round_wind: str = Wind.EAST.name
+    is_last_tile_in_the_game: bool = False
+    is_last_tile_of_its_kind: bool = False
+    is_replacement_tile: bool = False
+    is_robbing_the_kong: bool = False
+
+
+@app.get("/", response_class=HTMLResponse)
+def read_root() -> str:
+    base_dir = os.path.dirname(os.path.abspath(__file__))
+    html_path = os.path.join(base_dir, "index.html")
+    with open(html_path, encoding="utf-8") as f:
+        return f.read()
+
+
+@app.post("/score-check")
+def score_check(input: ScoreCheckInput) -> JSONResponse:
+    hand = raw_string_to_hand_class(input.raw_hand)
+    if (winning_tile := name_to_tile(input.winning_tile)) == Tile.F0:
+        return JSONResponse(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            content={"error": f"Invalid winning_tile: {input.winning_tile}"},
+        )
+    try:
+        seat_wind = Wind[input.seat_wind]
+    except KeyError:
+        return JSONResponse(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            content={"error": f"Invalid seat_wind: {input.seat_wind}"},
+        )
+    try:
+        round_wind = Wind[input.round_wind]
+    except KeyError:
+        return JSONResponse(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            content={"error": f"Invalid round_wind: {input.round_wind}"},
+        )
+
+    winning_conditions = create_default_winning_conditions(
+        winning_tile=winning_tile,
+        is_discarded=input.is_discarded,
+        seat_wind=seat_wind,
+        round_wind=round_wind,
+        is_last_tile_in_the_game=input.is_last_tile_in_the_game,
+        is_last_tile_of_its_kind=input.is_last_tile_of_its_kind,
+        is_replacement_tile=input.is_replacement_tile,
+        is_robbing_the_kong=input.is_robbing_the_kong,
+    )
+
+    score_calc = ScoreCalculator(hand=hand, winning_conditions=winning_conditions)
+
+    return JSONResponse(
+        status_code=status.HTTP_200_OK,
+        content={
+            "total_score": score_calc.result.total_score,
+            "yaku_score_list": [
+                {"name": yaku.name, "score": score}
+                for yaku, score in score_calc.result.yaku_score_list
+            ],
+        },
+    )
diff --git a/app/score_calculator/divide/general_shape.py b/app/score_calculator/divide/general_shape.py
index 270b9e2..cf51ac0 100644
--- a/app/score_calculator/divide/general_shape.py
+++ b/app/score_calculator/divide/general_shape.py
@@ -65,7 +65,6 @@ def create_from_hand(hand: Hand) -> BlockDivisionState:
 def divide_general_shape_knitted_sub(hand: Hand) -> list[list[Block]]:
     has_knitted_blocks: bool
     parsed_hands: list[list[Block]] = []
-
     for case in KNITTED_CASES:
         has_knitted_blocks = all(hand.tiles[tile] > 0 for tile in case)
         if not has_knitted_blocks:
@@ -75,7 +74,6 @@ def divide_general_shape_knitted_sub(hand: Hand) -> list[list[Block]]:
             Block(type=BlockType.KNITTED, tile=start_tile, is_opened=False)
             for start_tile in case[::KNITTED_GAP]
         )
-
         parsed_hands.extend(divide_general_shape(new_hand))
     return parsed_hands
 
@@ -88,7 +86,6 @@ def divide_general_shape(hand: Hand) -> list[list[Block]]:
     total_tiles_count: int = sum(hand.tiles)
     for block in hand.call_blocks:
         total_tiles_count -= 1 if block.type == BlockType.QUAD else 0
-
     if total_tiles_count != FULLY_HAND_SIZE:
         raise ValueError("Wrong hand size.")
 
diff --git a/app/score_calculator/divide/seven_pairs_shape.py b/app/score_calculator/divide/seven_pairs_shape.py
index 91c822d..101df36 100644
--- a/app/score_calculator/divide/seven_pairs_shape.py
+++ b/app/score_calculator/divide/seven_pairs_shape.py
@@ -8,7 +8,7 @@
 FULLY_HAND_SIZE: Final[int] = 14
 
 
-def divide_seven_pairs_shape(hand: Hand) -> list[Block]:
+def divide_seven_pairs_shape(hand: Hand) -> list[list[Block]]:
     parsed_blocks: list[Block] = []
 
     if len(hand.call_blocks) or sum(hand.tiles) != FULLY_HAND_SIZE:
@@ -21,4 +21,4 @@ def divide_seven_pairs_shape(hand: Hand) -> list[Block]:
             parsed_blocks.append(
                 Block(type=BlockType.PAIR, tile=Tile(tile), is_opened=False),
             )
-    return parsed_blocks
+    return [parsed_blocks]
diff --git a/app/score_calculator/enums/enums.py b/app/score_calculator/enums/enums.py
index c7130bd..cef414f 100644
--- a/app/score_calculator/enums/enums.py
+++ b/app/score_calculator/enums/enums.py
@@ -157,7 +157,7 @@ def number(self) -> int:
 
     @property
     def type(self) -> str:
-        return self.name[0]
+        return self.name[0] if not self.is_honor else "W" if self.is_wind else "D"
 
     @classmethod
     def all_tiles(cls) -> range:
diff --git a/app/score_calculator/result/result.py b/app/score_calculator/result/result.py
index e73f74d..290be0d 100644
--- a/app/score_calculator/result/result.py
+++ b/app/score_calculator/result/result.py
@@ -21,32 +21,62 @@ def add_yaku(self, yaku: Yaku, count: int) -> None:
 
 
 @dataclass
-class ScoringContext:
+class BlockRelationScoringContext:
     blocks: list[Block]
-    used_block_flag: list[bool]
+    used_block_flag: list[set[Yaku]]
 
     def get_yakus(self) -> list[Yaku]:
+        return (
+            self._get_yakus_by_length(4)
+            + self._get_yakus_by_length(3)
+            + self._get_yakus_of_two_blocks()
+        )
+
+    def _get_yakus_by_length(self, length: int) -> list[Yaku]:
+        result: list[Yaku] = []
+        for combination in combinations(range(4), length):
+            if all(self.used_block_flag[i] for i in combination):
+                continue
+            current_blocks: list[Block] = [self.blocks[i] for i in combination]
+            yakus: list[Yaku] = BlocksYakuChecker(current_blocks).yakus
+            if yakus and not any(
+                yakus[0] in self.used_block_flag[i] for i in combination
+            ):
+                result.extend(yakus)
+                for i in combination:
+                    self.used_block_flag[i].add(yakus[0])
+                break
+        return result
+
+    def _get_yakus_of_two_blocks(self) -> list[Yaku]:
         result: list[Yaku] = []
-        for length in (4, 3, 2):
-            for combination in combinations(range(4), length):
-                if all(self.used_block_flag[i] for i in combination):
-                    continue
-                if yaku := BlocksYakuChecker(
-                    [self.blocks[i] for i in combination],
-                ).yakus:
-                    result += yaku
-                    for i in combination:
-                        self.used_block_flag[i] = True
-                    if length != 2:
-                        break
+        stack: list[tuple[int, int]] = list(combinations(range(4), 2))
+        while stack:
+            combination = stack.pop()
+            if all(self.used_block_flag[i] for i in combination):
+                continue
+            current_blocks = [self.blocks[i] for i in combination]
+            yakus = BlocksYakuChecker(current_blocks).yakus
+            if yakus and not any(
+                yakus[0] in self.used_block_flag[i] for i in combination
+            ):
+                result.extend(yakus)
+                for i in combination:
+                    self.used_block_flag[i].add(yakus[0])
+                for new_combination in combinations(range(4), 2):
+                    if new_combination != combination and (
+                        combination[0] in new_combination
+                        or combination[1] in new_combination
+                    ):
+                        stack.append(new_combination)
         return result
 
     @staticmethod
     def create_from_blocks(
         blocks: list[Block],
-    ) -> ScoringContext:
-        used_block_flag: list[bool] = [False] * len(blocks)
-        return ScoringContext(
+    ) -> BlockRelationScoringContext:
+        used_block_flag: list[set[Yaku]] = [set() for _ in blocks]
+        return BlockRelationScoringContext(
             blocks=blocks,
             used_block_flag=used_block_flag,
         )
diff --git a/app/score_calculator/score_calculator.py b/app/score_calculator/score_calculator.py
index 5acf3e9..7fc8378 100644
--- a/app/score_calculator/score_calculator.py
+++ b/app/score_calculator/score_calculator.py
@@ -2,10 +2,20 @@
 from copy import deepcopy
 
 from app.score_calculator.block.block import Block
-from app.score_calculator.divide.general_shape import divide_general_shape
-from app.score_calculator.enums.enums import BlockType, Yaku
+from app.score_calculator.divide.general_shape import (
+    divide_general_shape,
+    divide_general_shape_knitted_sub,
+)
+from app.score_calculator.divide.honors_and_knitted_shape import (
+    can_divide_honors_and_knitted_shape,
+)
+from app.score_calculator.divide.seven_pairs_shape import divide_seven_pairs_shape
+from app.score_calculator.divide.thirteen_orphans_shape import (
+    can_divide_thirteen_orphans_shape,
+)
+from app.score_calculator.enums.enums import BlockType, Tile, Yaku
 from app.score_calculator.hand.hand import Hand
-from app.score_calculator.result.result import ScoreResult, ScoringContext
+from app.score_calculator.result.result import BlockRelationScoringContext, ScoreResult
 from app.score_calculator.tenpai_calculator import get_tenpai_tiles
 from app.score_calculator.utility.utility import (
     EXCLUDED_YAKUS,
@@ -31,31 +41,82 @@ def __init__(self, hand: Hand, winning_conditions: WinningConditions):
                 tenpai_hand=tenpai_hand,
             ),
         )
-        self.highest_result = ScoreResult(yaku_score_list=[])
+        self._highest_result = ScoreResult(yaku_score_list=[])
         self.is_blocks_divided = False
+        self._calculate()
+
+    @property
+    def result(self) -> ScoreResult:
+        return self._highest_result
+
+    def _calculate(self) -> None:
+        self._calculate_honors_and_knitted_shape_score()
+        if not self.is_blocks_divided:
+            self._calculate_thirteen_orphans_shape_score()
+        if not self.is_blocks_divided:
+            self._calculate_general_and_seven_pairs_shape_score()
+
+        if self.is_blocks_divided and self._highest_result.total_score == 0:
+            self._highest_result.add_yaku(yaku=Yaku.ChickenHand, count=1)
 
-    def general_shape_calculator(self) -> None:
-        parsed_hands: list[list[Block]] = divide_general_shape(self.hand)
+    def _calculate_general_and_seven_pairs_shape_score(self) -> None:
+        parsed_hands: list[list[Block]] = (
+            divide_general_shape(self.hand)
+            + divide_general_shape_knitted_sub(self.hand)
+            + divide_seven_pairs_shape(self.hand)
+        )
         if parsed_hands:
             self.is_blocks_divided = True
         for blocks in parsed_hands:
-            score_result = self._calculate_score_result(blocks)
-            self.highest_result = max(self.highest_result, score_result)
-
-    def _calculate_score_result(self, blocks: list[Block]) -> ScoreResult:
-        scoring_context: ScoringContext = ScoringContext.create_from_blocks(
-            blocks=[
-                deepcopy(block) for block in blocks if block.type != BlockType.PAIR
-            ],
-        )
+            score_result = self._calculate_score_result(blocks=blocks, yaku_list=[])
+            self._highest_result = max(self._highest_result, score_result)
 
-        yaku_list: list[Yaku] = []
-        yaku_list += HandYakuChecker(
-            blocks=deepcopy(blocks),
-            winning_conditions=deepcopy(self.winning_conditions),
-        ).yakus
-        yaku_list += BlocksYakuChecker(blocks=deepcopy(blocks)).yakus
-        yaku_list += scoring_context.get_yakus()
+    def _calculate_thirteen_orphans_shape_score(self) -> None:
+        if can_divide_thirteen_orphans_shape(self.hand):
+            self.is_blocks_divided = True
+            yaku_list: list[Yaku] = [Yaku.ThirteenOrphans]
+
+            score_result = self._calculate_score_result(blocks=[], yaku_list=yaku_list)
+            self._highest_result = max(self._highest_result, score_result)
+
+    def _calculate_honors_and_knitted_shape_score(self) -> None:
+        if can_divide_honors_and_knitted_shape(self.hand):
+            self.is_blocks_divided = True
+            yaku_list: list[Yaku] = []
+
+            honor_count = sum(1 for tile in Tile.honor_tiles() if self.hand.tiles[tile])
+            if honor_count == 7:
+                yaku_list.append(Yaku.GreaterHonorsAndKnittedTiles)
+            elif honor_count == 5:
+                yaku_list.extend(
+                    [Yaku.LesserHonorsAndKnittedTiles, Yaku.KnittedStraight],
+                )
+            else:
+                yaku_list.append(Yaku.LesserHonorsAndKnittedTiles)
+
+            score_result = self._calculate_score_result(blocks=[], yaku_list=yaku_list)
+            self._highest_result = max(self._highest_result, score_result)
+
+    def _calculate_score_result(
+        self,
+        blocks: list[Block],
+        yaku_list: list[Yaku],
+    ) -> ScoreResult:
+        scoring_context: BlockRelationScoringContext = (
+            BlockRelationScoringContext.create_from_blocks(
+                blocks=[
+                    deepcopy(block) for block in blocks if block.type != BlockType.PAIR
+                ],
+            )
+        )
+        if blocks:
+            yaku_list += HandYakuChecker(
+                blocks=deepcopy(blocks),
+                winning_conditions=deepcopy(self.winning_conditions),
+            ).yakus
+        if len(blocks) == 5:
+            yaku_list += BlocksYakuChecker(blocks=deepcopy(blocks)).yakus
+            yaku_list += scoring_context.get_yakus()
         yaku_list += WinningConditionsYakuChecker(
             blocks=deepcopy(blocks),
             winning_conditions=deepcopy(self.winning_conditions),
@@ -69,8 +130,11 @@ def _calculate_score_result(self, blocks: list[Block]) -> ScoreResult:
         # 사귀일 예외처리
         if (
             not (
-                Yaku.SevenPairs in yaku_dict
-                and (Yaku.AllTerminals in yaku_dict or Yaku.AllGreen in yaku_dict)
+                Yaku.QuadrupleChow in yaku_dict
+                or (
+                    Yaku.SevenPairs in yaku_dict
+                    and (Yaku.AllTerminals in yaku_dict or Yaku.AllGreen in yaku_dict)
+                )
             )
             and (
                 tile_hog_count := self.hand.tiles.count(4)
@@ -102,6 +166,7 @@ def _check_skip_block(self, block: Block, yaku_counter: Counter[Yaku]) -> bool:
             block.is_wind
             and (
                 Yaku.LittleFourWinds in yaku_counter
+                or Yaku.BigThreeWinds in yaku_counter
                 or block.tile
                 in {
                     self.winning_conditions.round_wind,
@@ -112,7 +177,7 @@ def _check_skip_block(self, block: Block, yaku_counter: Counter[Yaku]) -> bool:
 
     def _count_pung_of_terminals_and_honors(
         self,
-        scoring_context: ScoringContext,
+        scoring_context: BlockRelationScoringContext,
         yaku_counter: Counter[Yaku],
     ) -> int:
         if any(yaku in yaku_counter for yaku in YAKUS_INCLUDING_PUNG_OF_TOH):
diff --git a/app/score_calculator/tenpai_calculator.py b/app/score_calculator/tenpai_calculator.py
index 1584201..b0cdca6 100644
--- a/app/score_calculator/tenpai_calculator.py
+++ b/app/score_calculator/tenpai_calculator.py
@@ -1,7 +1,10 @@
 from copy import deepcopy
 from typing import Final
 
-from app.score_calculator.divide.general_shape import divide_general_shape
+from app.score_calculator.divide.general_shape import (
+    divide_general_shape,
+    divide_general_shape_knitted_sub,
+)
 from app.score_calculator.divide.honors_and_knitted_shape import (
     can_divide_honors_and_knitted_shape,
 )
@@ -28,12 +31,11 @@ def get_tenpai_tiles(tenpai_hand: Hand) -> list[Tile]:
     tenpai_tiles: list[Tile] = []
 
     for tile in Tile.all_tiles():
-        if tenpai_hand.tiles[tile] == 4:
-            continue
         hand = deepcopy(tenpai_hand)  # 이후 최적화 가능(hand가 변동되지 않아야함)
         hand.tiles[tile] += 1
         if (
             divide_general_shape(hand)
+            or divide_general_shape_knitted_sub(hand)
             or divide_seven_pairs_shape(hand)
             or can_divide_thirteen_orphans_shape(hand)
             or can_divide_honors_and_knitted_shape(hand)
diff --git a/app/score_calculator/utility/utility.py b/app/score_calculator/utility/utility.py
index 17c4a61..80511f2 100644
--- a/app/score_calculator/utility/utility.py
+++ b/app/score_calculator/utility/utility.py
@@ -98,7 +98,6 @@ def has_opened_blocks(blocks: list[Block]) -> bool:
         Yaku.ConcealedKong,
         Yaku.TwoMeldedKongs,
         Yaku.TwoConcealedKongs,
-        Yaku.ThreeKongs,
     ],
     Yaku.AllTerminalsAndHonors: [
         Yaku.AllPungs,
@@ -133,7 +132,7 @@ def has_opened_blocks(blocks: list[Block]) -> bool:
     Yaku.KnittedStraight: [],
     Yaku.UpperFour: [Yaku.NoHonorTiles],
     Yaku.LowerFour: [Yaku.NoHonorTiles],
-    Yaku.BigThreeWinds: [],
+    Yaku.BigThreeWinds: [],  # Yaku.PungOfTerminalsOrHonors by used block flag
     Yaku.MixedStraight: [],
     Yaku.ReversibleTiles: [Yaku.OneVoidedSuit],
     Yaku.MixedTripleChow: [],
diff --git a/app/score_calculator/yaku_check/blocks_yaku_checker.py b/app/score_calculator/yaku_check/blocks_yaku_checker.py
index 1992f75..d531336 100644
--- a/app/score_calculator/yaku_check/blocks_yaku_checker.py
+++ b/app/score_calculator/yaku_check/blocks_yaku_checker.py
@@ -47,6 +47,7 @@ def __init__(self, blocks: list[Block]):
                 (self.is_outside_hand, Yaku.OutsideHand),
                 (self.is_little_four_winds, Yaku.LittleFourWinds),
                 (self.is_little_three_dragons, Yaku.LittleThreeDragons),
+                (self.is_all_types, Yaku.AllTypes),
             ],
         }
         self.set_yakus()
@@ -227,7 +228,7 @@ def is_mixed_shifted_chows(self) -> bool:
         return (
             self.validate_blocks(lambda x: x.is_number and x.is_sequence)
             and self.tile_type_count == 3
-            and (self.has_constant_gap(1) or self.has_constant_gap(2))
+            and self.has_constant_gap(1)
         )
 
     # two blocks checker
@@ -255,15 +256,23 @@ def is_mixed_double_chow(self) -> bool:
 
     @property
     def is_short_straight(self) -> bool:
-        return self.validate_blocks(
-            lambda x: x.is_number and x.is_sequence,
-        ) and self.has_constant_gap(self.STRAIGHT_GAP)
+        return (
+            self.validate_blocks(
+                lambda x: x.is_number and x.is_sequence,
+            )
+            and self.tile_type_count == 1
+            and self.has_constant_gap(self.STRAIGHT_GAP)
+        )
 
     @property
     def is_two_terminal_chows(self) -> bool:
-        return self.validate_blocks(
-            lambda x: x.is_number and x.is_sequence,
-        ) and self.has_constant_gap(self.TERMINAL_GAP)
+        return (
+            self.validate_blocks(
+                lambda x: x.is_number and x.is_sequence,
+            )
+            and self.tile_type_count == 1
+            and self.has_constant_gap(self.TERMINAL_GAP)
+        )
 
     # four blocks checker
     @property
@@ -297,3 +306,7 @@ def is_outside_hand(self) -> bool:
     @property
     def is_all_fives(self) -> bool:
         return self.validate_blocks(lambda x: x.has_five)
+
+    @property
+    def is_all_types(self) -> bool:
+        return self.tile_type_count == 5
diff --git a/app/score_calculator/yaku_check/hand_yaku_checker.py b/app/score_calculator/yaku_check/hand_yaku_checker.py
index 5e1ba29..043a9c2 100644
--- a/app/score_calculator/yaku_check/hand_yaku_checker.py
+++ b/app/score_calculator/yaku_check/hand_yaku_checker.py
@@ -77,6 +77,14 @@ def has_constant_gap(self, gap: int) -> bool:
             for pair in zip(self.first_tile_numbers, self.first_tile_numbers[1:])
         )
 
+    def is_wait_in_knitted(self) -> bool:
+        return any(
+            self.winning_conditions.winning_tile
+            in {block.tile, block.tile + 3, block.tile + 6}
+            for block in self.blocks
+            if block.type == BlockType.KNITTED
+        )
+
     @cached_property
     def tiles(self) -> defaultdict[Tile, int]:
         _tiles: defaultdict[Tile, int] = defaultdict(int)
@@ -245,6 +253,23 @@ def _get_concealed_pung_count_conditions(
             (lambda: self.concealed_pungs_count == 2, Yaku.TwoConcealedPungs),
         ]
 
+    def _is_nine_gates(self) -> bool:
+        first_tile: Tile | None = next(iter(self.tiles.keys()), None)
+        if first_tile is None:
+            return False
+        base_tile: int = first_tile - first_tile.number
+        return (
+            self.validate_tiles(lambda t: t.is_number)
+            and self.num_tile_types_count == 1
+            and all(
+                self.tiles.get(Tile(base_tile + number), 0)
+                >= (3 if number in {1, 9} else 1)
+                for number in range(1, 10)
+            )
+            and self.winning_conditions.count_tenpai_tiles == 9
+            and self.validate_blocks(lambda b: not b.is_opened)
+        )
+
     def _get_hand_shape_conditions(self) -> list[tuple[Callable[[], bool], Yaku]]:
         return [
             (
@@ -254,17 +279,7 @@ def _get_hand_shape_conditions(self) -> list[tuple[Callable[[], bool], Yaku]]:
                 and self.has_constant_gap(1),
                 Yaku.SevenShiftedPairs,
             ),
-            (
-                lambda: self.validate_tiles(lambda t: t.is_number)
-                and self.num_tile_types_count == 1
-                and all(
-                    self.tiles[t] >= 3 if t.number in {1, 9} else self.tiles[t] >= 1
-                    for t in self.tiles
-                )
-                and self.winning_conditions.count_tenpai_tiles == 9
-                and not self.winning_conditions.is_discarded,
-                Yaku.NineGates,
-            ),
+            (self._is_nine_gates, Yaku.NineGates),
             (
                 lambda: len(self.blocks) == 5
                 and self.validate_tiles(
@@ -362,6 +377,7 @@ def _get_wait_conditions(self) -> list[tuple[Callable[[], bool], Yaku]]:
             ),
             (
                 lambda: self.winning_conditions.count_tenpai_tiles == 1
+                and not self.is_wait_in_knitted()
                 and any(
                     block.tile == self.winning_conditions.winning_tile
                     for block in self.blocks
diff --git a/app/score_calculator/yaku_check/winning_conditions_yaku_checker.py b/app/score_calculator/yaku_check/winning_conditions_yaku_checker.py
index 551d615..3a8448c 100644
--- a/app/score_calculator/yaku_check/winning_conditions_yaku_checker.py
+++ b/app/score_calculator/yaku_check/winning_conditions_yaku_checker.py
@@ -43,6 +43,9 @@ def blocks_checker(self) -> list[Yaku]:
             if (result := self._get_yaku_by_type(yaku_type)) != Yaku.ERROR
         ]
 
+    def validate_blocks(self, condition: Callable[[Block], bool]) -> bool:
+        return all(condition(block) for block in self.blocks)
+
     def is_concealed_hand(self) -> bool:
         return all(not block.is_opened for block in self.blocks)
 
@@ -85,6 +88,12 @@ def _get_concealed_state_conditions(self) -> list[tuple[Callable[[], bool], Yaku
                 and not self.is_concealed_hand(),
                 Yaku.SelfDrawn,
             ),
+            (
+                lambda: self.winning_conditions.is_discarded
+                and len(self.blocks) == 5
+                and self.validate_blocks(lambda b: b.is_pair or b.is_opened),
+                Yaku.MeldedHand,
+            ),
         ]
 
     def _get_fourth_tile_conditions(self) -> list[tuple[Callable[[], bool], Yaku]]:
diff --git a/scripts/__init__.py b/scripts/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/scripts/cli.py b/scripts/cli.py
new file mode 100644
index 0000000..e2a9039
--- /dev/null
+++ b/scripts/cli.py
@@ -0,0 +1,10 @@
+import uvicorn
+
+
+def start_dev_server() -> None:
+    uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
+
+
+def start_prod_server() -> None:
+    """프로덕션 서버를 시작합니다."""
+    uvicorn.run("app.main:app", host="0.0.0.0", port=8000)
diff --git a/tests/test_example.py b/tests/test_example.py
index 9e12f3d..2506171 100644
--- a/tests/test_example.py
+++ b/tests/test_example.py
@@ -135,7 +135,7 @@ def create_default_winning_conditions(
 def test_hand_yaku_checker(hand_string, expected_yaku, use_seven_pairs):
     hand = raw_string_to_hand_class(hand_string)
     blocks = (
-        divide_seven_pairs_shape(hand)
+        divide_seven_pairs_shape(hand)[0]
         if use_seven_pairs
         else divide_general_shape(hand)[0]
     )
@@ -267,7 +267,7 @@ def test_hand_shape_yakus(
 ):
     hand = raw_string_to_hand_class(hand_string)
     blocks = (
-        divide_seven_pairs_shape(hand)
+        divide_seven_pairs_shape(hand)[0]
         if use_seven_pairs
         else divide_general_shape(hand)[0]
     )
@@ -466,7 +466,7 @@ def test_block_yaku_checker():
     print(hand)
     blocks = divide_general_shape(hand)[0]
     print_blocks(blocks=blocks)
-    assert [Yaku.OutsideHand] == BlocksYakuChecker(blocks).yakus
+    assert Yaku.OutsideHand in BlocksYakuChecker(blocks).yakus
 
     assert [Yaku.BigFourWinds] == BlocksYakuChecker([Z111, Z222, Z333, Z4444]).yakus
     print(BlocksYakuChecker([Z11, Z222, Z333, Z4444, M111]).yakus)
diff --git a/tests/test_score_calculator_general.py b/tests/test_score_calculator_general.py
index 256b621..201b501 100644
--- a/tests/test_score_calculator_general.py
+++ b/tests/test_score_calculator_general.py
@@ -78,11 +78,76 @@ def create_default_winning_conditions(
             ],
             create_default_winning_conditions(winning_tile=Tile.Z7, is_discarded=True),
         ),
+        (
+            "19m19s19p12345677z",
+            [
+                (Yaku.ThirteenOrphans, 88),
+                (Yaku.FullyConcealedHand, 4),
+            ],
+            create_default_winning_conditions(winning_tile=Tile.P1, is_discarded=False),
+        ),
+        (
+            "19m19s19p12345677z",
+            [
+                (Yaku.ThirteenOrphans, 88),
+                (Yaku.FullyConcealedHand, 4),
+            ],
+            create_default_winning_conditions(winning_tile=Tile.P1, is_discarded=False),
+        ),
+        (
+            "[234s]147m258s36999p",
+            [
+                (Yaku.KnittedStraight, 12),
+                (Yaku.AllChows, 2),
+                (Yaku.SelfDrawn, 1),
+            ],
+            create_default_winning_conditions(winning_tile=Tile.P9, is_discarded=False),
+        ),
+        (
+            "[234s]147m258s369p11z",
+            [
+                (Yaku.KnittedStraight, 12),
+                (Yaku.SingleWait, 1),
+                (Yaku.SelfDrawn, 1),
+            ],
+            create_default_winning_conditions(winning_tile=Tile.Z1, is_discarded=False),
+        ),
+        (
+            "147m258p369s12345z",
+            [
+                (Yaku.KnittedStraight, 12),
+                (Yaku.LesserHonorsAndKnittedTiles, 12),
+                (Yaku.FullyConcealedHand, 4),
+            ],
+            create_default_winning_conditions(winning_tile=Tile.Z1, is_discarded=False),
+        ),
+        (
+            "[4444m][5555m][2222s]222p66p",
+            [
+                (Yaku.ThreeKongs, 32),
+                (Yaku.AllPungs, 6),
+                (Yaku.AllSimples, 2),
+                (Yaku.DoublePung, 2),
+                (Yaku.SingleWait, 1),
+            ],
+            create_default_winning_conditions(winning_tile=Tile.P6, is_discarded=True),
+        ),
+        (
+            "345678m67888p45s3s",
+            [
+                (Yaku.FullyConcealedHand, 4),
+                (Yaku.AllChows, 2),
+                (Yaku.AllSimples, 2),
+                (Yaku.MixedDoubleChow, 2),
+                (Yaku.ShortStraight, 1),
+            ],
+            create_default_winning_conditions(winning_tile=Tile.S3, is_discarded=False),
+        ),
     ],
 )
-def test_tenpai_tiles_checker(hand_string, yaku_score_list, winning_conditions):
+def test_score_checker(hand_string, yaku_score_list, winning_conditions):
     hand = raw_string_to_hand_class(hand_string)
-    sc = ScoreCalculator(hand, winning_conditions=winning_conditions)
-    sc.general_shape_calculator()
-    print(sc.highest_result.yaku_score_list)
-    assert set(sc.highest_result.yaku_score_list) == set(yaku_score_list)
+    sc = ScoreCalculator(hand=hand, winning_conditions=winning_conditions)
+    print(sc._highest_result.yaku_score_list)
+    print(hand)
+    assert set(sc._highest_result.yaku_score_list) == set(yaku_score_list)
diff --git a/tests/test_utils.py b/tests/test_utils.py
index f8240f8..d6a5a86 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,8 +1,9 @@
 from typing import Final
 
 from app.score_calculator.block.block import Block
-from app.score_calculator.enums.enums import BlockType, Tile
+from app.score_calculator.enums.enums import BlockType, Tile, Wind
 from app.score_calculator.hand.hand import Hand
+from app.score_calculator.winning_conditions.winning_conditions import WinningConditions
 
 
 def tile_to_name(tile: Tile) -> str:
@@ -42,7 +43,7 @@ def raw_string_to_hand_class(string: str) -> Hand:
             blocks_list.append(
                 Block(
                     type=block_type,
-                    tile=name_to_tile("".join(tile_stack[-2:])),
+                    tile=name_to_tile("".join(tile_stack[0] + tile_stack[-1])),
                     is_opened=c == "]",
                 ),
             )
@@ -94,3 +95,31 @@ def print_block(block: Block):
     elif block.type == BlockType.QUAD:
         print("}", end="")
     print(" ", end="")
+
+
+def create_default_winning_conditions(
+    winning_tile: Tile,
+    is_discarded: bool = True,
+    count_tenpai_tiles: int = 1,
+    seat_wind: Wind = Wind.EAST,
+    round_wind: Wind = Wind.EAST,
+    **extra_conditions,
+):
+    defaults = {
+        "is_last_tile_in_the_game": False,
+        "is_last_tile_of_its_kind": False,
+        "is_replacement_tile": False,
+        "is_robbing_the_kong": False,
+    }
+    defaults.update(extra_conditions)
+    return WinningConditions(
+        winning_tile=winning_tile,
+        is_discarded=is_discarded,
+        count_tenpai_tiles=count_tenpai_tiles,
+        seat_wind=seat_wind,
+        round_wind=round_wind,
+        is_last_tile_in_the_game=defaults["is_last_tile_in_the_game"],
+        is_last_tile_of_its_kind=defaults["is_last_tile_of_its_kind"],
+        is_replacement_tile=defaults["is_replacement_tile"],
+        is_robbing_the_kong=defaults["is_robbing_the_kong"],
+    )


Reviews:
user: login: nipplina
body: 다른 파트는 잘 모르겠습니다만, 최소한 역 및 점수의 산정과 관련된 다수의 오류가 수정되었음은 확인하였습니다.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-11T12:43:33Z
user: login: mcdbgk99
body: 이미 테스트케이스로 검증했을테니 더블체크용 질문 몇개 남김
state: APPROVED
author_association: NONE
submitted_at: 2025-03-11T13:00:48Z
user: login: Hot6Mania
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T14:18:55Z
user: login: Hot6Mania
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T14:22:01Z
user: login: Hot6Mania
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T14:24:07Z
user: login: Hot6Mania
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T14:24:39Z
user: login: taeho-gwon
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T19:44:19Z
user: login: Hot6Mania
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T19:50:46Z
user: login: Hot6Mania
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T19:51:17Z
user: login: Hot6Mania
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T20:17:24Z
user: login: taeho-gwon
body: 코드가 대강봐도 좋아진거 같네용.
고생하셨습니다.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T20:58:36Z

Review comments:
diff_hunk: @@ -33,29 +43,76 @@ def __init__(self, hand: Hand, winning_conditions: WinningConditions):
         )
         self.highest_result = ScoreResult(yaku_score_list=[])
         self.is_blocks_divided = False
+        self._calculate()
+
+    @property
+    def result(self) -> ScoreResult:
+        return self.highest_result
+
+    def _calculate(self) -> None:
+        self._general_and_seven_pairs_shape_calculator()
+        self._thirteen_orphans_shape_calculator()
+        self._honors_and_knitted_shape_calculator()
+        if self.is_blocks_divided and self.highest_result.total_score == 0:
+            self.highest_result.add_yaku(yaku=Yaku.ChickenHand, count=1)
 
-    def general_shape_calculator(self) -> None:
-        parsed_hands: list[list[Block]] = divide_general_shape(self.hand)
+    def _general_and_seven_pairs_shape_calculator(self) -> None:
+        parsed_hands: list[list[Block]] = (
+            divide_general_shape(self.hand)
+            + divide_general_shape_knitted_sub(self.hand)
+            + divide_seven_pairs_shape(self.hand)
+        )
         if parsed_hands:
             self.is_blocks_divided = True
         for blocks in parsed_hands:
-            score_result = self._calculate_score_result(blocks)
+            score_result = self._calculate_score_result(blocks=blocks, yaku_list=[])
+            self.highest_result = max(self.highest_result, score_result)
+
+    def _thirteen_orphans_shape_calculator(self) -> None:
+        if can_divide_thirteen_orphans_shape(self.hand):
+            self.is_blocks_divided = True
+            yaku_list: list[Yaku] = [Yaku.ThirteenOrphans]
+
+            score_result = self._calculate_score_result(blocks=[], yaku_list=yaku_list)
             self.highest_result = max(self.highest_result, score_result)
 
-    def _calculate_score_result(self, blocks: list[Block]) -> ScoreResult:
+    def _honors_and_knitted_shape_calculator(self) -> None:
+        if can_divide_honors_and_knitted_shape(self.hand):
+            self.is_blocks_divided = True
+            yaku_list: list[Yaku] = []
+
+            honor_count = sum(1 for tile in Tile.honor_tiles() if self.hand.tiles[tile])
+            match honor_count:
+                case 7:
+                    yaku_list.append(Yaku.GreaterHonorsAndKnittedTiles)
+                case 5:
+                    yaku_list.extend(
+                        [Yaku.LesserHonorsAndKnittedTiles, Yaku.KnittedStraight],
+                    )
+                case _:
+                    yaku_list.append(Yaku.LesserHonorsAndKnittedTiles)
path: app/score_calculator/score_calculator.py
user: login: mcdbgk99
body: ```
case 5:
KnittedStraight
case _":
LesserHonors...
```
같은식으로 하는건 별로인가요
created_at: 2025-03-11T12:50:33Z
author_association: NONE
diff_hunk: @@ -33,29 +43,76 @@ def __init__(self, hand: Hand, winning_conditions: WinningConditions):
         )
         self.highest_result = ScoreResult(yaku_score_list=[])
         self.is_blocks_divided = False
+        self._calculate()
+
+    @property
+    def result(self) -> ScoreResult:
+        return self.highest_result
+
+    def _calculate(self) -> None:
+        self._general_and_seven_pairs_shape_calculator()
+        self._thirteen_orphans_shape_calculator()
+        self._honors_and_knitted_shape_calculator()
+        if self.is_blocks_divided and self.highest_result.total_score == 0:
+            self.highest_result.add_yaku(yaku=Yaku.ChickenHand, count=1)
 
-    def general_shape_calculator(self) -> None:
-        parsed_hands: list[list[Block]] = divide_general_shape(self.hand)
+    def _general_and_seven_pairs_shape_calculator(self) -> None:
+        parsed_hands: list[list[Block]] = (
+            divide_general_shape(self.hand)
+            + divide_general_shape_knitted_sub(self.hand)
+            + divide_seven_pairs_shape(self.hand)
+        )
         if parsed_hands:
             self.is_blocks_divided = True
         for blocks in parsed_hands:
-            score_result = self._calculate_score_result(blocks)
+            score_result = self._calculate_score_result(blocks=blocks, yaku_list=[])
+            self.highest_result = max(self.highest_result, score_result)
+
+    def _thirteen_orphans_shape_calculator(self) -> None:
+        if can_divide_thirteen_orphans_shape(self.hand):
+            self.is_blocks_divided = True
+            yaku_list: list[Yaku] = [Yaku.ThirteenOrphans]
+
+            score_result = self._calculate_score_result(blocks=[], yaku_list=yaku_list)
             self.highest_result = max(self.highest_result, score_result)
 
-    def _calculate_score_result(self, blocks: list[Block]) -> ScoreResult:
+    def _honors_and_knitted_shape_calculator(self) -> None:
+        if can_divide_honors_and_knitted_shape(self.hand):
+            self.is_blocks_divided = True
+            yaku_list: list[Yaku] = []
+
+            honor_count = sum(1 for tile in Tile.honor_tiles() if self.hand.tiles[tile])
+            match honor_count:
+                case 7:
+                    yaku_list.append(Yaku.GreaterHonorsAndKnittedTiles)
+                case 5:
+                    yaku_list.extend(
+                        [Yaku.LesserHonorsAndKnittedTiles, Yaku.KnittedStraight],
+                    )
+                case _:
+                    yaku_list.append(Yaku.LesserHonorsAndKnittedTiles)
path: app/score_calculator/score_calculator.py
user: login: mcdbgk99
body: 설명이 좀 애매한데 패스쓰루 느낌?
created_at: 2025-03-11T12:51:10Z
author_association: NONE
diff_hunk: @@ -33,29 +43,76 @@ def __init__(self, hand: Hand, winning_conditions: WinningConditions):
         )
         self.highest_result = ScoreResult(yaku_score_list=[])
         self.is_blocks_divided = False
+        self._calculate()
+
+    @property
+    def result(self) -> ScoreResult:
+        return self.highest_result
+
+    def _calculate(self) -> None:
+        self._general_and_seven_pairs_shape_calculator()
+        self._thirteen_orphans_shape_calculator()
+        self._honors_and_knitted_shape_calculator()
path: app/score_calculator/score_calculator.py
user: login: mcdbgk99
body: thirteen... / honors_and... 이 먼저 확인되면 general_and...는 확인할필요가 없음
created_at: 2025-03-11T12:55:47Z
author_association: NONE
diff_hunk: @@ -255,15 +256,23 @@ def is_mixed_double_chow(self) -> bool:
 
     @property
     def is_short_straight(self) -> bool:
-        return self.validate_blocks(
-            lambda x: x.is_number and x.is_sequence,
-        ) and self.has_constant_gap(self.STRAIGHT_GAP)
+        return (
+            self.validate_blocks(
+                lambda x: x.is_number and x.is_sequence,
+            )
+            and self.tile_type_count == 1
path: app/score_calculator/yaku_check/blocks_yaku_checker.py
user: login: mcdbgk99
body: short_straight 블럭만 들어오나요?
created_at: 2025-03-11T12:57:53Z
author_association: NONE
diff_hunk: @@ -255,15 +256,23 @@ def is_mixed_double_chow(self) -> bool:
 
     @property
     def is_short_straight(self) -> bool:
-        return self.validate_blocks(
-            lambda x: x.is_number and x.is_sequence,
-        ) and self.has_constant_gap(self.STRAIGHT_GAP)
+        return (
+            self.validate_blocks(
+                lambda x: x.is_number and x.is_sequence,
+            )
+            and self.tile_type_count == 1
+            and self.has_constant_gap(self.STRAIGHT_GAP)
+        )
 
     @property
     def is_two_terminal_chows(self) -> bool:
-        return self.validate_blocks(
-            lambda x: x.is_number and x.is_sequence,
-        ) and self.has_constant_gap(self.TERMINAL_GAP)
+        return (
+            self.validate_blocks(
+                lambda x: x.is_number and x.is_sequence,
+            )
+            and self.tile_type_count == 1
path: app/score_calculator/yaku_check/blocks_yaku_checker.py
user: login: mcdbgk99
body: two_ternimal_chows 블럭만 들어오나요?
created_at: 2025-03-11T12:58:54Z
author_association: NONE
diff_hunk: @@ -33,29 +43,76 @@ def __init__(self, hand: Hand, winning_conditions: WinningConditions):
         )
         self.highest_result = ScoreResult(yaku_score_list=[])
         self.is_blocks_divided = False
+        self._calculate()
+
+    @property
+    def result(self) -> ScoreResult:
+        return self.highest_result
+
+    def _calculate(self) -> None:
+        self._general_and_seven_pairs_shape_calculator()
+        self._thirteen_orphans_shape_calculator()
+        self._honors_and_knitted_shape_calculator()
+        if self.is_blocks_divided and self.highest_result.total_score == 0:
+            self.highest_result.add_yaku(yaku=Yaku.ChickenHand, count=1)
 
-    def general_shape_calculator(self) -> None:
-        parsed_hands: list[list[Block]] = divide_general_shape(self.hand)
+    def _general_and_seven_pairs_shape_calculator(self) -> None:
+        parsed_hands: list[list[Block]] = (
+            divide_general_shape(self.hand)
+            + divide_general_shape_knitted_sub(self.hand)
+            + divide_seven_pairs_shape(self.hand)
+        )
         if parsed_hands:
             self.is_blocks_divided = True
         for blocks in parsed_hands:
-            score_result = self._calculate_score_result(blocks)
+            score_result = self._calculate_score_result(blocks=blocks, yaku_list=[])
+            self.highest_result = max(self.highest_result, score_result)
+
+    def _thirteen_orphans_shape_calculator(self) -> None:
+        if can_divide_thirteen_orphans_shape(self.hand):
+            self.is_blocks_divided = True
+            yaku_list: list[Yaku] = [Yaku.ThirteenOrphans]
+
+            score_result = self._calculate_score_result(blocks=[], yaku_list=yaku_list)
             self.highest_result = max(self.highest_result, score_result)
 
-    def _calculate_score_result(self, blocks: list[Block]) -> ScoreResult:
+    def _honors_and_knitted_shape_calculator(self) -> None:
+        if can_divide_honors_and_knitted_shape(self.hand):
+            self.is_blocks_divided = True
+            yaku_list: list[Yaku] = []
+
+            honor_count = sum(1 for tile in Tile.honor_tiles() if self.hand.tiles[tile])
+            match honor_count:
+                case 7:
+                    yaku_list.append(Yaku.GreaterHonorsAndKnittedTiles)
+                case 5:
+                    yaku_list.extend(
+                        [Yaku.LesserHonorsAndKnittedTiles, Yaku.KnittedStraight],
+                    )
+                case _:
+                    yaku_list.append(Yaku.LesserHonorsAndKnittedTiles)
path: app/score_calculator/score_calculator.py
user: login: Hot6Mania
body: c++에선 되는데 python은 이게 최선인듯?

created_at: 2025-03-11T14:18:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,29 +43,76 @@ def __init__(self, hand: Hand, winning_conditions: WinningConditions):
         )
         self.highest_result = ScoreResult(yaku_score_list=[])
         self.is_blocks_divided = False
+        self._calculate()
+
+    @property
+    def result(self) -> ScoreResult:
+        return self.highest_result
+
+    def _calculate(self) -> None:
+        self._general_and_seven_pairs_shape_calculator()
+        self._thirteen_orphans_shape_calculator()
+        self._honors_and_knitted_shape_calculator()
path: app/score_calculator/score_calculator.py
user: login: Hot6Mania
body: 배타적인 관계긴 한데 꼭 정리해야하나 싶긴함 함부게쌤이 일단 문제없이 구현하는것이 필요시 나중에 최적화하는 거라고 하셨는데 지금 필요하다고 느껴지면 이부분 최적화할까요
created_at: 2025-03-11T14:22:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -255,15 +256,23 @@ def is_mixed_double_chow(self) -> bool:
 
     @property
     def is_short_straight(self) -> bool:
-        return self.validate_blocks(
-            lambda x: x.is_number and x.is_sequence,
-        ) and self.has_constant_gap(self.STRAIGHT_GAP)
+        return (
+            self.validate_blocks(
+                lambda x: x.is_number and x.is_sequence,
+            )
+            and self.tile_type_count == 1
path: app/score_calculator/yaku_check/blocks_yaku_checker.py
user: login: Hot6Mania
body: 0. 2블럭인 경우
1. 수패이며, 슌쯔이며,
2. 같은 색깔의 수패이며,
3. GAP이 3일 경우

created_at: 2025-03-11T14:24:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -255,15 +256,23 @@ def is_mixed_double_chow(self) -> bool:
 
     @property
     def is_short_straight(self) -> bool:
-        return self.validate_blocks(
-            lambda x: x.is_number and x.is_sequence,
-        ) and self.has_constant_gap(self.STRAIGHT_GAP)
+        return (
+            self.validate_blocks(
+                lambda x: x.is_number and x.is_sequence,
+            )
+            and self.tile_type_count == 1
+            and self.has_constant_gap(self.STRAIGHT_GAP)
+        )
 
     @property
     def is_two_terminal_chows(self) -> bool:
-        return self.validate_blocks(
-            lambda x: x.is_number and x.is_sequence,
-        ) and self.has_constant_gap(self.TERMINAL_GAP)
+        return (
+            self.validate_blocks(
+                lambda x: x.is_number and x.is_sequence,
+            )
+            and self.tile_type_count == 1
path: app/score_calculator/yaku_check/blocks_yaku_checker.py
user: login: Hot6Mania
body: 0. 2블럭인 경우
1. 수패이며, 슌쯔이며,
2. 같은 색깔의 수패이며,
3. GAP이 6일 경우

2블럭임이 보장됩니다.
created_at: 2025-03-11T14:24:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,238 @@
+<!DOCTYPE html>
+<html lang="ko">
+
+<head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Score Checker</title>
+    <style>
+        body {
+            font-family: Arial, sans-serif;
+            margin: 2rem;
+        }
+
+        h1 {
+            margin-bottom: 1rem;
+        }
+
+        form {
+            /* 폼 자체는 왼쪽 정렬로 두고, 모바일 대응 위해 너비 제한 X */
+        }
+
+        .form-row {
+            display: flex;
+            align-items: center;
+            margin-bottom: 1rem;
+        }
+
+        .form-row label {
+            width: 180px;
+            margin-right: 0.5rem;
+        }
+
+        .form-row input,
+        .form-row select {
+            /* 기본적으로 auto 크기 */
+        }
+
+        table {
+            border-collapse: collapse;
+            margin-top: 1rem;
+            table-layout: auto;
+            /* 자동 레이아웃 */
+            width: auto;
+            /* 내용에 맞춰 자동으로 */
+        }
+
+        table,
+        th,
+        td {
+            border: 1px solid #ccc;
+        }
+
+        th,
+        td {
+            padding: 8px;
+            text-align: center;
+            white-space: nowrap;
+            /* 줄바꿈 없이 한 줄로 표시 */
+        }
+
+        th {
+            background-color: #f2f2f2;
+        }
+
+        /* 모바일 화면에서 너무 좁아지면 세로로 전환 */
+        @media (max-width: 600px) {
+            .form-row {
+                flex-direction: column;
+                align-items: flex-start;
+            }
+
+            .form-row label {
+                margin-right: 0;
+                margin-bottom: 0.3rem;
+                width: auto;
+                /* 레이블 너비 해제 */
+            }
+        }
+    </style>
+</head>
+
+<body>
+    <h1>Score Checker</h1>
+    <form id="scoreForm">
+        <div class="form-row">
+            <label for="raw_hand">Raw Hand:</label>
+            <input type="text" id="raw_hand" name="raw_hand" required />
+        </div>
+        <div class="form-row">
+            <label for="winning_tile">Winning Tile:</label>
+            <input type="text" id="winning_tile" name="winning_tile" required />
+        </div>
+        <div class="form-row">
+            <label for="is_discarded">Is Discarded:</label>
+            <input type="checkbox" id="is_discarded" name="is_discarded" checked />
+        </div>
+        <div class="form-row">
+            <label for="seat_wind">Seat Wind:</label>
+            <select id="seat_wind" name="seat_wind">
+                <option value="EAST" selected>EAST</option>
+                <option value="SOUTH">SOUTH</option>
+                <option value="WEST">WEST</option>
+                <option value="NORTH">NORTH</option>
+            </select>
+        </div>
+        <div class="form-row">
+            <label for="round_wind">Round Wind:</label>
+            <select id="round_wind" name="round_wind">
+                <option value="EAST" selected>EAST</option>
+                <option value="SOUTH">SOUTH</option>
+                <option value="WEST">WEST</option>
+                <option value="NORTH">NORTH</option>
+            </select>
+        </div>
+        <div class="form-row">
+            <label for="is_last_tile_in_the_game">Last Tile in Game:</label>
+            <input type="checkbox" id="is_last_tile_in_the_game" name="is_last_tile_in_the_game" />
+        </div>
+        <div class="form-row">
+            <label for="is_last_tile_of_its_kind">Last Tile of Its Kind:</label>
+            <input type="checkbox" id="is_last_tile_of_its_kind" name="is_last_tile_of_its_kind" />
+        </div>
+        <div class="form-row">
+            <label for="is_replacement_tile">Is Replacement Tile:</label>
+            <input type="checkbox" id="is_replacement_tile" name="is_replacement_tile" />
+        </div>
+        <div class="form-row">
+            <label for="is_robbing_the_kong">Is Robbing the Kong:</label>
+            <input type="checkbox" id="is_robbing_the_kong" name="is_robbing_the_kong" />
+        </div>
+        <button type="submit">Check Score</button>
+    </form>
+
+    <h2>Result</h2>
+    <div id="result"></div>
+
+    <script>
+        // 대문자를 기준으로 띄어쓰기 (CamelCase -> "Camel Case")
+        function splitCamelCase(str) {
+            return str.replace(/([A-Z])/g, " $1").trim();
+        }
+
+        const form = document.getElementById("scoreForm");
+        const resultDiv = document.getElementById("result");
+
+        form.addEventListener("submit", async (e) => {
+            e.preventDefault();
+
+            const raw_hand = document.getElementById("raw_hand").value;
+            const winning_tile = document.getElementById("winning_tile").value;
+            const is_discarded = document.getElementById("is_discarded").checked;
+            const seat_wind = document.getElementById("seat_wind").value;
+            const round_wind = document.getElementById("round_wind").value;
+            const is_last_tile_in_the_game = document.getElementById("is_last_tile_in_the_game").checked;
+            const is_last_tile_of_its_kind = document.getElementById("is_last_tile_of_its_kind").checked;
+            const is_replacement_tile = document.getElementById("is_replacement_tile").checked;
+            const is_robbing_the_kong = document.getElementById("is_robbing_the_kong").checked;
+
+            const data = {
+                raw_hand,
+                winning_tile,
+                is_discarded,
+                seat_wind,
+                round_wind,
+                is_last_tile_in_the_game,
+                is_last_tile_of_its_kind,
+                is_replacement_tile,
+                is_robbing_the_kong,
+            };
+
+            try {
+                const response = await fetch("/score-check", {
+                    method: "POST",
+                    headers: { "Content-Type": "application/json" },
+                    body: JSON.stringify(data),
+                });
+                const result = await response.json();
+
+                if (result.error) {
+                    resultDiv.textContent = result.error;
+                    return;
+                }
+
+                // 결과 영역 초기화
+                resultDiv.innerHTML = "";
+
+                // 1) Total Score 표시
+                const totalScoreDiv = document.createElement("div");
+                totalScoreDiv.style.marginBottom = "1rem";
+                totalScoreDiv.textContent = "Total Score: " + result.total_score;
+                resultDiv.appendChild(totalScoreDiv);
+
+                // 2) 점수 내림차순 정렬
+                const sortedList = [...result.yaku_score_list].sort((a, b) => b.score - a.score);
+
+                // 3) yaku_score_list 데이터를 테이블로 구성
+                const table = document.createElement("table");
+                const thead = document.createElement("thead");
+                const tbody = document.createElement("tbody");
+
+                // 테이블 헤더
+                const headerRow = document.createElement("tr");
+                const yakuHeader = document.createElement("th");
+                yakuHeader.textContent = "Yaku";
+                const scoreHeader = document.createElement("th");
+                scoreHeader.textContent = "Score";
+                headerRow.appendChild(yakuHeader);
+                headerRow.appendChild(scoreHeader);
+                thead.appendChild(headerRow);
+
+                // 테이블 본문
+                sortedList.forEach(({ name, score }) => {
+                    const row = document.createElement("tr");
+                    const yakuCell = document.createElement("td");
+                    const scoreCell = document.createElement("td");
+
+                    // 대문자 기준으로 띄어쓰기
+                    yakuCell.textContent = splitCamelCase(name);
+                    scoreCell.textContent = score;
+
+                    row.appendChild(yakuCell);
+                    row.appendChild(scoreCell);
+                    tbody.appendChild(row);
+                });
+
+                table.appendChild(thead);
+                table.appendChild(tbody);
+
+                // 결과 영역에 테이블 추가
+                resultDiv.appendChild(table);
+            } catch (err) {
+                resultDiv.textContent = "Error: " + err;
+            }
+        });
+    </script>
+</body>
+
+</html>
path: app/index.html
user: login: taeho-gwon
body: python code가 아니라서 linter가 동작을 안해서 eof처리가 안됬네요.
뭐 둬도 괜찮겠죠.
created_at: 2025-03-11T18:24:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,8 +1,89 @@
-from fastapi import FastAPI
+import os
+
+from fastapi import FastAPI, status
+from fastapi.responses import HTMLResponse, JSONResponse
+from pydantic import BaseModel
+
+from app.score_calculator.enums.enums import Tile, Wind
+from app.score_calculator.score_calculator import ScoreCalculator
+from tests.test_utils import (
+    create_default_winning_conditions,
+    name_to_tile,
+    raw_string_to_hand_class,
+)
 
 app = FastAPI()
 
 
-@app.get("/")
-def read_root() -> dict[str, str]:
-    return {"Hello": "World"}
+class ScoreCheckInput(BaseModel):
+    raw_hand: str
+    winning_tile: str
+    is_discarded: bool
+    seat_wind: str = Wind.EAST.name
+    round_wind: str = Wind.EAST.name
+    is_last_tile_in_the_game: bool = False
+    is_last_tile_of_its_kind: bool = False
+    is_replacement_tile: bool = False
+    is_robbing_the_kong: bool = False
+
+
+@app.get("/", response_class=HTMLResponse)
+def read_root() -> str:
+    base_dir = os.path.dirname(os.path.abspath(__file__))
+    html_path = os.path.join(base_dir, "index.html")
+    with open(html_path, encoding="utf-8") as f:
+        return f.read()
+
+
+@app.post("/score-check")
+def score_check(input: ScoreCheckInput) -> JSONResponse:
+    hand = raw_string_to_hand_class(input.raw_hand)
+    if (winning_tile := name_to_tile(input.winning_tile)) == Tile.F0:
+        return JSONResponse(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            content={"error": f"Invalid winning_tile: {input.winning_tile}"},
+        )
+    try:
+        seat_wind = Wind[input.seat_wind]
+    except KeyError:
+        return JSONResponse(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            content={"error": f"Invalid seat_wind: {input.seat_wind}"},
+        )
+    try:
+        round_wind = Wind[input.round_wind]
+    except KeyError:
+        return JSONResponse(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            content={"error": f"Invalid round_wind: {input.round_wind}"},
+        )
+
+    winning_conditions = create_default_winning_conditions(
+        winning_tile=winning_tile,
+        is_discarded=input.is_discarded,
+        seat_wind=seat_wind,
+        round_wind=round_wind,
+        is_last_tile_in_the_game=input.is_last_tile_in_the_game,
+        is_last_tile_of_its_kind=input.is_last_tile_of_its_kind,
+        is_replacement_tile=input.is_replacement_tile,
+        is_robbing_the_kong=input.is_robbing_the_kong,
+    )
+
+    score_calc = ScoreCalculator(hand=hand, winning_conditions=winning_conditions)
+
+    return JSONResponse(
+        status_code=status.HTTP_200_OK,
+        content={
+            "total_score": score_calc.result.total_score,
+            "yaku_score_list": [
+                {"name": yaku.name, "score": score}
+                for yaku, score in score_calc.result.yaku_score_list
+            ],
+        },
+    )
+
+
+if __name__ == "__main__":
+    import uvicorn
+
+    uvicorn.run("app.main:app", host="127.0.0.1", port=8000, reload=True)
path: app/main.py
user: login: taeho-gwon
body: 아주 특수한 경우가 아니라면 코드 중간에 import하는 걸 그렇게 선호하진 않습니다.
import를 특수한 경우에만 해야한다면 파일을 분리해야하는 신호라고 생각하기 때문입니다.
근데 이거 그냥 name main없이 그냥 start 명령어로 실행하면 도는 거 아닌가요?
created_at: 2025-03-11T18:27:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -23,20 +23,36 @@ def add_yaku(self, yaku: Yaku, count: int) -> None:
 @dataclass
 class ScoringContext:
     blocks: list[Block]
-    used_block_flag: list[bool]
+    used_block_flag: list[set[Yaku]]
 
     def get_yakus(self) -> list[Yaku]:
         result: list[Yaku] = []
         for length in (4, 3, 2):
-            for combination in combinations(range(4), length):
+            stack: list[tuple[int, ...]] = list(combinations(range(4), length))
+            while stack:
+                combination: tuple[int, ...] = stack.pop()
                 if all(self.used_block_flag[i] for i in combination):
                     continue
-                if yaku := BlocksYakuChecker(
-                    [self.blocks[i] for i in combination],
-                ).yakus:
-                    result += yaku
+                if (
+                    yakus := BlocksYakuChecker(
+                        [self.blocks[i] for i in combination],
+                    ).yakus
+                ) and not any(yakus[0] in self.used_block_flag[i] for i in combination):
+                    result += yakus
+                    if length == 2:
+                        stack.extend(
+                            new_combination
+                            for new_combination in list(combinations(range(4), length))
+                            if (
+                                new_combination != combination
+                                and (
+                                    combination[0] in set(new_combination)
+                                    or combination[1] in set(new_combination)
+                                )
+                            )
+                        )
                     for i in combination:
-                        self.used_block_flag[i] = True
+                        self.used_block_flag[i].add(yakus[0])
                     if length != 2:
                         break
         return result
path: app/score_calculator/result/result.py
user: login: taeho-gwon
body: 요함수가 좀 복잡하다는 느낌이네요.
```python
def get_yakus():
    return f(4)+f(3)+f(2)
```
같은 느낌으로 좀 단순하게 유지하고 중간의 복잡한 반복문을 정리하는 것도 좋을것 같습니다.
특히 length==2일때 동작이 다른걸 억지로 우겨넣으려니까 복잡해진거 같네요.
좀 적당히 나누는게 좋을것 같아요.
created_at: 2025-03-11T19:30:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,29 +43,76 @@ def __init__(self, hand: Hand, winning_conditions: WinningConditions):
         )
         self.highest_result = ScoreResult(yaku_score_list=[])
         self.is_blocks_divided = False
+        self._calculate()
+
+    @property
+    def result(self) -> ScoreResult:
+        return self.highest_result
+
+    def _calculate(self) -> None:
+        self._general_and_seven_pairs_shape_calculator()
+        self._thirteen_orphans_shape_calculator()
+        self._honors_and_knitted_shape_calculator()
path: app/score_calculator/score_calculator.py
user: login: taeho-gwon
body: 명백히 나은 방식이 있고, 고치는데 오래걸리지 않으니 바로 수정하는게 좋겠습니다.
제 불만은 함수가 동사가 아닌것입니다.
created_at: 2025-03-11T19:33:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -45,7 +61,7 @@ def get_yakus(self) -> list[Yaku]:
     def create_from_blocks(
         blocks: list[Block],
     ) -> ScoringContext:
-        used_block_flag: list[bool] = [False] * len(blocks)
+        used_block_flag: list[set[Yaku]] = [set() for _ in range(len(blocks))]
path: app/score_calculator/result/result.py
user: login: taeho-gwon
body: 처음하시는 분들이 이거 실수 많이 하시는데 좋네요.
특별한 이유가 없다면 range로 돌지 말고, 직접 block으로 돕시다.
```python
[set() for _ in blocks]
```
created_at: 2025-03-11T19:34:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,29 +43,76 @@ def __init__(self, hand: Hand, winning_conditions: WinningConditions):
         )
         self.highest_result = ScoreResult(yaku_score_list=[])
         self.is_blocks_divided = False
+        self._calculate()
+
+    @property
+    def result(self) -> ScoreResult:
+        return self.highest_result
path: app/score_calculator/score_calculator.py
user: login: taeho-gwon
body: 이 property를 별도로 만들어야하는 이유가 있나요?
ReadOnly로 두는 게 목표라면 highest_result를 private처럼 표시해주세요
created_at: 2025-03-11T19:37:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,29 +43,76 @@ def __init__(self, hand: Hand, winning_conditions: WinningConditions):
         )
         self.highest_result = ScoreResult(yaku_score_list=[])
         self.is_blocks_divided = False
+        self._calculate()
+
+    @property
+    def result(self) -> ScoreResult:
+        return self.highest_result
+
+    def _calculate(self) -> None:
+        self._general_and_seven_pairs_shape_calculator()
+        self._thirteen_orphans_shape_calculator()
+        self._honors_and_knitted_shape_calculator()
+        if self.is_blocks_divided and self.highest_result.total_score == 0:
+            self.highest_result.add_yaku(yaku=Yaku.ChickenHand, count=1)
 
-    def general_shape_calculator(self) -> None:
-        parsed_hands: list[list[Block]] = divide_general_shape(self.hand)
+    def _general_and_seven_pairs_shape_calculator(self) -> None:
+        parsed_hands: list[list[Block]] = (
+            divide_general_shape(self.hand)
+            + divide_general_shape_knitted_sub(self.hand)
+            + divide_seven_pairs_shape(self.hand)
+        )
         if parsed_hands:
             self.is_blocks_divided = True
         for blocks in parsed_hands:
-            score_result = self._calculate_score_result(blocks)
+            score_result = self._calculate_score_result(blocks=blocks, yaku_list=[])
+            self.highest_result = max(self.highest_result, score_result)
+
+    def _thirteen_orphans_shape_calculator(self) -> None:
+        if can_divide_thirteen_orphans_shape(self.hand):
+            self.is_blocks_divided = True
+            yaku_list: list[Yaku] = [Yaku.ThirteenOrphans]
+
+            score_result = self._calculate_score_result(blocks=[], yaku_list=yaku_list)
             self.highest_result = max(self.highest_result, score_result)
 
-    def _calculate_score_result(self, blocks: list[Block]) -> ScoreResult:
+    def _honors_and_knitted_shape_calculator(self) -> None:
+        if can_divide_honors_and_knitted_shape(self.hand):
+            self.is_blocks_divided = True
+            yaku_list: list[Yaku] = []
+
+            honor_count = sum(1 for tile in Tile.honor_tiles() if self.hand.tiles[tile])
+            match honor_count:
+                case 7:
+                    yaku_list.append(Yaku.GreaterHonorsAndKnittedTiles)
+                case 5:
+                    yaku_list.extend(
+                        [Yaku.LesserHonorsAndKnittedTiles, Yaku.KnittedStraight],
+                    )
+                case _:
+                    yaku_list.append(Yaku.LesserHonorsAndKnittedTiles)
path: app/score_calculator/score_calculator.py
user: login: taeho-gwon
body: 굳이 이런거 까지 패턴매칭을 쓸필요는 없어보이네요.
이런건 if else가 더 간단할것 같습니다.
match case를 굳이 다 써야할 필요는 없습니다.
created_at: 2025-03-11T19:42:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -255,14 +263,21 @@ def _get_hand_shape_conditions(self) -> list[tuple[Callable[[], bool], Yaku]]:
                 Yaku.SevenShiftedPairs,
             ),
             (
-                lambda: self.validate_tiles(lambda t: t.is_number)
-                and self.num_tile_types_count == 1
-                and all(
-                    self.tiles[t] >= 3 if t.number in {1, 9} else self.tiles[t] >= 1
-                    for t in self.tiles
-                )
-                and self.winning_conditions.count_tenpai_tiles == 9
-                and not self.winning_conditions.is_discarded,
+                lambda: (
+                    (first_key := next(iter(self.tiles.keys()))) is not None
+                    and self.validate_tiles(lambda t: t.is_number)
+                    and self.num_tile_types_count == 1
+                    and all(
+                        self.tiles.get(
+                            (tile := Tile(first_key - first_key.number + i)),
+                            0,
+                        )
+                        >= (3 if tile.number in {1, 9} else 1)
+                        for i in range(1, 10)
+                    )
+                    and self.winning_conditions.count_tenpai_tiles == 9
+                    and self.validate_blocks(lambda b: not b.is_opened)
+                ),
path: app/score_calculator/yaku_check/hand_yaku_checker.py
user: login: taeho-gwon
body: 람다가 이정도로 복잡하면 따로 빼는게....
created_at: 2025-03-11T19:43:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,238 @@
+<!DOCTYPE html>
+<html lang="ko">
+
+<head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Score Checker</title>
+    <style>
+        body {
+            font-family: Arial, sans-serif;
+            margin: 2rem;
+        }
+
+        h1 {
+            margin-bottom: 1rem;
+        }
+
+        form {
+            /* 폼 자체는 왼쪽 정렬로 두고, 모바일 대응 위해 너비 제한 X */
+        }
+
+        .form-row {
+            display: flex;
+            align-items: center;
+            margin-bottom: 1rem;
+        }
+
+        .form-row label {
+            width: 180px;
+            margin-right: 0.5rem;
+        }
+
+        .form-row input,
+        .form-row select {
+            /* 기본적으로 auto 크기 */
+        }
+
+        table {
+            border-collapse: collapse;
+            margin-top: 1rem;
+            table-layout: auto;
+            /* 자동 레이아웃 */
+            width: auto;
+            /* 내용에 맞춰 자동으로 */
+        }
+
+        table,
+        th,
+        td {
+            border: 1px solid #ccc;
+        }
+
+        th,
+        td {
+            padding: 8px;
+            text-align: center;
+            white-space: nowrap;
+            /* 줄바꿈 없이 한 줄로 표시 */
+        }
+
+        th {
+            background-color: #f2f2f2;
+        }
+
+        /* 모바일 화면에서 너무 좁아지면 세로로 전환 */
+        @media (max-width: 600px) {
+            .form-row {
+                flex-direction: column;
+                align-items: flex-start;
+            }
+
+            .form-row label {
+                margin-right: 0;
+                margin-bottom: 0.3rem;
+                width: auto;
+                /* 레이블 너비 해제 */
+            }
+        }
+    </style>
+</head>
+
+<body>
+    <h1>Score Checker</h1>
+    <form id="scoreForm">
+        <div class="form-row">
+            <label for="raw_hand">Raw Hand:</label>
+            <input type="text" id="raw_hand" name="raw_hand" required />
+        </div>
+        <div class="form-row">
+            <label for="winning_tile">Winning Tile:</label>
+            <input type="text" id="winning_tile" name="winning_tile" required />
+        </div>
+        <div class="form-row">
+            <label for="is_discarded">Is Discarded:</label>
+            <input type="checkbox" id="is_discarded" name="is_discarded" checked />
+        </div>
+        <div class="form-row">
+            <label for="seat_wind">Seat Wind:</label>
+            <select id="seat_wind" name="seat_wind">
+                <option value="EAST" selected>EAST</option>
+                <option value="SOUTH">SOUTH</option>
+                <option value="WEST">WEST</option>
+                <option value="NORTH">NORTH</option>
+            </select>
+        </div>
+        <div class="form-row">
+            <label for="round_wind">Round Wind:</label>
+            <select id="round_wind" name="round_wind">
+                <option value="EAST" selected>EAST</option>
+                <option value="SOUTH">SOUTH</option>
+                <option value="WEST">WEST</option>
+                <option value="NORTH">NORTH</option>
+            </select>
+        </div>
+        <div class="form-row">
+            <label for="is_last_tile_in_the_game">Last Tile in Game:</label>
+            <input type="checkbox" id="is_last_tile_in_the_game" name="is_last_tile_in_the_game" />
+        </div>
+        <div class="form-row">
+            <label for="is_last_tile_of_its_kind">Last Tile of Its Kind:</label>
+            <input type="checkbox" id="is_last_tile_of_its_kind" name="is_last_tile_of_its_kind" />
+        </div>
+        <div class="form-row">
+            <label for="is_replacement_tile">Is Replacement Tile:</label>
+            <input type="checkbox" id="is_replacement_tile" name="is_replacement_tile" />
+        </div>
+        <div class="form-row">
+            <label for="is_robbing_the_kong">Is Robbing the Kong:</label>
+            <input type="checkbox" id="is_robbing_the_kong" name="is_robbing_the_kong" />
+        </div>
+        <button type="submit">Check Score</button>
+    </form>
+
+    <h2>Result</h2>
+    <div id="result"></div>
+
+    <script>
+        // 대문자를 기준으로 띄어쓰기 (CamelCase -> "Camel Case")
+        function splitCamelCase(str) {
+            return str.replace(/([A-Z])/g, " $1").trim();
+        }
+
+        const form = document.getElementById("scoreForm");
+        const resultDiv = document.getElementById("result");
+
+        form.addEventListener("submit", async (e) => {
+            e.preventDefault();
+
+            const raw_hand = document.getElementById("raw_hand").value;
+            const winning_tile = document.getElementById("winning_tile").value;
+            const is_discarded = document.getElementById("is_discarded").checked;
+            const seat_wind = document.getElementById("seat_wind").value;
+            const round_wind = document.getElementById("round_wind").value;
+            const is_last_tile_in_the_game = document.getElementById("is_last_tile_in_the_game").checked;
+            const is_last_tile_of_its_kind = document.getElementById("is_last_tile_of_its_kind").checked;
+            const is_replacement_tile = document.getElementById("is_replacement_tile").checked;
+            const is_robbing_the_kong = document.getElementById("is_robbing_the_kong").checked;
+
+            const data = {
+                raw_hand,
+                winning_tile,
+                is_discarded,
+                seat_wind,
+                round_wind,
+                is_last_tile_in_the_game,
+                is_last_tile_of_its_kind,
+                is_replacement_tile,
+                is_robbing_the_kong,
+            };
+
+            try {
+                const response = await fetch("/score-check", {
+                    method: "POST",
+                    headers: { "Content-Type": "application/json" },
+                    body: JSON.stringify(data),
+                });
+                const result = await response.json();
+
+                if (result.error) {
+                    resultDiv.textContent = result.error;
+                    return;
+                }
+
+                // 결과 영역 초기화
+                resultDiv.innerHTML = "";
+
+                // 1) Total Score 표시
+                const totalScoreDiv = document.createElement("div");
+                totalScoreDiv.style.marginBottom = "1rem";
+                totalScoreDiv.textContent = "Total Score: " + result.total_score;
+                resultDiv.appendChild(totalScoreDiv);
+
+                // 2) 점수 내림차순 정렬
+                const sortedList = [...result.yaku_score_list].sort((a, b) => b.score - a.score);
+
+                // 3) yaku_score_list 데이터를 테이블로 구성
+                const table = document.createElement("table");
+                const thead = document.createElement("thead");
+                const tbody = document.createElement("tbody");
+
+                // 테이블 헤더
+                const headerRow = document.createElement("tr");
+                const yakuHeader = document.createElement("th");
+                yakuHeader.textContent = "Yaku";
+                const scoreHeader = document.createElement("th");
+                scoreHeader.textContent = "Score";
+                headerRow.appendChild(yakuHeader);
+                headerRow.appendChild(scoreHeader);
+                thead.appendChild(headerRow);
+
+                // 테이블 본문
+                sortedList.forEach(({ name, score }) => {
+                    const row = document.createElement("tr");
+                    const yakuCell = document.createElement("td");
+                    const scoreCell = document.createElement("td");
+
+                    // 대문자 기준으로 띄어쓰기
+                    yakuCell.textContent = splitCamelCase(name);
+                    scoreCell.textContent = score;
+
+                    row.appendChild(yakuCell);
+                    row.appendChild(scoreCell);
+                    tbody.appendChild(row);
+                });
+
+                table.appendChild(thead);
+                table.appendChild(tbody);
+
+                // 결과 영역에 테이블 추가
+                resultDiv.appendChild(table);
+            } catch (err) {
+                resultDiv.textContent = "Error: " + err;
+            }
+        });
+    </script>
+</body>
+
+</html>
path: app/index.html
user: login: Hot6Mania
body: format on save 옵션이 되어있어서 eof가 삭제되었네요 껐습니다
created_at: 2025-03-11T19:50:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,8 +1,89 @@
-from fastapi import FastAPI
+import os
+
+from fastapi import FastAPI, status
+from fastapi.responses import HTMLResponse, JSONResponse
+from pydantic import BaseModel
+
+from app.score_calculator.enums.enums import Tile, Wind
+from app.score_calculator.score_calculator import ScoreCalculator
+from tests.test_utils import (
+    create_default_winning_conditions,
+    name_to_tile,
+    raw_string_to_hand_class,
+)
 
 app = FastAPI()
 
 
-@app.get("/")
-def read_root() -> dict[str, str]:
-    return {"Hello": "World"}
+class ScoreCheckInput(BaseModel):
+    raw_hand: str
+    winning_tile: str
+    is_discarded: bool
+    seat_wind: str = Wind.EAST.name
+    round_wind: str = Wind.EAST.name
+    is_last_tile_in_the_game: bool = False
+    is_last_tile_of_its_kind: bool = False
+    is_replacement_tile: bool = False
+    is_robbing_the_kong: bool = False
+
+
+@app.get("/", response_class=HTMLResponse)
+def read_root() -> str:
+    base_dir = os.path.dirname(os.path.abspath(__file__))
+    html_path = os.path.join(base_dir, "index.html")
+    with open(html_path, encoding="utf-8") as f:
+        return f.read()
+
+
+@app.post("/score-check")
+def score_check(input: ScoreCheckInput) -> JSONResponse:
+    hand = raw_string_to_hand_class(input.raw_hand)
+    if (winning_tile := name_to_tile(input.winning_tile)) == Tile.F0:
+        return JSONResponse(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            content={"error": f"Invalid winning_tile: {input.winning_tile}"},
+        )
+    try:
+        seat_wind = Wind[input.seat_wind]
+    except KeyError:
+        return JSONResponse(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            content={"error": f"Invalid seat_wind: {input.seat_wind}"},
+        )
+    try:
+        round_wind = Wind[input.round_wind]
+    except KeyError:
+        return JSONResponse(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            content={"error": f"Invalid round_wind: {input.round_wind}"},
+        )
+
+    winning_conditions = create_default_winning_conditions(
+        winning_tile=winning_tile,
+        is_discarded=input.is_discarded,
+        seat_wind=seat_wind,
+        round_wind=round_wind,
+        is_last_tile_in_the_game=input.is_last_tile_in_the_game,
+        is_last_tile_of_its_kind=input.is_last_tile_of_its_kind,
+        is_replacement_tile=input.is_replacement_tile,
+        is_robbing_the_kong=input.is_robbing_the_kong,
+    )
+
+    score_calc = ScoreCalculator(hand=hand, winning_conditions=winning_conditions)
+
+    return JSONResponse(
+        status_code=status.HTTP_200_OK,
+        content={
+            "total_score": score_calc.result.total_score,
+            "yaku_score_list": [
+                {"name": yaku.name, "score": score}
+                for yaku, score in score_calc.result.yaku_score_list
+            ],
+        },
+    )
+
+
+if __name__ == "__main__":
+    import uvicorn
+
+    uvicorn.run("app.main:app", host="127.0.0.1", port=8000, reload=True)
path: app/main.py
user: login: Hot6Mania
body: 제거했습니다
지금은 core 코드처럼 start로 키고있어서 필요없네요
created_at: 2025-03-11T19:51:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -255,14 +263,21 @@ def _get_hand_shape_conditions(self) -> list[tuple[Callable[[], bool], Yaku]]:
                 Yaku.SevenShiftedPairs,
             ),
             (
-                lambda: self.validate_tiles(lambda t: t.is_number)
-                and self.num_tile_types_count == 1
-                and all(
-                    self.tiles[t] >= 3 if t.number in {1, 9} else self.tiles[t] >= 1
-                    for t in self.tiles
-                )
-                and self.winning_conditions.count_tenpai_tiles == 9
-                and not self.winning_conditions.is_discarded,
+                lambda: (
+                    (first_key := next(iter(self.tiles.keys()))) is not None
+                    and self.validate_tiles(lambda t: t.is_number)
+                    and self.num_tile_types_count == 1
+                    and all(
+                        self.tiles.get(
+                            (tile := Tile(first_key - first_key.number + i)),
+                            0,
+                        )
+                        >= (3 if tile.number in {1, 9} else 1)
+                        for i in range(1, 10)
+                    )
+                    and self.winning_conditions.count_tenpai_tiles == 9
+                    and self.validate_blocks(lambda b: not b.is_opened)
+                ),
path: app/score_calculator/yaku_check/hand_yaku_checker.py
user: login: Hot6Mania
body: 넵 뺐습니다
created_at: 2025-03-11T20:17:24Z
author_association: CONTRIBUTOR

PR comments:
user: login: Hot6Mania
created_at: 2025-03-11T12:48:27Z
body: > 다른 파트는 잘 모르겠습니다만, 최소한 역 및 점수의 산정과 관련된 다수의 오류가 수정되었음은 확인하였습니다.

같이 검증하느라 수고하셨습니다! 더 오류가 있을지는 모르겠지만, 많은 부분을 잡아서 다행이네요
author_association: CONTRIBUTOR
user: login: Hot6Mania
created_at: 2025-03-11T21:01:21Z
body: > 코드가 대강봐도 좋아진거 같네용. 고생하셨습니다.

길고 상세한 리뷰 정말 고생 많으셨고 정말 감사합니다. 도움이 많이 됩니다. 더 좋은 코드 짜도록 노력하겠습니다.
fastapi 기본 구조 공부해서 core server코드 참고하면서 main에 router없이 임시로 열어논 endpoint들은 내일 즉시 정리하겠습니다.
author_association: CONTRIBUTOR