Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ CI=false npm run build # 빌드
| 1 | 인증/API 레이어 통일 및 레거시 제거 | **완료** |
| 2 | 게임 라우팅 및 진행 상태 정합성 수정 | **완료** |
| 3 | 디자인 시스템 재확립 및 대형 화면 분해 | 진행 중 |
| 4 | 스토리 분기 엔진 데이터화 | 예정 |
| 4 | 스토리 분기 엔진 데이터화 | **완료** |
| 5 | 문서 최종 동기화 및 배포 | 예정 |

각 Phase는 **1 이슈 = 1 브랜치 = 1 PR**로 진행한다.
Expand Down
28 changes: 20 additions & 8 deletions docs/GAME-FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,19 @@ Main2(카페)의 마지막 씬 "바닥에 돈이 떨어져 있는 것을 발견

## 점수 임계치

`src/game/storyFlow.js`의 `resolveNextStep()`에 정의되어 있다.
아래는 **현재 실제 동작**이며, `src/game/storyFlow.test.js`가 이 표를 그대로 검증한다.
**`src/data/stageRules.json`이 단일 출처다.** `src/game/storyFlow.js`는 이 규칙을 평가만 한다.
아래 표는 그 데이터의 내용이며, `src/game/storyFlow.test.js`가 그대로 검증한다.

```json
"0": [
{ "when": { "scoreMin": 15, "scoreMax": 40 }, "to": 1 },
{ "when": { "scoreMax": 15 }, "to": 2 },
{ "to": 3 }
]
```

규칙 배열을 위에서부터 평가해 첫 번째로 조건을 만족하는 대상으로 이동한다.
`when`이 없는 규칙은 기본값 역할을 한다.

| 스테이지 | 조건 | 분기 |
| -------- | ----------------------- | ------------------------------------------------------------- |
Expand Down Expand Up @@ -97,16 +108,17 @@ Main2(카페)의 마지막 씬 "바닥에 돈이 떨어져 있는 것을 발견
이 절의 내용은 `docs/superpowers/specs/2026-07-27-codebase-standardization-design.md` 1.2절의
진단과 대응한다.

### 1. 컴포넌트가 스토리 구조를 알고 있다 — **부분 해소**
### 1. 컴포넌트가 스토리 구조를 알고 있다 — **해소됨**

씬 전이 규칙이 `scene`/`index` 숫자 조건문 약 60줄로 컴포넌트에 박혀 있었다.

`src/game/storyFlow.js`로 **순수 함수 추출을 완료**했다. 컴포넌트에서 `scene ===` 조건문이
사라졌고, 전이 규칙은 이제 테스트 가능한 함수다. `src/game/storyFlow.test.js`의 25개
테스트가 **현재 동작의 기대값 표** 역할을 한다.
**해소 방식**

1. `src/game/storyFlow.js`로 순수 함수 추출 (컴포넌트의 `scene ===` 조건문 0건)
2. `src/game/storyFlow.test.js`에 전 경로 회귀 테스트 25개 — **현재 동작의 기대값 표**
3. 규칙을 `src/data/stageRules.json`으로 이관. 위 테스트가 데이터화 전후 모두 통과함을 확인

다만 규칙은 여전히 **코드**에 있다. JSON 데이터로 옮기는 작업이 남아 있으며,
그때 위 테스트가 회귀 안전망이 된다.
이제 **씬을 추가하거나 점수 임계치를 바꾸려면 JSON만 고치면 된다.** 코드는 손대지 않는다.

### 2. 우연히 동작하는 분기

