diff --git a/Fantasy-server/.agents/skills/commit/SKILL.md b/Fantasy-server/.agents/skills/commit/SKILL.md index cdb80b7..ef3b242 100644 --- a/Fantasy-server/.agents/skills/commit/SKILL.md +++ b/Fantasy-server/.agents/skills/commit/SKILL.md @@ -1,14 +1,14 @@ --- name: commit description: Creates Git commits by splitting changes into logical units. Use for staging files and writing commit messages. -allowed-tools: Bash(git status:*), Bash(git diff:*), Bash(git add:*), Bash(git commit:*), Bash(git log:*) +allowed-tools: Bash(git status:*), Bash(git diff:*), Bash(git add:*), Bash(git commit:*), Bash(git log:*), Bash(git branch:*), Bash(git switch:*) --- Create Git commits following the project's commit conventions. ## Argument -`$ARGUMENTS` — optional GitHub issue number (e.g. `/commit 42`) +`$ARGUMENTS` - optional GitHub issue number (e.g. `/commit 42`) - If provided, add `#42` as the commit body (blank line after subject, then the reference). - If omitted, commit without any issue reference. @@ -28,23 +28,23 @@ With issue number: ``` **Types**: -- `feat` — new feature added -- `fix` — bug fix, missing config, or missing DI registration -- `update` — modification to existing code -- `chore` — tooling, CI/CD, dependency updates, config changes unrelated to app logic +- `feat` - new feature added +- `fix` - bug fix, missing config, or missing DI registration +- `update` - modification to existing code +- `chore` - tooling, CI/CD, dependency updates, config changes unrelated to app logic **Description rules**: - Written in **Korean** -- Short and imperative (단문) +- Short and imperative - No trailing punctuation (`.`, `!` etc.) -- Avoid noun-ending style — prefer verb style +- Avoid noun-ending style; prefer verb style **Examples**: ``` feat: 로그인 로직 추가 ``` ``` -fix: 세션 DI 누락 수정 +fix: 인증 DI 누락 수정 #12 ``` @@ -54,18 +54,35 @@ See `.claude/skills/commit/examples/type-guide.md` for a boundary-rule table and **Do NOT**: - Add Claude as co-author - Write descriptions in English +- Commit directly on `develop` + +## Branch Rule + +Always check the current branch before staging or committing. + +- If the current branch is **not** `develop`, proceed with the normal commit flow. +- If the current branch **is** `develop`: + - Review the changes first with `git status` and `git diff` + - Infer the dominant logical unit and create a new working branch before any `git add` or `git commit` + - Use a short branch name based on the work, such as `feat/player-resource-seed`, `fix/player-stage-config`, or `update/player-domain-cleanup` + - Run `git switch -c ` + - Confirm the branch changed successfully, then continue the commit flow on that new branch + +Do not keep working on `develop` after detecting staged or unstaged changes there. ## Steps -1. Check all changes with `git status` and `git diff` -2. Categorize changes into logical units: - - New feature addition → `feat` - - Bug / missing registration fix → `fix` - - Modification to existing code → `update` -3. Group files by each logical unit -4. For each group: +1. Check the current branch with `git branch --show-current` +2. Check all changes with `git status` and `git diff` +3. If the current branch is `develop`, create and switch to a new branch that matches the dominant logical unit before staging anything +4. Categorize changes into logical units: + - New feature addition - `feat` + - Bug / missing registration fix - `fix` + - Modification to existing code - `update` +5. Group files by each logical unit +6. For each group: - Stage only the relevant files with `git add ` - Write a concise commit message following the format above - If `$ARGUMENTS` is provided: `git commit -m "{subject}" -m "#{issue}"` - If `$ARGUMENTS` is omitted: `git commit -m "{subject}"` -5. Verify results with `git log --oneline -n {number of commits made}` +7. Verify results with `git log --oneline -n {number of commits made}` diff --git a/Fantasy-server/.agents/skills/write-pr/SKILL.md b/Fantasy-server/.agents/skills/write-pr/SKILL.md index 59c7d9a..4633c5c 100644 --- a/Fantasy-server/.agents/skills/write-pr/SKILL.md +++ b/Fantasy-server/.agents/skills/write-pr/SKILL.md @@ -129,16 +129,19 @@ rm PR_BODY.md - Follow the PR Title Convention below -**Step 3. Write PR body** +**Step 3. Ask the user which title to use** -- Follow the PR Body Template below -- Save to `PR_BODY.md` +Use AskUserQuestion and present exactly these choices before proceeding: +- `1`: first generated title +- `2`: second generated title +- `3`: third generated title +- Wait for the user's answer before continuing to the next step +- Use the selected numbered option to determine the final PR title -**Step 4. Ask the user** +**Step 4. Write PR body** -Use AskUserQuestion with a `choices` array: -- Options: the 3 generated titles plus `직접 입력` as the last option -- If the user selects `직접 입력`, ask a follow-up AskUserQuestion for the custom title +- Follow the PR Body Template below +- Save to `PR_BODY.md` **Step 5. Select labels** @@ -147,7 +150,7 @@ Use AskUserQuestion with a `choices` array: **Step 6. Create PR to `{Base Branch}`** -- Use the selected title, or the custom title if the user chose `직접 입력` +- Use the title selected by the user from options `1` / `2` / `3` ```bash ./scripts/create-pr.sh "{chosen title}" PR_BODY.md "{label1,label2}" diff --git a/Fantasy-server/.claude/skills/commit/SKILL.md b/Fantasy-server/.claude/skills/commit/SKILL.md index 9aef98b..ef3b242 100644 --- a/Fantasy-server/.claude/skills/commit/SKILL.md +++ b/Fantasy-server/.claude/skills/commit/SKILL.md @@ -1,71 +1,88 @@ ---- -name: commit -description: Creates Git commits by splitting changes into logical units. Use for staging files and writing commit messages. -allowed-tools: Bash(git status:*), Bash(git diff:*), Bash(git add:*), Bash(git commit:*), Bash(git log:*) ---- - -Create Git commits following the project's commit conventions. - -## Argument - -`$ARGUMENTS` — optional GitHub issue number (e.g. `/commit 42`) - -- If provided, add `#42` as the commit body (blank line after subject, then the reference). -- If omitted, commit without any issue reference. - -## Commit Message Format - -Subject line only (no issue): -``` -{type}: {Korean description} -``` - -With issue number: -``` -{type}: {Korean description} - -#{issue} -``` - -**Types**: -- `feat` — new feature added -- `fix` — bug fix, missing config, or missing DI registration -- `update` — modification to existing code -- `chore` — tooling, CI/CD, dependency updates, config changes unrelated to app logic - -**Description rules**: -- Written in **Korean** -- Short and imperative (단문) -- No trailing punctuation (`.`, `!` etc.) -- Avoid noun-ending style — prefer verb style - -**Examples**: -``` -feat: 로그인 로직 추가 -``` -``` -fix: 세션 DI 누락 수정 - -#12 -``` - -See `.claude/skills/commit/examples/type-guide.md` for a boundary-rule table and real scenarios from this project. - -**Do NOT**: -- Add Claude as co-author -- Write descriptions in English - -## Steps - -1. Check all changes with `git status` and `git diff` -2. Categorize changes into logical units: - - New feature addition → `feat` - - Bug / missing registration fix → `fix` - - Modification to existing code → `update` -3. Group files by each logical unit -4. For each group: - - Stage only the relevant files with `git add ` - - Write a concise commit message following the format above - - If `$ARGUMENTS` is provided: `git commit -m "{subject}" -m "#{issue}"` - - If `$ARGUMENTS` is omitted: `git commit -m "{subject}"` -5. Verify results with `git log --oneline -n {number of commits made}` +--- +name: commit +description: Creates Git commits by splitting changes into logical units. Use for staging files and writing commit messages. +allowed-tools: Bash(git status:*), Bash(git diff:*), Bash(git add:*), Bash(git commit:*), Bash(git log:*), Bash(git branch:*), Bash(git switch:*) +--- + +Create Git commits following the project's commit conventions. + +## Argument + +`$ARGUMENTS` - optional GitHub issue number (e.g. `/commit 42`) + +- If provided, add `#42` as the commit body (blank line after subject, then the reference). +- If omitted, commit without any issue reference. + +## Commit Message Format + +Subject line only (no issue): +``` +{type}: {Korean description} +``` + +With issue number: +``` +{type}: {Korean description} + +#{issue} +``` + +**Types**: +- `feat` - new feature added +- `fix` - bug fix, missing config, or missing DI registration +- `update` - modification to existing code +- `chore` - tooling, CI/CD, dependency updates, config changes unrelated to app logic + +**Description rules**: +- Written in **Korean** +- Short and imperative +- No trailing punctuation (`.`, `!` etc.) +- Avoid noun-ending style; prefer verb style + +**Examples**: +``` +feat: 로그인 로직 추가 +``` +``` +fix: 인증 DI 누락 수정 + +#12 +``` + +See `.claude/skills/commit/examples/type-guide.md` for a boundary-rule table and real scenarios from this project. + +**Do NOT**: +- Add Claude as co-author +- Write descriptions in English +- Commit directly on `develop` + +## Branch Rule + +Always check the current branch before staging or committing. + +- If the current branch is **not** `develop`, proceed with the normal commit flow. +- If the current branch **is** `develop`: + - Review the changes first with `git status` and `git diff` + - Infer the dominant logical unit and create a new working branch before any `git add` or `git commit` + - Use a short branch name based on the work, such as `feat/player-resource-seed`, `fix/player-stage-config`, or `update/player-domain-cleanup` + - Run `git switch -c ` + - Confirm the branch changed successfully, then continue the commit flow on that new branch + +Do not keep working on `develop` after detecting staged or unstaged changes there. + +## Steps + +1. Check the current branch with `git branch --show-current` +2. Check all changes with `git status` and `git diff` +3. If the current branch is `develop`, create and switch to a new branch that matches the dominant logical unit before staging anything +4. Categorize changes into logical units: + - New feature addition - `feat` + - Bug / missing registration fix - `fix` + - Modification to existing code - `update` +5. Group files by each logical unit +6. For each group: + - Stage only the relevant files with `git add ` + - Write a concise commit message following the format above + - If `$ARGUMENTS` is provided: `git commit -m "{subject}" -m "#{issue}"` + - If `$ARGUMENTS` is omitted: `git commit -m "{subject}"` +7. Verify results with `git log --oneline -n {number of commits made}` diff --git a/Fantasy-server/.claude/skills/plan-deep-dive/SKILL.md b/Fantasy-server/.claude/skills/plan-deep-dive/SKILL.md index 31577f5..b6939e9 100644 --- a/Fantasy-server/.claude/skills/plan-deep-dive/SKILL.md +++ b/Fantasy-server/.claude/skills/plan-deep-dive/SKILL.md @@ -1,8 +1,32 @@ ---- +--- name: plan-deep-dive description: Conduct an in-depth structured interview with the user to uncover non-obvious requirements, tradeoffs, and constraints, then produce a detailed implementation spec file. -argument-hint: [instructions] -allowed-tools: AskUserQuestion, Write +argument-hint: [feature description or task goal] +allowed-tools: AskUserQuestion, Write, Bash(mkdir:*) --- -Follow the user instructions and interview me in detail using the AskUserQuestionTool about literally anything: technical implementation, UI & UX, concerns, tradeoffs, etc. but make sure the questions are not obvious. be very in-depth and continue interviewing me continually until it's complete. then, write the spec to a file. $ARGUMENTS \ No newline at end of file +Use `$ARGUMENTS` as the primary description of the feature or task. Start the interview based on it and expand into detailed requirements. + +If `$ARGUMENTS` is provided, treat it as the initial feature idea and begin the interview immediately without asking for clarification of the topic itself. + +Interview me in detail using the AskUserQuestionTool about anything non-obvious: technical implementation, UI & UX, concerns, tradeoffs, constraints, edge cases, etc. Be very in-depth and continue interviewing me continually until it's complete. + +Once the interview is complete and you have enough information to write the spec: + +1. Derive a concise `feature-name` in kebab-case based on the full interview context. Do not rely solely on `$ARGUMENTS` — use it only as an initial hint. Examples: `dungeon-system`, `auth-refresh`, `player-inventory`. + +2. Create the plans directory if it doesn't exist: + ```bash + mkdir -p .claude/plans + ``` + +3. Write the full spec to `.claude/plans/{feature-name}-plan.md`. Use structured markdown with headings (`#`, `##`, `###`), lists, and tables where appropriate. + +4. Copy the same content verbatim to `.claude/current_plan.md` (overwrite — do not summarize). This file always reflects the most recently created plan. + +5. Tell the user: + - The plan was saved to `.claude/plans/{feature-name}-plan.md` + - `.claude/current_plan.md` was updated + - They can run `/review-plan` to send this plan to Codex for review + +$ARGUMENTS diff --git a/Fantasy-server/.claude/skills/pr/SKILL.md b/Fantasy-server/.claude/skills/pr/SKILL.md index ac40341..cf3ee12 100644 --- a/Fantasy-server/.claude/skills/pr/SKILL.md +++ b/Fantasy-server/.claude/skills/pr/SKILL.md @@ -7,47 +7,49 @@ context: fork Generate a PR based on the current branch. Behavior differs depending on the branch. +Use `references/label.md` to select 1-2 PR labels before creating the PR. Apply the selected labels when running the PR creation step. + ## Steps ### Step 0. Initialize & Branch Discovery 1. Identify the current branch using `git branch --show-current`. -2. **Check for Arguments**: - - **If an argument is provided (e.g., `/pr {target}`)**: Set `{Base Branch}` = `{target}` and proceed directly to **Case 3**. - - **If no argument is provided**: Follow the **Branch-Based Behavior** below: - - Current branch is `develop` → **Case 1** - - Current branch matches `release/x.x.x` → **Case 2** - - Any other branch → **Case 3** with `{Base Branch}` = `develop` +2. Check for arguments: + - If an argument is provided, for example `/pr {target}`, set `{Base Branch}` = `{target}` and proceed directly to Case 3. + - If no argument is provided, follow the branch-based behavior below: + - Current branch is `develop` -> Case 1 + - Current branch matches `release/x.x.x` -> Case 2 + - Any other branch -> Case 3 with `{Base Branch}` = `develop` --- -## Branch-Based Behavior (Default) +## Branch-Based Behavior -### Case 1: Current branch is `develop` +### Case 1. Current branch is `develop` **Step 1. Check the current version** - Check git tags: `git tag --sort=-v:refname | head -10` - Check existing release branches: `git branch -a | grep release` -- Determine the latest version (e.g., `1.0.0`) +- Determine the latest version, for example `1.0.0` **Step 2. Analyze changes and recommend version bump** - Commits: `git log main..HEAD --oneline` - Diff stats: `git diff main...HEAD --stat` - Recommend one of: - - **Major** (x.0.0): Breaking changes, incompatible API changes - - **Minor** (0.x.0): New backward-compatible features - - **Patch** (0.0.x): Bug fixes only + - Major (`x.0.0`): breaking changes or incompatible API changes + - Minor (`0.x.0`): new backward-compatible features + - Patch (`0.0.x`): bug fixes only - Briefly explain why you chose that level **Step 3. Ask the user for a version number** Use AskUserQuestion: -> "현재 버전: {current_version} -> 추천 버전 업: {Major/Minor/Patch} → {recommended_version} -> 이유: {brief reason} +> Current version: {current_version} +> Recommended bump: {Major/Minor/Patch} -> {recommended_version} +> Reason: {brief reason} > -> 사용할 버전 번호를 입력해주세요. (예: 1.0.1)" +> Enter the release version. Example: `1.0.1` **Step 4. Create a release branch** @@ -55,17 +57,24 @@ Use AskUserQuestion: git checkout -b release/{version} ``` -**Step 5. Write PR body** following the PR Body Template below -- Analyze changes from `main` branch +**Step 5. Write PR body** + +- Analyze changes from `main` +- Follow the PR Body Template below - Save to `PR_BODY.md` -**Step 6. Create PR to `main`** +**Step 6. Select labels** + +- Follow `references/label.md` +- Select 1-2 PR-eligible labels that match the change + +**Step 7. Create PR to `main`** ```bash -gh pr create --title "release/{version}" --body-file PR_BODY.md --base main +./scripts/create-pr.sh "release/{version}" PR_BODY.md "{label1,label2}" ``` -**Step 7. Delete PR_BODY.md** +**Step 8. Delete PR_BODY.md** ```bash rm PR_BODY.md @@ -73,25 +82,34 @@ rm PR_BODY.md --- -### Case 2: Current branch is `release/x.x.x` +### Case 2. Current branch is `release/x.x.x` + +**Step 1. Extract version** -**Step 1. Extract version** from branch name (e.g., `release/1.2.0` → `1.2.0`) +- Extract the version from the branch name, for example `release/1.2.0` -> `1.2.0` **Step 2. Analyze changes from `main`** - Commits: `git log main..HEAD --oneline` - Diff stats: `git diff main...HEAD --stat` -**Step 3. Write PR body** following the PR Body Template below +**Step 3. Write PR body** + +- Follow the PR Body Template below - Save to `PR_BODY.md` -**Step 4. Create PR to `main`** +**Step 4. Select labels** + +- Follow `references/label.md` +- Select 1-2 PR-eligible labels that match the change + +**Step 5. Create PR to `main`** ```bash -gh pr create --title "release/{version}" --body-file PR_BODY.md --base main +./scripts/create-pr.sh "release/{version}" PR_BODY.md "{label1,label2}" ``` -**Step 5. Delete PR_BODY.md** +**Step 6. Delete PR_BODY.md** ```bash rm PR_BODY.md @@ -99,7 +117,7 @@ rm PR_BODY.md --- -### Case 3: Any other branch +### Case 3. Any other branch **Step 1. Analyze changes from `{Base Branch}`** @@ -107,21 +125,35 @@ rm PR_BODY.md - Diff stats: `git diff {Base Branch}...HEAD --stat` - Detailed diff: `git diff {Base Branch}...HEAD` -**Step 2. Suggest three PR titles** following the PR Title Convention below +**Step 2. Suggest three PR titles** + +- Follow the PR Title Convention below + +**Step 3. Ask the user which title to use** + +Use AskUserQuestion and present exactly these choices before proceeding: +- `1`: first generated title +- `2`: second generated title +- `3`: third generated title +- Wait for the user's answer before continuing to the next step +- Use the selected numbered option to determine the final PR title -**Step 3. Write PR body** following the PR Body Template below +**Step 4. Write PR body** + +- Follow the PR Body Template below - Save to `PR_BODY.md` -**Step 4. Ask the user** using AskUserQuestion with a `choices` array: -- Options: the 3 generated titles + "직접 입력" as the last option -- If the user selects "직접 입력", ask a follow-up AskUserQuestion for the custom title +**Step 5. Select labels** + +- Follow `references/label.md` +- Select 1-2 PR-eligible labels that match the change **Step 6. Create PR to `{Base Branch}`** -- Use the selected title, or the custom title if the user chose "직접 입력" +- Use the title selected by the user from options `1` / `2` / `3` ```bash -gh pr create --title "{chosen title}" --body-file PR_BODY.md --base {Base Branch} +./scripts/create-pr.sh "{chosen title}" PR_BODY.md "{label1,label2}" ``` **Step 7. Delete PR_BODY.md** @@ -137,36 +169,38 @@ rm PR_BODY.md Format: `{type}: {Korean description}` **Types:** -- `feat` — new feature added -- `fix` — bug fix or missing configuration/DI registration -- `update` — modification to existing code -- `docs` — documentation changes -- `refactor` — refactoring without behavior change -- `test` — adding or updating tests -- `chore` — tooling, CI/CD, dependency updates, config changes unrelated to app logic +- `feat`: new feature added +- `fix`: bug fix or missing configuration or DI registration +- `update`: modification to existing code +- `docs`: documentation changes +- `refactor`: refactoring without behavior change +- `test`: adding or updating tests +- `chore`: tooling, CI/CD, dependency updates, or config changes unrelated to app logic **Rules:** - Description in Korean -- Short and imperative (단문) +- Short and imperative - No trailing punctuation **Examples:** -- `feat: 방 생성 API 추가` -- `fix: Key Vault 연동 방식을 AddAzureKeyVault으로 변경` -- `refactor: 로그인 로직 리팩토링` +- `feat: 계정 생성 API 추가` +- `fix: Key Vault 설정 누락된 AddAzureKeyVault로 변경` +- `refactor: 로그 처리 로직 개선` -See `.claude/skills/pr/examples/feature-to-develop.md` for a complete example (title options + filled body) of a feature → develop PR. +See `examples/feature-to-develop.md` for a complete example of a feature -> develop PR. ---- +## Labels + +Follow `references/label.md` and select 1-2 labels before the PR creation step. ## PR Body Template -Follow this exact structure (keep the emoji headers as-is): +Follow this exact structure: -!.claude/skills/pr/templates/pr-body.md +`templates/pr-body.md` **Rules:** -- Analyze commits and diffs to fill in `작업 내용` with a concise bullet list +- Analyze commits and diffs to fill in the work summary with concise bullet points - Keep the total body under 2500 characters - Write in Korean -- No emojis in text content (keep the section header emojis) +- Do not add emojis in the body text diff --git a/Fantasy-server/.claude/skills/pr/references/label.md b/Fantasy-server/.claude/skills/pr/references/label.md new file mode 100644 index 0000000..072e28c --- /dev/null +++ b/Fantasy-server/.claude/skills/pr/references/label.md @@ -0,0 +1,22 @@ +# GitHub Labels Reference + +Select **1-2 labels** from the PR-eligible list below. Do NOT use issue-only or manual labels. + +## PR-Eligible Labels (auto-selectable) + +| Label | When to use | +|-------------------|-----------------------------------------------------------| +| `enhancement:개선사항` | New feature, improvement to existing feature, refactoring | +| `bug:버그` | Bug fix | +| `documentation:문서` | Docs-only changes (README, CONTRIBUTING, comments) | + | + + +## Quick Decision + +``` +Bug fix? -> bug:버그 +New feature or improvement? -> enhancement:개선사항 +Docs only? -> documentation:문서 +Unsure? -> enhancement:개선사항 +``` diff --git a/Fantasy-server/.claude/skills/pr/scripts/create-pr.sh b/Fantasy-server/.claude/skills/pr/scripts/create-pr.sh new file mode 100644 index 0000000..23b4415 --- /dev/null +++ b/Fantasy-server/.claude/skills/pr/scripts/create-pr.sh @@ -0,0 +1,41 @@ +#!/bin/bash +set -e + +TITLE="${1:?Error: PR title is required. Usage: create-pr.sh <body-file> [label1,label2,...]}" +BODY_FILE="${2:?Error: Body file is required. Usage: create-pr.sh <title> <body-file> [label1,label2,...]}" +LABELS="${3:-}" + +if [ ! -f "$BODY_FILE" ]; then + echo "ERROR: Body file not found: $BODY_FILE" >&2 + exit 1 +fi + +if ! command -v gh >/dev/null 2>&1; then + echo "ERROR: GitHub CLI (gh) is not installed." >&2 + exit 1 +fi + +CURRENT=$(git branch --show-current) +case "$CURRENT" in + feature/*) BASE="develop" ;; + develop) BASE="main" ;; + *) BASE=$(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || echo "develop") ;; +esac + +ARGS=(gh pr create --title "$TITLE" --body-file "$BODY_FILE" --base "$BASE") + +if [ -n "$LABELS" ]; then + IFS=',' read -ra LABEL_ARRAY <<< "$LABELS" + for label in "${LABEL_ARRAY[@]}"; do + trimmed=$(echo "$label" | xargs) + [ -n "$trimmed" ] && ARGS+=(--label "$trimmed") + done +fi + +echo "Creating PR..." +echo " Title : $TITLE" +echo " Base : $BASE" +[ -n "$LABELS" ] && echo " Labels: $LABELS" +echo "" + +"${ARGS[@]}" diff --git a/Fantasy-server/.claude/skills/review-plan/SKILL.md b/Fantasy-server/.claude/skills/review-plan/SKILL.md new file mode 100644 index 0000000..077bb6e --- /dev/null +++ b/Fantasy-server/.claude/skills/review-plan/SKILL.md @@ -0,0 +1,28 @@ +--- +name: review-plan +description: Sends a plan file to Codex CLI for non-interactive review. Saves results to .claude/reviews/{feature-name}-codex-review.md and prints a summary. +argument-hint: [plan-file-path] +allowed-tools: Bash(.claude/skills/review-plan/review-plan.sh:*) +context: fork +--- + +# Review Plan with Codex CLI + +Send an implementation plan to Codex for a structured non-interactive review. + +## Step 1 — Run the script + +```bash +bash .claude/skills/review-plan/review-plan.sh $ARGUMENTS +``` + +The script handles all steps: +1. Resolves the plan file (`$ARGUMENTS` or `.claude/current_plan.md`) +2. Derives a kebab-case feature name from the first `# ` heading (falls back to filename stem) +3. Creates `.claude/reviews/` if needed +4. Runs `codex exec` with the review prompt +5. Prints the review output and a summary footer + +## Step 2 — Handle errors + +- Exit code non-zero → show the error output and stop with: "Codex 실행에 실패했습니다. 위 오류를 확인해주세요." diff --git a/Fantasy-server/.claude/skills/review-plan/review-plan.sh b/Fantasy-server/.claude/skills/review-plan/review-plan.sh new file mode 100644 index 0000000..fcc5f73 --- /dev/null +++ b/Fantasy-server/.claude/skills/review-plan/review-plan.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# review-plan.sh — Send a plan file to Codex CLI for non-interactive review. +# Usage: review-plan.sh [plan-file-path] +# +# Defaults to .claude/current_plan.md when no argument is given. +# Output saved to .claude/reviews/{feature-name}-codex-review.md + +set -euo pipefail + +# ── Step 1: Determine plan file ────────────────────────────────────────────── +PLAN_FILE="${1:-.claude/current_plan.md}" + +if [[ ! -f "$PLAN_FILE" ]]; then + if [[ "${1:-}" == "" ]]; then + echo "활성 플랜이 없습니다. \`/plan-deep-dive\`를 먼저 실행하거나, \`/review-plan <파일경로>\`로 경로를 직접 지정해주세요." >&2 + else + echo "파일을 찾을 수 없습니다: $PLAN_FILE" >&2 + fi + exit 1 +fi + +# ── Step 2: Derive feature name ────────────────────────────────────────────── +# Read the first '# ' heading from the plan file. +HEADING=$(grep -m1 '^# ' "$PLAN_FILE" | sed 's/^# //' || true) + +if [[ -n "$HEADING" ]]; then + # Convert to kebab-case: lowercase, collapse whitespace/special chars to '-' + FEATURE_NAME=$(echo "$HEADING" \ + | tr '[:upper:]' '[:lower:]' \ + | sed 's/[^a-z0-9가-힣]/-/g' \ + | sed 's/-\+/-/g' \ + | sed 's/^-//;s/-$//') +else + # Fall back to input filename stem + BASENAME=$(basename "$PLAN_FILE" .md) + FEATURE_NAME="${BASENAME%-plan}" + FEATURE_NAME="${FEATURE_NAME:-current}" +fi + +# ── Step 3: Prepare output path ────────────────────────────────────────────── +mkdir -p .claude/reviews +OUTPUT_FILE=".claude/reviews/${FEATURE_NAME}-codex-review.md" + +echo "플랜 : $PLAN_FILE" +echo "출력 : $OUTPUT_FILE" +echo "" + +# ── Step 4: Run Codex review ───────────────────────────────────────────────── +codex exec \ + --ephemeral \ + --full-auto \ + -s read-only \ + -o "$OUTPUT_FILE" \ + "다음은 구현 플랜입니다. 아래 4가지 관점에서 한국어로 상세히 리뷰해주세요. + +1. 실현 가능성 — 기술 스택, 복잡도, 의존성 관점에서 실현 가능한가? +2. 누락된 단계 — 빠진 구현 단계나 고려사항이 있는가? +3. 위험 요소 — 버그, 성능, 보안, 설계 문제가 있는가? +4. 개선 제안 — 더 나은 접근법이나 최적화 방법이 있는가? + +각 항목은 구체적으로 작성해주세요. 관련된 파일명, 레이어, 수정 방향을 포함하면 좋습니다." \ + < "$PLAN_FILE" + +# ── Step 5: Print results ───────────────────────────────────────────────────── +echo "" +cat "$OUTPUT_FILE" +echo "" +echo "리뷰 완료 → $OUTPUT_FILE" +echo "입력 플랜 → $PLAN_FILE" diff --git a/Fantasy-server/.claude/skills/review-pr/SKILL.md b/Fantasy-server/.claude/skills/review-pr/SKILL.md index 45c416f..b9099a2 100644 --- a/Fantasy-server/.claude/skills/review-pr/SKILL.md +++ b/Fantasy-server/.claude/skills/review-pr/SKILL.md @@ -14,11 +14,11 @@ Output directory: `.pr-tmp/<PR_NUMBER>/` Output files: - `pr_meta.json` — PR metadata (number, title, url, base/head branch, author) -- `review_comments.json` — inline review comments (id, path, line, side, body, user, createdAt) -- `issue_comments.json` — PR-level (non-inline) comments (id, body, user, createdAt) -- `commits.txt` — commits in this PR -- `changed_files.txt` — changed file paths -- `diff.txt` — full diff + - `review_comments.json` — inline review comments (id, path, line, side, body, user, createdAt) + - `issue_comments.json` — PR-level (non-inline) comments (id, body, user, createdAt) + - `commits.txt` — commits in this PR + - `changed_files.txt` — changed file paths + - `diff.txt` — full diff ## Step 2 — Assess Each Comment @@ -34,14 +34,14 @@ For each comment in `review_comments.json` (and `issue_comments.json` if it refe - `.claude/rules/domain-patterns.md` — service, repository, controller implementation patterns - `.claude/rules/global-patterns.md` — JWT, Redis, rate limiting infrastructure patterns - `.claude/rules/testing.md` — test naming, mocking with NSubstitute, FluentAssertions -2. **Language/framework best practices** (secondary): C# / ASP.NET Core / .NET 10 official guidelines - - Apply only when no matching project rule exists + 2. **Language/framework best practices** (secondary): C# / ASP.NET Core / .NET 10 official guidelines + - Apply only when no matching project rule exists ### Verdicts - **VALID**: reviewer is correct → attempt auto code fix -- **INVALID**: reviewer is wrong with a clear refutation → skip, post refutation reply -- **PARTIAL**: intent is correct but application method or scope is ambiguous → confirm with AskUserQuestion + - **INVALID**: reviewer is wrong with a clear refutation → skip, post refutation reply + - **PARTIAL**: intent is correct but application method or scope is ambiguous → confirm with AskUserQuestion Always cite a specific source in the rationale (e.g. `code-style.md §Naming`, `conventions.md §Entity Configuration`). @@ -50,13 +50,13 @@ Always cite a specific source in the rationale (e.g. `code-style.md §Naming`, ` ### VALID → Auto fix 1. Read the target file with the Read tool -2. Apply the reviewer's concern with the Edit tool -3. Run `/test` to verify the build and tests pass; fix any failures before continuing -4. Commit the change -5. Record the short commit hash for use in Step 5: - ```bash - git rev-parse --short HEAD - ``` + 2. Apply the reviewer's concern with the Edit tool + 3. Run `/test` to verify the build and tests pass; fix any failures before continuing + 4. Commit the change + 5. Record the short commit hash for use in Step 5: + ```bash + git rev-parse --short HEAD + ``` On failure: record the reason and fall back to PARTIAL. @@ -76,8 +76,8 @@ Accept? (y / n / s = skip for now) ``` - `y`: treat as VALID, attempt code fix -- `n`: treat as INVALID, skip -- `s` / other: record as PENDING + - `n`: treat as INVALID, skip + - `s` / other: record as PENDING ## Step 4 — Print Report diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Config/DungeonServiceConfig.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Config/DungeonServiceConfig.cs new file mode 100644 index 0000000..e747324 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Config/DungeonServiceConfig.cs @@ -0,0 +1,17 @@ +using Fantasy.Server.Domain.Dungeon.Service; +using Fantasy.Server.Domain.Dungeon.Service.Interface; + +namespace Fantasy.Server.Domain.Dungeon.Config; + +public static class DungeonServiceConfig +{ + public static IServiceCollection AddDungeonServices(this IServiceCollection services) + { + services.AddScoped<ICombatStatCalculator, CombatStatCalculator>(); + services.AddScoped<IBasicDungeonClaimService, BasicDungeonClaimService>(); + services.AddScoped<IGoldDungeonService, GoldDungeonService>(); + services.AddScoped<IWeaponDungeonService, WeaponDungeonService>(); + services.AddScoped<IBossDungeonService, BossDungeonService>(); + return services; + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Controller/DungeonController.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Controller/DungeonController.cs new file mode 100644 index 0000000..92575b2 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Controller/DungeonController.cs @@ -0,0 +1,64 @@ +using Fantasy.Server.Domain.Dungeon.Dto.Request; +using Fantasy.Server.Domain.Dungeon.Dto.Response; +using Fantasy.Server.Domain.Dungeon.Service.Interface; +using Fantasy.Server.Domain.Player.Enum; +using Gamism.SDK.Core.Network; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace Fantasy.Server.Domain.Dungeon.Controller; + +[ApiController] +[Route("v1/dungeon")] +[Authorize] +[EnableRateLimiting("game")] +public class DungeonController : ControllerBase +{ + private readonly IBasicDungeonClaimService _basicDungeonClaimService; + private readonly IGoldDungeonService _goldDungeonService; + private readonly IWeaponDungeonService _weaponDungeonService; + private readonly IBossDungeonService _bossDungeonService; + + public DungeonController( + IBasicDungeonClaimService basicDungeonClaimService, + IGoldDungeonService goldDungeonService, + IWeaponDungeonService weaponDungeonService, + IBossDungeonService bossDungeonService) + { + _basicDungeonClaimService = basicDungeonClaimService; + _goldDungeonService = goldDungeonService; + _weaponDungeonService = weaponDungeonService; + _bossDungeonService = bossDungeonService; + } + + [HttpPost("basic/claim")] + public async Task<CommonApiResponse<BasicDungeonClaimResponse>> BasicClaim([FromQuery] JobType jobType) + { + var result = await _basicDungeonClaimService.ExecuteAsync(jobType); + return CommonApiResponse.Success("기본 던전 정산이 완료되었습니다.", result); + } + + [HttpPost("gold")] + public async Task<CommonApiResponse<GoldDungeonResponse>> Gold( + [FromQuery] JobType jobType, + [FromBody] GoldDungeonRequest request) + { + var result = await _goldDungeonService.ExecuteAsync(jobType, request); + return CommonApiResponse.Success("골드 던전이 완료되었습니다.", result); + } + + [HttpPost("weapon")] + public async Task<CommonApiResponse<WeaponDungeonResponse>> Weapon([FromQuery] JobType jobType) + { + var result = await _weaponDungeonService.ExecuteAsync(jobType); + return CommonApiResponse.Success("무기 던전이 완료되었습니다.", result); + } + + [HttpPost("boss")] + public async Task<CommonApiResponse<BossDungeonResponse>> Boss([FromQuery] JobType jobType) + { + var result = await _bossDungeonService.ExecuteAsync(jobType); + return CommonApiResponse.Success("보스 던전이 완료되었습니다.", result); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Request/GoldDungeonRequest.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Request/GoldDungeonRequest.cs new file mode 100644 index 0000000..8701f5d --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Request/GoldDungeonRequest.cs @@ -0,0 +1,8 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fantasy.Server.Domain.Dungeon.Dto.Request; + +public record GoldDungeonRequest( + [Required][Range(0, int.MaxValue)] int Clicks, + [Required][Range(30, 60)] int DurationSeconds +); diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/BasicDungeonClaimResponse.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/BasicDungeonClaimResponse.cs new file mode 100644 index 0000000..8a67fa1 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/BasicDungeonClaimResponse.cs @@ -0,0 +1,11 @@ +using Fantasy.Server.Domain.LevelUp.Dto.Response; + +namespace Fantasy.Server.Domain.Dungeon.Dto.Response; + +public record BasicDungeonClaimResponse( + long EarnedGold, + long EarnedXp, + long NewMaxStage, + long NewLevel, + List<LevelUpResult> LevelUps +); diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/BossDungeonResponse.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/BossDungeonResponse.cs new file mode 100644 index 0000000..acdb21e --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/BossDungeonResponse.cs @@ -0,0 +1,11 @@ +using Fantasy.Server.Domain.LevelUp.Dto.Response; + +namespace Fantasy.Server.Domain.Dungeon.Dto.Response; + +public record BossDungeonResponse( + bool Cleared, + long EarnedMithril, + DroppedWeaponInfo? DroppedWeapon, + long EarnedXp, + List<LevelUpResult> LevelUps +); diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/DroppedWeaponInfo.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/DroppedWeaponInfo.cs new file mode 100644 index 0000000..234c6a6 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/DroppedWeaponInfo.cs @@ -0,0 +1,5 @@ +using Fantasy.Server.Domain.GameData.Enum; + +namespace Fantasy.Server.Domain.Dungeon.Dto.Response; + +public record DroppedWeaponInfo(int WeaponId, string Name, WeaponGrade Grade); diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/GoldDungeonResponse.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/GoldDungeonResponse.cs new file mode 100644 index 0000000..bf62a6b --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/GoldDungeonResponse.cs @@ -0,0 +1,3 @@ +namespace Fantasy.Server.Domain.Dungeon.Dto.Response; + +public record GoldDungeonResponse(long EarnedGold, bool MithrilDropped); diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/WeaponDungeonResponse.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/WeaponDungeonResponse.cs new file mode 100644 index 0000000..bfb120d --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/WeaponDungeonResponse.cs @@ -0,0 +1,7 @@ +namespace Fantasy.Server.Domain.Dungeon.Dto.Response; + +public record WeaponDungeonResponse( + bool Cleared, + List<DroppedWeaponInfo> DroppedWeapons, + long DroppedScrolls +); diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/BasicDungeonClaimService.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/BasicDungeonClaimService.cs new file mode 100644 index 0000000..64a6f5f --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/BasicDungeonClaimService.cs @@ -0,0 +1,158 @@ +using Fantasy.Server.Domain.Dungeon.Dto.Response; +using Fantasy.Server.Domain.Dungeon.Service.Interface; +using Fantasy.Server.Domain.GameData.Entity; +using Fantasy.Server.Domain.GameData.Service.Interface; +using Fantasy.Server.Domain.LevelUp.Service.Interface; +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Global.Infrastructure; +using Fantasy.Server.Global.Security.Provider; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; + +namespace Fantasy.Server.Domain.Dungeon.Service; + +public class BasicDungeonClaimService : IBasicDungeonClaimService +{ + private const long MaxOfflineSeconds = 8 * 60 * 60; // 8시간 상한 + + private readonly IPlayerRepository _playerRepository; + private readonly IPlayerResourceRepository _playerResourceRepository; + private readonly IPlayerStageRepository _playerStageRepository; + private readonly IPlayerSessionRepository _playerSessionRepository; + private readonly IPlayerWeaponRepository _playerWeaponRepository; + private readonly IPlayerSkillRepository _playerSkillRepository; + private readonly IPlayerRedisRepository _playerRedisRepository; + private readonly IGameDataCacheService _gameDataCacheService; + private readonly ILevelUpService _levelUpService; + private readonly IAppDbTransactionRunner _transactionRunner; + private readonly ICurrentUserProvider _currentUserProvider; + private readonly ICombatStatCalculator _calculator; + + public BasicDungeonClaimService( + IPlayerRepository playerRepository, + IPlayerResourceRepository playerResourceRepository, + IPlayerStageRepository playerStageRepository, + IPlayerSessionRepository playerSessionRepository, + IPlayerWeaponRepository playerWeaponRepository, + IPlayerSkillRepository playerSkillRepository, + IPlayerRedisRepository playerRedisRepository, + IGameDataCacheService gameDataCacheService, + ILevelUpService levelUpService, + IAppDbTransactionRunner transactionRunner, + ICurrentUserProvider currentUserProvider, + ICombatStatCalculator calculator) + { + _playerRepository = playerRepository; + _playerResourceRepository = playerResourceRepository; + _playerStageRepository = playerStageRepository; + _playerSessionRepository = playerSessionRepository; + _playerWeaponRepository = playerWeaponRepository; + _playerSkillRepository = playerSkillRepository; + _playerRedisRepository = playerRedisRepository; + _gameDataCacheService = gameDataCacheService; + _levelUpService = levelUpService; + _transactionRunner = transactionRunner; + _currentUserProvider = currentUserProvider; + _calculator = calculator; + } + + public async Task<BasicDungeonClaimResponse> ExecuteAsync(JobType jobType) + { + var accountId = _currentUserProvider.GetAccountId(); + + var player = await _playerRepository.FindByAccountAndJobAsync(accountId, jobType) + ?? throw new NotFoundException("플레이어 데이터를 찾을 수 없습니다."); + + var resource = await _playerResourceRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 재화 데이터를 찾을 수 없습니다."); + + var stage = await _playerStageRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 스테이지 데이터를 찾을 수 없습니다."); + + var session = await _playerSessionRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 세션 데이터를 찾을 수 없습니다."); + + var weapons = await _playerWeaponRepository.FindAllByPlayerIdAsync(player.Id); + var skills = await _playerSkillRepository.FindAllByPlayerIdAsync(player.Id); + + var elapsedSeconds = Math.Min( + (long)(DateTime.UtcNow - stage.LastCalculatedAt).TotalSeconds, + MaxOfflineSeconds); + + if (elapsedSeconds <= 0) + return new BasicDungeonClaimResponse(0, 0, stage.MaxStage, player.Level, []); + + var stageData = await _gameDataCacheService.GetStageDataAsync(stage.MaxStage); + if (stageData is null) + throw new NotFoundException("스테이지 데이터를 찾을 수 없습니다."); + + var jobStat = await _gameDataCacheService.GetJobBaseStatAsync(player.JobType) + ?? throw new NotFoundException("직업 기본 스탯 데이터를 찾을 수 없습니다."); + + WeaponData? weaponData = null; + long weaponEnhancement = 0; + if (session.LastWeaponId.HasValue) + { + weaponData = await _gameDataCacheService.GetWeaponDataAsync(session.LastWeaponId.Value); + var equippedWeapon = weapons.FirstOrDefault(w => w.WeaponId == session.LastWeaponId.Value); + weaponEnhancement = equippedWeapon?.EnhancementLevel ?? 0; + } + + var jobSkillData = await _gameDataCacheService.GetSkillDataByJobAsync(player.JobType); + var unlockedPassiveSkills = skills + .Where(s => s.IsUnlocked) + .Select(s => jobSkillData.FirstOrDefault(sd => sd.SkillId == s.SkillId)) + .Where(sd => sd is not null && !sd.IsActive) + .Select(sd => (Skill: sd!, IsPassive: true)); + + var combatStat = _calculator.Calculate(player.Level, jobStat, weaponData, weaponEnhancement, unlockedPassiveSkills); + var dps = _calculator.CalculateDps(combatStat); + + var (earnedGold, earnedXp, newMaxStage) = SimulateDungeon( + dps, combatStat.Hp, elapsedSeconds, stage.MaxStage, stageData); + + var levelUps = await _levelUpService.ApplyExpAsync(player, resource, earnedXp); + resource.UpdateGold(resource.Gold + earnedGold); + stage.Update(newMaxStage); + stage.UpdateLastCalculatedAt(); + + await _transactionRunner.ExecuteAsync(async () => + { + await _playerRepository.UpdateAsync(player); + await _playerResourceRepository.UpdateAsync(resource); + await _playerStageRepository.UpdateAsync(stage); + }); + + await _playerRedisRepository.DeleteAsync(accountId, jobType); + + return new BasicDungeonClaimResponse(earnedGold, earnedXp, newMaxStage, player.Level, levelUps); + } + + private static (long Gold, long Xp, long NewMaxStage) SimulateDungeon( + double dps, long hp, long elapsedSeconds, long currentMaxStage, StageData stageData) + { + long earnedGold = 0; + long earnedXp = 0; + long newMaxStage = currentMaxStage; + long remainingSeconds = elapsedSeconds; + + // 현재 스테이지 클리어 가능 여부 확인 + var timeToKill = stageData.MonsterHp / Math.Max(dps, 1.0); + if (timeToKill <= remainingSeconds) + { + // 클리어 가능: 경과 시간 동안 보상 적립 + earnedGold += stageData.GoldPerSecond * remainingSeconds; + earnedXp += stageData.XpPerSecond * remainingSeconds; + newMaxStage = Math.Max(newMaxStage, currentMaxStage + 1); + } + else + { + // 클리어 불가: 경과 시간만큼 보상만 + earnedGold += stageData.GoldPerSecond * remainingSeconds; + earnedXp += stageData.XpPerSecond * remainingSeconds; + } + + return (earnedGold, earnedXp, newMaxStage); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/BossDungeonService.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/BossDungeonService.cs new file mode 100644 index 0000000..5018bf5 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/BossDungeonService.cs @@ -0,0 +1,145 @@ +using Fantasy.Server.Domain.Dungeon.Dto.Response; +using Fantasy.Server.Domain.Dungeon.Service.Interface; +using Fantasy.Server.Domain.GameData.Entity; +using Fantasy.Server.Domain.GameData.Enum; +using Fantasy.Server.Domain.GameData.Service.Interface; +using Fantasy.Server.Domain.LevelUp.Service.Interface; +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Global.Infrastructure; +using Fantasy.Server.Global.Security.Provider; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; + +namespace Fantasy.Server.Domain.Dungeon.Service; + +public class BossDungeonService : IBossDungeonService +{ + private const long BossMithrilReward = 1; + private const long BossXpMultiplier = 10; + + private readonly IPlayerRepository _playerRepository; + private readonly IPlayerResourceRepository _playerResourceRepository; + private readonly IPlayerStageRepository _playerStageRepository; + private readonly IPlayerSessionRepository _playerSessionRepository; + private readonly IPlayerWeaponRepository _playerWeaponRepository; + private readonly IPlayerSkillRepository _playerSkillRepository; + private readonly IPlayerRedisRepository _playerRedisRepository; + private readonly IGameDataCacheService _gameDataCacheService; + private readonly ILevelUpService _levelUpService; + private readonly IAppDbTransactionRunner _transactionRunner; + private readonly ICurrentUserProvider _currentUserProvider; + private readonly ICombatStatCalculator _calculator; + + public BossDungeonService( + IPlayerRepository playerRepository, + IPlayerResourceRepository playerResourceRepository, + IPlayerStageRepository playerStageRepository, + IPlayerSessionRepository playerSessionRepository, + IPlayerWeaponRepository playerWeaponRepository, + IPlayerSkillRepository playerSkillRepository, + IPlayerRedisRepository playerRedisRepository, + IGameDataCacheService gameDataCacheService, + ILevelUpService levelUpService, + IAppDbTransactionRunner transactionRunner, + ICurrentUserProvider currentUserProvider, + ICombatStatCalculator calculator) + { + _playerRepository = playerRepository; + _playerResourceRepository = playerResourceRepository; + _playerStageRepository = playerStageRepository; + _playerSessionRepository = playerSessionRepository; + _playerWeaponRepository = playerWeaponRepository; + _playerSkillRepository = playerSkillRepository; + _playerRedisRepository = playerRedisRepository; + _gameDataCacheService = gameDataCacheService; + _levelUpService = levelUpService; + _transactionRunner = transactionRunner; + _currentUserProvider = currentUserProvider; + _calculator = calculator; + } + + public async Task<BossDungeonResponse> ExecuteAsync(JobType jobType) + { + var accountId = _currentUserProvider.GetAccountId(); + + var player = await _playerRepository.FindByAccountAndJobAsync(accountId, jobType) + ?? throw new NotFoundException("플레이어 데이터를 찾을 수 없습니다."); + + var resource = await _playerResourceRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 재화 데이터를 찾을 수 없습니다."); + + var stage = await _playerStageRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 스테이지 데이터를 찾을 수 없습니다."); + + var session = await _playerSessionRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 세션 데이터를 찾을 수 없습니다."); + + var weapons = await _playerWeaponRepository.FindAllByPlayerIdAsync(player.Id); + var skills = await _playerSkillRepository.FindAllByPlayerIdAsync(player.Id); + + var jobStat = await _gameDataCacheService.GetJobBaseStatAsync(player.JobType) + ?? throw new NotFoundException("직업 기본 스탯 데이터를 찾을 수 없습니다."); + + WeaponData? weaponData = null; + long weaponEnhancement = 0; + if (session.LastWeaponId.HasValue) + { + weaponData = await _gameDataCacheService.GetWeaponDataAsync(session.LastWeaponId.Value); + var equippedWeapon = weapons.FirstOrDefault(w => w.WeaponId == session.LastWeaponId.Value); + weaponEnhancement = equippedWeapon?.EnhancementLevel ?? 0; + } + + var jobSkillData = await _gameDataCacheService.GetSkillDataByJobAsync(player.JobType); + var unlockedPassiveSkills = skills + .Where(s => s.IsUnlocked) + .Select(s => jobSkillData.FirstOrDefault(sd => sd.SkillId == s.SkillId)) + .Where(sd => sd is not null && !sd.IsActive) + .Select(sd => (Skill: sd!, IsPassive: true)); + + var combatStat = _calculator.Calculate(player.Level, jobStat, weaponData, weaponEnhancement, unlockedPassiveSkills); + + var stageData = await _gameDataCacheService.GetStageDataAsync(stage.MaxStage); + if (stageData is null) + throw new NotFoundException("스테이지 데이터를 찾을 수 없습니다."); + + // 보스는 일반 몬스터의 5배 체력 + var bossHp = stageData.MonsterHp * 5; + var dps = _calculator.CalculateDps(combatStat); + var cleared = dps * 30 >= bossHp; + + if (!cleared) + return new BossDungeonResponse(false, 0, null, 0, []); + + var earnedXp = stageData.XpPerSecond * BossXpMultiplier; + var levelUps = await _levelUpService.ApplyExpAsync(player, resource, earnedXp); + resource.UpdateChangeData(null, resource.Mithril + BossMithrilReward, null); + + DroppedWeaponInfo? droppedWeapon = null; + var aWeapons = await _gameDataCacheService.GetWeaponDataByGradeAsync(WeaponGrade.A); + var aJobWeapons = aWeapons.Where(w => w.JobType == jobType).ToList(); + List<WeaponChangeItem> weaponChanges = []; + + if (aJobWeapons.Count > 0) + { + var dropped = aJobWeapons[Random.Shared.Next(aJobWeapons.Count)]; + droppedWeapon = new DroppedWeaponInfo(dropped.WeaponId, dropped.Name, dropped.Grade); + + var existing = weapons.FirstOrDefault(w => w.WeaponId == dropped.WeaponId); + weaponChanges.Add(new WeaponChangeItem(dropped.WeaponId, (existing?.Count ?? 0) + 1, + existing?.EnhancementLevel ?? 0, existing?.AwakeningCount ?? 0)); + } + + await _transactionRunner.ExecuteAsync(async () => + { + await _playerRepository.UpdateAsync(player); + await _playerResourceRepository.UpdateAsync(resource); + if (weaponChanges.Count > 0) + await _playerWeaponRepository.UpsertRangeAsync(player.Id, weaponChanges); + }); + + await _playerRedisRepository.DeleteAsync(accountId, jobType); + + return new BossDungeonResponse(true, BossMithrilReward, droppedWeapon, earnedXp, levelUps); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/CombatStatCalculator.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/CombatStatCalculator.cs new file mode 100644 index 0000000..e1a5b0a --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/CombatStatCalculator.cs @@ -0,0 +1,64 @@ +using Fantasy.Server.Domain.Dungeon.Service.Interface; +using Fantasy.Server.Domain.GameData.Entity; +using Fantasy.Server.Domain.GameData.Enum; + +namespace Fantasy.Server.Domain.Dungeon.Service; + +public record CombatStat(long Atk, long Hp, double CritRate, double CritDmgMultiplier); + +public class CombatStatCalculator : ICombatStatCalculator +{ + public CombatStat Calculate( + long level, + JobBaseStat jobStat, + WeaponData? weapon, + long weaponEnhancementLevel, + IEnumerable<(SkillData Skill, bool IsPassive)> unlockedSkills) + { + var atk = (long)(jobStat.BaseAtk + jobStat.AtkPerLevel * (level - 1)); + var hp = (long)(jobStat.BaseHp + jobStat.HpPerLevel * (level - 1)); + var critRate = jobStat.CritRate; + var critDmg = jobStat.CritDmgMultiplier; + + if (weapon is not null) + atk += weapon.BaseAtk + weapon.AtkPerEnhancement * weaponEnhancementLevel; + + double totalAtkPercent = 0; + double totalHpPercent = 0; + + foreach (var (skill, _) in unlockedSkills) + { + switch (skill.EffectType) + { + case SkillEffectType.AtkFlat: + atk += (long)skill.EffectValue; + break; + case SkillEffectType.AtkPercent: + totalAtkPercent += skill.EffectValue; + break; + case SkillEffectType.HpFlat: + hp += (long)skill.EffectValue; + break; + case SkillEffectType.HpPercent: + totalHpPercent += skill.EffectValue; + break; + case SkillEffectType.CritRate: + critRate += skill.EffectValue; + break; + case SkillEffectType.CritDmg: + critDmg += skill.EffectValue; + break; + } + } + + if (totalAtkPercent != 0) + atk = (long)(atk * (1 + totalAtkPercent / 100.0)); + if (totalHpPercent != 0) + hp = (long)(hp * (1 + totalHpPercent / 100.0)); + + return new CombatStat(atk, hp, critRate, critDmg); + } + + public double CalculateDps(CombatStat stat) + => stat.Atk * (1 + stat.CritRate * (stat.CritDmgMultiplier - 1)); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/GoldDungeonService.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/GoldDungeonService.cs new file mode 100644 index 0000000..75da89d --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/GoldDungeonService.cs @@ -0,0 +1,59 @@ +using Fantasy.Server.Domain.Dungeon.Dto.Request; +using Fantasy.Server.Domain.Dungeon.Dto.Response; +using Fantasy.Server.Domain.Dungeon.Service.Interface; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Global.Security.Provider; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; + +namespace Fantasy.Server.Domain.Dungeon.Service; + +public class GoldDungeonService : IGoldDungeonService +{ + private const int MaxClicksPerSecond = 15; + private const long GoldPerClick = 10; + private const int MithrilDropRatePercent = 2; + + private readonly IPlayerRepository _playerRepository; + private readonly IPlayerResourceRepository _playerResourceRepository; + private readonly IPlayerRedisRepository _playerRedisRepository; + private readonly ICurrentUserProvider _currentUserProvider; + + public GoldDungeonService( + IPlayerRepository playerRepository, + IPlayerResourceRepository playerResourceRepository, + IPlayerRedisRepository playerRedisRepository, + ICurrentUserProvider currentUserProvider) + { + _playerRepository = playerRepository; + _playerResourceRepository = playerResourceRepository; + _playerRedisRepository = playerRedisRepository; + _currentUserProvider = currentUserProvider; + } + + public async Task<GoldDungeonResponse> ExecuteAsync(JobType jobType, GoldDungeonRequest request) + { + if (request.Clicks > MaxClicksPerSecond * request.DurationSeconds) + throw new BadRequestException("비정상적인 클릭 횟수입니다."); + + var accountId = _currentUserProvider.GetAccountId(); + + var player = await _playerRepository.FindByAccountAndJobAsync(accountId, jobType) + ?? throw new NotFoundException("플레이어 데이터를 찾을 수 없습니다."); + + var resource = await _playerResourceRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 재화 데이터를 찾을 수 없습니다."); + + var earnedGold = request.Clicks * GoldPerClick; + var mithrilDropped = Random.Shared.Next(0, 100) < MithrilDropRatePercent; + + resource.UpdateGold(resource.Gold + earnedGold); + if (mithrilDropped) + resource.UpdateChangeData(null, resource.Mithril + 1, null); + + await _playerResourceRepository.UpdateAsync(resource); + await _playerRedisRepository.DeleteAsync(accountId, jobType); + + return new GoldDungeonResponse(earnedGold, mithrilDropped); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/Interface/IBasicDungeonClaimService.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/Interface/IBasicDungeonClaimService.cs new file mode 100644 index 0000000..86d566a --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/Interface/IBasicDungeonClaimService.cs @@ -0,0 +1,9 @@ +using Fantasy.Server.Domain.Dungeon.Dto.Response; +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.Dungeon.Service.Interface; + +public interface IBasicDungeonClaimService +{ + Task<BasicDungeonClaimResponse> ExecuteAsync(JobType jobType); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/Interface/IBossDungeonService.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/Interface/IBossDungeonService.cs new file mode 100644 index 0000000..806cd39 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/Interface/IBossDungeonService.cs @@ -0,0 +1,9 @@ +using Fantasy.Server.Domain.Dungeon.Dto.Response; +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.Dungeon.Service.Interface; + +public interface IBossDungeonService +{ + Task<BossDungeonResponse> ExecuteAsync(JobType jobType); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/Interface/ICombatStatCalculator.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/Interface/ICombatStatCalculator.cs new file mode 100644 index 0000000..885510b --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/Interface/ICombatStatCalculator.cs @@ -0,0 +1,15 @@ +using Fantasy.Server.Domain.GameData.Entity; + +namespace Fantasy.Server.Domain.Dungeon.Service.Interface; + +public interface ICombatStatCalculator +{ + CombatStat Calculate( + long level, + JobBaseStat jobStat, + WeaponData? weapon, + long weaponEnhancementLevel, + IEnumerable<(SkillData Skill, bool IsPassive)> unlockedSkills); + + double CalculateDps(CombatStat stat); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/Interface/IGoldDungeonService.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/Interface/IGoldDungeonService.cs new file mode 100644 index 0000000..889bd62 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/Interface/IGoldDungeonService.cs @@ -0,0 +1,10 @@ +using Fantasy.Server.Domain.Dungeon.Dto.Request; +using Fantasy.Server.Domain.Dungeon.Dto.Response; +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.Dungeon.Service.Interface; + +public interface IGoldDungeonService +{ + Task<GoldDungeonResponse> ExecuteAsync(JobType jobType, GoldDungeonRequest request); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/Interface/IWeaponDungeonService.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/Interface/IWeaponDungeonService.cs new file mode 100644 index 0000000..ad9c25b --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/Interface/IWeaponDungeonService.cs @@ -0,0 +1,9 @@ +using Fantasy.Server.Domain.Dungeon.Dto.Response; +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.Dungeon.Service.Interface; + +public interface IWeaponDungeonService +{ + Task<WeaponDungeonResponse> ExecuteAsync(JobType jobType); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/WeaponDungeonService.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/WeaponDungeonService.cs new file mode 100644 index 0000000..6fc4ea0 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/WeaponDungeonService.cs @@ -0,0 +1,160 @@ +using Fantasy.Server.Domain.Dungeon.Dto.Response; +using Fantasy.Server.Domain.Dungeon.Service.Interface; +using Fantasy.Server.Domain.GameData.Entity; +using Fantasy.Server.Domain.GameData.Enum; +using Fantasy.Server.Domain.GameData.Service.Interface; +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Global.Security.Provider; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; + +namespace Fantasy.Server.Domain.Dungeon.Service; + +public class WeaponDungeonService : IWeaponDungeonService +{ + private const int BGradeDropRatePercent = 20; + private const int CGradeDropRatePercent = 70; + private const int ScrollDropRatePercent = 30; + + private readonly IPlayerRepository _playerRepository; + private readonly IPlayerResourceRepository _playerResourceRepository; + private readonly IPlayerStageRepository _playerStageRepository; + private readonly IPlayerSessionRepository _playerSessionRepository; + private readonly IPlayerWeaponRepository _playerWeaponRepository; + private readonly IPlayerSkillRepository _playerSkillRepository; + private readonly IPlayerRedisRepository _playerRedisRepository; + private readonly IGameDataCacheService _gameDataCacheService; + private readonly ICurrentUserProvider _currentUserProvider; + private readonly ICombatStatCalculator _calculator; + + public WeaponDungeonService( + IPlayerRepository playerRepository, + IPlayerResourceRepository playerResourceRepository, + IPlayerStageRepository playerStageRepository, + IPlayerSessionRepository playerSessionRepository, + IPlayerWeaponRepository playerWeaponRepository, + IPlayerSkillRepository playerSkillRepository, + IPlayerRedisRepository playerRedisRepository, + IGameDataCacheService gameDataCacheService, + ICurrentUserProvider currentUserProvider, + ICombatStatCalculator calculator) + { + _playerRepository = playerRepository; + _playerResourceRepository = playerResourceRepository; + _playerStageRepository = playerStageRepository; + _playerSessionRepository = playerSessionRepository; + _playerWeaponRepository = playerWeaponRepository; + _playerSkillRepository = playerSkillRepository; + _playerRedisRepository = playerRedisRepository; + _gameDataCacheService = gameDataCacheService; + _currentUserProvider = currentUserProvider; + _calculator = calculator; + } + + public async Task<WeaponDungeonResponse> ExecuteAsync(JobType jobType) + { + var accountId = _currentUserProvider.GetAccountId(); + + var player = await _playerRepository.FindByAccountAndJobAsync(accountId, jobType) + ?? throw new NotFoundException("플레이어 데이터를 찾을 수 없습니다."); + + var resource = await _playerResourceRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 재화 데이터를 찾을 수 없습니다."); + + var stage = await _playerStageRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 스테이지 데이터를 찾을 수 없습니다."); + + var session = await _playerSessionRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 세션 데이터를 찾을 수 없습니다."); + + var weapons = await _playerWeaponRepository.FindAllByPlayerIdAsync(player.Id); + var skills = await _playerSkillRepository.FindAllByPlayerIdAsync(player.Id); + + var jobStat = await _gameDataCacheService.GetJobBaseStatAsync(player.JobType) + ?? throw new NotFoundException("직업 기본 스탯 데이터를 찾을 수 없습니다."); + + WeaponData? weaponData = null; + long weaponEnhancement = 0; + if (session.LastWeaponId.HasValue) + { + weaponData = await _gameDataCacheService.GetWeaponDataAsync(session.LastWeaponId.Value); + var equippedWeapon = weapons.FirstOrDefault(w => w.WeaponId == session.LastWeaponId.Value); + weaponEnhancement = equippedWeapon?.EnhancementLevel ?? 0; + } + + var jobSkillData = await _gameDataCacheService.GetSkillDataByJobAsync(player.JobType); + var unlockedPassiveSkills = skills + .Where(s => s.IsUnlocked) + .Select(s => jobSkillData.FirstOrDefault(sd => sd.SkillId == s.SkillId)) + .Where(sd => sd is not null && !sd.IsActive) + .Select(sd => (Skill: sd!, IsPassive: true)); + + var combatStat = _calculator.Calculate(player.Level, jobStat, weaponData, weaponEnhancement, unlockedPassiveSkills); + + var stageData = await _gameDataCacheService.GetStageDataAsync(stage.MaxStage); + if (stageData is null) + throw new NotFoundException("스테이지 데이터를 찾을 수 없습니다."); + + var dps = _calculator.CalculateDps(combatStat); + var cleared = dps * 30 >= stageData.MonsterHp; + + var droppedWeapons = new List<DroppedWeaponInfo>(); + long droppedScrolls = 0; + + if (cleared) + { + // B등급 드랍 시도 + if (Random.Shared.Next(0, 100) < BGradeDropRatePercent) + { + var bWeapons = await _gameDataCacheService.GetWeaponDataByGradeAsync(WeaponGrade.B); + var bJobWeapons = bWeapons.Where(w => w.JobType == jobType).ToList(); + if (bJobWeapons.Count > 0) + { + var dropped = bJobWeapons[Random.Shared.Next(bJobWeapons.Count)]; + droppedWeapons.Add(new DroppedWeaponInfo(dropped.WeaponId, dropped.Name, dropped.Grade)); + } + } + // C등급 드랍 시도 + if (Random.Shared.Next(0, 100) < CGradeDropRatePercent) + { + var cWeapons = await _gameDataCacheService.GetWeaponDataByGradeAsync(WeaponGrade.C); + var cJobWeapons = cWeapons.Where(w => w.JobType == jobType).ToList(); + if (cJobWeapons.Count > 0) + { + var dropped = cJobWeapons[Random.Shared.Next(cJobWeapons.Count)]; + droppedWeapons.Add(new DroppedWeaponInfo(dropped.WeaponId, dropped.Name, dropped.Grade)); + } + } + + // 스크롤 드랍 시도 + if (Random.Shared.Next(0, 100) < ScrollDropRatePercent) + droppedScrolls = 1; + } + + if (droppedWeapons.Count > 0 || droppedScrolls > 0) + { + var weaponChanges = droppedWeapons + .Select(w => + { + var existing = weapons.FirstOrDefault(pw => pw.WeaponId == w.WeaponId); + return new WeaponChangeItem(w.WeaponId, (existing?.Count ?? 0) + 1, + existing?.EnhancementLevel ?? 0, existing?.AwakeningCount ?? 0); + }) + .ToList(); + + if (weaponChanges.Count > 0) + await _playerWeaponRepository.UpsertRangeAsync(player.Id, weaponChanges); + + if (droppedScrolls > 0) + { + resource.UpdateChangeData(resource.EnhancementScroll + droppedScrolls, null, null); + await _playerResourceRepository.UpdateAsync(resource); + } + + await _playerRedisRepository.DeleteAsync(accountId, jobType); + } + + return new WeaponDungeonResponse(cleared, droppedWeapons, droppedScrolls); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/GameData/Config/GameDataServiceConfig.cs b/Fantasy-server/Fantasy.Server/Domain/GameData/Config/GameDataServiceConfig.cs new file mode 100644 index 0000000..cdd2acd --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/GameData/Config/GameDataServiceConfig.cs @@ -0,0 +1,16 @@ +using Fantasy.Server.Domain.GameData.Repository; +using Fantasy.Server.Domain.GameData.Repository.Interface; +using Fantasy.Server.Domain.GameData.Service; +using Fantasy.Server.Domain.GameData.Service.Interface; + +namespace Fantasy.Server.Domain.GameData.Config; + +public static class GameDataServiceConfig +{ + public static IServiceCollection AddGameDataServices(this IServiceCollection services) + { + services.AddScoped<IGameDataRepository, GameDataRepository>(); + services.AddScoped<IGameDataCacheService, GameDataCacheService>(); + return services; + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/Config/JobBaseStatConfig.cs b/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/Config/JobBaseStatConfig.cs new file mode 100644 index 0000000..1d39532 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/Config/JobBaseStatConfig.cs @@ -0,0 +1,23 @@ +using Fantasy.Server.Domain.GameData.Entity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Fantasy.Server.Domain.GameData.Entity.Config; + +public class JobBaseStatConfig : IEntityTypeConfiguration<JobBaseStat> +{ + public void Configure(EntityTypeBuilder<JobBaseStat> builder) + { + builder.ToTable("job_base_stat", "game_data"); + + builder.HasKey(j => j.JobType); + + builder.Property(j => j.JobType).IsRequired().HasConversion<string>(); + builder.Property(j => j.BaseHp).IsRequired(); + builder.Property(j => j.BaseAtk).IsRequired(); + builder.Property(j => j.CritRate).IsRequired(); + builder.Property(j => j.CritDmgMultiplier).IsRequired(); + builder.Property(j => j.HpPerLevel).IsRequired(); + builder.Property(j => j.AtkPerLevel).IsRequired(); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/Config/LevelTableConfig.cs b/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/Config/LevelTableConfig.cs new file mode 100644 index 0000000..08e73ec --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/Config/LevelTableConfig.cs @@ -0,0 +1,19 @@ +using Fantasy.Server.Domain.GameData.Entity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Fantasy.Server.Domain.GameData.Entity.Config; + +public class LevelTableConfig : IEntityTypeConfiguration<LevelTable> +{ + public void Configure(EntityTypeBuilder<LevelTable> builder) + { + builder.ToTable("level_table", "game_data"); + + builder.HasKey(l => l.Level); + + builder.Property(l => l.Level).IsRequired().ValueGeneratedNever(); + builder.Property(l => l.RequiredExp).IsRequired(); + builder.Property(l => l.RewardSp).IsRequired(); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/Config/SkillDataConfig.cs b/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/Config/SkillDataConfig.cs new file mode 100644 index 0000000..c8a3631 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/Config/SkillDataConfig.cs @@ -0,0 +1,23 @@ +using Fantasy.Server.Domain.GameData.Entity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Fantasy.Server.Domain.GameData.Entity.Config; + +public class SkillDataConfig : IEntityTypeConfiguration<SkillData> +{ + public void Configure(EntityTypeBuilder<SkillData> builder) + { + builder.ToTable("skill_data", "game_data"); + + builder.HasKey(s => s.SkillId); + + builder.Property(s => s.SkillId).ValueGeneratedNever(); + builder.Property(s => s.JobType).IsRequired().HasConversion<string>(); + builder.Property(s => s.IsActive).IsRequired(); + builder.Property(s => s.SpCost).IsRequired(); + builder.Property(s => s.PrereqSkillId); + builder.Property(s => s.EffectType).IsRequired().HasConversion<string>(); + builder.Property(s => s.EffectValue).IsRequired(); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/Config/StageDataConfig.cs b/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/Config/StageDataConfig.cs new file mode 100644 index 0000000..342ba1e --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/Config/StageDataConfig.cs @@ -0,0 +1,22 @@ +using Fantasy.Server.Domain.GameData.Entity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Fantasy.Server.Domain.GameData.Entity.Config; + +public class StageDataConfig : IEntityTypeConfiguration<StageData> +{ + public void Configure(EntityTypeBuilder<StageData> builder) + { + builder.ToTable("stage_data", "game_data"); + + builder.HasKey(s => s.Stage); + + builder.Property(s => s.Stage).ValueGeneratedNever(); + builder.Property(s => s.MonsterHp).IsRequired(); + builder.Property(s => s.MonsterAtk).IsRequired(); + builder.Property(s => s.XpPerSecond).IsRequired(); + builder.Property(s => s.GoldPerSecond).IsRequired(); + builder.Property(s => s.IsBossStage).IsRequired().HasDefaultValue(false); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/Config/WeaponDataConfig.cs b/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/Config/WeaponDataConfig.cs new file mode 100644 index 0000000..1a1f434 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/Config/WeaponDataConfig.cs @@ -0,0 +1,22 @@ +using Fantasy.Server.Domain.GameData.Entity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Fantasy.Server.Domain.GameData.Entity.Config; + +public class WeaponDataConfig : IEntityTypeConfiguration<WeaponData> +{ + public void Configure(EntityTypeBuilder<WeaponData> builder) + { + builder.ToTable("weapon_data", "game_data"); + + builder.HasKey(w => w.WeaponId); + + builder.Property(w => w.WeaponId).ValueGeneratedNever(); + builder.Property(w => w.Name).IsRequired().HasMaxLength(50); + builder.Property(w => w.Grade).IsRequired().HasConversion<string>(); + builder.Property(w => w.JobType).IsRequired().HasConversion<string>(); + builder.Property(w => w.BaseAtk).IsRequired(); + builder.Property(w => w.AtkPerEnhancement).IsRequired(); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/JobBaseStat.cs b/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/JobBaseStat.cs new file mode 100644 index 0000000..a577b81 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/JobBaseStat.cs @@ -0,0 +1,32 @@ +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.GameData.Entity; + +public class JobBaseStat +{ + public JobType JobType { get; private set; } + public long BaseHp { get; private set; } + public long BaseAtk { get; private set; } + public double CritRate { get; private set; } + public double CritDmgMultiplier { get; private set; } + public double HpPerLevel { get; private set; } + public double AtkPerLevel { get; private set; } + + public static JobBaseStat Create( + JobType jobType, + long baseHp, + long baseAtk, + double critRate, + double critDmgMultiplier, + double hpPerLevel, + double atkPerLevel) => new() + { + JobType = jobType, + BaseHp = baseHp, + BaseAtk = baseAtk, + CritRate = critRate, + CritDmgMultiplier = critDmgMultiplier, + HpPerLevel = hpPerLevel, + AtkPerLevel = atkPerLevel + }; +} diff --git a/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/LevelTable.cs b/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/LevelTable.cs new file mode 100644 index 0000000..ecb1189 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/LevelTable.cs @@ -0,0 +1,15 @@ +namespace Fantasy.Server.Domain.GameData.Entity; + +public class LevelTable +{ + public long Level { get; private set; } + public long RequiredExp { get; private set; } + public long RewardSp { get; private set; } + + public static LevelTable Create(long level, long requiredExp, long rewardSp) => new() + { + Level = level, + RequiredExp = requiredExp, + RewardSp = rewardSp + }; +} diff --git a/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/SkillData.cs b/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/SkillData.cs new file mode 100644 index 0000000..11db7cb --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/SkillData.cs @@ -0,0 +1,33 @@ +using Fantasy.Server.Domain.GameData.Enum; +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.GameData.Entity; + +public class SkillData +{ + public int SkillId { get; private set; } + public JobType JobType { get; private set; } + public bool IsActive { get; private set; } + public long SpCost { get; private set; } + public int? PrereqSkillId { get; private set; } + public SkillEffectType EffectType { get; private set; } + public double EffectValue { get; private set; } + + public static SkillData Create( + int skillId, + JobType jobType, + bool isActive, + long spCost, + int? prereqSkillId, + SkillEffectType effectType, + double effectValue) => new() + { + SkillId = skillId, + JobType = jobType, + IsActive = isActive, + SpCost = spCost, + PrereqSkillId = prereqSkillId, + EffectType = effectType, + EffectValue = effectValue + }; +} diff --git a/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/StageData.cs b/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/StageData.cs new file mode 100644 index 0000000..afd5b43 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/StageData.cs @@ -0,0 +1,27 @@ +namespace Fantasy.Server.Domain.GameData.Entity; + +public class StageData +{ + public long Stage { get; private set; } + public long MonsterHp { get; private set; } + public long MonsterAtk { get; private set; } + public long XpPerSecond { get; private set; } + public long GoldPerSecond { get; private set; } + public bool IsBossStage { get; private set; } + + public static StageData Create( + long stage, + long monsterHp, + long monsterAtk, + long xpPerSecond, + long goldPerSecond, + bool isBossStage = false) => new() + { + Stage = stage, + MonsterHp = monsterHp, + MonsterAtk = monsterAtk, + XpPerSecond = xpPerSecond, + GoldPerSecond = goldPerSecond, + IsBossStage = isBossStage + }; +} diff --git a/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/WeaponData.cs b/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/WeaponData.cs new file mode 100644 index 0000000..d6b8056 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/GameData/Entity/WeaponData.cs @@ -0,0 +1,30 @@ +using Fantasy.Server.Domain.GameData.Enum; +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.GameData.Entity; + +public class WeaponData +{ + public int WeaponId { get; private set; } + public string Name { get; private set; } = string.Empty; + public WeaponGrade Grade { get; private set; } + public JobType JobType { get; private set; } + public long BaseAtk { get; private set; } + public long AtkPerEnhancement { get; private set; } + + public static WeaponData Create( + int weaponId, + string name, + WeaponGrade grade, + JobType jobType, + long baseAtk, + long atkPerEnhancement) => new() + { + WeaponId = weaponId, + Name = name, + Grade = grade, + JobType = jobType, + BaseAtk = baseAtk, + AtkPerEnhancement = atkPerEnhancement + }; +} diff --git a/Fantasy-server/Fantasy.Server/Domain/GameData/Enum/SkillEffectType.cs b/Fantasy-server/Fantasy.Server/Domain/GameData/Enum/SkillEffectType.cs new file mode 100644 index 0000000..8fdac2e --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/GameData/Enum/SkillEffectType.cs @@ -0,0 +1,13 @@ +namespace Fantasy.Server.Domain.GameData.Enum; + +public enum SkillEffectType +{ + AtkFlat, + AtkPercent, + HpFlat, + HpPercent, + CritRate, + CritDmg, + CooldownReduce, + ElementalBoost +} diff --git a/Fantasy-server/Fantasy.Server/Domain/GameData/Enum/WeaponGrade.cs b/Fantasy-server/Fantasy.Server/Domain/GameData/Enum/WeaponGrade.cs new file mode 100644 index 0000000..b6f962a --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/GameData/Enum/WeaponGrade.cs @@ -0,0 +1,9 @@ +namespace Fantasy.Server.Domain.GameData.Enum; + +public enum WeaponGrade +{ + C = 0, + B = 1, + A = 2, + S = 3 +} diff --git a/Fantasy-server/Fantasy.Server/Domain/GameData/Repository/GameDataRepository.cs b/Fantasy-server/Fantasy.Server/Domain/GameData/Repository/GameDataRepository.cs new file mode 100644 index 0000000..783de4b --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/GameData/Repository/GameDataRepository.cs @@ -0,0 +1,28 @@ +using Fantasy.Server.Domain.GameData.Entity; +using Fantasy.Server.Domain.GameData.Repository.Interface; +using Fantasy.Server.Global.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace Fantasy.Server.Domain.GameData.Repository; + +public class GameDataRepository : IGameDataRepository +{ + private readonly AppDbContext _db; + + public GameDataRepository(AppDbContext db) => _db = db; + + public async Task<List<LevelTable>> GetAllLevelTablesAsync() + => await _db.LevelTables.AsNoTracking().OrderBy(l => l.Level).ToListAsync(); + + public async Task<List<WeaponData>> GetAllWeaponDatasAsync() + => await _db.WeaponDatas.AsNoTracking().ToListAsync(); + + public async Task<List<SkillData>> GetAllSkillDatasAsync() + => await _db.SkillDatas.AsNoTracking().ToListAsync(); + + public async Task<List<StageData>> GetAllStageDatasAsync() + => await _db.StageDatas.AsNoTracking().OrderBy(s => s.Stage).ToListAsync(); + + public async Task<List<JobBaseStat>> GetAllJobBaseStatsAsync() + => await _db.JobBaseStats.AsNoTracking().ToListAsync(); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/GameData/Repository/Interface/IGameDataRepository.cs b/Fantasy-server/Fantasy.Server/Domain/GameData/Repository/Interface/IGameDataRepository.cs new file mode 100644 index 0000000..6a3fc5d --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/GameData/Repository/Interface/IGameDataRepository.cs @@ -0,0 +1,12 @@ +using Fantasy.Server.Domain.GameData.Entity; + +namespace Fantasy.Server.Domain.GameData.Repository.Interface; + +public interface IGameDataRepository +{ + Task<List<LevelTable>> GetAllLevelTablesAsync(); + Task<List<WeaponData>> GetAllWeaponDatasAsync(); + Task<List<SkillData>> GetAllSkillDatasAsync(); + Task<List<StageData>> GetAllStageDatasAsync(); + Task<List<JobBaseStat>> GetAllJobBaseStatsAsync(); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/GameData/Service/GameDataCacheService.cs b/Fantasy-server/Fantasy.Server/Domain/GameData/Service/GameDataCacheService.cs new file mode 100644 index 0000000..e406b17 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/GameData/Service/GameDataCacheService.cs @@ -0,0 +1,131 @@ +using System.Text.Json; +using Fantasy.Server.Domain.GameData.Entity; +using Fantasy.Server.Domain.GameData.Enum; +using Fantasy.Server.Domain.GameData.Repository.Interface; +using Fantasy.Server.Domain.GameData.Service.Interface; +using Fantasy.Server.Domain.Player.Enum; +using Microsoft.Extensions.Caching.Distributed; + +namespace Fantasy.Server.Domain.GameData.Service; + +public class GameDataCacheService : IGameDataCacheService +{ + private const string LevelTableKey = "game_data:level_table"; + private const string WeaponDataKey = "game_data:weapon_data"; + private const string SkillDataKey = "game_data:skill_data"; + private const string StageDataKey = "game_data:stage_data"; + private const string JobBaseStatKey = "game_data:job_base_stat"; + private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(24); + + private readonly IGameDataRepository _repository; + private readonly IDistributedCache _cache; + + public GameDataCacheService(IGameDataRepository repository, IDistributedCache cache) + { + _repository = repository; + _cache = cache; + } + + public async Task<Dictionary<long, LevelTable>> GetLevelTableAsync() + { + var json = await _cache.GetStringAsync(LevelTableKey); + if (json is not null) + return JsonSerializer.Deserialize<Dictionary<long, LevelTable>>(json)!; + + var data = await _repository.GetAllLevelTablesAsync(); + var dict = data.ToDictionary(l => l.Level); + await _cache.SetStringAsync(LevelTableKey, JsonSerializer.Serialize(dict), + new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = CacheTtl }); + return dict; + } + + public async Task<List<WeaponData>> GetWeaponDataByGradeAsync(WeaponGrade grade) + { + var all = await GetAllWeaponDatasAsync(); + return all.Where(w => w.Grade == grade).ToList(); + } + + public async Task<List<WeaponData>> GetWeaponDataByJobAsync(JobType jobType) + { + var all = await GetAllWeaponDatasAsync(); + return all.Where(w => w.JobType == jobType).ToList(); + } + + public async Task<WeaponData?> GetWeaponDataAsync(int weaponId) + { + var all = await GetAllWeaponDatasAsync(); + return all.FirstOrDefault(w => w.WeaponId == weaponId); + } + + public async Task<List<SkillData>> GetSkillDataByJobAsync(JobType jobType) + { + var all = await GetAllSkillDatasAsync(); + return all.Where(s => s.JobType == jobType).ToList(); + } + + public async Task<SkillData?> GetSkillDataAsync(int skillId) + { + var all = await GetAllSkillDatasAsync(); + return all.FirstOrDefault(s => s.SkillId == skillId); + } + + public async Task<StageData?> GetStageDataAsync(long stage) + { + var all = await GetAllStageDatasAsync(); + return all.FirstOrDefault(s => s.Stage == stage); + } + + public async Task<JobBaseStat?> GetJobBaseStatAsync(JobType jobType) + { + var all = await GetAllJobBaseStatsAsync(); + return all.FirstOrDefault(j => j.JobType == jobType); + } + + private async Task<List<WeaponData>> GetAllWeaponDatasAsync() + { + var json = await _cache.GetStringAsync(WeaponDataKey); + if (json is not null) + return JsonSerializer.Deserialize<List<WeaponData>>(json)!; + + var data = await _repository.GetAllWeaponDatasAsync(); + await _cache.SetStringAsync(WeaponDataKey, JsonSerializer.Serialize(data), + new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = CacheTtl }); + return data; + } + + private async Task<List<SkillData>> GetAllSkillDatasAsync() + { + var json = await _cache.GetStringAsync(SkillDataKey); + if (json is not null) + return JsonSerializer.Deserialize<List<SkillData>>(json)!; + + var data = await _repository.GetAllSkillDatasAsync(); + await _cache.SetStringAsync(SkillDataKey, JsonSerializer.Serialize(data), + new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = CacheTtl }); + return data; + } + + private async Task<List<StageData>> GetAllStageDatasAsync() + { + var json = await _cache.GetStringAsync(StageDataKey); + if (json is not null) + return JsonSerializer.Deserialize<List<StageData>>(json)!; + + var data = await _repository.GetAllStageDatasAsync(); + await _cache.SetStringAsync(StageDataKey, JsonSerializer.Serialize(data), + new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = CacheTtl }); + return data; + } + + private async Task<List<JobBaseStat>> GetAllJobBaseStatsAsync() + { + var json = await _cache.GetStringAsync(JobBaseStatKey); + if (json is not null) + return JsonSerializer.Deserialize<List<JobBaseStat>>(json)!; + + var data = await _repository.GetAllJobBaseStatsAsync(); + await _cache.SetStringAsync(JobBaseStatKey, JsonSerializer.Serialize(data), + new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = CacheTtl }); + return data; + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/GameData/Service/Interface/IGameDataCacheService.cs b/Fantasy-server/Fantasy.Server/Domain/GameData/Service/Interface/IGameDataCacheService.cs new file mode 100644 index 0000000..42d9f8e --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/GameData/Service/Interface/IGameDataCacheService.cs @@ -0,0 +1,17 @@ +using Fantasy.Server.Domain.GameData.Entity; +using Fantasy.Server.Domain.GameData.Enum; +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.GameData.Service.Interface; + +public interface IGameDataCacheService +{ + Task<Dictionary<long, LevelTable>> GetLevelTableAsync(); + Task<List<WeaponData>> GetWeaponDataByGradeAsync(WeaponGrade grade); + Task<List<WeaponData>> GetWeaponDataByJobAsync(JobType jobType); + Task<WeaponData?> GetWeaponDataAsync(int weaponId); + Task<List<SkillData>> GetSkillDataByJobAsync(JobType jobType); + Task<SkillData?> GetSkillDataAsync(int skillId); + Task<StageData?> GetStageDataAsync(long stage); + Task<JobBaseStat?> GetJobBaseStatAsync(JobType jobType); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/LevelUp/Config/LevelUpServiceConfig.cs b/Fantasy-server/Fantasy.Server/Domain/LevelUp/Config/LevelUpServiceConfig.cs new file mode 100644 index 0000000..8d2c4a0 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/LevelUp/Config/LevelUpServiceConfig.cs @@ -0,0 +1,13 @@ +using Fantasy.Server.Domain.LevelUp.Service; +using Fantasy.Server.Domain.LevelUp.Service.Interface; + +namespace Fantasy.Server.Domain.LevelUp.Config; + +public static class LevelUpServiceConfig +{ + public static IServiceCollection AddLevelUpServices(this IServiceCollection services) + { + services.AddScoped<ILevelUpService, LevelUpService>(); + return services; + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/LevelUp/Dto/Response/LevelUpResult.cs b/Fantasy-server/Fantasy.Server/Domain/LevelUp/Dto/Response/LevelUpResult.cs new file mode 100644 index 0000000..856f3ff --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/LevelUp/Dto/Response/LevelUpResult.cs @@ -0,0 +1,3 @@ +namespace Fantasy.Server.Domain.LevelUp.Dto.Response; + +public record LevelUpResult(long NewLevel, long EarnedSp); diff --git a/Fantasy-server/Fantasy.Server/Domain/LevelUp/Service/Interface/ILevelUpService.cs b/Fantasy-server/Fantasy.Server/Domain/LevelUp/Service/Interface/ILevelUpService.cs new file mode 100644 index 0000000..f06c65a --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/LevelUp/Service/Interface/ILevelUpService.cs @@ -0,0 +1,10 @@ +using Fantasy.Server.Domain.LevelUp.Dto.Response; +using Fantasy.Server.Domain.Player.Entity; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; + +namespace Fantasy.Server.Domain.LevelUp.Service.Interface; + +public interface ILevelUpService +{ + Task<List<LevelUpResult>> ApplyExpAsync(PlayerEntity player, PlayerResource resource, long earnedExp); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/LevelUp/Service/LevelUpService.cs b/Fantasy-server/Fantasy.Server/Domain/LevelUp/Service/LevelUpService.cs new file mode 100644 index 0000000..18c4728 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/LevelUp/Service/LevelUpService.cs @@ -0,0 +1,41 @@ +using Fantasy.Server.Domain.GameData.Service.Interface; +using Fantasy.Server.Domain.LevelUp.Dto.Response; +using Fantasy.Server.Domain.LevelUp.Service.Interface; +using Fantasy.Server.Domain.Player.Entity; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; + +namespace Fantasy.Server.Domain.LevelUp.Service; + +public class LevelUpService : ILevelUpService +{ + private const long MaxLevel = 100; + + private readonly IGameDataCacheService _gameDataCacheService; + + public LevelUpService(IGameDataCacheService gameDataCacheService) + { + _gameDataCacheService = gameDataCacheService; + } + + public async Task<List<LevelUpResult>> ApplyExpAsync(PlayerEntity player, PlayerResource resource, long earnedExp) + { + var levelTable = await _gameDataCacheService.GetLevelTableAsync(); + var levelUps = new List<LevelUpResult>(); + + while (player.Level < MaxLevel && levelTable.TryGetValue(player.Level, out var current)) + { + var remaining = current.RequiredExp - player.Exp; + if (earnedExp < remaining) + break; + + earnedExp -= remaining; + player.UpdateLevel(player.Level + 1); + player.UpdateExp(0); + resource.UpdateChangeData(null, null, resource.Sp + current.RewardSp); + levelUps.Add(new LevelUpResult(player.Level, current.RewardSp)); + } + + player.UpdateExp(player.Exp + earnedExp); + return levelUps; + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerResourceConfig.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerResourceConfig.cs index 89e93cd..72853c8 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerResourceConfig.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerResourceConfig.cs @@ -34,6 +34,10 @@ public void Configure(EntityTypeBuilder<PlayerResource> builder) .IsRequired() .HasDefaultValue(0L); + builder.Property(r => r.SmithGrade) + .IsRequired() + .HasDefaultValue(0); + builder.Property(r => r.UpdatedAt) .IsRequired(); diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerStageConfig.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerStageConfig.cs index 883934e..9277726 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerStageConfig.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerStageConfig.cs @@ -22,6 +22,10 @@ public void Configure(EntityTypeBuilder<PlayerStage> builder) .IsRequired() .HasDefaultValue(1L); + builder.Property(s => s.LastCalculatedAt) + .IsRequired() + .HasDefaultValueSql("NOW()"); + builder.HasIndex(s => s.PlayerId) .IsUnique(); diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerResource.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerResource.cs index f61a60c..44afeca 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerResource.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerResource.cs @@ -8,6 +8,7 @@ public class PlayerResource public long EnhancementScroll { get; private set; } public long Mithril { get; private set; } public long Sp { get; private set; } + public int SmithGrade { get; private set; } public DateTime UpdatedAt { get; private set; } public static PlayerResource Create(long playerId) => new() @@ -17,6 +18,7 @@ public class PlayerResource EnhancementScroll = 0, Mithril = 0, Sp = 0, + SmithGrade = 0, UpdatedAt = DateTime.UtcNow }; diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerStage.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerStage.cs index 6ba3064..142c076 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerStage.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerStage.cs @@ -5,15 +5,29 @@ public class PlayerStage public long Id { get; private set; } public long PlayerId { get; private set; } public long MaxStage { get; private set; } + public DateTime LastCalculatedAt { get; private set; } public static PlayerStage Create(long playerId) => new() { PlayerId = playerId, - MaxStage = 1 + MaxStage = 1, + LastCalculatedAt = DateTime.UtcNow + }; + + public static PlayerStage Create(long playerId, long maxStage, DateTime lastCalculatedAt) => new() + { + PlayerId = playerId, + MaxStage = maxStage, + LastCalculatedAt = lastCalculatedAt }; public void Update(long maxStage) { MaxStage = maxStage; } + + public void UpdateLastCalculatedAt() + { + LastCalculatedAt = DateTime.UtcNow; + } } diff --git a/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContext.cs b/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContext.cs index 7a86382..afdd72e 100644 --- a/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContext.cs +++ b/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContext.cs @@ -1,4 +1,5 @@ using Fantasy.Server.Domain.Account.Entity; +using Fantasy.Server.Domain.GameData.Entity; using Fantasy.Server.Domain.Player.Entity; using Microsoft.EntityFrameworkCore; @@ -19,6 +20,12 @@ public AppDbContext(DbContextOptions<AppDbContext> options) public DbSet<PlayerWeapon> PlayerWeapons => Set<PlayerWeapon>(); public DbSet<PlayerSkill> PlayerSkills => Set<PlayerSkill>(); + public DbSet<LevelTable> LevelTables => Set<LevelTable>(); + public DbSet<WeaponData> WeaponDatas => Set<WeaponData>(); + public DbSet<SkillData> SkillDatas => Set<SkillData>(); + public DbSet<StageData> StageDatas => Set<StageData>(); + public DbSet<JobBaseStat> JobBaseStats => Set<JobBaseStat>(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/Fantasy-server/Fantasy.Server/Migrations/20260407155405_DungeonSystemSetup.Designer.cs b/Fantasy-server/Fantasy.Server/Migrations/20260407155405_DungeonSystemSetup.Designer.cs new file mode 100644 index 0000000..7ec2c2e --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Migrations/20260407155405_DungeonSystemSetup.Designer.cs @@ -0,0 +1,456 @@ +// <auto-generated /> +using System; +using Fantasy.Server.Global.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fantasy.Server.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260407155405_DungeonSystemSetup")] + partial class DungeonSystemSetup + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Fantasy.Server.Domain.Account.Entity.Account", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<DateTime>("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property<bool>("IsNewAccount") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property<string>("Password") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("account", "account"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.GameData.Entity.JobBaseStat", b => + { + b.Property<string>("JobType") + .HasColumnType("text"); + + b.Property<double>("AtkPerLevel") + .HasColumnType("double precision"); + + b.Property<long>("BaseAtk") + .HasColumnType("bigint"); + + b.Property<long>("BaseHp") + .HasColumnType("bigint"); + + b.Property<double>("CritDmgMultiplier") + .HasColumnType("double precision"); + + b.Property<double>("CritRate") + .HasColumnType("double precision"); + + b.Property<double>("HpPerLevel") + .HasColumnType("double precision"); + + b.HasKey("JobType"); + + b.ToTable("job_base_stat", "game_data"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.GameData.Entity.LevelTable", b => + { + b.Property<long>("Level") + .HasColumnType("bigint"); + + b.Property<long>("RequiredExp") + .HasColumnType("bigint"); + + b.Property<long>("RewardSp") + .HasColumnType("bigint"); + + b.HasKey("Level"); + + b.ToTable("level_table", "game_data"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.GameData.Entity.SkillData", b => + { + b.Property<int>("SkillId") + .HasColumnType("integer"); + + b.Property<string>("EffectType") + .IsRequired() + .HasColumnType("text"); + + b.Property<double>("EffectValue") + .HasColumnType("double precision"); + + b.Property<bool>("IsActive") + .HasColumnType("boolean"); + + b.Property<string>("JobType") + .IsRequired() + .HasColumnType("text"); + + b.Property<int?>("PrereqSkillId") + .HasColumnType("integer"); + + b.Property<long>("SpCost") + .HasColumnType("bigint"); + + b.HasKey("SkillId"); + + b.ToTable("skill_data", "game_data"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.GameData.Entity.StageData", b => + { + b.Property<long>("Stage") + .HasColumnType("bigint"); + + b.Property<long>("GoldPerSecond") + .HasColumnType("bigint"); + + b.Property<bool>("IsBossStage") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property<long>("MonsterAtk") + .HasColumnType("bigint"); + + b.Property<long>("MonsterHp") + .HasColumnType("bigint"); + + b.Property<long>("XpPerSecond") + .HasColumnType("bigint"); + + b.HasKey("Stage"); + + b.ToTable("stage_data", "game_data"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.GameData.Entity.WeaponData", b => + { + b.Property<int>("WeaponId") + .HasColumnType("integer"); + + b.Property<long>("AtkPerEnhancement") + .HasColumnType("bigint"); + + b.Property<long>("BaseAtk") + .HasColumnType("bigint"); + + b.Property<string>("Grade") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("JobType") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("WeaponId"); + + b.ToTable("weapon_data", "game_data"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.Player", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<long>("AccountId") + .HasColumnType("bigint"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<long>("Exp") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<string>("JobType") + .IsRequired() + .HasColumnType("text"); + + b.Property<long>("Level") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "JobType") + .IsUnique(); + + b.ToTable("player", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerResource", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<long>("EnhancementScroll") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<long>("Gold") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<long>("Mithril") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<long>("PlayerId") + .HasColumnType("bigint"); + + b.Property<int>("SmithGrade") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property<long>("Sp") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("player_resource", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSession", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.PrimitiveCollection<int[]>("ActiveSkills") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("integer[]") + .HasDefaultValueSql("ARRAY[]::integer[]"); + + b.Property<int?>("LastWeaponId") + .HasColumnType("integer"); + + b.Property<long>("PlayerId") + .HasColumnType("bigint"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("player_session", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSkill", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<bool>("IsUnlocked") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property<long>("PlayerId") + .HasColumnType("bigint"); + + b.Property<int>("SkillId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "SkillId") + .IsUnique(); + + b.ToTable("player_skill", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerStage", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<DateTime>("LastCalculatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property<long>("MaxStage") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property<long>("PlayerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("player_stage", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerWeapon", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<long>("AwakeningCount") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<long>("Count") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<long>("EnhancementLevel") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<long>("PlayerId") + .HasColumnType("bigint"); + + b.Property<int>("WeaponId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "WeaponId") + .IsUnique(); + + b.ToTable("player_weapon", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerResource", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithOne() + .HasForeignKey("Fantasy.Server.Domain.Player.Entity.PlayerResource", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSession", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithOne() + .HasForeignKey("Fantasy.Server.Domain.Player.Entity.PlayerSession", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSkill", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithMany() + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerStage", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithOne() + .HasForeignKey("Fantasy.Server.Domain.Player.Entity.PlayerStage", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerWeapon", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithMany() + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Fantasy-server/Fantasy.Server/Migrations/20260407155405_DungeonSystemSetup.cs b/Fantasy-server/Fantasy.Server/Migrations/20260407155405_DungeonSystemSetup.cs new file mode 100644 index 0000000..7b01617 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Migrations/20260407155405_DungeonSystemSetup.cs @@ -0,0 +1,152 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Fantasy.Server.Migrations +{ + /// <inheritdoc /> + public partial class DungeonSystemSetup : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "game_data"); + + migrationBuilder.AddColumn<DateTime>( + name: "LastCalculatedAt", + schema: "player", + table: "player_stage", + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "NOW()"); + + migrationBuilder.AddColumn<int>( + name: "SmithGrade", + schema: "player", + table: "player_resource", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "job_base_stat", + schema: "game_data", + columns: table => new + { + JobType = table.Column<string>(type: "text", nullable: false), + BaseHp = table.Column<long>(type: "bigint", nullable: false), + BaseAtk = table.Column<long>(type: "bigint", nullable: false), + CritRate = table.Column<double>(type: "double precision", nullable: false), + CritDmgMultiplier = table.Column<double>(type: "double precision", nullable: false), + HpPerLevel = table.Column<double>(type: "double precision", nullable: false), + AtkPerLevel = table.Column<double>(type: "double precision", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_job_base_stat", x => x.JobType); + }); + + migrationBuilder.CreateTable( + name: "level_table", + schema: "game_data", + columns: table => new + { + Level = table.Column<long>(type: "bigint", nullable: false), + RequiredExp = table.Column<long>(type: "bigint", nullable: false), + RewardSp = table.Column<long>(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_level_table", x => x.Level); + }); + + migrationBuilder.CreateTable( + name: "skill_data", + schema: "game_data", + columns: table => new + { + SkillId = table.Column<int>(type: "integer", nullable: false), + JobType = table.Column<string>(type: "text", nullable: false), + IsActive = table.Column<bool>(type: "boolean", nullable: false), + SpCost = table.Column<long>(type: "bigint", nullable: false), + PrereqSkillId = table.Column<int>(type: "integer", nullable: true), + EffectType = table.Column<string>(type: "text", nullable: false), + EffectValue = table.Column<double>(type: "double precision", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_skill_data", x => x.SkillId); + }); + + migrationBuilder.CreateTable( + name: "stage_data", + schema: "game_data", + columns: table => new + { + Stage = table.Column<long>(type: "bigint", nullable: false), + MonsterHp = table.Column<long>(type: "bigint", nullable: false), + MonsterAtk = table.Column<long>(type: "bigint", nullable: false), + XpPerSecond = table.Column<long>(type: "bigint", nullable: false), + GoldPerSecond = table.Column<long>(type: "bigint", nullable: false), + IsBossStage = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false) + }, + constraints: table => + { + table.PrimaryKey("PK_stage_data", x => x.Stage); + }); + + migrationBuilder.CreateTable( + name: "weapon_data", + schema: "game_data", + columns: table => new + { + WeaponId = table.Column<int>(type: "integer", nullable: false), + Name = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false), + Grade = table.Column<string>(type: "text", nullable: false), + JobType = table.Column<string>(type: "text", nullable: false), + BaseAtk = table.Column<long>(type: "bigint", nullable: false), + AtkPerEnhancement = table.Column<long>(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_weapon_data", x => x.WeaponId); + }); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "job_base_stat", + schema: "game_data"); + + migrationBuilder.DropTable( + name: "level_table", + schema: "game_data"); + + migrationBuilder.DropTable( + name: "skill_data", + schema: "game_data"); + + migrationBuilder.DropTable( + name: "stage_data", + schema: "game_data"); + + migrationBuilder.DropTable( + name: "weapon_data", + schema: "game_data"); + + migrationBuilder.DropColumn( + name: "LastCalculatedAt", + schema: "player", + table: "player_stage"); + + migrationBuilder.DropColumn( + name: "SmithGrade", + schema: "player", + table: "player_resource"); + } + } +} diff --git a/Fantasy-server/Fantasy.Server/Migrations/AppDbContextModelSnapshot.cs b/Fantasy-server/Fantasy.Server/Migrations/AppDbContextModelSnapshot.cs index e8d4612..65bac5f 100644 --- a/Fantasy-server/Fantasy.Server/Migrations/AppDbContextModelSnapshot.cs +++ b/Fantasy-server/Fantasy.Server/Migrations/AppDbContextModelSnapshot.cs @@ -63,6 +63,136 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("account", "account"); }); + modelBuilder.Entity("Fantasy.Server.Domain.GameData.Entity.JobBaseStat", b => + { + b.Property<string>("JobType") + .HasColumnType("text"); + + b.Property<double>("AtkPerLevel") + .HasColumnType("double precision"); + + b.Property<long>("BaseAtk") + .HasColumnType("bigint"); + + b.Property<long>("BaseHp") + .HasColumnType("bigint"); + + b.Property<double>("CritDmgMultiplier") + .HasColumnType("double precision"); + + b.Property<double>("CritRate") + .HasColumnType("double precision"); + + b.Property<double>("HpPerLevel") + .HasColumnType("double precision"); + + b.HasKey("JobType"); + + b.ToTable("job_base_stat", "game_data"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.GameData.Entity.LevelTable", b => + { + b.Property<long>("Level") + .HasColumnType("bigint"); + + b.Property<long>("RequiredExp") + .HasColumnType("bigint"); + + b.Property<long>("RewardSp") + .HasColumnType("bigint"); + + b.HasKey("Level"); + + b.ToTable("level_table", "game_data"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.GameData.Entity.SkillData", b => + { + b.Property<int>("SkillId") + .HasColumnType("integer"); + + b.Property<string>("EffectType") + .IsRequired() + .HasColumnType("text"); + + b.Property<double>("EffectValue") + .HasColumnType("double precision"); + + b.Property<bool>("IsActive") + .HasColumnType("boolean"); + + b.Property<string>("JobType") + .IsRequired() + .HasColumnType("text"); + + b.Property<int?>("PrereqSkillId") + .HasColumnType("integer"); + + b.Property<long>("SpCost") + .HasColumnType("bigint"); + + b.HasKey("SkillId"); + + b.ToTable("skill_data", "game_data"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.GameData.Entity.StageData", b => + { + b.Property<long>("Stage") + .HasColumnType("bigint"); + + b.Property<long>("GoldPerSecond") + .HasColumnType("bigint"); + + b.Property<bool>("IsBossStage") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property<long>("MonsterAtk") + .HasColumnType("bigint"); + + b.Property<long>("MonsterHp") + .HasColumnType("bigint"); + + b.Property<long>("XpPerSecond") + .HasColumnType("bigint"); + + b.HasKey("Stage"); + + b.ToTable("stage_data", "game_data"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.GameData.Entity.WeaponData", b => + { + b.Property<int>("WeaponId") + .HasColumnType("integer"); + + b.Property<long>("AtkPerEnhancement") + .HasColumnType("bigint"); + + b.Property<long>("BaseAtk") + .HasColumnType("bigint"); + + b.Property<string>("Grade") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("JobType") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("WeaponId"); + + b.ToTable("weapon_data", "game_data"); + }); + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.Player", b => { b.Property<long>("Id") @@ -128,6 +258,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property<long>("PlayerId") .HasColumnType("bigint"); + b.Property<int>("SmithGrade") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + b.Property<long>("Sp") .ValueGeneratedOnAdd() .HasColumnType("bigint") @@ -210,6 +345,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + b.Property<DateTime>("LastCalculatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + b.Property<long>("MaxStage") .ValueGeneratedOnAdd() .HasColumnType("bigint") diff --git a/Fantasy-server/Fantasy.Server/Program.cs b/Fantasy-server/Fantasy.Server/Program.cs index c210e84..4039a0d 100644 --- a/Fantasy-server/Fantasy.Server/Program.cs +++ b/Fantasy-server/Fantasy.Server/Program.cs @@ -1,5 +1,8 @@ using Fantasy.Server.Domain.Account.Config; using Fantasy.Server.Domain.Auth.Config; +using Fantasy.Server.Domain.Dungeon.Config; +using Fantasy.Server.Domain.GameData.Config; +using Fantasy.Server.Domain.LevelUp.Config; using Fantasy.Server.Domain.Player.Config; using Fantasy.Server.Global.Config; using Fantasy.Server.Global.Security.Config; @@ -25,6 +28,9 @@ builder.Services.AddAuthServices(); builder.Services.AddPlayerServices(); builder.Services.AddSecurityServices(); +builder.Services.AddGameDataServices(); +builder.Services.AddLevelUpServices(); +builder.Services.AddDungeonServices(); var app = builder.Build(); diff --git a/Fantasy-server/Fantasy.Test/Dungeon/Service/BasicDungeonClaimServiceTests.cs b/Fantasy-server/Fantasy.Test/Dungeon/Service/BasicDungeonClaimServiceTests.cs new file mode 100644 index 0000000..d8ba37a --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Dungeon/Service/BasicDungeonClaimServiceTests.cs @@ -0,0 +1,203 @@ +using Fantasy.Server.Domain.Dungeon.Service; +using Fantasy.Server.Domain.Dungeon.Service.Interface; +using Fantasy.Server.Domain.GameData.Entity; +using Fantasy.Server.Domain.GameData.Service.Interface; +using Fantasy.Server.Domain.LevelUp.Service.Interface; +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Global.Infrastructure; +using Fantasy.Server.Global.Security.Provider; +using FluentAssertions; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; +using NSubstitute; +using Xunit; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; + +namespace Fantasy.Test.Dungeon.Service; + +public class BasicDungeonClaimServiceTests +{ + private static BasicDungeonClaimService BuildSut( + IPlayerRepository? playerRepo = null, + IPlayerResourceRepository? resourceRepo = null, + IPlayerStageRepository? stageRepo = null, + IPlayerSessionRepository? sessionRepo = null, + IPlayerWeaponRepository? weaponRepo = null, + IPlayerSkillRepository? skillRepo = null, + IPlayerRedisRepository? redisRepo = null, + IGameDataCacheService? cache = null, + ILevelUpService? levelUpService = null, + IAppDbTransactionRunner? txRunner = null, + ICurrentUserProvider? userProvider = null, + ICombatStatCalculator? calculator = null) + { + playerRepo ??= Substitute.For<IPlayerRepository>(); + resourceRepo ??= Substitute.For<IPlayerResourceRepository>(); + stageRepo ??= Substitute.For<IPlayerStageRepository>(); + sessionRepo ??= Substitute.For<IPlayerSessionRepository>(); + weaponRepo ??= Substitute.For<IPlayerWeaponRepository>(); + skillRepo ??= Substitute.For<IPlayerSkillRepository>(); + redisRepo ??= Substitute.For<IPlayerRedisRepository>(); + cache ??= Substitute.For<IGameDataCacheService>(); + levelUpService ??= Substitute.For<ILevelUpService>(); + txRunner ??= Substitute.For<IAppDbTransactionRunner>(); + userProvider ??= Substitute.For<ICurrentUserProvider>(); + calculator ??= new CombatStatCalculator(); + + return new BasicDungeonClaimService( + playerRepo, resourceRepo, stageRepo, sessionRepo, + weaponRepo, skillRepo, redisRepo, cache, + levelUpService, txRunner, userProvider, calculator); + } + + public class 플레이어가_없을_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + + [Fact] + public async Task NotFoundException이_발생한다() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(Arg.Any<long>(), Arg.Any<JobType>()) + .Returns((PlayerEntity?)null); + + var sut = BuildSut(playerRepo: _playerRepository, userProvider: _currentUserProvider); + + var act = async () => await sut.ExecuteAsync(JobType.Warrior); + + await act.Should().ThrowAsync<NotFoundException>(); + } + } + + public class 경과_시간이_0일_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For<IPlayerResourceRepository>(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For<IPlayerStageRepository>(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For<IPlayerSessionRepository>(); + private readonly IPlayerWeaponRepository _playerWeaponRepository = Substitute.For<IPlayerWeaponRepository>(); + private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For<IPlayerSkillRepository>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + + public 경과_시간이_0일_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + _playerResourceRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerResource.Create(1L)); + // LastCalculatedAt = UtcNow → 경과 시간 0 + _playerStageRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerStage.Create(1L, maxStage: 1, lastCalculatedAt: DateTime.UtcNow)); + _playerSessionRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerSession.Create(1L)); + _playerWeaponRepository.FindAllByPlayerIdAsync(Arg.Any<long>()).Returns([]); + _playerSkillRepository.FindAllByPlayerIdAsync(Arg.Any<long>()).Returns([]); + } + + [Fact] + public async Task 보상이_0으로_반환된다() + { + var sut = BuildSut( + playerRepo: _playerRepository, + resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, + sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, + skillRepo: _playerSkillRepository, + userProvider: _currentUserProvider); + + var result = await sut.ExecuteAsync(JobType.Warrior); + + result.EarnedGold.Should().Be(0); + result.EarnedXp.Should().Be(0); + } + } + + public class 오프라인_시간이_있을_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For<IPlayerResourceRepository>(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For<IPlayerStageRepository>(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For<IPlayerSessionRepository>(); + private readonly IPlayerWeaponRepository _playerWeaponRepository = Substitute.For<IPlayerWeaponRepository>(); + private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For<IPlayerSkillRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly IGameDataCacheService _gameDataCacheService = Substitute.For<IGameDataCacheService>(); + private readonly ILevelUpService _levelUpService = Substitute.For<ILevelUpService>(); + private readonly IAppDbTransactionRunner _transactionRunner = Substitute.For<IAppDbTransactionRunner>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + + public 오프라인_시간이_있을_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + _playerResourceRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerResource.Create(1L)); + // 1시간 전에 마지막 정산 + _playerStageRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerStage.Create(1L, maxStage: 1, lastCalculatedAt: DateTime.UtcNow.AddHours(-1))); + _playerSessionRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerSession.Create(1L)); + _playerWeaponRepository.FindAllByPlayerIdAsync(Arg.Any<long>()).Returns([]); + _playerSkillRepository.FindAllByPlayerIdAsync(Arg.Any<long>()).Returns([]); + + var stageData = StageData.Create(1, monsterHp: 50, monsterAtk: 10, xpPerSecond: 5, goldPerSecond: 10); + _gameDataCacheService.GetStageDataAsync(1).Returns(stageData); + _gameDataCacheService.GetJobBaseStatAsync(JobType.Warrior) + .Returns(JobBaseStat.Create(JobType.Warrior, 1000, 200, 0.1, 1.5, 50, 10)); + _gameDataCacheService.GetSkillDataByJobAsync(Arg.Any<JobType>()).Returns([]); + _levelUpService.ApplyExpAsync(Arg.Any<PlayerEntity>(), Arg.Any<PlayerResource>(), Arg.Any<long>()) + .Returns([]); + _transactionRunner.ExecuteAsync(Arg.Any<Func<Task>>()) + .Returns(callInfo => callInfo.Arg<Func<Task>>()()); + } + + [Fact] + public async Task 경과시간_만큼_보상이_지급된다() + { + var sut = BuildSut( + playerRepo: _playerRepository, + resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, + sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, + skillRepo: _playerSkillRepository, + redisRepo: _playerRedisRepository, + cache: _gameDataCacheService, + levelUpService: _levelUpService, + txRunner: _transactionRunner, + userProvider: _currentUserProvider); + + var result = await sut.ExecuteAsync(JobType.Warrior); + + result.EarnedGold.Should().BeGreaterThan(0); + result.EarnedXp.Should().BeGreaterThan(0); + } + + [Fact] + public async Task DB와_Redis_캐시가_업데이트된다() + { + var sut = BuildSut( + playerRepo: _playerRepository, + resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, + sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, + skillRepo: _playerSkillRepository, + redisRepo: _playerRedisRepository, + cache: _gameDataCacheService, + levelUpService: _levelUpService, + txRunner: _transactionRunner, + userProvider: _currentUserProvider); + + await sut.ExecuteAsync(JobType.Warrior); + + await _transactionRunner.Received(1).ExecuteAsync(Arg.Any<Func<Task>>()); + await _playerRedisRepository.Received(1).DeleteAsync(1L, JobType.Warrior); + } + } +} diff --git a/Fantasy-server/Fantasy.Test/Dungeon/Service/BossDungeonServiceTests.cs b/Fantasy-server/Fantasy.Test/Dungeon/Service/BossDungeonServiceTests.cs new file mode 100644 index 0000000..aee6c45 --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Dungeon/Service/BossDungeonServiceTests.cs @@ -0,0 +1,315 @@ +using Fantasy.Server.Domain.Dungeon.Service; +using Fantasy.Server.Domain.Dungeon.Service.Interface; +using Fantasy.Server.Domain.GameData.Entity; +using Fantasy.Server.Domain.GameData.Enum; +using Fantasy.Server.Domain.GameData.Service.Interface; +using Fantasy.Server.Domain.LevelUp.Service.Interface; +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Global.Infrastructure; +using Fantasy.Server.Global.Security.Provider; +using FluentAssertions; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; +using NSubstitute; +using Xunit; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; + +namespace Fantasy.Test.Dungeon.Service; + +public class BossDungeonServiceTests +{ + private static BossDungeonService BuildSut( + IPlayerRepository? playerRepo = null, + IPlayerResourceRepository? resourceRepo = null, + IPlayerStageRepository? stageRepo = null, + IPlayerSessionRepository? sessionRepo = null, + IPlayerWeaponRepository? weaponRepo = null, + IPlayerSkillRepository? skillRepo = null, + IPlayerRedisRepository? redisRepo = null, + IGameDataCacheService? cache = null, + ILevelUpService? levelUpService = null, + IAppDbTransactionRunner? txRunner = null, + ICurrentUserProvider? userProvider = null, + ICombatStatCalculator? calculator = null) + { + playerRepo ??= Substitute.For<IPlayerRepository>(); + resourceRepo ??= Substitute.For<IPlayerResourceRepository>(); + stageRepo ??= Substitute.For<IPlayerStageRepository>(); + sessionRepo ??= Substitute.For<IPlayerSessionRepository>(); + weaponRepo ??= Substitute.For<IPlayerWeaponRepository>(); + skillRepo ??= Substitute.For<IPlayerSkillRepository>(); + redisRepo ??= Substitute.For<IPlayerRedisRepository>(); + cache ??= Substitute.For<IGameDataCacheService>(); + levelUpService ??= Substitute.For<ILevelUpService>(); + txRunner ??= Substitute.For<IAppDbTransactionRunner>(); + userProvider ??= Substitute.For<ICurrentUserProvider>(); + calculator ??= new CombatStatCalculator(); + + return new BossDungeonService( + playerRepo, resourceRepo, stageRepo, sessionRepo, + weaponRepo, skillRepo, redisRepo, cache, + levelUpService, txRunner, userProvider, calculator); + } + + public class 플레이어가_없을_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + + [Fact] + public async Task NotFoundException이_발생한다() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(Arg.Any<long>(), Arg.Any<JobType>()) + .Returns((PlayerEntity?)null); + + var sut = BuildSut(playerRepo: _playerRepository, userProvider: _currentUserProvider); + + var act = async () => await sut.ExecuteAsync(JobType.Warrior); + + await act.Should().ThrowAsync<NotFoundException>(); + } + } + + public class 전투력이_부족해서_클리어_실패할_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For<IPlayerResourceRepository>(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For<IPlayerStageRepository>(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For<IPlayerSessionRepository>(); + private readonly IPlayerWeaponRepository _playerWeaponRepository = Substitute.For<IPlayerWeaponRepository>(); + private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For<IPlayerSkillRepository>(); + private readonly IGameDataCacheService _gameDataCacheService = Substitute.For<IGameDataCacheService>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + + public 전투력이_부족해서_클리어_실패할_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); // 레벨 1, 아무 장비 없음 + _playerResourceRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerResource.Create(1L)); + _playerStageRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerStage.Create(1L)); + _playerSessionRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerSession.Create(1L)); + _playerWeaponRepository.FindAllByPlayerIdAsync(Arg.Any<long>()).Returns([]); + _playerSkillRepository.FindAllByPlayerIdAsync(Arg.Any<long>()).Returns([]); + + // 보스 HP: 1_000_000 * 5 → 플레이어 DPS(110)로 클리어 불가 + var stageData = StageData.Create(1, monsterHp: 1_000_000, monsterAtk: 999, xpPerSecond: 5, goldPerSecond: 10); + _gameDataCacheService.GetStageDataAsync(1).Returns(stageData); + _gameDataCacheService.GetJobBaseStatAsync(JobType.Warrior) + .Returns(JobBaseStat.Create(JobType.Warrior, 1000, 100, 0, 1.5, 10, 10)); + _gameDataCacheService.GetSkillDataByJobAsync(Arg.Any<JobType>()).Returns([]); + } + + [Fact] + public async Task Cleared가_false이다() + { + var sut = BuildSut( + playerRepo: _playerRepository, + resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, + sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, + skillRepo: _playerSkillRepository, + cache: _gameDataCacheService, + userProvider: _currentUserProvider); + + var result = await sut.ExecuteAsync(JobType.Warrior); + + result.Cleared.Should().BeFalse(); + } + + [Fact] + public async Task 미스릴과_무기_보상이_없다() + { + var sut = BuildSut( + playerRepo: _playerRepository, + resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, + sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, + skillRepo: _playerSkillRepository, + cache: _gameDataCacheService, + userProvider: _currentUserProvider); + + var result = await sut.ExecuteAsync(JobType.Warrior); + + result.EarnedMithril.Should().Be(0); + result.DroppedWeapon.Should().BeNull(); + } + } + + public class 전투력이_충분해서_클리어_성공할_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For<IPlayerResourceRepository>(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For<IPlayerStageRepository>(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For<IPlayerSessionRepository>(); + private readonly IPlayerWeaponRepository _playerWeaponRepository = Substitute.For<IPlayerWeaponRepository>(); + private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For<IPlayerSkillRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly IGameDataCacheService _gameDataCacheService = Substitute.For<IGameDataCacheService>(); + private readonly ILevelUpService _levelUpService = Substitute.For<ILevelUpService>(); + private readonly IAppDbTransactionRunner _transactionRunner = Substitute.For<IAppDbTransactionRunner>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + + public 전투력이_충분해서_클리어_성공할_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + _playerResourceRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerResource.Create(1L)); + _playerStageRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerStage.Create(1L)); + _playerSessionRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerSession.Create(1L)); + _playerWeaponRepository.FindAllByPlayerIdAsync(Arg.Any<long>()).Returns([]); + _playerSkillRepository.FindAllByPlayerIdAsync(Arg.Any<long>()).Returns([]); + + // 보스 HP = 1 * 5 = 5 → DPS(100) > 5 → 클리어 가능 + var stageData = StageData.Create(1, monsterHp: 1, monsterAtk: 1, xpPerSecond: 5, goldPerSecond: 10); + _gameDataCacheService.GetStageDataAsync(1).Returns(stageData); + _gameDataCacheService.GetJobBaseStatAsync(JobType.Warrior) + .Returns(JobBaseStat.Create(JobType.Warrior, 1000, 100, 0, 1.5, 10, 10)); + _gameDataCacheService.GetSkillDataByJobAsync(Arg.Any<JobType>()).Returns([]); + _gameDataCacheService.GetWeaponDataByGradeAsync(WeaponGrade.A) + .Returns([WeaponData.Create(10, "A등급검", WeaponGrade.A, JobType.Warrior, 500, 20)]); + _levelUpService.ApplyExpAsync(Arg.Any<PlayerEntity>(), Arg.Any<PlayerResource>(), Arg.Any<long>()) + .Returns([]); + _transactionRunner.ExecuteAsync(Arg.Any<Func<Task>>()) + .Returns(callInfo => callInfo.Arg<Func<Task>>()()); + } + + [Fact] + public async Task Cleared가_true이다() + { + var sut = BuildSut( + playerRepo: _playerRepository, + resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, + sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, + skillRepo: _playerSkillRepository, + redisRepo: _playerRedisRepository, + cache: _gameDataCacheService, + levelUpService: _levelUpService, + txRunner: _transactionRunner, + userProvider: _currentUserProvider); + + var result = await sut.ExecuteAsync(JobType.Warrior); + + result.Cleared.Should().BeTrue(); + } + + [Fact] + public async Task 미스릴이_지급된다() + { + var sut = BuildSut( + playerRepo: _playerRepository, + resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, + sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, + skillRepo: _playerSkillRepository, + redisRepo: _playerRedisRepository, + cache: _gameDataCacheService, + levelUpService: _levelUpService, + txRunner: _transactionRunner, + userProvider: _currentUserProvider); + + var result = await sut.ExecuteAsync(JobType.Warrior); + + result.EarnedMithril.Should().BeGreaterThan(0); + } + + [Fact] + public async Task Redis_캐시가_무효화된다() + { + var sut = BuildSut( + playerRepo: _playerRepository, + resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, + sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, + skillRepo: _playerSkillRepository, + redisRepo: _playerRedisRepository, + cache: _gameDataCacheService, + levelUpService: _levelUpService, + txRunner: _transactionRunner, + userProvider: _currentUserProvider); + + await sut.ExecuteAsync(JobType.Warrior); + + await _playerRedisRepository.Received(1).DeleteAsync(1L, JobType.Warrior); + } + } + + public class DPS_곱하기_30이_보스HP와_정확히_같을_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For<IPlayerResourceRepository>(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For<IPlayerStageRepository>(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For<IPlayerSessionRepository>(); + private readonly IPlayerWeaponRepository _playerWeaponRepository = Substitute.For<IPlayerWeaponRepository>(); + private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For<IPlayerSkillRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly IGameDataCacheService _gameDataCacheService = Substitute.For<IGameDataCacheService>(); + private readonly ILevelUpService _levelUpService = Substitute.For<ILevelUpService>(); + private readonly IAppDbTransactionRunner _transactionRunner = Substitute.For<IAppDbTransactionRunner>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + + public DPS_곱하기_30이_보스HP와_정확히_같을_때() + { + // atk=100, critRate=0 → dps=100 → dps*30=3000 + // bossHp = monsterHp * 5 = 600 * 5 = 3000 → dps*30 == bossHp → 클리어 + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + _playerResourceRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerResource.Create(1L)); + _playerStageRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerStage.Create(1L)); + _playerSessionRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerSession.Create(1L)); + _playerWeaponRepository.FindAllByPlayerIdAsync(Arg.Any<long>()).Returns([]); + _playerSkillRepository.FindAllByPlayerIdAsync(Arg.Any<long>()).Returns([]); + + var stageData = StageData.Create(1, monsterHp: 600, monsterAtk: 1, xpPerSecond: 5, goldPerSecond: 10); + _gameDataCacheService.GetStageDataAsync(1).Returns(stageData); + _gameDataCacheService.GetJobBaseStatAsync(JobType.Warrior) + .Returns(JobBaseStat.Create(JobType.Warrior, 1000, 100, 0, 1.5, 10, 10)); + _gameDataCacheService.GetSkillDataByJobAsync(Arg.Any<JobType>()).Returns([]); + _gameDataCacheService.GetWeaponDataByGradeAsync(WeaponGrade.A).Returns([]); + _levelUpService.ApplyExpAsync(Arg.Any<PlayerEntity>(), Arg.Any<PlayerResource>(), Arg.Any<long>()) + .Returns([]); + _transactionRunner.ExecuteAsync(Arg.Any<Func<Task>>()) + .Returns(callInfo => callInfo.Arg<Func<Task>>()()); + } + + [Fact] + public async Task Cleared가_true이다() + { + var sut = BuildSut( + playerRepo: _playerRepository, + resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, + sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, + skillRepo: _playerSkillRepository, + redisRepo: _playerRedisRepository, + cache: _gameDataCacheService, + levelUpService: _levelUpService, + txRunner: _transactionRunner, + userProvider: _currentUserProvider); + + var result = await sut.ExecuteAsync(JobType.Warrior); + + result.Cleared.Should().BeTrue(); + } + } +} diff --git a/Fantasy-server/Fantasy.Test/Dungeon/Service/CombatStatCalculatorTests.cs b/Fantasy-server/Fantasy.Test/Dungeon/Service/CombatStatCalculatorTests.cs new file mode 100644 index 0000000..31ae7dd --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Dungeon/Service/CombatStatCalculatorTests.cs @@ -0,0 +1,131 @@ +using Fantasy.Server.Domain.Dungeon.Service; +using Fantasy.Server.Domain.GameData.Entity; +using Fantasy.Server.Domain.GameData.Enum; +using Fantasy.Server.Domain.Player.Enum; +using FluentAssertions; +using Xunit; + +namespace Fantasy.Test.Dungeon.Service; + +public class CombatStatCalculatorTests +{ + private readonly CombatStatCalculator _sut = new(); + + private static JobBaseStat MakeJobStat() => + JobBaseStat.Create(JobType.Warrior, baseHp: 1000, baseAtk: 100, critRate: 0.1, critDmgMultiplier: 1.5, hpPerLevel: 50, atkPerLevel: 10); + + public class 무기와_스킬이_없을_때 + { + private readonly CombatStatCalculator _sut = new(); + + [Fact] + public void 직업_기본_스탯으로_계산된다() + { + var jobStat = MakeJobStat(); + + var result = _sut.Calculate(1, jobStat, null, 0, []); + + result.Atk.Should().Be(jobStat.BaseAtk); + result.Hp.Should().Be(jobStat.BaseHp); + result.CritRate.Should().Be(jobStat.CritRate); + result.CritDmgMultiplier.Should().Be(jobStat.CritDmgMultiplier); + } + + [Fact] + public void 레벨이_높을수록_스탯이_증가한다() + { + var jobStat = MakeJobStat(); + + var lv1 = _sut.Calculate(1, jobStat, null, 0, []); + var lv10 = _sut.Calculate(10, jobStat, null, 0, []); + + lv10.Atk.Should().BeGreaterThan(lv1.Atk); + lv10.Hp.Should().BeGreaterThan(lv1.Hp); + } + + private static JobBaseStat MakeJobStat() => + JobBaseStat.Create(JobType.Warrior, 1000, 100, 0.1, 1.5, 50, 10); + } + + public class 무기를_장착했을_때 + { + private readonly CombatStatCalculator _sut = new(); + private readonly JobBaseStat _jobStat = JobBaseStat.Create(JobType.Warrior, 1000, 100, 0.1, 1.5, 50, 10); + + [Fact] + public void 무기_기본_공격력이_합산된다() + { + var weapon = WeaponData.Create(1, "검", WeaponGrade.C, JobType.Warrior, baseAtk: 200, atkPerEnhancement: 10); + + var result = _sut.Calculate(1, _jobStat, weapon, 0, []); + + result.Atk.Should().Be(_jobStat.BaseAtk + weapon.BaseAtk); + } + + [Fact] + public void 강화_레벨에_비례해_공격력이_증가한다() + { + var weapon = WeaponData.Create(1, "검", WeaponGrade.C, JobType.Warrior, baseAtk: 200, atkPerEnhancement: 10); + + var lv0 = _sut.Calculate(1, _jobStat, weapon, 0, []); + var lv5 = _sut.Calculate(1, _jobStat, weapon, 5, []); + + lv5.Atk.Should().Be(lv0.Atk + weapon.AtkPerEnhancement * 5); + } + } + + public class 패시브_스킬이_있을_때 + { + private readonly CombatStatCalculator _sut = new(); + private readonly JobBaseStat _jobStat = JobBaseStat.Create(JobType.Warrior, 1000, 100, 0.1, 1.5, 50, 10); + + [Fact] + public void AtkFlat_스킬이_공격력에_더해진다() + { + var skill = SkillData.Create(1, JobType.Warrior, isActive: false, spCost: 2, + prereqSkillId: null, effectType: SkillEffectType.AtkFlat, effectValue: 50); + + var result = _sut.Calculate(1, _jobStat, null, 0, + [(Skill: skill, IsPassive: true)]); + + result.Atk.Should().Be(_jobStat.BaseAtk + 50); + } + + [Fact] + public void CritRate_스킬이_크리티컬_확률에_더해진다() + { + var skill = SkillData.Create(1, JobType.Warrior, isActive: false, spCost: 2, + prereqSkillId: null, effectType: SkillEffectType.CritRate, effectValue: 0.1); + + var result = _sut.Calculate(1, _jobStat, null, 0, + [(Skill: skill, IsPassive: true)]); + + result.CritRate.Should().BeApproximately(_jobStat.CritRate + 0.1, 1e-9); + } + } + + public class DPS_계산 + { + private readonly CombatStatCalculator _sut = new(); + + [Fact] + public void 크리티컬이_없을_때_DPS는_공격력과_같다() + { + var stat = new CombatStat(Atk: 100, Hp: 1000, CritRate: 0, CritDmgMultiplier: 1.5); + + var dps = _sut.CalculateDps(stat); + + dps.Should().BeApproximately(100, 1e-9); + } + + [Fact] + public void 크리티컬이_있을_때_DPS가_증가한다() + { + var stat = new CombatStat(Atk: 100, Hp: 1000, CritRate: 1.0, CritDmgMultiplier: 2.0); + + var dps = _sut.CalculateDps(stat); + + dps.Should().BeApproximately(200, 1e-9); + } + } +} diff --git a/Fantasy-server/Fantasy.Test/Dungeon/Service/GoldDungeonServiceTests.cs b/Fantasy-server/Fantasy.Test/Dungeon/Service/GoldDungeonServiceTests.cs new file mode 100644 index 0000000..2c36829 --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Dungeon/Service/GoldDungeonServiceTests.cs @@ -0,0 +1,133 @@ +using Fantasy.Server.Domain.Dungeon.Dto.Request; +using Fantasy.Server.Domain.Dungeon.Service; +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Global.Security.Provider; +using FluentAssertions; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; +using NSubstitute; +using Xunit; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; + +namespace Fantasy.Test.Dungeon.Service; + +public class GoldDungeonServiceTests +{ + public class 정상_요청일_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For<IPlayerResourceRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly GoldDungeonService _sut; + + public 정상_요청일_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + _playerResourceRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerResource.Create(1L)); + + _sut = new GoldDungeonService( + _playerRepository, _playerResourceRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task 클릭수에_비례한_골드가_반환된다() + { + var request = new GoldDungeonRequest(Clicks: 10, DurationSeconds: 30); + + var result = await _sut.ExecuteAsync(JobType.Warrior, request); + + result.EarnedGold.Should().Be(10 * 10); // 10 clicks * GoldPerClick(10) + } + + [Fact] + public async Task 재화가_업데이트된다() + { + var request = new GoldDungeonRequest(Clicks: 10, DurationSeconds: 30); + + await _sut.ExecuteAsync(JobType.Warrior, request); + + await _playerResourceRepository.Received(1).UpdateAsync(Arg.Any<PlayerResource>()); + } + + [Fact] + public async Task Redis_캐시가_무효화된다() + { + var request = new GoldDungeonRequest(Clicks: 10, DurationSeconds: 30); + + await _sut.ExecuteAsync(JobType.Warrior, request); + + await _playerRedisRepository.Received(1).DeleteAsync(1L, JobType.Warrior); + } + } + + public class CPS_한계를_초과할_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For<IPlayerResourceRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly GoldDungeonService _sut; + + public CPS_한계를_초과할_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _sut = new GoldDungeonService( + _playerRepository, _playerResourceRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task BadRequestException이_발생한다() + { + // MaxCPS = 15, DurationSeconds = 30 → 최대 클릭 = 450 + var request = new GoldDungeonRequest(Clicks: 451, DurationSeconds: 30); + + var act = async () => await _sut.ExecuteAsync(JobType.Warrior, request); + + await act.Should().ThrowAsync<BadRequestException>(); + } + + [Fact] + public async Task 재화가_업데이트되지_않는다() + { + var request = new GoldDungeonRequest(Clicks: 1000, DurationSeconds: 30); + + try { await _sut.ExecuteAsync(JobType.Warrior, request); } catch { } + + await _playerResourceRepository.DidNotReceive().UpdateAsync(Arg.Any<PlayerResource>()); + } + } + + public class 플레이어가_없을_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For<IPlayerResourceRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly GoldDungeonService _sut; + + public 플레이어가_없을_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(Arg.Any<long>(), Arg.Any<JobType>()) + .Returns((PlayerEntity?)null); + + _sut = new GoldDungeonService( + _playerRepository, _playerResourceRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task NotFoundException이_발생한다() + { + var request = new GoldDungeonRequest(Clicks: 10, DurationSeconds: 30); + + var act = async () => await _sut.ExecuteAsync(JobType.Warrior, request); + + await act.Should().ThrowAsync<NotFoundException>(); + } + } +} diff --git a/Fantasy-server/Fantasy.Test/Dungeon/Service/WeaponDungeonServiceTests.cs b/Fantasy-server/Fantasy.Test/Dungeon/Service/WeaponDungeonServiceTests.cs new file mode 100644 index 0000000..b3ace2f --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Dungeon/Service/WeaponDungeonServiceTests.cs @@ -0,0 +1,271 @@ +using Fantasy.Server.Domain.Dungeon.Service; +using Fantasy.Server.Domain.Dungeon.Service.Interface; +using Fantasy.Server.Domain.GameData.Entity; +using Fantasy.Server.Domain.GameData.Enum; +using Fantasy.Server.Domain.GameData.Service.Interface; +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Global.Security.Provider; +using FluentAssertions; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; +using NSubstitute; +using Xunit; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; + +namespace Fantasy.Test.Dungeon.Service; + +public class WeaponDungeonServiceTests +{ + private static WeaponDungeonService BuildSut( + IPlayerRepository? playerRepo = null, + IPlayerResourceRepository? resourceRepo = null, + IPlayerStageRepository? stageRepo = null, + IPlayerSessionRepository? sessionRepo = null, + IPlayerWeaponRepository? weaponRepo = null, + IPlayerSkillRepository? skillRepo = null, + IPlayerRedisRepository? redisRepo = null, + IGameDataCacheService? cache = null, + ICurrentUserProvider? userProvider = null, + ICombatStatCalculator? calculator = null) + { + playerRepo ??= Substitute.For<IPlayerRepository>(); + resourceRepo ??= Substitute.For<IPlayerResourceRepository>(); + stageRepo ??= Substitute.For<IPlayerStageRepository>(); + sessionRepo ??= Substitute.For<IPlayerSessionRepository>(); + weaponRepo ??= Substitute.For<IPlayerWeaponRepository>(); + skillRepo ??= Substitute.For<IPlayerSkillRepository>(); + redisRepo ??= Substitute.For<IPlayerRedisRepository>(); + cache ??= Substitute.For<IGameDataCacheService>(); + userProvider ??= Substitute.For<ICurrentUserProvider>(); + calculator ??= new CombatStatCalculator(); + + return new WeaponDungeonService( + playerRepo, resourceRepo, stageRepo, sessionRepo, + weaponRepo, skillRepo, redisRepo, cache, + userProvider, calculator); + } + + public class 플레이어가_없을_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + + [Fact] + public async Task NotFoundException이_발생한다() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(Arg.Any<long>(), Arg.Any<JobType>()) + .Returns((PlayerEntity?)null); + + var sut = BuildSut(playerRepo: _playerRepository, userProvider: _currentUserProvider); + + var act = async () => await sut.ExecuteAsync(JobType.Warrior); + + await act.Should().ThrowAsync<NotFoundException>(); + } + } + + public class 전투력이_부족해서_클리어_실패할_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For<IPlayerResourceRepository>(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For<IPlayerStageRepository>(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For<IPlayerSessionRepository>(); + private readonly IPlayerWeaponRepository _playerWeaponRepository = Substitute.For<IPlayerWeaponRepository>(); + private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For<IPlayerSkillRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly IGameDataCacheService _gameDataCacheService = Substitute.For<IGameDataCacheService>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + + public 전투력이_부족해서_클리어_실패할_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); // 레벨 1, 장비 없음 + _playerResourceRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerResource.Create(1L)); + _playerStageRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerStage.Create(1L)); + _playerSessionRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerSession.Create(1L)); + _playerWeaponRepository.FindAllByPlayerIdAsync(Arg.Any<long>()).Returns([]); + _playerSkillRepository.FindAllByPlayerIdAsync(Arg.Any<long>()).Returns([]); + + // 몬스터 HP가 매우 높아 클리어 불가 (DPS * 30 < monsterHp) + var stageData = StageData.Create(1, monsterHp: 10_000_000, monsterAtk: 999, xpPerSecond: 5, goldPerSecond: 10); + _gameDataCacheService.GetStageDataAsync(1).Returns(stageData); + _gameDataCacheService.GetJobBaseStatAsync(JobType.Warrior) + .Returns(JobBaseStat.Create(JobType.Warrior, 1000, 100, 0, 1.5, 10, 10)); + _gameDataCacheService.GetSkillDataByJobAsync(Arg.Any<JobType>()).Returns([]); + } + + [Fact] + public async Task Cleared가_false이다() + { + var sut = BuildSut( + playerRepo: _playerRepository, + resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, + sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, + skillRepo: _playerSkillRepository, + redisRepo: _playerRedisRepository, + cache: _gameDataCacheService, + userProvider: _currentUserProvider); + + var result = await sut.ExecuteAsync(JobType.Warrior); + + result.Cleared.Should().BeFalse(); + } + + [Fact] + public async Task 드랍_보상이_없다() + { + var sut = BuildSut( + playerRepo: _playerRepository, + resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, + sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, + skillRepo: _playerSkillRepository, + redisRepo: _playerRedisRepository, + cache: _gameDataCacheService, + userProvider: _currentUserProvider); + + var result = await sut.ExecuteAsync(JobType.Warrior); + + result.DroppedWeapons.Should().BeEmpty(); + result.DroppedScrolls.Should().Be(0); + } + + [Fact] + public async Task Redis_캐시가_무효화되지_않는다() + { + var sut = BuildSut( + playerRepo: _playerRepository, + resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, + sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, + skillRepo: _playerSkillRepository, + redisRepo: _playerRedisRepository, + cache: _gameDataCacheService, + userProvider: _currentUserProvider); + + await sut.ExecuteAsync(JobType.Warrior); + + await _playerRedisRepository.DidNotReceive().DeleteAsync(Arg.Any<long>(), Arg.Any<JobType>()); + } + } + + public class 전투력이_충분해서_클리어_성공할_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For<IPlayerResourceRepository>(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For<IPlayerStageRepository>(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For<IPlayerSessionRepository>(); + private readonly IPlayerWeaponRepository _playerWeaponRepository = Substitute.For<IPlayerWeaponRepository>(); + private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For<IPlayerSkillRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly IGameDataCacheService _gameDataCacheService = Substitute.For<IGameDataCacheService>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + + public 전투력이_충분해서_클리어_성공할_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + _playerResourceRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerResource.Create(1L)); + _playerStageRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerStage.Create(1L)); + _playerSessionRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerSession.Create(1L)); + _playerWeaponRepository.FindAllByPlayerIdAsync(Arg.Any<long>()).Returns([]); + _playerSkillRepository.FindAllByPlayerIdAsync(Arg.Any<long>()).Returns([]); + + // 몬스터 HP = 1 → DPS(100) * 30 = 3000 >> 1 → 클리어 가능 + var stageData = StageData.Create(1, monsterHp: 1, monsterAtk: 1, xpPerSecond: 5, goldPerSecond: 10); + _gameDataCacheService.GetStageDataAsync(1).Returns(stageData); + _gameDataCacheService.GetJobBaseStatAsync(JobType.Warrior) + .Returns(JobBaseStat.Create(JobType.Warrior, 1000, 100, 0, 1.5, 10, 10)); + _gameDataCacheService.GetSkillDataByJobAsync(Arg.Any<JobType>()).Returns([]); + _gameDataCacheService.GetWeaponDataByGradeAsync(Arg.Any<WeaponGrade>()).Returns([]); + } + + [Fact] + public async Task Cleared가_true이다() + { + var sut = BuildSut( + playerRepo: _playerRepository, + resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, + sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, + skillRepo: _playerSkillRepository, + redisRepo: _playerRedisRepository, + cache: _gameDataCacheService, + userProvider: _currentUserProvider); + + var result = await sut.ExecuteAsync(JobType.Warrior); + + result.Cleared.Should().BeTrue(); + } + } + + public class DPS_곱하기_30이_몬스터HP와_정확히_같을_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For<IPlayerResourceRepository>(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For<IPlayerStageRepository>(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For<IPlayerSessionRepository>(); + private readonly IPlayerWeaponRepository _playerWeaponRepository = Substitute.For<IPlayerWeaponRepository>(); + private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For<IPlayerSkillRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly IGameDataCacheService _gameDataCacheService = Substitute.For<IGameDataCacheService>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + + public DPS_곱하기_30이_몬스터HP와_정확히_같을_때() + { + // atk=100, critRate=0 → dps=100 → dps*30=3000 == monsterHp=3000 → 클리어 + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + _playerResourceRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerResource.Create(1L)); + _playerStageRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerStage.Create(1L)); + _playerSessionRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerSession.Create(1L)); + _playerWeaponRepository.FindAllByPlayerIdAsync(Arg.Any<long>()).Returns([]); + _playerSkillRepository.FindAllByPlayerIdAsync(Arg.Any<long>()).Returns([]); + + var stageData = StageData.Create(1, monsterHp: 3000, monsterAtk: 1, xpPerSecond: 5, goldPerSecond: 10); + _gameDataCacheService.GetStageDataAsync(1).Returns(stageData); + _gameDataCacheService.GetJobBaseStatAsync(JobType.Warrior) + .Returns(JobBaseStat.Create(JobType.Warrior, 1000, 100, 0, 1.5, 10, 10)); + _gameDataCacheService.GetSkillDataByJobAsync(Arg.Any<JobType>()).Returns([]); + _gameDataCacheService.GetWeaponDataByGradeAsync(Arg.Any<WeaponGrade>()).Returns([]); + } + + [Fact] + public async Task Cleared가_true이다() + { + var sut = BuildSut( + playerRepo: _playerRepository, + resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, + sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, + skillRepo: _playerSkillRepository, + redisRepo: _playerRedisRepository, + cache: _gameDataCacheService, + userProvider: _currentUserProvider); + + var result = await sut.ExecuteAsync(JobType.Warrior); + + result.Cleared.Should().BeTrue(); + } + } +} diff --git a/Fantasy-server/Fantasy.Test/Global/Infrastructure/AppDbTransactionRunnerTests.cs b/Fantasy-server/Fantasy.Test/Global/Infrastructure/AppDbTransactionRunnerTests.cs index 169250d..bbd26f3 100644 --- a/Fantasy-server/Fantasy.Test/Global/Infrastructure/AppDbTransactionRunnerTests.cs +++ b/Fantasy-server/Fantasy.Test/Global/Infrastructure/AppDbTransactionRunnerTests.cs @@ -1,4 +1,5 @@ using System.Data; +using Fantasy.Server.Domain.GameData.Entity; using Fantasy.Server.Domain.Player.Entity; using Fantasy.Server.Domain.Player.Enum; using Fantasy.Server.Global.Infrastructure; @@ -96,6 +97,12 @@ public TestAppDbContext(DbContextOptions<AppDbContext> options) protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Ignore<JobBaseStat>(); + modelBuilder.Ignore<LevelTable>(); + modelBuilder.Ignore<StageData>(); + modelBuilder.Ignore<WeaponData>(); + modelBuilder.Ignore<SkillData>(); + modelBuilder.Entity<PlayerEntity>(entity => { entity.ToTable("players"); diff --git a/Fantasy-server/Fantasy.Test/LevelUp/Service/LevelUpServiceTests.cs b/Fantasy-server/Fantasy.Test/LevelUp/Service/LevelUpServiceTests.cs new file mode 100644 index 0000000..3a47536 --- /dev/null +++ b/Fantasy-server/Fantasy.Test/LevelUp/Service/LevelUpServiceTests.cs @@ -0,0 +1,209 @@ +using Fantasy.Server.Domain.GameData.Entity; +using Fantasy.Server.Domain.GameData.Service.Interface; +using Fantasy.Server.Domain.LevelUp.Service; +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Enum; +using FluentAssertions; +using NSubstitute; +using Xunit; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; + +namespace Fantasy.Test.LevelUp.Service; + +public class LevelUpServiceTests +{ + private static readonly Dictionary<long, LevelTable> SampleLevelTable = new() + { + [1] = LevelTable.Create(1, requiredExp: 100, rewardSp: 3), + [2] = LevelTable.Create(2, requiredExp: 200, rewardSp: 5), + [3] = LevelTable.Create(3, requiredExp: 400, rewardSp: 7), + }; + + private static PlayerEntity MakePlayer(long level = 1, long exp = 0) + { + var p = PlayerEntity.Create(1L, JobType.Warrior); + p.UpdateLevel(level); + p.UpdateExp(exp); + return p; + } + + private static PlayerResource MakeResource() => PlayerResource.Create(1L); + + public class XP가_부족할_때 + { + private readonly IGameDataCacheService _cache = Substitute.For<IGameDataCacheService>(); + private readonly LevelUpService _sut; + + public XP가_부족할_때() + { + _cache.GetLevelTableAsync().Returns(SampleLevelTable); + _sut = new LevelUpService(_cache); + } + + [Fact] + public async Task 레벨업이_발생하지_않는다() + { + var player = MakePlayer(level: 1, exp: 0); + var resource = MakeResource(); + + var result = await _sut.ApplyExpAsync(player, resource, earnedExp: 50); + + result.Should().BeEmpty(); + player.Level.Should().Be(1); + } + + [Fact] + public async Task XP가_누적된다() + { + var player = MakePlayer(level: 1, exp: 0); + var resource = MakeResource(); + + await _sut.ApplyExpAsync(player, resource, earnedExp: 50); + + player.Exp.Should().Be(50); + } + } + + public class 정확한_XP로_레벨업할_때 + { + private readonly IGameDataCacheService _cache = Substitute.For<IGameDataCacheService>(); + private readonly LevelUpService _sut; + + public 정확한_XP로_레벨업할_때() + { + _cache.GetLevelTableAsync().Returns(SampleLevelTable); + _sut = new LevelUpService(_cache); + } + + [Fact] + public async Task 레벨이_1_증가한다() + { + var player = MakePlayer(level: 1, exp: 0); + var resource = MakeResource(); + + await _sut.ApplyExpAsync(player, resource, earnedExp: 100); + + player.Level.Should().Be(2); + } + + [Fact] + public async Task 레벨업_결과가_반환된다() + { + var player = MakePlayer(level: 1, exp: 0); + var resource = MakeResource(); + + var result = await _sut.ApplyExpAsync(player, resource, earnedExp: 100); + + result.Should().HaveCount(1); + result[0].NewLevel.Should().Be(2); + result[0].EarnedSp.Should().Be(3); + } + + [Fact] + public async Task SP가_지급된다() + { + var player = MakePlayer(level: 1, exp: 0); + var resource = MakeResource(); + + await _sut.ApplyExpAsync(player, resource, earnedExp: 100); + + resource.Sp.Should().Be(3); + } + + [Fact] + public async Task 레벨업_후_남은_XP가_0이다() + { + var player = MakePlayer(level: 1, exp: 0); + var resource = MakeResource(); + + await _sut.ApplyExpAsync(player, resource, earnedExp: 100); + + player.Exp.Should().Be(0); + } + } + + public class 초과_XP로_연속_레벨업할_때 + { + private readonly IGameDataCacheService _cache = Substitute.For<IGameDataCacheService>(); + private readonly LevelUpService _sut; + + public 초과_XP로_연속_레벨업할_때() + { + _cache.GetLevelTableAsync().Returns(SampleLevelTable); + _sut = new LevelUpService(_cache); + } + + [Fact] + public async Task 레벨이_2_증가한다() + { + var player = MakePlayer(level: 1, exp: 0); + var resource = MakeResource(); + + // 레벨1 → 2 (100 XP), 레벨2 → 3 (200 XP) = 300 XP + await _sut.ApplyExpAsync(player, resource, earnedExp: 300); + + player.Level.Should().Be(3); + } + + [Fact] + public async Task 레벨업_결과_목록이_순서대로_반환된다() + { + var player = MakePlayer(level: 1, exp: 0); + var resource = MakeResource(); + + var result = await _sut.ApplyExpAsync(player, resource, earnedExp: 300); + + result.Should().HaveCount(2); + result[0].NewLevel.Should().Be(2); + result[1].NewLevel.Should().Be(3); + } + + [Fact] + public async Task 초과된_XP가_다음_레벨에_누적된다() + { + var player = MakePlayer(level: 1, exp: 0); + var resource = MakeResource(); + + // 100 (lv1→2) + 200 (lv2→3) + 50 남음 + await _sut.ApplyExpAsync(player, resource, earnedExp: 350); + + player.Exp.Should().Be(50); + } + + [Fact] + public async Task SP가_레벨업마다_지급된다() + { + var player = MakePlayer(level: 1, exp: 0); + var resource = MakeResource(); + + await _sut.ApplyExpAsync(player, resource, earnedExp: 300); + + resource.Sp.Should().Be(3 + 5); // lv1 reward + lv2 reward + } + } + + public class 기존_XP가_있을_때 + { + private readonly IGameDataCacheService _cache = Substitute.For<IGameDataCacheService>(); + private readonly LevelUpService _sut; + + public 기존_XP가_있을_때() + { + _cache.GetLevelTableAsync().Returns(SampleLevelTable); + _sut = new LevelUpService(_cache); + } + + [Fact] + public async Task 기존_XP와_합산하여_레벨업을_판정한다() + { + var player = MakePlayer(level: 1, exp: 80); + var resource = MakeResource(); + + // 80 + 30 = 110 >= 100 → 레벨업 + var result = await _sut.ApplyExpAsync(player, resource, earnedExp: 30); + + result.Should().HaveCount(1); + player.Level.Should().Be(2); + } + } +} diff --git a/Fantasy-server/Fantasy.Test/Player/Repository/PlayerSkillRepositoryTests.cs b/Fantasy-server/Fantasy.Test/Player/Repository/PlayerSkillRepositoryTests.cs index d5b4008..30ad3e4 100644 --- a/Fantasy-server/Fantasy.Test/Player/Repository/PlayerSkillRepositoryTests.cs +++ b/Fantasy-server/Fantasy.Test/Player/Repository/PlayerSkillRepositoryTests.cs @@ -1,4 +1,5 @@ using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.GameData.Entity; using Fantasy.Server.Domain.Player.Entity; using Fantasy.Server.Domain.Player.Repository; using Fantasy.Server.Global.Infrastructure; @@ -71,6 +72,12 @@ public TestAppDbContext(DbContextOptions<AppDbContext> options) protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Ignore<JobBaseStat>(); + modelBuilder.Ignore<LevelTable>(); + modelBuilder.Ignore<StageData>(); + modelBuilder.Ignore<WeaponData>(); + modelBuilder.Ignore<SkillData>(); + modelBuilder.Entity<PlayerSkill>(entity => { entity.ToTable("player_skills"); diff --git a/Fantasy-server/Fantasy.Test/Player/Repository/PlayerWeaponRepositoryTests.cs b/Fantasy-server/Fantasy.Test/Player/Repository/PlayerWeaponRepositoryTests.cs index 138d52b..734fcaf 100644 --- a/Fantasy-server/Fantasy.Test/Player/Repository/PlayerWeaponRepositoryTests.cs +++ b/Fantasy-server/Fantasy.Test/Player/Repository/PlayerWeaponRepositoryTests.cs @@ -1,4 +1,5 @@ using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.GameData.Entity; using Fantasy.Server.Domain.Player.Entity; using Fantasy.Server.Domain.Player.Repository; using Fantasy.Server.Global.Infrastructure; @@ -73,6 +74,12 @@ public TestAppDbContext(DbContextOptions<AppDbContext> options) protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Ignore<JobBaseStat>(); + modelBuilder.Ignore<LevelTable>(); + modelBuilder.Ignore<StageData>(); + modelBuilder.Ignore<WeaponData>(); + modelBuilder.Ignore<SkillData>(); + modelBuilder.Entity<PlayerWeapon>(entity => { entity.ToTable("player_weapons");