diff --git a/CHANGELOG.json b/CHANGELOG.json index a3a3fd3..dd8b91c 100644 --- a/CHANGELOG.json +++ b/CHANGELOG.json @@ -1,11 +1,26 @@ { "metadata": { - "lastUpdated": "2026-08-08T12:00:52Z", - "currentVersion": "1.4.14", + "lastUpdated": "2026-08-08T12:45:23Z", + "currentVersion": "1.4.15", "projectType": "project_type: react # spring, flutter, react, react-native, react-native-expo, node, basic", - "totalReleases": 29 + "totalReleases": 30 }, "releases": [ + { + "version": "1.4.15", + "project_type": "project_type: react # spring, flutter, react, react-native, react-native-expo, node, basic", + "date": "2026-08-08", + "pr_number": 116, + "raw_summary": "Summary by CodeRabbit\n릴리스 노트\n\n개선\n\n스토리 진행 규칙을 분리해 안정성 향상", + "parsed_changes": { + "개선": { + "title": "개선", + "items": [ + "스토리 진행 규칙을 분리해 안정성 향상" + ] + } + } + }, { "version": "1.4.14", "project_type": "project_type: react # spring, flutter, react, react-native, react-native-expo, node, basic", diff --git a/CHANGELOG.md b/CHANGELOG.md index b0b3e32..588de83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,16 @@ # Changelog -**현재 버전:** 1.4.14 -**마지막 업데이트:** 2026-08-08T12:00:52Z +**현재 버전:** 1.4.15 +**마지막 업데이트:** 2026-08-08T12:45:23Z + +--- + +## [1.4.15] - 2026-08-08 + +**PR:** #116 + +**개선** +- 스토리 진행 규칙을 분리해 안정성 향상 --- diff --git a/README.md b/README.md index 5754acb..b3adae5 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ -## 최신 버전 : v1.4.13 (2026-08-08) +## 최신 버전 : v1.4.14 (2026-08-08) [전체 업데이트 내역 보기](CHANGELOG.md) diff --git a/docs/GAME-FLOW.md b/docs/GAME-FLOW.md index cd0c1a3..a6994a6 100644 --- a/docs/GAME-FLOW.md +++ b/docs/GAME-FLOW.md @@ -54,8 +54,8 @@ Main2(카페)의 마지막 씬 "바닥에 돈이 떨어져 있는 것을 발견 ## 점수 임계치 -`src/components/GameUI/BagelSelectPageComponent.jsx`의 `onSubClicked()`에 하드코딩되어 있다. -아래는 **현재 실제 동작**이다. +`src/game/storyFlow.js`의 `resolveNextStep()`에 정의되어 있다. +아래는 **현재 실제 동작**이며, `src/game/storyFlow.test.js`가 이 표를 그대로 검증한다. | 스테이지 | 조건 | 분기 | | -------- | ----------------------- | ------------------------------------------------------------- | @@ -97,10 +97,16 @@ Main2(카페)의 마지막 씬 "바닥에 돈이 떨어져 있는 것을 발견 이 절의 내용은 `docs/superpowers/specs/2026-07-27-codebase-standardization-design.md` 1.2절의 진단과 대응한다. -### 1. 컴포넌트가 스토리 구조를 알고 있다 +### 1. 컴포넌트가 스토리 구조를 알고 있다 — **부분 해소** -씬 전이 규칙이 `scene`/`index` 숫자 조건문 약 60줄로 컴포넌트에 박혀 있다. **씬을 하나 -추가하려면 데이터가 아니라 컴포넌트를 고쳐야 한다.** +씬 전이 규칙이 `scene`/`index` 숫자 조건문 약 60줄로 컴포넌트에 박혀 있었다. + +`src/game/storyFlow.js`로 **순수 함수 추출을 완료**했다. 컴포넌트에서 `scene ===` 조건문이 +사라졌고, 전이 규칙은 이제 테스트 가능한 함수다. `src/game/storyFlow.test.js`의 25개 +테스트가 **현재 동작의 기대값 표** 역할을 한다. + +다만 규칙은 여전히 **코드**에 있다. JSON 데이터로 옮기는 작업이 남아 있으며, +그때 위 테스트가 회귀 안전망이 된다. ### 2. 우연히 동작하는 분기 diff --git a/package.json b/package.json index 3e16ac7..b13f816 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "re-wave", - "version": "1.4.14", + "version": "1.4.15", "private": true, "dependencies": { "@testing-library/jest-dom": "^5.17.0", diff --git a/src/components/GameUI/BagelSelectPageComponent.jsx b/src/components/GameUI/BagelSelectPageComponent.jsx index 552fdcb..5088c1e 100644 --- a/src/components/GameUI/BagelSelectPageComponent.jsx +++ b/src/components/GameUI/BagelSelectPageComponent.jsx @@ -7,6 +7,7 @@ import styled from "styled-components"; import { scoreAtom, characterNameAtom } from "../../atom/atom"; import { ROUTES } from "../../constants/routes"; import { markGameCompleted } from "../../game/progress"; +import { resolveNextStep, resolveStageExit } from "../../game/storyFlow"; import BagelChoiceButton from "./BagelChoiceButton"; import BagelDialogBox from "./BagelDialogBox"; @@ -175,13 +176,8 @@ const BagelSelectPageComponent = ({ backgroundImage, characterImage, storyData, }; const onClicked = (option, i) => { - if (scene === 2) { - if (i === 0) { - setBase("/main4"); - } else { - setBase("/main3"); - } - } + // 다음 스테이지 목적지는 선택에 따라 갈릴 수 있다 (카페의 돈 줍기) + setBase(resolveStageExit(scene, i, url)); if (option.error) { navigate(ROUTES.GAME_OVER); } else { @@ -199,54 +195,13 @@ const BagelSelectPageComponent = ({ backgroundImage, characterImage, storyData, } else { setToggle(false); - if (scene === 2) { - if (index === 0) { - if (score >= 15 && score < 40) { - setIndex(1); - } else if (score < 15) { - setIndex(2); - } else { - setIndex(3); - } - } else if (index !== 1 && index !== 2) { - setIndex((prev) => prev + 1); - } else { - setIndex(3); - } - } else if (scene === 3) { - if (index === 1) { - if (score >= 60) { - setIndex(2); - } else if (score >= 20 && score < 60) { - setIndex(3); - } else { - setIndex(4); - } - } else if (index === 2 || index === 3 || index === 4) { - setIndex(5); - } else { - setIndex((prev) => prev + 1); - } - } else if (scene === 4) { - if (index === 2) { - if (score >= 70) { - setIndex(3); - } else { - setIndex(4); - } - } else { - setIndex((prev) => prev + 1); - } - } else if (scene === 5) { - if (index === 2) { - // 결과·엔딩 화면은 게임 완료자만 진입할 수 있다 - markGameCompleted(); - navigate(ROUTES.RESULT); - } else { - setIndex((prev) => prev + 1); - } + const step = resolveNextStep(scene, index, score); + if (step.type === "navigate") { + // 결과·엔딩 화면은 게임 완료자만 진입할 수 있다 + markGameCompleted(); + navigate(step.to); } else { - setIndex((prev) => prev + 1); + setIndex(step.index); } } }; diff --git a/src/game/storyFlow.js b/src/game/storyFlow.js new file mode 100644 index 0000000..2bb7163 --- /dev/null +++ b/src/game/storyFlow.js @@ -0,0 +1,91 @@ +import { ROUTES } from "../constants/routes"; + +/** + * 스토리 전이 규칙 + * + * 이 규칙은 원래 `BagelSelectPageComponent`의 `onSubClicked()` 안에 + * `scene`/`index` 숫자 조건문 약 60줄로 박혀 있었다. 컴포넌트가 스토리 구조를 + * 알고 있어서, 씬을 하나 추가하려면 데이터가 아니라 컴포넌트를 고쳐야 했다. + * + * 여기서는 **동작을 그대로 유지한 채** 순수 함수로만 떼어냈다. + * 순수 함수가 되면 전 경로를 테스트로 검증할 수 있고, 그 테스트가 + * 이후 데이터화(JSON 스키마 전환)의 회귀 안전망이 된다. + * + * 값은 바꾸지 않았다. 임계치가 이상해 보여도 그것이 현재 동작이다. + * 근거는 docs/GAME-FLOW.md에 기록되어 있다. + */ + +/** 스테이지 종료를 뜻하는 신호. 씬 인덱스 대신 이 값을 반환한다. */ +export const STAGE_END = "STAGE_END"; + +/** + * 현재 씬을 마쳤을 때 다음에 무엇을 할지 결정한다. + * + * @param {number} scene 스테이지 번호 (1~5) + * @param {number} index 방금 마친 씬 인덱스 + * @param {number} score 현재까지 누적 점수 + * @returns {{type: "scene", index: number} | {type: "navigate", to: string} | {type: "end"}} + */ +export const resolveNextStep = (scene, index, score) => { + const toScene = (i) => ({ type: "scene", index: i }); + + if (scene === 2) { + if (index === 0) { + if (score >= 15 && score < 40) return toScene(1); + if (score < 15) return toScene(2); + return toScene(3); + } + // 점수 분기로 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); +}; + +/** + * 스테이지를 마쳤을 때 이동할 다음 스테이지 경로. + * + * 카페(2)에서 "돈을 줍는다"(첫 선택지)를 고르면 우산(4), 아니면 동굴(3)로 간다. + * `data4`의 도입부 "얻은 돈으로 편의점에서 우산을 사올 수 있었다"가 이 선택과 연결된다. + * + * @param {number} scene 스테이지 번호 + * @param {number} choiceIndex 고른 선택지 인덱스 + * @param {string} defaultRoute 분기가 없는 스테이지의 기본 목적지 + * @returns {string} 다음 스테이지 경로 + */ +export const resolveStageExit = (scene, choiceIndex, defaultRoute) => { + if (scene === 2) { + return choiceIndex === 0 ? ROUTES.MAIN4 : ROUTES.MAIN3; + } + return defaultRoute; +}; + +const storyFlow = { STAGE_END, resolveNextStep, resolveStageExit }; + +export default storyFlow; diff --git a/src/game/storyFlow.test.js b/src/game/storyFlow.test.js new file mode 100644 index 0000000..6c89bfd --- /dev/null +++ b/src/game/storyFlow.test.js @@ -0,0 +1,156 @@ +import { ROUTES } from "../constants/routes"; + +import { resolveNextStep, resolveStageExit } from "./storyFlow"; + +/** + * 이 테스트는 **현재 동작의 기대값 표**다. + * + * 스토리 분기를 JSON 데이터로 옮길 때, 마이그레이션 전후로 이 테스트가 모두 통과해야 + * 회귀가 없다고 말할 수 있다. 값이 이상해 보여도 바꾸지 않는다 — + * 여기 적힌 것이 실제로 배포되어 동작 중인 규칙이다. + * + * 근거: docs/GAME-FLOW.md + */ + +const sceneAt = (index) => ({ type: "scene", index }); + +describe("스테이지 1 (집) — 분기 없음", () => { + it("씬을 순서대로 넘긴다", () => { + expect(resolveNextStep(1, 0, 0)).toEqual(sceneAt(1)); + expect(resolveNextStep(1, 3, 999)).toEqual(sceneAt(4)); + }); + + it("점수가 진행에 영향을 주지 않는다", () => { + expect(resolveNextStep(1, 2, 0)).toEqual(resolveNextStep(1, 2, 500)); + }); +}); + +describe("스테이지 2 (카페) — 첫 씬에서 점수로 갈린다", () => { + it("15점 미만이면 씬 2로", () => { + expect(resolveNextStep(2, 0, 0)).toEqual(sceneAt(2)); + expect(resolveNextStep(2, 0, 14)).toEqual(sceneAt(2)); + }); + + it("15~39점이면 씬 1로", () => { + expect(resolveNextStep(2, 0, 15)).toEqual(sceneAt(1)); + expect(resolveNextStep(2, 0, 39)).toEqual(sceneAt(1)); + }); + + it("40점 이상이면 씬 3으로", () => { + expect(resolveNextStep(2, 0, 40)).toEqual(sceneAt(3)); + expect(resolveNextStep(2, 0, 100)).toEqual(sceneAt(3)); + }); + + // 점수 분기로 1 또는 2에 들어왔으면 공통 씬(3)으로 합류한다 + it("씬 1·2를 마치면 씬 3으로 합류한다", () => { + expect(resolveNextStep(2, 1, 0)).toEqual(sceneAt(3)); + expect(resolveNextStep(2, 2, 0)).toEqual(sceneAt(3)); + }); + + it("씬 3을 마치면 스테이지가 끝난다 (plot 길이 4 초과)", () => { + expect(resolveNextStep(2, 3, 0)).toEqual(sceneAt(4)); + }); +}); + +describe("스테이지 3 (동굴) — 두 번째 씬에서 점수로 갈린다", () => { + it("60점 이상이면 씬 2로", () => { + expect(resolveNextStep(3, 1, 60)).toEqual(sceneAt(2)); + expect(resolveNextStep(3, 1, 200)).toEqual(sceneAt(2)); + }); + + it("20~59점이면 씬 3으로", () => { + expect(resolveNextStep(3, 1, 20)).toEqual(sceneAt(3)); + expect(resolveNextStep(3, 1, 59)).toEqual(sceneAt(3)); + }); + + it("20점 미만이면 씬 4로", () => { + expect(resolveNextStep(3, 1, 0)).toEqual(sceneAt(4)); + expect(resolveNextStep(3, 1, 19)).toEqual(sceneAt(4)); + }); + + it("씬 2·3·4를 마치면 씬 5로 합류한다", () => { + expect(resolveNextStep(3, 2, 0)).toEqual(sceneAt(5)); + expect(resolveNextStep(3, 3, 0)).toEqual(sceneAt(5)); + expect(resolveNextStep(3, 4, 0)).toEqual(sceneAt(5)); + }); + + it("첫 씬은 순서대로 넘어간다", () => { + expect(resolveNextStep(3, 0, 0)).toEqual(sceneAt(1)); + }); + + it("씬 5를 마치면 스테이지가 끝난다 (plot 길이 6 초과)", () => { + expect(resolveNextStep(3, 5, 0)).toEqual(sceneAt(6)); + }); +}); + +describe("스테이지 4 (우산) — 특수 씬 임계치", () => { + // 데이터에는 love: 60이 선언돼 있으나 실제 동작은 70이다. + // 값 불일치는 알려진 결함이며 마이그레이션 시 동작(70)을 유지한다. + it("70점 이상이면 특수 씬(3)으로 간다", () => { + expect(resolveNextStep(4, 2, 70)).toEqual(sceneAt(3)); + expect(resolveNextStep(4, 2, 150)).toEqual(sceneAt(3)); + }); + + it("70점 미만이면 특수 씬을 건너뛴다", () => { + expect(resolveNextStep(4, 2, 69)).toEqual(sceneAt(4)); + expect(resolveNextStep(4, 2, 0)).toEqual(sceneAt(4)); + }); + + it("선언값 60은 임계치가 아니다", () => { + expect(resolveNextStep(4, 2, 60)).toEqual(sceneAt(4)); + }); + + it("나머지 씬은 순서대로 넘어간다", () => { + expect(resolveNextStep(4, 0, 0)).toEqual(sceneAt(1)); + expect(resolveNextStep(4, 3, 0)).toEqual(sceneAt(4)); + }); +}); + +describe("스테이지 5 (해변) — 결과 화면으로 탈출", () => { + it("마지막 씬을 마치면 결과 화면으로 이동한다", () => { + expect(resolveNextStep(5, 2, 0)).toEqual({ type: "navigate", to: ROUTES.RESULT }); + }); + + it("점수와 무관하게 결과 화면으로 간다", () => { + expect(resolveNextStep(5, 2, 999)).toEqual({ type: "navigate", to: ROUTES.RESULT }); + }); + + it("앞선 씬은 순서대로 넘어간다", () => { + expect(resolveNextStep(5, 0, 0)).toEqual(sceneAt(1)); + expect(resolveNextStep(5, 1, 0)).toEqual(sceneAt(2)); + }); +}); + +describe("스테이지 종료 시 다음 스테이지", () => { + // 카페에서 돈을 줍는 선택이 우산(4) 경로를 연다. + // data4의 "얻은 돈으로 편의점에서 우산을 사올 수 있었다"와 연결된다. + it("카페에서 첫 선택지(돈을 줍는다)를 고르면 우산으로 간다", () => { + expect(resolveStageExit(2, 0, ROUTES.MAIN3)).toBe(ROUTES.MAIN4); + }); + + it("카페에서 다른 선택지를 고르면 동굴로 간다", () => { + expect(resolveStageExit(2, 1, ROUTES.MAIN3)).toBe(ROUTES.MAIN3); + expect(resolveStageExit(2, 2, ROUTES.MAIN3)).toBe(ROUTES.MAIN3); + }); + + it("분기가 없는 스테이지는 기본 경로를 따른다", () => { + expect(resolveStageExit(1, 0, ROUTES.MAIN2)).toBe(ROUTES.MAIN2); + expect(resolveStageExit(3, 0, ROUTES.MAIN5)).toBe(ROUTES.MAIN5); + expect(resolveStageExit(4, 1, ROUTES.MAIN5)).toBe(ROUTES.MAIN5); + }); +}); + +describe("전 경로 회귀 표 — 게임 한 판의 스테이지 이동", () => { + // 실제 플레이에서 가능한 두 갈래를 명시적으로 고정한다 + it("돈을 주우면 집→카페→우산→해변", () => { + expect(resolveStageExit(1, 0, ROUTES.MAIN2)).toBe(ROUTES.MAIN2); + expect(resolveStageExit(2, 0, ROUTES.MAIN3)).toBe(ROUTES.MAIN4); + expect(resolveStageExit(4, 0, ROUTES.MAIN5)).toBe(ROUTES.MAIN5); + }); + + it("돈을 줍지 않으면 집→카페→동굴→해변", () => { + expect(resolveStageExit(1, 0, ROUTES.MAIN2)).toBe(ROUTES.MAIN2); + expect(resolveStageExit(2, 1, ROUTES.MAIN3)).toBe(ROUTES.MAIN3); + expect(resolveStageExit(3, 0, ROUTES.MAIN5)).toBe(ROUTES.MAIN5); + }); +}); diff --git a/version.yml b/version.yml index 83405a5..aece267 100644 --- a/version.yml +++ b/version.yml @@ -6,7 +6,7 @@ # GitHub Actions 워크플로우가 이 파일을 읽어 자동으로 버전을 관리 # # 사용법: -# 1. version: "1.4.14" +# 1. version: "1.4.15" # 2. project_type: 프로젝트 타입 지정 # # 자동 버전 업데이트: @@ -15,7 +15,7 @@ # # 프로젝트 타입별 동기화 파일: # - spring: build.gradle (version = "x.y.z") -# - flutter: pubspec.yaml (version: "1.4.14" +# - flutter: pubspec.yaml (version: "1.4.15" # - react/node: package.json ("version": "x.y.z") # - react-native: iOS Info.plist 또는 Android build.gradle # - react-native-expo: app.json (expo.version) @@ -26,8 +26,8 @@ # - 버전은 항상 높은 버전으로 자동 동기화됩니다 # =================================================================== -version: "1.4.14" +version: "1.4.15" project_type: react # spring, flutter, react, react-native, react-native-expo, node, basic metadata: - last_updated: "2026-08-08 12:00:34" + last_updated: "2026-08-08 12:44:58" last_updated_by: "Cassiiopeia"