Expand Down
67 changes: 67 additions & 0 deletions src/data/stageRules.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
{
"_comment": [
"스토리 전이 규칙. 원래는 컴포넌트에 숫자 조건문 60줄로 박혀 있었다.",
"",
"구조: stages[스테이지번호].scenes[씬인덱스] = 전이 규칙 배열",
" - 배열을 위에서부터 평가해 첫 번째로 조건을 만족하는 규칙을 따른다",
" - when 없는 규칙은 항상 참 (기본값 역할)",
" - when.scoreMin / when.scoreMax 로 점수 구간 지정 (Max는 미만)",
" - to: 다음 씬 인덱스 / exit: 화면 이동",
"",
"규칙이 없는 씬은 index + 1 로 순서대로 진행한다.",
"stageExit: 스테이지를 마쳤을 때 선택지에 따라 갈리는 다음 스테이지",
"",
"값의 근거는 docs/GAME-FLOW.md, 검증은 src/game/storyFlow.test.js"
],

"stages": {
"2": {
"scenes": {
"0": [
{ "when": { "scoreMin": 15, "scoreMax": 40 }, "to": 1 },
{ "when": { "scoreMax": 15 }, "to": 2 },
{ "to": 3 }
],
"1": [{ "to": 3 }],
"2": [{ "to": 3 }]
},
"stageExit": {
"_comment": "카페에서 '돈을 줍는다'(첫 선택지)를 고르면 우산 경로가 열린다",
"byChoice": { "0": "/main4" },
"default": "/main3"
}
Comment on lines +28 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

종료 경로 규칙이 선택지 발생 씬을 식별하지 못합니다. 현재 선택지마다 종료 경로를 계산하지만 규칙과 API 모두 씬 인덱스를 사용하지 않습니다. 마지막 선택지가 우연히 값을 덮어쓰는 현재 구조는 씬 추가 또는 순서 변경 후 잘못된 최종 경로를 만들 수 있습니다.

  • src/data/stageRules.json#L28-L32: stageExit를 완료 씬 인덱스별 규칙으로 변경하세요.
  • src/game/storyFlow.js#L67-L71: resolveStageExit가 씬 인덱스를 받고, 해당 씬의 byChoice만 조회하도록 변경하세요. 호출부와 회귀 테스트도 함께 변경하세요.
  • docs/GAME-FLOW.md#L121-L121: 구현이 씬별 종료 규칙을 보장한 후에만 “JSON만 고치면 된다”는 설명을 유지하세요.
📍 Affects 3 files
  • src/data/stageRules.json#L28-L32 (this comment)
  • src/game/storyFlow.js#L67-L71
  • docs/GAME-FLOW.md#L121-L121
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/data/stageRules.json` around lines 28 - 32, 종료 경로가 선택지 발생 씬을 기준으로 결정되도록
수정하세요. src/data/stageRules.json 28-32의 stageExit를 완료 씬 인덱스별 규칙으로 재구성하고,
src/game/storyFlow.js 67-71의 resolveStageExit가 씬 인덱스를 받아 해당 씬의 byChoice만 조회하도록
호출부와 회귀 테스트를 갱신하세요. 구현이 씬별 규칙을 보장한 뒤 docs/GAME-FLOW.md 121의 “JSON만 고치면 된다” 설명을
유지하세요.

},

"3": {
"scenes": {
"1": [
{ "when": { "scoreMin": 60 }, "to": 2 },
{ "when": { "scoreMin": 20 }, "to": 3 },
{ "to": 4 }
],
"2": [{ "to": 5 }],
"3": [{ "to": 5 }],
"4": [{ "to": 5 }]
}
},

"4": {
"scenes": {
"2": [
{
"_comment": "데이터의 love:60 선언과 달리 실제 임계치는 70이다 (동작 유지)",
"when": { "scoreMin": 70 },
"to": 3
},
{ "to": 4 }
]
}
},

"5": {
"scenes": {
"2": [{ "exit": "/result" }]
}
}
}
}
94 changes: 41 additions & 53 deletions src/game/storyFlow.js
Original file line number Diff line number Diff line change
@@ -1,71 +1,56 @@
import { ROUTES } from "../constants/routes";
import stageRules from "../data/stageRules.json";

/**
* 스토리 전이 규칙
* 스토리 전이 엔진
*
* 이 규칙은 원래 `BagelSelectPageComponent`의 `onSubClicked()` 안에
* `scene`/`index` 숫자 조건문 약 60줄로 박혀 있었다. 컴포넌트가 스토리 구조를
* 알고 있어서, 씬을 하나 추가하려면 데이터가 아니라 컴포넌트를 고쳐야 했다.
* 규칙은 `src/data/stageRules.json`에 있다. 이 파일은 그 규칙을 **평가**만 한다.
* 씬을 추가하거나 점수 임계치를 바꾸려면 JSON만 고치면 되고, 이 코드는 손대지 않는다.
*
* 여기서는 **동작을 그대로 유지한 채** 순수 함수로만 떼어냈다.
* 순수 함수가 되면 전 경로를 테스트로 검증할 수 있고, 그 테스트가
* 이후 데이터화(JSON 스키마 전환)의 회귀 안전망이 된다.
* 원래는 `BagelSelectPageComponent`의 `onSubClicked()`에 숫자 조건문 60줄로 박혀 있었다.
* 컴포넌트가 스토리 구조를 알고 있어서, 씬 하나를 추가하려면 컴포넌트를 고쳐야 했다.
*
* 값은 바꾸지 않았다. 임계치가 이상해 보여도 그것이 현재 동작이다.
* 근거는 docs/GAME-FLOW.md에 기록되어 있다.
* 동작은 그대로다. 값의 근거는 docs/GAME-FLOW.md,
* 회귀 검증은 storyFlow.test.js의 전 경로 기대값 표가 담당한다.
*/

/** 스테이지 종료를 뜻하는 신호. 씬 인덱스 대신 이 값을 반환한다. */
export const STAGE_END = "STAGE_END";
/**
* 전이 규칙의 조건을 평가한다.
* `when`이 없으면 항상 참 — 규칙 배열의 마지막 기본값으로 쓴다.
*
* @param {Object} [when] `{ scoreMin, scoreMax }`. scoreMax는 **미만**
* @param {number} score
* @returns {boolean}
*/
const matches = (when, score) => {
if (!when) return true;
if (when.scoreMin !== undefined && score < when.scoreMin) return false;
if (when.scoreMax !== undefined && score >= when.scoreMax) return false;
return true;
};

/**
* 현재 씬을 마쳤을 때 다음에 무엇을 할지 결정한다.
*
* @param {number} scene 스테이지 번호 (1~5)
* @param {number} index 방금 마친 씬 인덱스
* @param {number} score 현재까지 누적 점수
* @returns {{type: "scene", index: number} | {type: "navigate", to: string} | {type: "end"}}
* @returns {{type: "scene", index: number} | {type: "navigate", to: string}}
*/
export const resolveNextStep = (scene, index, score) => {
const toScene = (i) => ({ type: "scene", index: i });
const rules = stageRules.stages?.[String(scene)]?.scenes?.[String(index)];

if (scene === 2) {
if (index === 0) {
if (score >= 15 && score < 40) return toScene(1);
if (score < 15) return toScene(2);
return toScene(3);
if (rules) {
const matched = rules.find((rule) => matches(rule.when, score));
if (matched) {
if (matched.exit) return { type: "navigate", to: matched.exit };
return { type: "scene", index: matched.to };
}
// 점수 분기로 1 또는 2에 들어왔다면 공통 씬(3)으로 합류한다
if (index === 1 || index === 2) return toScene(3);
return toScene(index + 1);
}

if (scene === 3) {
if (index === 1) {
if (score >= 60) return toScene(2);
if (score >= 20) return toScene(3);
return toScene(4);
}
if (index === 2 || index === 3 || index === 4) return toScene(5);
return toScene(index + 1);
}

if (scene === 4) {
// 점수가 높으면 특수 씬(맥주)을 거친다.
// 데이터에 `love: 60`이 선언돼 있으나 실제 동작은 70이다 (docs/GAME-FLOW.md 참조)
if (index === 2) {
return score >= 70 ? toScene(3) : toScene(4);
}
return toScene(index + 1);
}

if (scene === 5) {
// 마지막 스테이지를 마치면 결과 화면으로 나간다
if (index === 2) return { type: "navigate", to: ROUTES.RESULT };
return toScene(index + 1);
}

return toScene(index + 1);
// 규칙이 없는 씬은 순서대로 진행한다.
// 씬 인덱스가 데이터 길이를 넘으면 컴포넌트가 "다음 스테이지로" 버튼을 띄운다.
return { type: "scene", index: index + 1 };
};

/**
Expand All @@ -76,16 +61,19 @@ export const resolveNextStep = (scene, index, score) => {
*
* @param {number} scene 스테이지 번호
* @param {number} choiceIndex 고른 선택지 인덱스
* @param {string} defaultRoute 분기가 없는 스테이지의 기본 목적지
* @param {string} defaultRoute 분기 규칙이 없는 스테이지의 기본 목적지
* @returns {string} 다음 스테이지 경로
*/
export const resolveStageExit = (scene, choiceIndex, defaultRoute) => {
if (scene === 2) {
return choiceIndex === 0 ? ROUTES.MAIN4 : ROUTES.MAIN3;
}
return defaultRoute;
const exitRule = stageRules.stages?.[String(scene)]?.stageExit;
if (!exitRule) return defaultRoute;

return exitRule.byChoice?.[String(choiceIndex)] ?? exitRule.default ?? defaultRoute;
};

const storyFlow = { STAGE_END, resolveNextStep, resolveStageExit };
/** 결과 화면 경로. 데이터의 exit 값과 일치해야 한다 */
export const RESULT_ROUTE = ROUTES.RESULT;

const storyFlow = { resolveNextStep, resolveStageExit, RESULT_ROUTE };

export default storyFlow;
Loading